feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat #1
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user