feat(backend): JIT-provision the local User on first Authentik login

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>
This commit is contained in:
2026-09-10 08:05:18 +02:00
co-authored by Claude Sonnet 5
parent 6ed5aa2c76
commit f03b209e84
6 changed files with 201 additions and 56 deletions
+16 -10
View File
@@ -5,24 +5,28 @@ 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;
email?: string;
given_name?: string;
family_name?: string;
}
/// Validates access tokens issued by Authentik (resource-server pattern):
/// signature is checked against Authentik's JWKS, then the local Membership
/// table decides what the user may do. Authentik itself is only the identity
/// source, never asked for authorization here.
/// 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({
@@ -41,13 +45,15 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
}
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
const user = await this.prisma.user.findUnique({
where: { authentikSub: payload.sub },
include: { memberships: { where: { status: 'ACTIVE' } } },
});
if (!user) {
throw new UnauthorizedException('User not provisioned locally yet');
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,