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
+2 -26
View File
@@ -8,6 +8,7 @@ import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { TokenVerificationService } from '../auth/token-verification.service';
import { resolveOrProvisionAuthentikUser } from '../auth/provision-user';
/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in
/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus
@@ -52,7 +53,7 @@ export class OnboardingService {
throw new BadRequestException('Gemeinde does not belong to this KC');
}
const user = await this.upsertUser(claims);
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims);
const existing = await this.prisma.membership.findUnique({
where: {
@@ -119,31 +120,6 @@ export class OnboardingService {
return membership;
}
private async upsertUser(claims: {
sub: string;
email: string;
firstName: string;
lastName: string;
}) {
const email = claims.email.toLowerCase();
const existing = await this.prisma.user.findUnique({
where: { authentikSub: claims.sub },
});
if (existing) {
return existing;
}
const user = await this.prisma.user.create({
data: {
authentikSub: claims.sub,
email,
firstName: claims.firstName,
lastName: claims.lastName,
},
});
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
return user;
}
private summary(
membershipId: string,
status: MembershipStatus,