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

@ -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()}`);

View file

@ -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<APIKeyResponseDto> {
return this.service.getMine(auth);
}
@Get(':id')
@Authenticated({ permission: Permission.ApiKeyRead })
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {

View file

@ -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,

View file

@ -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) {

View file

@ -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();

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 { 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<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> {
const key = await this.apiKeyRepository.getById(auth.user.id, id);
if (!key) {

View file

@ -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] });

View file

@ -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}`);
}