feat: revocable read-only API keys for machine access
Integrations such as the Wabenhain shop sync no longer need a service account with a password (and without MFA). An admin creates a key under Verwaltung > Automatisierung > API-Schlüssel. - Key format ack_<8>_<43>; only its SHA-256 hash is stored, the key is shown exactly once. Optional expiry, "last used" timestamp, revocation takes effect immediately; create/revoke are audit-logged without the key. - A key is not a user: JwtAuthGuard sets request.apiKey, PermissionsGuard admits it only to routes whose permission is among its scopes and never to user-only routes. Scopes come from a short allowlist (shop-sync:read). - Managing keys requires the new permission api-keys:manage. - Keys are excluded from backups on purpose; a restore means issuing a new key. Additive migration (new table). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ede86c062a
commit
445fb3b94b
21 files changed
+848
-5
No files matched your search
@@ -2,16 +2,19 @@ import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||
import { ApiKeysService } from '../../api-keys/api-keys.service';
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
let guard: JwtAuthGuard;
|
||||
let reflector: { getAllAndOverride: jest.Mock };
|
||||
let permissionResolver: { isTokenValid: jest.Mock };
|
||||
let apiKeys: { authenticate: jest.Mock };
|
||||
|
||||
function contextWithUser(
|
||||
user: { id: string; tokenIssuedAt?: number } | undefined,
|
||||
authorization?: string,
|
||||
): ExecutionContext {
|
||||
const request = { user };
|
||||
const request = { user, headers: { authorization } };
|
||||
return {
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
@@ -22,9 +25,11 @@ describe('JwtAuthGuard', () => {
|
||||
beforeEach(() => {
|
||||
reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) };
|
||||
permissionResolver = { isTokenValid: jest.fn() };
|
||||
apiKeys = { authenticate: jest.fn() };
|
||||
guard = new JwtAuthGuard(
|
||||
reflector as unknown as Reflector,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
apiKeys as unknown as ApiKeysService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,4 +77,27 @@ describe('JwtAuthGuard', () => {
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('authenticates an API key as a machine principal, not as a user', async () => {
|
||||
const principal = { id: 'key-1', scopes: ['shop-sync:read'] };
|
||||
apiKeys.authenticate.mockResolvedValue(principal);
|
||||
const context = contextWithUser(undefined, 'Bearer ack_abcdefgh_secret');
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
apiKey?: unknown;
|
||||
user?: unknown;
|
||||
}>();
|
||||
expect(request.apiKey).toBe(principal);
|
||||
expect(request.user).toBeUndefined();
|
||||
expect(permissionResolver.isTokenValid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unknown, revoked or expired API key', async () => {
|
||||
apiKeys.authenticate.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
guard.canActivate(contextWithUser(undefined, 'Bearer ack_abcdefgh_x')),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,16 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||
import { AuthenticatedUser } from '../types/authenticated-user';
|
||||
import {
|
||||
ApiKeyPrincipal,
|
||||
ApiKeysService,
|
||||
extractApiKeyToken,
|
||||
} from '../../api-keys/api-keys.service';
|
||||
|
||||
interface RequestWithUser {
|
||||
headers: { authorization?: string };
|
||||
user?: AuthenticatedUser;
|
||||
apiKey?: ApiKeyPrincipal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,6 +35,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly apiKeys: ApiKeysService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -40,11 +48,25 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
|
||||
// An API key ("Bearer ack_…") authenticates a machine, not a user: no
|
||||
// request.user is set, so it can never pass a user-only route, and
|
||||
// PermissionsGuard checks its scopes instead of a role.
|
||||
const apiKeyToken = extractApiKeyToken(request.headers.authorization);
|
||||
if (apiKeyToken !== null) {
|
||||
const principal = await this.apiKeys.authenticate(apiKeyToken);
|
||||
if (!principal) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
request.apiKey = principal;
|
||||
return true;
|
||||
}
|
||||
|
||||
const authenticated = (await super.canActivate(context)) as boolean;
|
||||
if (!authenticated) {
|
||||
return false;
|
||||
}
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const user = request.user;
|
||||
if (
|
||||
!user ||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorat
|
||||
import { PermissionKey } from '../permissions/permission-catalog';
|
||||
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||
import { AuthenticatedUser } from '../types/authenticated-user';
|
||||
import type { ApiKeyPrincipal } from '../../api-keys/api-keys.service';
|
||||
|
||||
interface RequestWithUser {
|
||||
user?: AuthenticatedUser;
|
||||
apiKey?: ApiKeyPrincipal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +49,15 @@ export class PermissionsGuard implements CanActivate {
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const user = request.user;
|
||||
|
||||
// API keys pass only routes that name a permission the key carries -
|
||||
// never @AnyAuthenticatedUser routes, which are meant for people.
|
||||
if (request.apiKey) {
|
||||
const scopes: readonly string[] = request.apiKey.scopes;
|
||||
return Boolean(
|
||||
requiredPermissions?.some((permission) => scopes.includes(permission)),
|
||||
);
|
||||
}
|
||||
|
||||
if (requiredPermissions && requiredPermissions.length > 0) {
|
||||
if (!user) {
|
||||
return false;
|
||||
|
||||
@@ -315,6 +315,12 @@ export const PERMISSION_CATALOG = [
|
||||
[['read', 'Freigegebene Daten für den Shop lesen']],
|
||||
3300,
|
||||
),
|
||||
...group(
|
||||
'API-Schlüssel',
|
||||
'api-keys',
|
||||
[['manage', 'Anlegen, ansehen und widerrufen']],
|
||||
3400,
|
||||
),
|
||||
] as const satisfies readonly PermissionCatalogEntry[];
|
||||
|
||||
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||
Reference in new issue
Block a user