feat: get metadata about the current api key (#21027)

This commit is contained in:
Jason Rasmussen 2025-08-18 19:15:03 -04:00 committed by GitHub
parent a313e4338e
commit e00556a34a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 174 additions and 12 deletions

View File

@ -77,6 +77,7 @@ Class | Method | HTTP request | Description
*APIKeysApi* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} | *APIKeysApi* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} |
*APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} | *APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} |
*APIKeysApi* | [**getApiKeys**](doc//APIKeysApi.md#getapikeys) | **GET** /api-keys | *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} | *APIKeysApi* | [**updateApiKey**](doc//APIKeysApi.md#updateapikey) | **PUT** /api-keys/{id} |
*ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities | *ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities |
*ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} | *ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} |

View File

@ -213,6 +213,47 @@ class APIKeysApi {
return null; return null;
} }
/// Performs an HTTP 'GET /api-keys/me' operation and returns the [Response].
Future<Response> getMyApiKeyWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/api-keys/me';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<APIKeyResponseDto?> 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. /// This endpoint requires the `apiKey.update` permission.
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].

View File

@ -1488,6 +1488,38 @@
"description": "This endpoint requires the `apiKey.create` permission." "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}": { "/api-keys/{id}": {
"delete": { "delete": {
"operationId": "deleteApiKey", "operationId": "deleteApiKey",

View File

@ -2027,6 +2027,14 @@ export function createApiKey({ apiKeyCreateDto }: {
body: 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. * This endpoint requires the `apiKey.delete` permission.
*/ */

View File

@ -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 { Permission } from 'src/enum';
import { ApiKeyService } from 'src/services/api-key.service'; import { ApiKeyService } from 'src/services/api-key.service';
import request from 'supertest'; import request from 'supertest';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(APIKeyController.name, () => { describe(ApiKeyController.name, () => {
let ctx: ControllerContext; let ctx: ControllerContext;
const service = mockBaseService(ApiKeyService); const service = mockBaseService(ApiKeyService);
beforeAll(async () => { beforeAll(async () => {
ctx = await controllerSetup(APIKeyController, [{ provide: ApiKeyService, useValue: service }]); ctx = await controllerSetup(ApiKeyController, [{ provide: ApiKeyService, useValue: service }]);
return () => ctx.close(); 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', () => { describe('GET /api-keys/:id', () => {
it('should be an authenticated route', async () => { it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`); await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`);

View File

@ -9,7 +9,7 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('API Keys') @ApiTags('API Keys')
@Controller('api-keys') @Controller('api-keys')
export class APIKeyController { export class ApiKeyController {
constructor(private service: ApiKeyService) {} constructor(private service: ApiKeyService) {}
@Post() @Post()
@ -24,6 +24,12 @@ export class APIKeyController {
return this.service.getAll(auth); return this.service.getAll(auth);
} }
@Get('me')
@Authenticated({ permission: false })
async getMyApiKey(@Auth() auth: AuthDto): Promise<APIKeyResponseDto> {
return this.service.getMine(auth);
}
@Get(':id') @Get(':id')
@Authenticated({ permission: Permission.ApiKeyRead }) @Authenticated({ permission: Permission.ApiKeyRead })
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> { getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {

View File

@ -1,6 +1,6 @@
import { ActivityController } from 'src/controllers/activity.controller'; import { ActivityController } from 'src/controllers/activity.controller';
import { AlbumController } from 'src/controllers/album.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 { AppController } from 'src/controllers/app.controller';
import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetController } from 'src/controllers/asset.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'; import { ViewController } from 'src/controllers/view.controller';
export const controllers = [ export const controllers = [
APIKeyController, ApiKeyController,
ActivityController, ActivityController,
AlbumController, AlbumController,
AppController, AppController,

View File

@ -17,7 +17,7 @@ import { UAParser } from 'ua-parser-js';
type AdminRoute = { admin?: true }; type AdminRoute = { admin?: true };
type SharedLinkRoute = { sharedLink?: true }; type SharedLinkRoute = { sharedLink?: true };
type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute); type AuthenticatedOptions = { permission?: Permission | false } & (AdminRoute | SharedLinkRoute);
export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => { export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => {
const decorators: MethodDecorator[] = [ const decorators: MethodDecorator[] = [
@ -32,7 +32,7 @@ export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorat
} }
if (options?.permission) { if (options?.permission) {
decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission ?? Permission.All)); decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission));
} }
if ((options as SharedLinkRoute)?.sharedLink) { if ((options as SharedLinkRoute)?.sharedLink) {

View File

@ -1,4 +1,4 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { ApiKeyService } from 'src/services/api-key.service'; import { ApiKeyService } from 'src/services/api-key.service';
import { factory, newUuid } from 'test/small.factory'; 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', () => { describe('getById', () => {
it('should throw an error if the key is not found', async () => { it('should throw an error if the key is not found', async () => {
const auth = factory.auth(); const auth = factory.auth();

View File

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { ApiKey } from 'src/database'; import { ApiKey } from 'src/database';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.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); await this.apiKeyRepository.delete(auth.user.id, id);
} }
async getMine(auth: AuthDto): Promise<APIKeyResponseDto> {
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<APIKeyResponseDto> { async getById(auth: AuthDto, id: string): Promise<APIKeyResponseDto> {
const key = await this.apiKeyRepository.getById(auth.user.id, id); const key = await this.apiKeyRepository.getById(auth.user.id, id);
if (!key) { if (!key) {

View File

@ -518,6 +518,20 @@ describe(AuthService.name, () => {
await expect(result).rejects.toThrow('Missing required permission: all'); 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 () => { it('should return an auth dto', async () => {
const authUser = factory.authUser(); const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [Permission.All] }); const authApiKey = factory.authApiKey({ permissions: [Permission.All] });

View File

@ -48,7 +48,8 @@ export type ValidateRequest = {
metadata: { metadata: {
sharedLinkRoute: boolean; sharedLinkRoute: boolean;
adminRoute: boolean; adminRoute: boolean;
permission?: Permission; /** `false` explicitly means no permission is required, which otherwise defaults to `all` */
permission?: Permission | false;
uri: string; uri: string;
}; };
}; };
@ -187,7 +188,11 @@ export class AuthService extends BaseService {
throw new ForbiddenException('Forbidden'); 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}`); throw new ForbiddenException(`Missing required permission: ${requestedPermission}`);
} }