From f03b209e8422a0e3c02124f230bc417e609be2de Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:05:18 +0200 Subject: [PATCH] feat(backend): JIT-provision the local User on first Authentik login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 27 ++++--- src/auth/authentik.strategy.ts | 26 ++++--- src/auth/provision-user.spec.ts | 104 +++++++++++++++++++++++++ src/auth/provision-user.ts | 58 ++++++++++++++ src/auth/token-verification.service.ts | 14 ++-- src/onboarding/onboarding.service.ts | 28 +------ 6 files changed, 201 insertions(+), 56 deletions(-) create mode 100644 src/auth/provision-user.spec.ts create mode 100644 src/auth/provision-user.ts diff --git a/README.md b/README.md index d005f65..6077422 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,16 @@ client's host - no separate web server is needed. ## Auth model -- Leitungsteam and Gemeinde Verantwortliche are provisioned in Authentik - (the "Konfi-Castle-ID"); this API acts as an OIDC **resource server**, - verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and - then resolving local `Membership` rows to determine role + KC/Gemeinde - scope. Clients perform the actual Authorization Code + PKCE flow against - Authentik directly. +- Leitungsteam and Gemeinde Verantwortliche sign in with Authentik (the + "Konfi-Castle-ID"); this API acts as an OIDC **resource server**, verifying + access tokens against Authentik's JWKS (`AuthentikStrategy`). The local + `User` is provisioned just-in-time on first login from the token claims + (`resolveOrProvisionAuthentikUser`); role + KC/Gemeinde scope then come + from local `Membership` rows (only `status = ACTIVE` ones count). A freshly + provisioned user has no membership and thus no rights until one is granted + (LT: manually for now; Verantwortliche: the `onboarding/` approval flow). + Clients perform the Authorization Code + PKCE flow against Authentik + directly. - Gemeinde Teamer are **local accounts** (no Authentik): a `User` row with a `passwordHash` and `kcId` set, `authentikSub` left null. A Gemeinde Verantwortliche/r creates them directly or via a `TeamerInvite` @@ -105,8 +109,9 @@ client's host - no separate web server is needed. Leitungsteam roles are global across all KCs). All planned backend phases are implemented. `npm test` runs Jest unit tests -(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`; -Prisma mocked). Remaining work: the Flutter clients (see repo root README), -Authentik JIT provisioning for LT (Verantwortliche already self-provision via -`onboarding/`), invite email delivery, and the first real Prisma migration -(only `schema.prisma` exists so far). +(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`, +`resolveOrProvisionAuthentikUser`; Prisma mocked). Remaining work: the +Flutter clients (see repo root README), deriving the LT `Membership` from +Authentik group claims (the `User` is provisioned, the role is not), invite +email delivery, and the first real Prisma migration (only `schema.prisma` +exists so far). diff --git a/src/auth/authentik.strategy.ts b/src/auth/authentik.strategy.ts index f5b88b8..ffcd0d3 100644 --- a/src/auth/authentik.strategy.ts +++ b/src/auth/authentik.strategy.ts @@ -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('AUTHENTIK_ISSUER_URL'); super({ @@ -41,13 +45,15 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { } async validate(payload: AuthentikJwtPayload): Promise { - 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, diff --git a/src/auth/provision-user.spec.ts b/src/auth/provision-user.spec.ts new file mode 100644 index 0000000..8377bec --- /dev/null +++ b/src/auth/provision-user.spec.ts @@ -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 }) => + 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'); + }); +}); diff --git a/src/auth/provision-user.ts b/src/auth/provision-user.ts new file mode 100644 index 0000000..4e15fed --- /dev/null +++ b/src/auth/provision-user.ts @@ -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 { + 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; + } +} diff --git a/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts index 73a09b5..a6fe7f7 100644 --- a/src/auth/token-verification.service.ts +++ b/src/auth/token-verification.service.ts @@ -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('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 { - 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, diff --git a/src/onboarding/onboarding.service.ts b/src/onboarding/onboarding.service.ts index 71b606f..081c6e5 100644 --- a/src/onboarding/onboarding.service.ts +++ b/src/onboarding/onboarding.service.ts @@ -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,