diff --git a/backend/.env.example b/backend/.env.example index b1cf0ea..0299619 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -39,6 +39,8 @@ AUTH_REGISTER_THROTTLE_TTL_MS=60000 AUTH_REGISTER_THROTTLE_LIMIT=5 AUTH_FORGOT_THROTTLE_TTL_MS=60000 AUTH_FORGOT_THROTTLE_LIMIT=5 +AUTH_TOKEN_THROTTLE_TTL_MS=60000 +AUTH_TOKEN_THROTTLE_LIMIT=10 # ─── CORS / Frontend ──────────────────────────────────────────────────────────── # Origin allowed by CORS (frontend URL). Must be a valid URI. @@ -46,6 +48,12 @@ ALLOWED_ORIGIN=http://localhost:5173 # Base URL used to construct password reset links sent via email. FRONTEND_URL=http://localhost:5173 +# ─── OAuth M2M ────────────────────────────────────────────────────────────────── +# Guards the POST /oauth-clients admin endpoint. Required in production (min 32 +# characters). In development/test, leaving this blank or unset disables the +# endpoint entirely (the guard will always return 401). Set a value to use it. +INTERNAL_API_KEY=your-internal-api-key-minimum-32-chars + # ─── UEX Sync ─────────────────────────────────────────────────────────────────── UEX_SYNC_ENABLED=true UEX_CATEGORIES_SYNC_ENABLED=true diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d920ed5..47abd61 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -25,6 +25,7 @@ import { LocationsModule } from './modules/locations/locations.module'; import { UserInventoryModule } from './modules/user-inventory/user-inventory.module'; import { OrgInventoryModule } from './modules/org-inventory/org-inventory.module'; import { HealthModule } from './health/health.module'; +import { OauthClientsModule } from './modules/oauth-clients/oauth-clients.module'; const isTest = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined; @@ -148,6 +149,7 @@ if (!isTest) { UserInventoryModule, OrgInventoryModule, HealthModule, + OauthClientsModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts index 3957455..8be49e2 100644 --- a/backend/src/config/env.validation.ts +++ b/backend/src/config/env.validation.ts @@ -60,4 +60,14 @@ export const envValidationSchema = Joi.object({ // Token cleanup scheduler (optional — defaults to 3 AM daily) REFRESH_TOKEN_CLEANUP_CRON: Joi.string().default(DEFAULT_CLEANUP_CRON), + + // OAuth M2M — internal API key for the /oauth-clients admin endpoint. + // Required in production; optional in development/test. + INTERNAL_API_KEY: Joi.string() + .min(32) + .when('NODE_ENV', { + is: 'production', + then: Joi.required(), + otherwise: Joi.string().min(32).optional().allow(''), + }), }); diff --git a/backend/src/data-source.ts b/backend/src/data-source.ts index 98c5706..29fb8ff 100644 --- a/backend/src/data-source.ts +++ b/backend/src/data-source.ts @@ -24,6 +24,7 @@ import { UexOutpost } from './modules/uex/entities/uex-outpost.entity'; import { UexPoi } from './modules/uex/entities/uex-poi.entity'; import { UexSyncState } from './modules/uex-sync/uex-sync-state.entity'; import { UexSyncConfig } from './modules/uex-sync/uex-sync-config.entity'; +import { OauthClient } from './modules/oauth-clients/oauth-client.entity'; import { CreateUsersTable1716956654528 } from './migrations/1716956654528-CreateUsersTable'; import { CreateOrganizationsRolesAndJunctionTable1730841000000 } from './migrations/1730841000000-CreateOrganizationsRolesAndJunctionTable'; @@ -49,6 +50,7 @@ import { CreateOrgInventoryItemsTable1764964935270 } from './migrations/17649649 import { AddUserInventoryUniqueIndex1765035000000 } from './migrations/1765035000000-AddUserInventoryUniqueIndex'; import { AddTokenCleanupIndexes1765038000000 } from './migrations/1765038000000-AddTokenCleanupIndexes'; import { DropRefreshTokensTable1777409770542 } from './migrations/1777409770542-DropRefreshTokensTable'; +import { CreateOauthClientsTable1777647814618 } from './migrations/1777647814618-CreateOauthClientsTable'; export const AppDataSource = new DataSource({ type: 'postgres', @@ -81,6 +83,7 @@ export const AppDataSource = new DataSource({ UexPoi, UexSyncState, UexSyncConfig, + OauthClient, ], migrations: [ // Core user/org/auth setup @@ -120,6 +123,9 @@ export const AppDataSource = new DataSource({ // Refresh tokens moved to Redis — drop the DB table DropRefreshTokensTable1777409770542, + + // OAuth 2.0 client credentials + CreateOauthClientsTable1777647814618, ], synchronize: false, }); diff --git a/backend/src/main.ts b/backend/src/main.ts index 7c8d726..6a3709f 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -118,6 +118,15 @@ async function bootstrap() { }, 'access-token', ) + .addApiKey( + { + type: 'apiKey', + in: 'header', + name: 'x-internal-api-key', + description: 'Internal API key for admin/automation endpoints', + }, + 'internal-api-key', + ) .build(); const document = SwaggerModule.createDocument(app, config); diff --git a/backend/src/migrations/1777647814618-CreateOauthClientsTable.ts b/backend/src/migrations/1777647814618-CreateOauthClientsTable.ts new file mode 100644 index 0000000..17e577b --- /dev/null +++ b/backend/src/migrations/1777647814618-CreateOauthClientsTable.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateOauthClientsTable1777647814618 + implements MigrationInterface +{ + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "oauth_clients" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "clientId" VARCHAR NOT NULL, + "clientSecretHash" VARCHAR NOT NULL, + "scopes" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_oauth_clients" PRIMARY KEY ("id"), + CONSTRAINT "UQ_oauth_clients_clientId" UNIQUE ("clientId") + ) + `); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "oauth_clients"`); + } +} diff --git a/backend/src/modules/auth/auth.controller.spec.ts b/backend/src/modules/auth/auth.controller.spec.ts index 69f0bad..0e81ce9 100644 --- a/backend/src/modules/auth/auth.controller.spec.ts +++ b/backend/src/modules/auth/auth.controller.spec.ts @@ -5,6 +5,7 @@ import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AuthenticatedRequest } from './interfaces/authenticated-request.interface'; import { RefreshTokenAuthGuard } from './refresh-token-auth.guard'; +import { OauthClientsService } from '../oauth-clients/oauth-clients.service'; describe('AuthController - Password Reset', () => { let controller: AuthController; @@ -40,6 +41,10 @@ describe('AuthController - Password Reset', () => { }, }, RefreshTokenAuthGuard, + { + provide: OauthClientsService, + useValue: { validateClient: jest.fn(), register: jest.fn() }, + }, ], }).compile(); diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index a391763..dff90e3 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -5,9 +5,11 @@ import { UseGuards, Request, Body, + Headers, Res, HttpCode, HttpStatus, + UnauthorizedException, } from '@nestjs/common'; import { ApiTags, @@ -29,9 +31,11 @@ import { ForgotPasswordDto, ResetPasswordDto, } from './dto/password-reset.dto'; +import { TokenRequestDto } from './dto/token-request.dto'; import { AuthenticatedRequest } from './interfaces/authenticated-request.interface'; import { RefreshTokenRequest } from './interfaces/refresh-token-request.interface'; import { ValidatedUser } from './interfaces/validated-user.interface'; +import { OauthClientsService } from '../oauth-clients/oauth-clients.service'; // Parse throttle config once at module load time. // Number() handles numeric strings and NaN from non-numeric input; the @@ -63,6 +67,11 @@ const FORGOT_LIMIT = toThrottleInt( process.env['AUTH_FORGOT_THROTTLE_LIMIT'], 5, ); +const TOKEN_TTL = toThrottleInt( + process.env['AUTH_TOKEN_THROTTLE_TTL_MS'], + 60_000, +); +const TOKEN_LIMIT = toThrottleInt(process.env['AUTH_TOKEN_THROTTLE_LIMIT'], 10); @ApiTags('auth') @Controller('auth') @@ -70,6 +79,7 @@ export class AuthController { constructor( private authService: AuthService, private configService: ConfigService, + private oauthClientsService: OauthClientsService, ) {} private cookieOptions(maxAge: number) { @@ -82,6 +92,84 @@ export class AuthController { }; } + @ApiOperation({ + summary: 'OAuth 2.0 Client Credentials token endpoint (M2M)', + description: + 'Accepts JSON body or application/x-www-form-urlencoded. ' + + 'Client credentials may also be supplied via Authorization: Basic ' + + 'with grant_type in the body.', + }) + @ApiBody({ type: TokenRequestDto }) + @ApiResponse({ status: 200, description: 'Access token issued' }) + @ApiResponse({ status: 401, description: 'Invalid client credentials' }) + @Throttle({ default: { ttl: TOKEN_TTL, limit: TOKEN_LIMIT } }) + @HttpCode(HttpStatus.OK) + @Post('token') + async token( + @Body() dto: TokenRequestDto, + @Headers('authorization') rawAuthHeader?: string | string[], + ) { + // Normalize: Express can produce string | string[] for a header value. + const authHeader = Array.isArray(rawAuthHeader) + ? rawAuthHeader[0] + : rawAuthHeader; + + // RFC 6749 §2.3.1: client may authenticate via Authorization: Basic + // base64(client_id:client_secret) instead of body parameters. + let clientId = dto.client_id; + let clientSecret = dto.client_secret; + + if (authHeader?.match(/^basic /i)) { + const encoded = authHeader.slice(authHeader.indexOf(' ') + 1).trim(); + const decoded = Buffer.from(encoded, 'base64').toString(); + const colon = decoded.indexOf(':'); + if (colon < 1) { + throw new UnauthorizedException('Malformed Basic authorization header'); + } + clientId = decoded.substring(0, colon); + clientSecret = decoded.substring(colon + 1); + } + + if (!clientId || !clientSecret) { + throw new UnauthorizedException( + 'Client credentials required: supply client_id and client_secret in the body or via Authorization: Basic', + ); + } + + const client = await this.oauthClientsService.validateClient( + clientId, + clientSecret, + ); + + // An absent scope parameter grants the full registered set (RFC 6749 §4.4.2). + // When a scope parameter is present, every requested scope must be in the + // client's registered set — silently dropping unknown scopes would let callers + // mint tokens without realising their scope request was partially ignored. + const parsedScopes = dto.scope + ? dto.scope.split(' ').filter(Boolean) + : null; + // Treat a whitespace-only scope string (e.g. scope="+") as absent so it + // falls back to the client's full registered set rather than minting an + // empty-scope token. + const requestedScopes = + parsedScopes && parsedScopes.length > 0 ? parsedScopes : null; + + if (requestedScopes) { + const unauthorized = requestedScopes.filter( + (s) => !client.scopes.includes(s), + ); + if (unauthorized.length > 0) { + throw new UnauthorizedException( + 'Requested scope is not permitted for this client', + ); + } + } + + const grantedScopes = requestedScopes ?? client.scopes; + + return this.authService.issueClientToken(client, grantedScopes); + } + @ApiOperation({ summary: 'Login user' }) @ApiBody({ schema: { diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 8113cb2..95c1585 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -8,14 +8,18 @@ import { TokenCleanupService } from './token-cleanup.service'; import { LocalStrategy } from './local.strategy'; import { JwtStrategy } from './jwt.strategy'; import { UsersModule } from '../users/users.module'; +import { OauthClientsModule } from '../oauth-clients/oauth-clients.module'; import { PasswordReset } from './password-reset.entity'; import { RefreshTokenAuthGuard } from './refresh-token-auth.guard'; +import { ClientAuthGuard } from './guards/client-auth.guard'; +import { ScopesGuard } from './guards/scopes.guard'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { createClient } from 'redis'; @Module({ imports: [ UsersModule, + OauthClientsModule, PassportModule, TypeOrmModule.forFeature([PasswordReset]), JwtModule.registerAsync({ @@ -34,6 +38,8 @@ import { createClient } from 'redis'; LocalStrategy, JwtStrategy, RefreshTokenAuthGuard, + ClientAuthGuard, + ScopesGuard, { provide: REDIS_CLIENT, inject: [ConfigService], @@ -59,6 +65,6 @@ import { createClient } from 'redis'; }, }, ], - exports: [AuthService], + exports: [AuthService, ClientAuthGuard, ScopesGuard], }) export class AuthModule {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index a2288b8..b5ffbd5 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -22,6 +22,8 @@ import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ValidatedUser } from './interfaces/validated-user.interface'; import { JwtPayload } from './interfaces/jwt-payload.interface'; +import { ClientJwtPayload } from './interfaces/client-jwt-payload.interface'; +import { OauthClient } from '../oauth-clients/oauth-client.entity'; export const REDIS_CLIENT = Symbol('REDIS_CLIENT'); @@ -427,6 +429,41 @@ export class AuthService { return { message: 'Password has been reset successfully' }; } + async issueClientToken( + client: OauthClient, + grantedScopes: string[] = client.scopes, + ): Promise<{ + access_token: string; + token_type: 'Bearer'; + expires_in: number; + }> { + const CLIENT_TTL_SECONDS = 3600; + const jti = crypto.randomUUID(); + const payload: ClientJwtPayload = { + sub: client.clientId, + type: 'client', + scopes: grantedScopes, + jti, + }; + const access_token = this.jwtService.sign(payload, { + expiresIn: CLIENT_TTL_SECONDS, + }); + // Track live client tokens so a future admin revoke endpoint can enumerate + // and invalidate them per client. Key: client-token:{clientId}:{jti}. + // Revocation itself uses the existing blacklist:{jti} mechanism checked by + // isAccessTokenBlacklisted. + await this.authSet( + `client-token:${client.clientId}:${jti}`, + '1', + CLIENT_TTL_SECONDS * 1000, + ); + return { + access_token, + token_type: 'Bearer', + expires_in: CLIENT_TTL_SECONDS, + }; + } + async changePassword( userId: number, currentPassword: string, diff --git a/backend/src/modules/auth/decorators/require-scopes.decorator.ts b/backend/src/modules/auth/decorators/require-scopes.decorator.ts new file mode 100644 index 0000000..0ca0a54 --- /dev/null +++ b/backend/src/modules/auth/decorators/require-scopes.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const REQUIRE_SCOPES_KEY = 'requiredScopes'; + +export const RequireScopes = (...scopes: string[]) => + SetMetadata(REQUIRE_SCOPES_KEY, scopes); diff --git a/backend/src/modules/auth/dto/token-request.dto.ts b/backend/src/modules/auth/dto/token-request.dto.ts new file mode 100644 index 0000000..a066cfd --- /dev/null +++ b/backend/src/modules/auth/dto/token-request.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, Equals } from 'class-validator'; + +export class TokenRequestDto { + @ApiProperty({ example: 'client_credentials' }) + @IsString() + @Equals('client_credentials', { + message: 'grant_type must be "client_credentials"', + }) + grant_type!: string; + + @ApiProperty({ example: 'station-bot', required: false }) + @IsOptional() + @IsString() + @IsNotEmpty() + client_id?: string; + + @ApiProperty({ example: 'super-secret-value', required: false }) + @IsOptional() + @IsString() + @IsNotEmpty() + client_secret?: string; + + @ApiProperty({ example: 'bot:api', required: false }) + @IsOptional() + @IsString() + scope?: string; +} diff --git a/backend/src/modules/auth/guards/client-auth.guard.ts b/backend/src/modules/auth/guards/client-auth.guard.ts new file mode 100644 index 0000000..1502e84 --- /dev/null +++ b/backend/src/modules/auth/guards/client-auth.guard.ts @@ -0,0 +1,73 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import { ClientJwtPayload } from '../interfaces/client-jwt-payload.interface'; +import { AuthService } from '../auth.service'; + +/** + * Accepts requests carrying a valid client JWT (type === 'client'). + * Attaches the decoded payload to request.clientToken for downstream guards. + */ +@Injectable() +export class ClientAuthGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + private readonly authService: AuthService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const token = this.extractToken(req); + + if (!token) { + throw new UnauthorizedException('No bearer token provided'); + } + + let payload: ClientJwtPayload; + try { + payload = this.jwtService.verify(token, { + secret: this.configService.get('JWT_SECRET'), + }); + } catch { + throw new UnauthorizedException('Invalid or expired token'); + } + + if (payload.type !== 'client') { + throw new UnauthorizedException('Token is not a client token'); + } + + if ( + typeof payload.sub !== 'string' || + !payload.sub || + typeof payload.jti !== 'string' || + !payload.jti || + !Array.isArray(payload.scopes) + ) { + throw new UnauthorizedException('Token is missing required claims'); + } + + if (await this.authService.isAccessTokenBlacklisted(payload.jti)) { + throw new UnauthorizedException('Token has been revoked'); + } + + (req as Request & { clientToken: ClientJwtPayload }).clientToken = payload; + return true; + } + + private extractToken(req: Request): string | null { + const auth = Array.isArray(req.headers.authorization) + ? req.headers.authorization[0] + : req.headers.authorization; + if (auth?.match(/^bearer /i)) { + return auth.slice(auth.indexOf(' ') + 1).trim(); + } + return null; + } +} diff --git a/backend/src/modules/auth/guards/scopes.guard.ts b/backend/src/modules/auth/guards/scopes.guard.ts new file mode 100644 index 0000000..819e6bc --- /dev/null +++ b/backend/src/modules/auth/guards/scopes.guard.ts @@ -0,0 +1,44 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { REQUIRE_SCOPES_KEY } from '../decorators/require-scopes.decorator'; +import { ClientJwtPayload } from '../interfaces/client-jwt-payload.interface'; + +/** + * Checks that the client token attached by ClientAuthGuard holds all scopes + * listed in the @RequireScopes decorator. Must be used after ClientAuthGuard. + */ +@Injectable() +export class ScopesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const required = this.reflector.getAllAndOverride( + REQUIRE_SCOPES_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!required || required.length === 0) { + return true; + } + + const req = context + .switchToHttp() + .getRequest(); + const tokenScopes = req.clientToken?.scopes ?? []; + + const missing = required.filter((s) => !tokenScopes.includes(s)); + if (missing.length > 0) { + throw new ForbiddenException( + `Missing required scopes: ${missing.join(', ')}`, + ); + } + + return true; + } +} diff --git a/backend/src/modules/auth/interfaces/client-jwt-payload.interface.ts b/backend/src/modules/auth/interfaces/client-jwt-payload.interface.ts new file mode 100644 index 0000000..64e663a --- /dev/null +++ b/backend/src/modules/auth/interfaces/client-jwt-payload.interface.ts @@ -0,0 +1,12 @@ +export interface ClientJwtPayload { + /** OAuth client ID (subject) */ + sub: string; + /** Discriminates client tokens from user tokens */ + type: 'client'; + /** Granted scopes */ + scopes: string[]; + /** JWT ID — stored in Redis for revocation */ + jti: string; + iat?: number; + exp?: number; +} diff --git a/backend/src/modules/oauth-clients/dto/register-oauth-client.dto.ts b/backend/src/modules/oauth-clients/dto/register-oauth-client.dto.ts new file mode 100644 index 0000000..4c55a40 --- /dev/null +++ b/backend/src/modules/oauth-clients/dto/register-oauth-client.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + MinLength, + MaxLength, + IsArray, + ArrayNotEmpty, + Matches, +} from 'class-validator'; + +export class RegisterOauthClientDto { + @ApiProperty({ example: 'station-bot' }) + @IsString() + @IsNotEmpty() + @Matches(/^[a-z0-9-]+$/, { + message: 'clientId may only contain lowercase letters, digits, and hyphens', + }) + clientId!: string; + + @ApiProperty({ example: 'super-secret-value', minLength: 32, maxLength: 128 }) + @IsString() + @MinLength(32) + @MaxLength(128) + clientSecret!: string; + + @ApiProperty({ example: ['bot:api'] }) + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + @Matches(/^[^\s,]+$/, { + each: true, + message: 'Each scope must not contain whitespace or commas', + }) + scopes!: string[]; +} diff --git a/backend/src/modules/oauth-clients/internal-api-key.guard.ts b/backend/src/modules/oauth-clients/internal-api-key.guard.ts new file mode 100644 index 0000000..7908127 --- /dev/null +++ b/backend/src/modules/oauth-clients/internal-api-key.guard.ts @@ -0,0 +1,52 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import { timingSafeEqual } from 'crypto'; + +/** + * Guards the admin /oauth-clients endpoint with a static API key supplied via + * the INTERNAL_API_KEY environment variable. This endpoint is not public — it + * is called only from deployment automation or an admin shell. + */ +@Injectable() +export class InternalApiKeyGuard implements CanActivate { + constructor(private configService: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const apiKey = this.configService.get('INTERNAL_API_KEY'); + if (!apiKey) { + throw new UnauthorizedException( + 'INTERNAL_API_KEY is not configured on this server', + ); + } + + const req = context.switchToHttp().getRequest(); + + // Normalize to a single string first — Express can return string | string[]. + const normalize = (h: string | string[] | undefined): string => + Array.isArray(h) ? (h[0] ?? '') : (h ?? ''); + + const provided = normalize(req.headers['x-internal-api-key']); + + const providedBuf = Buffer.from(provided); + const apiKeyBuf = Buffer.from(apiKey); + let valid = false; + try { + valid = + providedBuf.length === apiKeyBuf.length && + timingSafeEqual(providedBuf, apiKeyBuf); + } catch { + // Length mismatch or other error — treat as invalid. + } + if (!valid) { + throw new UnauthorizedException('Invalid internal API key'); + } + + return true; + } +} diff --git a/backend/src/modules/oauth-clients/oauth-client.entity.ts b/backend/src/modules/oauth-clients/oauth-client.entity.ts new file mode 100644 index 0000000..b8025ad --- /dev/null +++ b/backend/src/modules/oauth-clients/oauth-client.entity.ts @@ -0,0 +1,27 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; + +@Entity('oauth_clients') +export class OauthClient { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ unique: true }) + clientId!: string; + + @Column() + clientSecretHash!: string; + + @Column('simple-array') + scopes!: string[]; + + @Column({ default: true }) + isActive!: boolean; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/backend/src/modules/oauth-clients/oauth-clients.controller.ts b/backend/src/modules/oauth-clients/oauth-clients.controller.ts new file mode 100644 index 0000000..9757afd --- /dev/null +++ b/backend/src/modules/oauth-clients/oauth-clients.controller.ts @@ -0,0 +1,45 @@ +import { + Controller, + Post, + Body, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiSecurity, +} from '@nestjs/swagger'; +import { OauthClientsService } from './oauth-clients.service'; +import { RegisterOauthClientDto } from './dto/register-oauth-client.dto'; +import { InternalApiKeyGuard } from './internal-api-key.guard'; + +@ApiTags('oauth-clients') +@ApiSecurity('internal-api-key') +@UseGuards(InternalApiKeyGuard) +@Controller('oauth-clients') +export class OauthClientsController { + constructor(private readonly oauthClientsService: OauthClientsService) {} + + @ApiOperation({ + summary: 'Register a new OAuth client (admin/internal only)', + }) + @ApiResponse({ status: 201, description: 'Client registered' }) + @ApiResponse({ + status: 401, + description: 'Missing or invalid internal API key', + }) + @ApiResponse({ status: 409, description: 'Client ID already exists' }) + @HttpCode(HttpStatus.CREATED) + @Post() + async register(@Body() dto: RegisterOauthClientDto) { + const client = await this.oauthClientsService.register( + dto.clientId, + dto.clientSecret, + dto.scopes, + ); + return { id: client.id, clientId: client.clientId, scopes: client.scopes }; + } +} diff --git a/backend/src/modules/oauth-clients/oauth-clients.module.ts b/backend/src/modules/oauth-clients/oauth-clients.module.ts new file mode 100644 index 0000000..6e234f0 --- /dev/null +++ b/backend/src/modules/oauth-clients/oauth-clients.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { OauthClient } from './oauth-client.entity'; +import { OauthClientsService } from './oauth-clients.service'; +import { OauthClientsController } from './oauth-clients.controller'; +import { InternalApiKeyGuard } from './internal-api-key.guard'; + +@Module({ + imports: [TypeOrmModule.forFeature([OauthClient])], + controllers: [OauthClientsController], + providers: [OauthClientsService, InternalApiKeyGuard], + exports: [OauthClientsService, InternalApiKeyGuard], +}) +export class OauthClientsModule {} diff --git a/backend/src/modules/oauth-clients/oauth-clients.service.spec.ts b/backend/src/modules/oauth-clients/oauth-clients.service.spec.ts new file mode 100644 index 0000000..52bf258 --- /dev/null +++ b/backend/src/modules/oauth-clients/oauth-clients.service.spec.ts @@ -0,0 +1,126 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConflictException, UnauthorizedException } from '@nestjs/common'; +import { OauthClientsService } from './oauth-clients.service'; +import { OauthClient } from './oauth-client.entity'; +import * as bcrypt from 'bcrypt'; + +const makeClient = (overrides: Partial = {}): OauthClient => + Object.assign(new OauthClient(), { + id: 'uuid-1', + clientId: 'station-bot', + clientSecretHash: '', + scopes: ['bot:api'], + isActive: true, + createdAt: new Date(), + ...overrides, + }); + +describe('OauthClientsService', () => { + let service: OauthClientsService; + const repo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OauthClientsService, + { provide: getRepositoryToken(OauthClient), useValue: repo }, + ], + }).compile(); + + service = module.get(OauthClientsService); + }); + + describe('register', () => { + it('creates a new client with a bcrypt-hashed secret', async () => { + repo.findOne.mockResolvedValue(null); + const client = makeClient(); + repo.create.mockReturnValue(client); + repo.save.mockResolvedValue(client); + + await service.register('station-bot', 'plaintext-secret-value', [ + 'bot:api', + ]); + + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ + clientId: 'station-bot', + scopes: ['bot:api'], + }), + ); + const savedArg = repo.create.mock.calls[0][0] as OauthClient; + expect(savedArg.clientSecretHash).not.toBe('plaintext-secret-value'); + const matches = await bcrypt.compare( + 'plaintext-secret-value', + savedArg.clientSecretHash, + ); + expect(matches).toBe(true); + }); + + it('throws ConflictException when clientId already exists', async () => { + repo.findOne.mockResolvedValue(makeClient()); + await expect( + service.register('station-bot', 'secret', ['bot:api']), + ).rejects.toThrow(ConflictException); + }); + }); + + describe('validateSecret', () => { + it('returns true for the correct secret', async () => { + const hash = await bcrypt.hash('correct-secret', 12); + const client = makeClient({ clientSecretHash: hash }); + await expect( + service.validateSecret(client, 'correct-secret'), + ).resolves.toBe(true); + }); + + it('returns false for an incorrect secret', async () => { + const hash = await bcrypt.hash('correct-secret', 12); + const client = makeClient({ clientSecretHash: hash }); + await expect( + service.validateSecret(client, 'wrong-secret'), + ).resolves.toBe(false); + }); + }); + + describe('validateClient', () => { + it('returns the client on valid credentials', async () => { + const hash = await bcrypt.hash('my-secret', 12); + const client = makeClient({ clientSecretHash: hash }); + repo.findOne.mockResolvedValue(client); + await expect( + service.validateClient('station-bot', 'my-secret'), + ).resolves.toBe(client); + }); + + it('throws UnauthorizedException when client not found', async () => { + repo.findOne.mockResolvedValue(null); + await expect(service.validateClient('unknown', 'secret')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('throws UnauthorizedException when client is inactive', async () => { + const hash = await bcrypt.hash('secret', 12); + repo.findOne.mockResolvedValue( + makeClient({ isActive: false, clientSecretHash: hash }), + ); + await expect( + service.validateClient('station-bot', 'secret'), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException for wrong secret', async () => { + const hash = await bcrypt.hash('correct', 12); + repo.findOne.mockResolvedValue(makeClient({ clientSecretHash: hash })); + await expect( + service.validateClient('station-bot', 'wrong'), + ).rejects.toThrow(UnauthorizedException); + }); + }); +}); diff --git a/backend/src/modules/oauth-clients/oauth-clients.service.ts b/backend/src/modules/oauth-clients/oauth-clients.service.ts new file mode 100644 index 0000000..9fc4252 --- /dev/null +++ b/backend/src/modules/oauth-clients/oauth-clients.service.ts @@ -0,0 +1,84 @@ +import { + Injectable, + ConflictException, + InternalServerErrorException, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as bcrypt from 'bcrypt'; +import { OauthClient } from './oauth-client.entity'; + +// Precomputed hash of the string "dummy" at cost 12. Used only so that +// unknown-client requests pay the same bcrypt cost as real ones, preventing +// timing-based client-ID enumeration. +const DUMMY_HASH = + '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LeKm6H5.RvBo8JXWi'; + +@Injectable() +export class OauthClientsService { + constructor( + @InjectRepository(OauthClient) + private readonly repo: Repository, + ) {} + + async register( + clientId: string, + plainSecret: string, + scopes: string[], + ): Promise { + const existing = await this.repo.findOne({ where: { clientId } }); + if (existing) { + throw new ConflictException(`Client '${clientId}' already exists`); + } + const clientSecretHash = await bcrypt.hash(plainSecret, 12); + const client = this.repo.create({ clientId, clientSecretHash, scopes }); + try { + return await this.repo.save(client); + } catch (err: unknown) { + const isUniqueViolation = + err && + typeof err === 'object' && + (('code' in err && err.code === '23505') || + ('driverError' in err && + err.driverError && + typeof err.driverError === 'object' && + 'code' in err.driverError && + err.driverError.code === '23505')); + if (isUniqueViolation) { + throw new ConflictException(`Client '${clientId}' already exists`); + } + throw new InternalServerErrorException('Failed to register client'); + } + } + + async findByClientId(clientId: string): Promise { + return this.repo.findOne({ where: { clientId } }); + } + + async validateSecret(client: OauthClient, secret: string): Promise { + return bcrypt.compare(secret, client.clientSecretHash); + } + + /** Validate credentials end-to-end; throws 401 on any failure. */ + async validateClient(clientId: string, secret: string): Promise { + const client = await this.findByClientId(clientId); + + // Always run a bcrypt compare — against the real hash when the client exists, + // against a dummy hash otherwise — so request timing cannot reveal whether a + // client_id is registered or inactive. + const hashToCompare = client ? client.clientSecretHash : DUMMY_HASH; + let secretValid = false; + try { + secretValid = await bcrypt.compare(secret, hashToCompare); + } catch { + // Treat a malformed hash the same as a wrong secret. + } + + if (!client || !client.isActive || !secretValid) { + throw new UnauthorizedException('Invalid client credentials'); + } + + return client; + } +} diff --git a/backend/test/oauth-client-credentials.e2e-spec.ts b/backend/test/oauth-client-credentials.e2e-spec.ts new file mode 100644 index 0000000..e33430b --- /dev/null +++ b/backend/test/oauth-client-credentials.e2e-spec.ts @@ -0,0 +1,297 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import { AppModule } from '../src/app.module'; +import { DataSource } from 'typeorm'; +import { OauthClient } from '../src/modules/oauth-clients/oauth-client.entity'; +import { OauthClientsService } from '../src/modules/oauth-clients/oauth-clients.service'; +import { seedSystemUser } from './helpers/seed-system-user'; + +const INTERNAL_API_KEY = 'e2e-internal-api-key-value-min-32chars!'; + +describe('OAuth Client Credentials (e2e)', () => { + let app: INestApplication; + let dataSource: DataSource; + let oauthClientsService: OauthClientsService; + const CLIENT_ID = 'e2e-test-bot'; + const CLIENT_SECRET = 'e2e-test-secret-value-min-32-chars!!'; + let previousInternalApiKey: string | undefined; + + beforeAll(async () => { + // Set before the module compiles so ConfigService sees it during app init. + // Capture the prior value so afterAll can restore it and avoid leaking + // into other e2e suites that share the same Jest worker process. + previousInternalApiKey = process.env['INTERNAL_API_KEY']; + process.env['INTERNAL_API_KEY'] = INTERNAL_API_KEY; + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + + dataSource = moduleFixture.get(DataSource); + oauthClientsService = + moduleFixture.get(OauthClientsService); + + await seedSystemUser(dataSource); + await oauthClientsService.register(CLIENT_ID, CLIENT_SECRET, ['bot:api']); + }); + + afterAll(async () => { + const repo = dataSource.getRepository(OauthClient); + await repo.delete({ clientId: CLIENT_ID }); + await app?.close(); + // Restore the previous value so this suite doesn't affect others. + if (previousInternalApiKey === undefined) { + delete process.env['INTERNAL_API_KEY']; + } else { + process.env['INTERNAL_API_KEY'] = previousInternalApiKey; + } + }); + + // --------------------------------------------------------------------------- + // 1. Happy path — valid credentials → token response + // --------------------------------------------------------------------------- + it('should return an access token for valid client credentials', async () => { + const res = await request(app.getHttpServer()) + .post('/auth/token') + .send({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + scope: 'bot:api', + }) + .expect(200); + + expect(res.body).toMatchObject({ + token_type: 'Bearer', + expires_in: expect.any(Number), + }); + expect(typeof res.body.access_token).toBe('string'); + expect(res.body.access_token.length).toBeGreaterThan(0); + }); + + // --------------------------------------------------------------------------- + // 2. Wrong secret → 401 + // --------------------------------------------------------------------------- + it('should reject invalid client_secret with 401', async () => { + await request(app.getHttpServer()) + .post('/auth/token') + .send({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + client_secret: 'definitely-wrong-secret-value!!!!!', + scope: 'bot:api', + }) + .expect(401); + }); + + // --------------------------------------------------------------------------- + // 3. Unknown client → 401 + // --------------------------------------------------------------------------- + it('should reject an unknown client_id with 401', async () => { + await request(app.getHttpServer()) + .post('/auth/token') + .send({ + grant_type: 'client_credentials', + client_id: 'no-such-client', + client_secret: 'irrelevant-secret-value-long-enough!!', + scope: 'bot:api', + }) + .expect(401); + }); + + // --------------------------------------------------------------------------- + // 4. Inactive client → 401 + // --------------------------------------------------------------------------- + it('should reject an inactive client with 401', async () => { + const repo = dataSource.getRepository(OauthClient); + await repo.update({ clientId: CLIENT_ID }, { isActive: false }); + + await request(app.getHttpServer()) + .post('/auth/token') + .send({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }) + .expect(401); + + await repo.update({ clientId: CLIENT_ID }, { isActive: true }); + }); + + // --------------------------------------------------------------------------- + // 5. Wrong grant_type → 400 + // --------------------------------------------------------------------------- + it('should reject unsupported grant_type with 400', async () => { + await request(app.getHttpServer()) + .post('/auth/token') + .send({ + grant_type: 'password', + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }) + .expect(400); + }); + + // --------------------------------------------------------------------------- + // 6. Issued token has the expected JWT payload and expiry metadata + // --------------------------------------------------------------------------- + it('should issue a token whose JWT is valid and carries the correct payload', async () => { + const tokenRes = await request(app.getHttpServer()) + .post('/auth/token') + .send({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }) + .expect(200); + + const token: string = tokenRes.body.access_token; + + // Decode payload (without verifying — just structural check) + const [, payloadB64] = token.split('.'); + const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); + + expect(payload.sub).toBe(CLIENT_ID); + expect(payload.type).toBe('client'); + expect(Array.isArray(payload.scopes)).toBe(true); + expect(payload.scopes).toContain('bot:api'); + expect(typeof payload.jti).toBe('string'); + // expires_in from the response is authoritative; exp-iat can be 3599 or + // 3600 depending on sub-second timing, so we check the response field instead. + expect(tokenRes.body.expires_in).toBe(3600); + }); + + // --------------------------------------------------------------------------- + // 7. Admin /oauth-clients endpoint requires INTERNAL_API_KEY + // --------------------------------------------------------------------------- + it('should reject POST /oauth-clients without the internal API key', async () => { + await request(app.getHttpServer()) + .post('/oauth-clients') + .send({ + clientId: 'sneaky-bot', + clientSecret: 'sneaky-secret-value-long-enough-here', + scopes: ['bot:api'], + }) + .expect(401); + }); + + // --------------------------------------------------------------------------- + // 8. Happy-path POST /oauth-clients with a valid INTERNAL_API_KEY + // --------------------------------------------------------------------------- + it('should register a client when the internal API key is valid', async () => { + const repo = dataSource.getRepository(OauthClient); + try { + const res = await request(app.getHttpServer()) + .post('/oauth-clients') + .set('x-internal-api-key', INTERNAL_API_KEY) + .send({ + clientId: 'e2e-admin-created-bot', + clientSecret: 'e2e-admin-secret-value-long-enough-here', + scopes: ['bot:api'], + }) + .expect(201); + + expect(res.body).toMatchObject({ + clientId: 'e2e-admin-created-bot', + scopes: expect.arrayContaining(['bot:api']), + }); + expect(typeof res.body.id).toBe('string'); + } finally { + await repo.delete({ clientId: 'e2e-admin-created-bot' }); + } + }); + + // --------------------------------------------------------------------------- + // 9. Authorization: Basic header is accepted as an alternative to body params + // --------------------------------------------------------------------------- + it('should accept client credentials via Authorization: Basic header', async () => { + const credentials = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString( + 'base64', + ); + + const res = await request(app.getHttpServer()) + .post('/auth/token') + .set('Authorization', `Basic ${credentials}`) + .send({ grant_type: 'client_credentials' }) + .expect(200); + + expect(res.body.token_type).toBe('Bearer'); + expect(typeof res.body.access_token).toBe('string'); + }); + + // --------------------------------------------------------------------------- + // 10. application/x-www-form-urlencoded body is accepted (OAuth spec format) + // --------------------------------------------------------------------------- + it('should accept credentials submitted as application/x-www-form-urlencoded', async () => { + const res = await request(app.getHttpServer()) + .post('/auth/token') + .type('form') + .send( + `grant_type=client_credentials&client_id=${encodeURIComponent(CLIENT_ID)}&client_secret=${encodeURIComponent(CLIENT_SECRET)}&scope=bot%3Aapi`, + ) + .expect(200); + + expect(res.body.token_type).toBe('Bearer'); + expect(typeof res.body.access_token).toBe('string'); + }); + + // --------------------------------------------------------------------------- + // 11. Requested scope is a valid subset of the registered scopes + // --------------------------------------------------------------------------- + it('should mint a token containing only the requested subset of scopes', async () => { + const repo = dataSource.getRepository(OauthClient); + await oauthClientsService.register( + 'e2e-multiscope-bot', + 'e2e-multiscope-secret-value-min-32!!', + ['bot:api', 'bot:read'], + ); + + try { + const res = await request(app.getHttpServer()) + .post('/auth/token') + .send({ + grant_type: 'client_credentials', + client_id: 'e2e-multiscope-bot', + client_secret: 'e2e-multiscope-secret-value-min-32!!', + scope: 'bot:read', + }) + .expect(200); + + const [, payloadB64] = (res.body.access_token as string).split('.'); + const payload = JSON.parse( + Buffer.from(payloadB64, 'base64url').toString(), + ); + expect(payload.scopes).toEqual(['bot:read']); + expect(payload.scopes).not.toContain('bot:api'); + } finally { + await repo.delete({ clientId: 'e2e-multiscope-bot' }); + } + }); + + // --------------------------------------------------------------------------- + // 12. Requesting a scope not in the registered set → 401 + // --------------------------------------------------------------------------- + it('should reject a scope not registered for the client', async () => { + await request(app.getHttpServer()) + .post('/auth/token') + .send({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + scope: 'admin:all', + }) + .expect(401); + }); +});