diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 0bee70d193..f0673e70b9 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -77,6 +77,7 @@ Class | Method | HTTP request | Description *APIKeysApi* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} | *APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} | *APIKeysApi* | [**getApiKeys**](doc//APIKeysApi.md#getapikeys) | **GET** /api-keys | +*APIKeysApi* | [**getMyApiKey**](doc//APIKeysApi.md#getmyapikey) | **GET** /api-keys/me | *APIKeysApi* | [**updateApiKey**](doc//APIKeysApi.md#updateapikey) | **PUT** /api-keys/{id} | *ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities | *ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} | diff --git a/mobile/openapi/lib/api/api_keys_api.dart b/mobile/openapi/lib/api/api_keys_api.dart index e86c63bc6e..3ac829c30c 100644 --- a/mobile/openapi/lib/api/api_keys_api.dart +++ b/mobile/openapi/lib/api/api_keys_api.dart @@ -213,6 +213,47 @@ class APIKeysApi { return null; } + /// Performs an HTTP 'GET /api-keys/me' operation and returns the [Response]. + Future getMyApiKeyWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/api-keys/me'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getMyApiKey() async { + final response = await getMyApiKeyWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'APIKeyResponseDto',) as APIKeyResponseDto; + + } + return null; + } + /// This endpoint requires the `apiKey.update` permission. /// /// Note: This method returns the HTTP [Response]. diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 32b26b0cf0..197d414921 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1488,6 +1488,38 @@ "description": "This endpoint requires the `apiKey.create` permission." } }, + "/api-keys/me": { + "get": { + "operationId": "getMyApiKey", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIKeyResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "API Keys" + ] + } + }, "/api-keys/{id}": { "delete": { "operationId": "deleteApiKey", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7a0d12f99f..f0cdbef508 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2027,6 +2027,14 @@ export function createApiKey({ apiKeyCreateDto }: { body: apiKeyCreateDto }))); } +export function getMyApiKey(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ApiKeyResponseDto; + }>("/api-keys/me", { + ...opts + })); +} /** * This endpoint requires the `apiKey.delete` permission. */ diff --git a/server/src/controllers/api-key.controller.spec.ts b/server/src/controllers/api-key.controller.spec.ts index 993ad012cc..c6dab09a3c 100644 --- a/server/src/controllers/api-key.controller.spec.ts +++ b/server/src/controllers/api-key.controller.spec.ts @@ -1,16 +1,16 @@ -import { APIKeyController } from 'src/controllers/api-key.controller'; +import { ApiKeyController } from 'src/controllers/api-key.controller'; import { Permission } from 'src/enum'; import { ApiKeyService } from 'src/services/api-key.service'; import request from 'supertest'; import { factory } from 'test/small.factory'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; -describe(APIKeyController.name, () => { +describe(ApiKeyController.name, () => { let ctx: ControllerContext; const service = mockBaseService(ApiKeyService); beforeAll(async () => { - ctx = await controllerSetup(APIKeyController, [{ provide: ApiKeyService, useValue: service }]); + ctx = await controllerSetup(ApiKeyController, [{ provide: ApiKeyService, useValue: service }]); return () => ctx.close(); }); @@ -33,6 +33,13 @@ describe(APIKeyController.name, () => { }); }); + describe('GET /api-keys/me', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/api-keys/me`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + describe('GET /api-keys/:id', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`); diff --git a/server/src/controllers/api-key.controller.ts b/server/src/controllers/api-key.controller.ts index dc9e85f33a..59b6908128 100644 --- a/server/src/controllers/api-key.controller.ts +++ b/server/src/controllers/api-key.controller.ts @@ -9,7 +9,7 @@ import { UUIDParamDto } from 'src/validation'; @ApiTags('API Keys') @Controller('api-keys') -export class APIKeyController { +export class ApiKeyController { constructor(private service: ApiKeyService) {} @Post() @@ -24,6 +24,12 @@ export class APIKeyController { return this.service.getAll(auth); } + @Get('me') + @Authenticated({ permission: false }) + async getMyApiKey(@Auth() auth: AuthDto): Promise { + return this.service.getMine(auth); + } + @Get(':id') @Authenticated({ permission: Permission.ApiKeyRead }) getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 137abf103c..e3661ec794 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -1,6 +1,6 @@ import { ActivityController } from 'src/controllers/activity.controller'; import { AlbumController } from 'src/controllers/album.controller'; -import { APIKeyController } from 'src/controllers/api-key.controller'; +import { ApiKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetController } from 'src/controllers/asset.controller'; @@ -34,7 +34,7 @@ import { UserController } from 'src/controllers/user.controller'; import { ViewController } from 'src/controllers/view.controller'; export const controllers = [ - APIKeyController, + ApiKeyController, ActivityController, AlbumController, AppController, diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 80d7a37435..8af7bf7fb3 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -17,7 +17,7 @@ import { UAParser } from 'ua-parser-js'; type AdminRoute = { admin?: true }; type SharedLinkRoute = { sharedLink?: true }; -type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute); +type AuthenticatedOptions = { permission?: Permission | false } & (AdminRoute | SharedLinkRoute); export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => { const decorators: MethodDecorator[] = [ @@ -32,7 +32,7 @@ export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorat } if (options?.permission) { - decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission ?? Permission.All)); + decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission)); } if ((options as SharedLinkRoute)?.sharedLink) { diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index fffe7bb536..8d48b47f1e 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Permission } from 'src/enum'; import { ApiKeyService } from 'src/services/api-key.service'; import { factory, newUuid } from 'test/small.factory'; @@ -134,6 +134,41 @@ describe(ApiKeyService.name, () => { }); }); + describe('getMine', () => { + it('should not work with a session token', async () => { + const session = factory.session(); + const auth = factory.auth({ session }); + + mocks.apiKey.getById.mockResolvedValue(void 0); + + await expect(sut.getMine(auth)).rejects.toBeInstanceOf(ForbiddenException); + + expect(mocks.apiKey.getById).not.toHaveBeenCalled(); + }); + + it('should throw an error if the key is not found', async () => { + const apiKey = factory.authApiKey(); + const auth = factory.auth({ apiKey }); + + mocks.apiKey.getById.mockResolvedValue(void 0); + + await expect(sut.getMine(auth)).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.apiKey.getById).toHaveBeenCalledWith(auth.user.id, apiKey.id); + }); + + it('should get a key by id', async () => { + const auth = factory.auth(); + const apiKey = factory.apiKey({ userId: auth.user.id }); + + mocks.apiKey.getById.mockResolvedValue(apiKey); + + await sut.getById(auth, apiKey.id); + + expect(mocks.apiKey.getById).toHaveBeenCalledWith(auth.user.id, apiKey.id); + }); + }); + describe('getById', () => { it('should throw an error if the key is not found', async () => { const auth = factory.auth(); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 82d4eabdfd..96671daab1 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { ApiKey } from 'src/database'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -46,6 +46,19 @@ export class ApiKeyService extends BaseService { await this.apiKeyRepository.delete(auth.user.id, id); } + async getMine(auth: AuthDto): Promise { + if (!auth.apiKey) { + throw new ForbiddenException('Not authenticated with an API Key'); + } + + const key = await this.apiKeyRepository.getById(auth.user.id, auth.apiKey.id); + if (!key) { + throw new BadRequestException('API Key not found'); + } + + return this.map(key); + } + async getById(auth: AuthDto, id: string): Promise { const key = await this.apiKeyRepository.getById(auth.user.id, id); if (!key) { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index a76fc13009..f4ff0ee8c8 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -518,6 +518,20 @@ describe(AuthService.name, () => { await expect(result).rejects.toThrow('Missing required permission: all'); }); + it('should not require any permission when metadata is set to `false`', async () => { + const authUser = factory.authUser(); + const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] }); + + mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + + const result = sut.authenticate({ + headers: { 'x-api-key': 'auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test', permission: false }, + }); + await expect(result).resolves.toEqual({ user: authUser, apiKey: expect.objectContaining(authApiKey) }); + }); + it('should return an auth dto', async () => { const authUser = factory.authUser(); const authApiKey = factory.authApiKey({ permissions: [Permission.All] }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 1e65ba3272..69d872e8c9 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -48,7 +48,8 @@ export type ValidateRequest = { metadata: { sharedLinkRoute: boolean; adminRoute: boolean; - permission?: Permission; + /** `false` explicitly means no permission is required, which otherwise defaults to `all` */ + permission?: Permission | false; uri: string; }; }; @@ -187,7 +188,11 @@ export class AuthService extends BaseService { throw new ForbiddenException('Forbidden'); } - if (authDto.apiKey && !isGranted({ requested: [requestedPermission], current: authDto.apiKey.permissions })) { + if ( + authDto.apiKey && + requestedPermission !== false && + !isGranted({ requested: [requestedPermission], current: authDto.apiKey.permissions }) + ) { throw new ForbiddenException(`Missing required permission: ${requestedPermission}`); }