feat(backend): self-registration for Gemeinde Verantwortliche

New onboarding/ module. A prospective Verantwortliche/r signs in with their
Konfi-Castle-ID (Authentik), looks up a KC by invite code, picks an existing
Gemeinde, and registers:

- GET  /api/onboarding/kc/:inviteCode  -> KC name + its Gemeinden (public;
  the invite code is the shared secret)
- POST /api/onboarding/verantwortliche -> verifies the raw Authentik bearer
  token's claims (no local Membership required yet via new
  TokenVerificationService.verifyAuthentikClaims), JIT-provisions the local
  User, and creates a Membership with status PENDING. Idempotent per
  (user, kc, gemeinde).
- GET  /api/onboarding/requests?kcId=            (LT) list pending
- POST /api/onboarding/requests/:id/approve|reject (LT) approve flips to
  ACTIVE, reject deletes.

Schema: Membership gains status (enum MembershipStatus { ACTIVE, PENDING },
default ACTIVE). AuthentikStrategy / TokenVerificationService / TeamAuthService
now load only ACTIVE memberships, so a pending request grants nothing until
approved. Membership create/update/delete flow through the sync log.

Tests: onboarding.service.spec.ts (14 cases); npm test green at 46.
Docs (plan + backend README) updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 08:01:19 +02:00
co-authored by Claude Sonnet 5
parent d48c07b0e4
commit 6ed5aa2c76
11 changed files with 524 additions and 13 deletions
+28 -6
View File
@@ -25,7 +25,15 @@ export class TokenVerificationService {
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
}
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
/// Verifies an Authentik token's signature and returns its identity claims,
/// without requiring a local User to exist yet (used by the onboarding
/// self-registration path, which provisions that User).
async verifyAuthentikClaims(token: string): Promise<{
sub: string;
email: string;
firstName: string;
lastName: string;
}> {
const decoded = jwt.decode(token, { complete: true });
const kid = decoded?.header.kid;
if (!kid) {
@@ -35,14 +43,28 @@ export class TokenVerificationService {
const payload = jwt.verify(token, key.getPublicKey(), {
issuer: this.issuerUrl,
algorithms: ['RS256'],
}) as jwt.JwtPayload;
if (!payload.sub) {
throw new UnauthorizedException('Authentik token missing subject');
}) as jwt.JwtPayload & {
email?: string;
given_name?: string;
family_name?: string;
};
if (!payload.sub || !payload.email) {
throw new UnauthorizedException('Authentik token missing subject or email');
}
return {
sub: payload.sub,
email: payload.email,
firstName: payload.given_name ?? '',
lastName: payload.family_name ?? '',
};
}
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
const { sub } = await this.verifyAuthentikClaims(token);
const user = await this.prisma.user.findUnique({
where: { authentikSub: payload.sub },
include: { memberships: true },
where: { authentikSub: sub },
include: { memberships: { where: { status: 'ACTIVE' } } },
});
if (!user) {
throw new UnauthorizedException('User not provisioned locally yet');