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,
+104
View File
@@ -0,0 +1,104 @@
import { Prisma } from '@prisma/client';
import { resolveOrProvisionAuthentikUser } from './provision-user';
const CLAIMS = {
sub: 'sub-1',
email: 'New.Person@Example.org',
firstName: 'New',
lastName: 'Person',
};
function p2002() {
return new Prisma.PrismaClientKnownRequestError('unique', {
code: 'P2002',
clientVersion: 'test',
});
}
function makeMocks() {
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
return { sync };
}
describe('resolveOrProvisionAuthentikUser', () => {
it('returns the existing user without creating or capturing', async () => {
const { sync } = makeMocks();
const existing = { id: 'u-1', authentikSub: 'sub-1', memberships: [] };
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(existing),
create: jest.fn(),
},
};
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
expect(res).toBe(existing);
expect(prisma.user.create).not.toHaveBeenCalled();
expect(sync.capture).not.toHaveBeenCalled();
});
it('provisions a new user from claims (lowercased email) and captures it', async () => {
const { sync } = makeMocks();
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'u-2', ...data }),
),
},
};
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
expect(prisma.user.create).toHaveBeenCalledWith({
data: {
authentikSub: 'sub-1',
email: 'new.person@example.org',
firstName: 'New',
lastName: 'Person',
},
});
expect(res.memberships).toEqual([]);
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-2', expect.anything());
});
it('recovers from a concurrent-create race (P2002) by re-reading', async () => {
const { sync } = makeMocks();
const raced = { id: 'u-3', authentikSub: 'sub-1', memberships: [] };
const prisma = {
user: {
findUnique: jest
.fn()
.mockResolvedValueOnce(null) // first check: not there yet
.mockResolvedValueOnce(raced), // after the failed insert: it exists
create: jest.fn().mockRejectedValue(p2002()),
},
};
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
expect(res).toBe(raced);
expect(sync.capture).not.toHaveBeenCalled();
});
it('rethrows a P2002 when the row still cannot be found', async () => {
const { sync } = makeMocks();
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockRejectedValue(p2002()),
},
};
await expect(
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS),
).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError);
});
it('rethrows a non-P2002 error', async () => {
const { sync } = makeMocks();
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockRejectedValue(new Error('db down')),
},
};
await expect(
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS),
).rejects.toThrow('db down');
});
});
+58
View File
@@ -0,0 +1,58 @@
import { Prisma, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
export interface AuthentikClaims {
sub: string;
email: string;
firstName: string;
lastName: string;
}
type UserWithActiveMemberships = Prisma.UserGetPayload<{
include: { memberships: true };
}>;
/// Resolves an Authentik identity to its local `User`, creating one from the
/// token claims on first login (JIT provisioning). The new user has no
/// memberships and therefore no rights until one is granted (LT via Authentik
/// group sync — still manual — or the onboarding approval flow). Shared by
/// AuthentikStrategy and the WS token path so both provision identically.
export async function resolveOrProvisionAuthentikUser(
prisma: PrismaClient,
sync: SyncService,
claims: AuthentikClaims,
): Promise<UserWithActiveMemberships> {
const existing = await prisma.user.findUnique({
where: { authentikSub: claims.sub },
include: { memberships: { where: { status: 'ACTIVE' } } },
});
if (existing) {
return existing;
}
try {
const user = await prisma.user.create({
data: {
authentikSub: claims.sub,
email: claims.email.toLowerCase(),
firstName: claims.firstName,
lastName: claims.lastName,
},
});
await sync.capture('User', SyncOperation.CREATE, user.id, user);
return { ...user, memberships: [] };
} catch (err) {
// Lost a race with a concurrent first login — the row exists now.
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
const user = await prisma.user.findUnique({
where: { authentikSub: claims.sub },
include: { memberships: { where: { status: 'ACTIVE' } } },
});
if (user) {
return user;
}
}
throw err;
}
}
+5 -9
View File
@@ -4,9 +4,11 @@ import { JwtService } from '@nestjs/jwt';
import * as jwt from 'jsonwebtoken';
import * as jwksRsa from 'jwks-rsa';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request';
import { GuestJwtPayload } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service';
import { resolveOrProvisionAuthentikUser } from './provision-user';
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
@@ -20,6 +22,7 @@ export class TokenVerificationService {
private readonly prisma: PrismaClient,
private readonly guestJwt: JwtService,
private readonly teamAuth: TeamAuthService,
private readonly sync: SyncService,
) {
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
@@ -60,15 +63,8 @@ export class TokenVerificationService {
}
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
const { sub } = await this.verifyAuthentikClaims(token);
const user = await this.prisma.user.findUnique({
where: { authentikSub: sub },
include: { memberships: { where: { status: 'ACTIVE' } } },
});
if (!user) {
throw new UnauthorizedException('User not provisioned locally yet');
}
const claims = await this.verifyAuthentikClaims(token);
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims);
return {
userId: user.id,
authentikSub: user.authentikSub,
+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,