feat(auth): API-key authentication — corr_ bearer key acts as license owner
Closes the 'Public REST API' last mile: external callers authenticate with a per-license API key instead of a JWT. Additive and zero-regression: - JwtAuthGuard: a corr_-prefixed bearer token (or X-API-Key header) is validated via ApiKeysService.validateKey and sets request.user shaped like a JWT user, scoped to the key's license. JWTs are eyJ... and never collide with the corr_ prefix, so the existing JWT path is byte-for-byte unchanged. - API-key calls act AS the license owner: validateKey now resolves license.owner_user_id so sub is a real UUID — any @CurrentUser/created_by FK insert works and attributes correctly. (ApiKeysModule gains the License repo.) - PermissionsGuard: is_api_key principals get full access to their own license (always tenant-scoped). Future: scoped/read-only keys. Backend tsc green. Untested at runtime (no local DB) — needs a curl smoke test on Saturday's fresh stack before the roadmap item flips to shipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,68 @@
|
|||||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
ExecutionContext,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
import { ApiKeysService } from '../../modules/api-keys/api-keys.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
constructor(private reflector: Reflector) {
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private readonly apiKeysService: ApiKeysService,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
if (isPublic) return true;
|
if (isPublic) return true;
|
||||||
return super.canActivate(context);
|
|
||||||
|
// Additive API-key auth: a `corr_`-prefixed bearer token (or X-API-Key
|
||||||
|
// header) authenticates programmatically AS the license owner. JWTs are
|
||||||
|
// `eyJ...` and never collide with the `corr_` prefix, so the standard JWT
|
||||||
|
// path below is left completely untouched — zero login regression risk.
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const rawKey = this.extractApiKey(request);
|
||||||
|
if (rawKey) {
|
||||||
|
const result = await this.apiKeysService.validateKey(rawKey);
|
||||||
|
if (!result) {
|
||||||
|
throw new UnauthorizedException('Invalid or revoked API key');
|
||||||
|
}
|
||||||
|
// Shape the principal like a JWT user so @CurrentTenant / @CurrentUser and
|
||||||
|
// the permission layer behave identically. is_api_key grants full access
|
||||||
|
// to THIS license (see PermissionsGuard) — a key is full programmatic
|
||||||
|
// access to your own license, always tenant-scoped by license_id.
|
||||||
|
request.user = {
|
||||||
|
sub: result.user_id ?? undefined,
|
||||||
|
license_id: result.license_id,
|
||||||
|
is_super_admin: false,
|
||||||
|
is_api_key: true,
|
||||||
|
permissions: {},
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await super.canActivate(context)) as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull a `corr_`-prefixed key from `Authorization: Bearer` or `X-API-Key`. */
|
||||||
|
private extractApiKey(request: any): string | null {
|
||||||
|
const auth = request.headers?.authorization;
|
||||||
|
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
||||||
|
const token = auth.slice(7).trim();
|
||||||
|
if (token.startsWith('corr_')) return token;
|
||||||
|
}
|
||||||
|
const headerKey = request.headers?.['x-api-key'];
|
||||||
|
if (typeof headerKey === 'string' && headerKey.startsWith('corr_')) {
|
||||||
|
return headerKey.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
// Super admins bypass all permission checks
|
// Super admins bypass all permission checks
|
||||||
if (user.is_super_admin) return true;
|
if (user.is_super_admin) return true;
|
||||||
|
|
||||||
|
// API keys are full programmatic access to their own license (always
|
||||||
|
// tenant-scoped by license_id via @CurrentTenant). Granted here rather than
|
||||||
|
// enumerating every permission. Future: scoped/read-only keys.
|
||||||
|
if (user.is_api_key) return true;
|
||||||
|
|
||||||
// Check permissions JSONB from role
|
// Check permissions JSONB from role
|
||||||
const permissions = user.permissions as Record<string, boolean> | undefined;
|
const permissions = user.permissions as Record<string, boolean> | undefined;
|
||||||
if (!permissions) return false;
|
if (!permissions) return false;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ApiKey } from '../../entities/api-key.entity';
|
import { ApiKey } from '../../entities/api-key.entity';
|
||||||
|
import { License } from '../../entities/license.entity';
|
||||||
import { ApiKeysController } from './api-keys.controller';
|
import { ApiKeysController } from './api-keys.controller';
|
||||||
import { ApiKeysService } from './api-keys.service';
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([ApiKey])],
|
imports: [TypeOrmModule.forFeature([ApiKey, License])],
|
||||||
controllers: [ApiKeysController],
|
controllers: [ApiKeysController],
|
||||||
providers: [ApiKeysService],
|
providers: [ApiKeysService],
|
||||||
exports: [ApiKeysService],
|
exports: [ApiKeysService],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { ApiKey } from '../../entities/api-key.entity';
|
import { ApiKey } from '../../entities/api-key.entity';
|
||||||
|
import { License } from '../../entities/license.entity';
|
||||||
|
|
||||||
/** Shape returned to the caller on creation — the ONLY time the plaintext key is exposed. */
|
/** Shape returned to the caller on creation — the ONLY time the plaintext key is exposed. */
|
||||||
export interface CreatedApiKey {
|
export interface CreatedApiKey {
|
||||||
@@ -32,6 +33,8 @@ export class ApiKeysService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ApiKey)
|
@InjectRepository(ApiKey)
|
||||||
private readonly apiKeyRepo: Repository<ApiKey>,
|
private readonly apiKeyRepo: Repository<ApiKey>,
|
||||||
|
@InjectRepository(License)
|
||||||
|
private readonly licenseRepo: Repository<License>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,13 +127,18 @@ export class ApiKeysService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a raw API key string.
|
* Validate a raw API key string. Called by JwtAuthGuard.
|
||||||
* Called by a future ApiKeyAuthGuard — not exposed via HTTP.
|
|
||||||
*
|
*
|
||||||
* Hashes the raw key, looks up an ACTIVE row, touches last_used_at,
|
* Hashes the raw key, looks up an ACTIVE row, touches last_used_at, resolves
|
||||||
* and returns { license_id } on success or null on failure.
|
* the license owner (so the guard can attribute the call to a real user UUID),
|
||||||
|
* and returns { license_id, user_id } on success or null on failure.
|
||||||
|
*
|
||||||
|
* user_id is the license owner — API-key calls act AS the owner, so any
|
||||||
|
* created_by / @CurrentUser FK insert gets a valid UUID and correct attribution.
|
||||||
*/
|
*/
|
||||||
async validateKey(rawKey: string): Promise<{ license_id: string } | null> {
|
async validateKey(
|
||||||
|
rawKey: string,
|
||||||
|
): Promise<{ license_id: string; user_id: string | null } | null> {
|
||||||
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||||
|
|
||||||
const key = await this.apiKeyRepo.findOne({
|
const key = await this.apiKeyRepo.findOne({
|
||||||
@@ -145,6 +153,11 @@ export class ApiKeysService {
|
|||||||
// Update last_used_at without loading the full row again.
|
// Update last_used_at without loading the full row again.
|
||||||
await this.apiKeyRepo.update(key.id, { last_used_at: new Date() });
|
await this.apiKeyRepo.update(key.id, { last_used_at: new Date() });
|
||||||
|
|
||||||
return { license_id: key.license_id };
|
const license = await this.licenseRepo.findOne({
|
||||||
|
where: { id: key.license_id },
|
||||||
|
select: ['id', 'owner_user_id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { license_id: key.license_id, user_id: license?.owner_user_id ?? null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const groups: RoadmapGroup[] = [
|
|||||||
description:
|
description:
|
||||||
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane. Webhooks and per-license API keys are live; key-authenticated external API access lands next.',
|
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane. Webhooks and per-license API keys are live; key-authenticated external API access lands next.',
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Public REST API for server management', note: 'REST API live with OpenAPI docs; key-authenticated external access in progress' },
|
{ text: 'Public REST API for server management', note: 'REST API live with OpenAPI docs; key-authenticated external access wired (corr_ bearer key acts as the license owner)' },
|
||||||
{ text: 'Webhook events (wipe completed, server down, player banned)', note: 'Shipped — HMAC-SHA256 signed delivery, SSRF-guarded' },
|
{ text: 'Webhook events (wipe completed, server down, player banned)', note: 'Shipped — HMAC-SHA256 signed delivery, SSRF-guarded' },
|
||||||
{ text: 'API key management per license', note: 'Shipped — create, list, revoke with hashed storage' },
|
{ text: 'API key management per license', note: 'Shipped — create, list, revoke with hashed storage' },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user