AuthentikStrategy no longer rejects a valid token whose user has no local row — it creates the User from the token claims (given_name/family_name/ email) via the new shared resolveOrProvisionAuthentikUser helper, which is race-safe (P2002 -> re-read) and captures the User to the sync log. The WS token path (TokenVerificationService.verifyAuthentik) and OnboardingService now use the same helper, removing three copies of the lookup/create logic. A provisioned user still has no Membership and therefore no rights: LT role assignment from Authentik groups is the remaining gap; Verantwortliche go through the onboarding approval flow. Tests: provision-user.spec.ts (existing/new/race/rethrow); npm test green at 51. Docs updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
69 lines
2.3 KiB
TypeScript
69 lines
2.3 KiB
TypeScript
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import { PassportStrategy } from '@nestjs/passport';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { Strategy } from 'passport-jwt';
|
|
import * as jwksRsa from 'jwks-rsa';
|
|
import { Request } from 'express';
|
|
import { PrismaClient } from '../prisma/prisma.module';
|
|
import { SyncService } from '../sync/sync.service';
|
|
import { AuthenticatedUser } from './authenticated-request';
|
|
import { resolveOrProvisionAuthentikUser } from './provision-user';
|
|
|
|
interface AuthentikJwtPayload {
|
|
sub: string;
|
|
email?: string;
|
|
given_name?: string;
|
|
family_name?: string;
|
|
}
|
|
|
|
/// Validates access tokens issued by Authentik (resource-server pattern):
|
|
/// signature is checked against Authentik's JWKS, the local `User` is
|
|
/// provisioned on first login (JIT), then the local Membership table decides
|
|
/// what the user may do. Authentik itself is only the identity source, never
|
|
/// asked for authorization here.
|
|
@Injectable()
|
|
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
|
constructor(
|
|
config: ConfigService,
|
|
private readonly prisma: PrismaClient,
|
|
private readonly sync: SyncService,
|
|
) {
|
|
const issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
|
super({
|
|
jwtFromRequest: (req: Request) =>
|
|
req.headers.authorization?.startsWith('Bearer ')
|
|
? req.headers.authorization.slice('Bearer '.length)
|
|
: null,
|
|
secretOrKeyProvider: jwksRsa.passportJwtSecret({
|
|
jwksUri: `${issuerUrl}/jwks/`,
|
|
cache: true,
|
|
rateLimit: true,
|
|
}),
|
|
issuer: issuerUrl,
|
|
algorithms: ['RS256'],
|
|
});
|
|
}
|
|
|
|
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
|
if (!payload.email) {
|
|
throw new UnauthorizedException('Authentik token missing email claim');
|
|
}
|
|
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, {
|
|
sub: payload.sub,
|
|
email: payload.email,
|
|
firstName: payload.given_name ?? '',
|
|
lastName: payload.family_name ?? '',
|
|
});
|
|
return {
|
|
userId: user.id,
|
|
authentikSub: user.authentikSub,
|
|
email: user.email,
|
|
memberships: user.memberships.map((m) => ({
|
|
kcId: m.kcId,
|
|
gemeindeId: m.gemeindeId,
|
|
role: m.role,
|
|
})),
|
|
};
|
|
}
|
|
}
|