feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat #1
@@ -4,6 +4,11 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public"
|
|||||||
# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/
|
# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/
|
||||||
AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app"
|
AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app"
|
||||||
|
|
||||||
|
# Name of the Authentik group whose members are Leitungsteam. Mirrored to
|
||||||
|
# User.isLeitungsteam on every login (the access token must carry a `groups`
|
||||||
|
# claim; add the "groups" scope to the Authentik provider).
|
||||||
|
AUTHENTIK_LEITUNGSTEAM_GROUP="Leitungsteam"
|
||||||
|
|
||||||
# Secret used to sign guest/Konfi session tokens (local accounts only)
|
# Secret used to sign guest/Konfi session tokens (local accounts only)
|
||||||
GUEST_JWT_SECRET="change-me"
|
GUEST_JWT_SECRET="change-me"
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ architecture context).
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET / TEAM_JWT_SECRET
|
cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP / GUEST_JWT_SECRET / TEAM_JWT_SECRET
|
||||||
npx prisma generate
|
npx prisma generate
|
||||||
npx prisma migrate dev --name init # requires a running PostgreSQL instance
|
npx prisma migrate dev --name init # requires a running PostgreSQL instance
|
||||||
npm run start:dev
|
npm run start:dev
|
||||||
@@ -24,12 +24,14 @@ client's host - no separate web server is needed.
|
|||||||
"Konfi-Castle-ID"); this API acts as an OIDC **resource server**, verifying
|
"Konfi-Castle-ID"); this API acts as an OIDC **resource server**, verifying
|
||||||
access tokens against Authentik's JWKS (`AuthentikStrategy`). The local
|
access tokens against Authentik's JWKS (`AuthentikStrategy`). The local
|
||||||
`User` is provisioned just-in-time on first login from the token claims
|
`User` is provisioned just-in-time on first login from the token claims
|
||||||
(`resolveOrProvisionAuthentikUser`); role + KC/Gemeinde scope then come
|
(`resolveOrProvisionAuthentikUser`), and `User.isLeitungsteam` is
|
||||||
from local `Membership` rows (only `status = ACTIVE` ones count). A freshly
|
reconciled on every login from the token's `groups` claim vs.
|
||||||
provisioned user has no membership and thus no rights until one is granted
|
`AUTHENTIK_LEITUNGSTEAM_GROUP` — `toAuthenticatedUser` then synthesises a
|
||||||
(LT: manually for now; Verantwortliche: the `onboarding/` approval flow).
|
virtual global `LEITUNGSTEAM` membership from that flag. Other roles come
|
||||||
Clients perform the Authorization Code + PKCE flow against Authentik
|
from local `Membership` rows (only `status = ACTIVE` ones count).
|
||||||
directly.
|
Verantwortliche self-provision through the `onboarding/` approval flow;
|
||||||
|
a user with neither the LT flag nor a membership has no rights. Clients
|
||||||
|
perform the Authorization Code + PKCE flow against Authentik directly.
|
||||||
- Gemeinde Teamer are **local accounts** (no Authentik): a `User` row with a
|
- Gemeinde Teamer are **local accounts** (no Authentik): a `User` row with a
|
||||||
`passwordHash` and `kcId` set, `authentikSub` left null. A Gemeinde
|
`passwordHash` and `kcId` set, `authentikSub` left null. A Gemeinde
|
||||||
Verantwortliche/r creates them directly or via a `TeamerInvite`
|
Verantwortliche/r creates them directly or via a `TeamerInvite`
|
||||||
@@ -110,8 +112,8 @@ client's host - no separate web server is needed.
|
|||||||
|
|
||||||
All planned backend phases are implemented. `npm test` runs Jest unit tests
|
All planned backend phases are implemented. `npm test` runs Jest unit tests
|
||||||
(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`,
|
(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`,
|
||||||
`resolveOrProvisionAuthentikUser`; Prisma mocked). Remaining work: the
|
`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked).
|
||||||
Flutter clients (see repo root README), deriving the LT `Membership` from
|
Remaining work: the Flutter clients (see repo root README), invite email
|
||||||
Authentik group claims (the `User` is provisioned, the role is not), invite
|
delivery, push notifications, and the first real Prisma migration (only
|
||||||
email delivery, and the first real Prisma migration (only `schema.prisma`
|
`schema.prisma` exists so far). Ops note: the Authentik provider must emit a
|
||||||
exists so far).
|
`groups` claim in the access token for the LT check to work.
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ model User {
|
|||||||
lastName String
|
lastName String
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
kcId String?
|
kcId String?
|
||||||
|
/// Mirrored from the caller's Authentik group membership on every login.
|
||||||
|
/// LEITUNGSTEAM is global (not KC-scoped), so it lives here rather than as
|
||||||
|
/// a per-KC Membership row; the auth layer synthesises a virtual global
|
||||||
|
/// LEITUNGSTEAM membership from this flag.
|
||||||
|
isLeitungsteam Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -7,22 +7,26 @@ import { Request } from 'express';
|
|||||||
import { PrismaClient } from '../prisma/prisma.module';
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
import { SyncService } from '../sync/sync.service';
|
import { SyncService } from '../sync/sync.service';
|
||||||
import { AuthenticatedUser } from './authenticated-request';
|
import { AuthenticatedUser } from './authenticated-request';
|
||||||
import { resolveOrProvisionAuthentikUser } from './provision-user';
|
import { resolveOrProvisionAuthentikUser, toAuthenticatedUser } from './provision-user';
|
||||||
|
|
||||||
interface AuthentikJwtPayload {
|
interface AuthentikJwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
given_name?: string;
|
given_name?: string;
|
||||||
family_name?: string;
|
family_name?: string;
|
||||||
|
groups?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates access tokens issued by Authentik (resource-server pattern):
|
/// Validates access tokens issued by Authentik (resource-server pattern):
|
||||||
/// signature is checked against Authentik's JWKS, the local `User` is
|
/// signature is checked against Authentik's JWKS, the local `User` is
|
||||||
/// provisioned on first login (JIT), then the local Membership table decides
|
/// provisioned on first login (JIT) and its LEITUNGSTEAM flag reconciled with
|
||||||
/// what the user may do. Authentik itself is only the identity source, never
|
/// the token's `groups` claim, then the local Membership table decides what
|
||||||
/// asked for authorization here.
|
/// the user may do. Authentik itself is only the identity source, never asked
|
||||||
|
/// for authorization here.
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
||||||
|
private readonly leitungsteamGroup: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: ConfigService,
|
config: ConfigService,
|
||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
@@ -42,27 +46,25 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
|||||||
issuer: issuerUrl,
|
issuer: issuerUrl,
|
||||||
algorithms: ['RS256'],
|
algorithms: ['RS256'],
|
||||||
});
|
});
|
||||||
|
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
||||||
if (!payload.email) {
|
if (!payload.email) {
|
||||||
throw new UnauthorizedException('Authentik token missing email claim');
|
throw new UnauthorizedException('Authentik token missing email claim');
|
||||||
}
|
}
|
||||||
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, {
|
const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup);
|
||||||
|
const user = await resolveOrProvisionAuthentikUser(
|
||||||
|
this.prisma,
|
||||||
|
this.sync,
|
||||||
|
{
|
||||||
sub: payload.sub,
|
sub: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
firstName: payload.given_name ?? '',
|
firstName: payload.given_name ?? '',
|
||||||
lastName: payload.family_name ?? '',
|
lastName: payload.family_name ?? '',
|
||||||
});
|
},
|
||||||
return {
|
isLeitungsteam,
|
||||||
userId: user.id,
|
);
|
||||||
authentikSub: user.authentikSub,
|
return toAuthenticatedUser(user);
|
||||||
email: user.email,
|
|
||||||
memberships: user.memberships.map((m) => ({
|
|
||||||
kcId: m.kcId,
|
|
||||||
gemeindeId: m.gemeindeId,
|
|
||||||
role: m.role,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma, Role } from '@prisma/client';
|
||||||
import { resolveOrProvisionAuthentikUser } from './provision-user';
|
import {
|
||||||
|
GLOBAL_LT_KC_ID,
|
||||||
|
resolveOrProvisionAuthentikUser,
|
||||||
|
toAuthenticatedUser,
|
||||||
|
} from './provision-user';
|
||||||
|
|
||||||
const CLAIMS = {
|
const CLAIMS = {
|
||||||
sub: 'sub-1',
|
sub: 'sub-1',
|
||||||
@@ -15,38 +19,36 @@ function p2002() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeMocks() {
|
|
||||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
|
||||||
return { sync };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('resolveOrProvisionAuthentikUser', () => {
|
describe('resolveOrProvisionAuthentikUser', () => {
|
||||||
it('returns the existing user without creating or capturing', async () => {
|
it('returns the existing user without creating or capturing when nothing changed', async () => {
|
||||||
const { sync } = makeMocks();
|
const sync = { capture: jest.fn() };
|
||||||
const existing = { id: 'u-1', authentikSub: 'sub-1', memberships: [] };
|
const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
|
||||||
const prisma = {
|
const prisma = {
|
||||||
user: {
|
user: {
|
||||||
findUnique: jest.fn().mockResolvedValue(existing),
|
findUnique: jest.fn().mockResolvedValue(existing),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
|
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
|
||||||
expect(res).toBe(existing);
|
expect(res).toBe(existing);
|
||||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||||
expect(sync.capture).not.toHaveBeenCalled();
|
expect(sync.capture).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('provisions a new user from claims (lowercased email) and captures it', async () => {
|
it('provisions a new user from claims (lowercased email) and captures it', async () => {
|
||||||
const { sync } = makeMocks();
|
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||||
const prisma = {
|
const prisma = {
|
||||||
user: {
|
user: {
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||||
Promise.resolve({ id: 'u-2', ...data }),
|
Promise.resolve({ id: 'u-2', isLeitungsteam: false, ...data }),
|
||||||
),
|
),
|
||||||
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
|
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
|
||||||
expect(prisma.user.create).toHaveBeenCalledWith({
|
expect(prisma.user.create).toHaveBeenCalledWith({
|
||||||
data: {
|
data: {
|
||||||
authentikSub: 'sub-1',
|
authentikSub: 'sub-1',
|
||||||
@@ -59,46 +61,115 @@ describe('resolveOrProvisionAuthentikUser', () => {
|
|||||||
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-2', expect.anything());
|
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-2', expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recovers from a concurrent-create race (P2002) by re-reading', async () => {
|
it('reconciles the LEITUNGSTEAM flag up when the token now has the group', async () => {
|
||||||
const { sync } = makeMocks();
|
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||||
const raced = { id: 'u-3', authentikSub: 'sub-1', memberships: [] };
|
const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
|
||||||
const prisma = {
|
const prisma = {
|
||||||
user: {
|
user: {
|
||||||
findUnique: jest
|
findUnique: jest.fn().mockResolvedValue(existing),
|
||||||
.fn()
|
create: jest.fn(),
|
||||||
.mockResolvedValueOnce(null) // first check: not there yet
|
update: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||||
.mockResolvedValueOnce(raced), // after the failed insert: it exists
|
Promise.resolve({ ...existing, ...data, memberships: [] }),
|
||||||
create: jest.fn().mockRejectedValue(p2002()),
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS);
|
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, true);
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'u-1' },
|
||||||
|
data: { isLeitungsteam: true },
|
||||||
|
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||||
|
});
|
||||||
|
expect(res.isLeitungsteam).toBe(true);
|
||||||
|
expect(sync.capture).toHaveBeenCalledWith('User', 'UPDATE', 'u-1', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconciles the LEITUNGSTEAM flag down when the group is gone', async () => {
|
||||||
|
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: true, memberships: [] };
|
||||||
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(existing),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||||
|
Promise.resolve({ ...existing, ...data, memberships: [] }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: { isLeitungsteam: false } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers from a concurrent-create race (P2002) by re-reading', async () => {
|
||||||
|
const sync = { capture: jest.fn() };
|
||||||
|
const raced = { id: 'u-3', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
|
||||||
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(raced),
|
||||||
|
create: jest.fn().mockRejectedValue(p2002()),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
|
||||||
expect(res).toBe(raced);
|
expect(res).toBe(raced);
|
||||||
expect(sync.capture).not.toHaveBeenCalled();
|
expect(sync.capture).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rethrows a P2002 when the row still cannot be found', async () => {
|
it('rethrows a P2002 when the row still cannot be found', async () => {
|
||||||
const { sync } = makeMocks();
|
const sync = { capture: jest.fn() };
|
||||||
const prisma = {
|
const prisma = {
|
||||||
user: {
|
user: {
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
create: jest.fn().mockRejectedValue(p2002()),
|
create: jest.fn().mockRejectedValue(p2002()),
|
||||||
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await expect(
|
await expect(
|
||||||
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS),
|
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false),
|
||||||
).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError);
|
).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rethrows a non-P2002 error', async () => {
|
it('rethrows a non-P2002 error', async () => {
|
||||||
const { sync } = makeMocks();
|
const sync = { capture: jest.fn() };
|
||||||
const prisma = {
|
const prisma = {
|
||||||
user: {
|
user: {
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
create: jest.fn().mockRejectedValue(new Error('db down')),
|
create: jest.fn().mockRejectedValue(new Error('db down')),
|
||||||
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await expect(
|
await expect(
|
||||||
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS),
|
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false),
|
||||||
).rejects.toThrow('db down');
|
).rejects.toThrow('db down');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toAuthenticatedUser', () => {
|
||||||
|
const row = {
|
||||||
|
id: 'u-1',
|
||||||
|
authentikSub: 'sub-1',
|
||||||
|
email: 'a@b.org',
|
||||||
|
isLeitungsteam: false,
|
||||||
|
memberships: [
|
||||||
|
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('maps membership rows straight through when not Leitungsteam', () => {
|
||||||
|
const res = toAuthenticatedUser(row as never);
|
||||||
|
expect(res.memberships).toEqual([
|
||||||
|
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepends a synthetic global LEITUNGSTEAM membership when the flag is set', () => {
|
||||||
|
const res = toAuthenticatedUser({ ...row, isLeitungsteam: true } as never);
|
||||||
|
expect(res.memberships[0]).toEqual({
|
||||||
|
kcId: GLOBAL_LT_KC_ID,
|
||||||
|
gemeindeId: null,
|
||||||
|
role: Role.LEITUNGSTEAM,
|
||||||
|
});
|
||||||
|
expect(res.memberships).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Prisma, SyncOperation } from '@prisma/client';
|
import { Prisma, Role, SyncOperation } from '@prisma/client';
|
||||||
import { PrismaClient } from '../prisma/prisma.module';
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
import { SyncService } from '../sync/sync.service';
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
import { AuthenticatedUser } from './authenticated-request';
|
||||||
|
|
||||||
export interface AuthentikClaims {
|
export interface AuthentikClaims {
|
||||||
sub: string;
|
sub: string;
|
||||||
@@ -9,19 +10,48 @@ export interface AuthentikClaims {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Placeholder kcId for the synthetic, global LEITUNGSTEAM membership. RolesGuard
|
||||||
|
/// never compares it (LT short-circuits the KC check), it only needs to exist.
|
||||||
|
export const GLOBAL_LT_KC_ID = '*';
|
||||||
|
|
||||||
type UserWithActiveMemberships = Prisma.UserGetPayload<{
|
type UserWithActiveMemberships = Prisma.UserGetPayload<{
|
||||||
include: { memberships: true };
|
include: { memberships: true };
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/// Resolves an Authentik identity to its local `User`, creating one from the
|
/// Resolves an Authentik identity to its local `User`, creating one from the
|
||||||
/// token claims on first login (JIT provisioning). The new user has no
|
/// token claims on first login (JIT provisioning), and reconciling the
|
||||||
/// memberships and therefore no rights until one is granted (LT via Authentik
|
/// `isLeitungsteam` flag with the caller's current Authentik group membership
|
||||||
/// group sync — still manual — or the onboarding approval flow). Shared by
|
/// on every login. A brand-new user has no `Membership` and therefore no
|
||||||
/// AuthentikStrategy and the WS token path so both provision identically.
|
/// rights until one is granted (the onboarding approval flow) or the LT flag
|
||||||
|
/// is set. Shared by AuthentikStrategy and the WS token path so both behave
|
||||||
|
/// identically.
|
||||||
export async function resolveOrProvisionAuthentikUser(
|
export async function resolveOrProvisionAuthentikUser(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
sync: SyncService,
|
sync: SyncService,
|
||||||
claims: AuthentikClaims,
|
claims: AuthentikClaims,
|
||||||
|
isLeitungsteam: boolean,
|
||||||
|
): Promise<UserWithActiveMemberships> {
|
||||||
|
const user = await loadOrCreate(prisma, sync, claims);
|
||||||
|
|
||||||
|
if (user.isLeitungsteam !== isLeitungsteam) {
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { isLeitungsteam },
|
||||||
|
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||||
|
});
|
||||||
|
await sync.capture('User', SyncOperation.UPDATE, updated.id, {
|
||||||
|
...updated,
|
||||||
|
memberships: undefined,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrCreate(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
sync: SyncService,
|
||||||
|
claims: AuthentikClaims,
|
||||||
): Promise<UserWithActiveMemberships> {
|
): Promise<UserWithActiveMemberships> {
|
||||||
const existing = await prisma.user.findUnique({
|
const existing = await prisma.user.findUnique({
|
||||||
where: { authentikSub: claims.sub },
|
where: { authentikSub: claims.sub },
|
||||||
@@ -56,3 +86,26 @@ export async function resolveOrProvisionAuthentikUser(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps a provisioned user row to the request-scoped shape, prepending a
|
||||||
|
/// synthetic global LEITUNGSTEAM membership when the flag is set.
|
||||||
|
export function toAuthenticatedUser(user: UserWithActiveMemberships): AuthenticatedUser {
|
||||||
|
const memberships = user.memberships.map((m) => ({
|
||||||
|
kcId: m.kcId,
|
||||||
|
gemeindeId: m.gemeindeId,
|
||||||
|
role: m.role,
|
||||||
|
}));
|
||||||
|
if (user.isLeitungsteam) {
|
||||||
|
memberships.unshift({
|
||||||
|
kcId: GLOBAL_LT_KC_ID,
|
||||||
|
gemeindeId: null,
|
||||||
|
role: Role.LEITUNGSTEAM,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
authentikSub: user.authentikSub,
|
||||||
|
email: user.email,
|
||||||
|
memberships,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { SyncService } from '../sync/sync.service';
|
|||||||
import { AuthenticatedUser } from './authenticated-request';
|
import { AuthenticatedUser } from './authenticated-request';
|
||||||
import { GuestJwtPayload } from './guest-auth.service';
|
import { GuestJwtPayload } from './guest-auth.service';
|
||||||
import { TeamAuthService } from './team-auth.service';
|
import { TeamAuthService } from './team-auth.service';
|
||||||
import { resolveOrProvisionAuthentikUser } from './provision-user';
|
import {
|
||||||
|
AuthentikClaims,
|
||||||
|
resolveOrProvisionAuthentikUser,
|
||||||
|
toAuthenticatedUser,
|
||||||
|
} from './provision-user';
|
||||||
|
|
||||||
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
|
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
|
||||||
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
|
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
|
||||||
@@ -16,6 +20,7 @@ import { resolveOrProvisionAuthentikUser } from './provision-user';
|
|||||||
export class TokenVerificationService {
|
export class TokenVerificationService {
|
||||||
private readonly issuerUrl: string;
|
private readonly issuerUrl: string;
|
||||||
private readonly jwks: jwksRsa.JwksClient;
|
private readonly jwks: jwksRsa.JwksClient;
|
||||||
|
private readonly leitungsteamGroup: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@@ -26,17 +31,16 @@ export class TokenVerificationService {
|
|||||||
) {
|
) {
|
||||||
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
||||||
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
|
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
|
||||||
|
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifies an Authentik token's signature and returns its identity claims,
|
/// Verifies an Authentik token's signature and returns its identity claims
|
||||||
/// without requiring a local User to exist yet (used by the onboarding
|
/// plus whether the caller is in the Leitungsteam group, without requiring
|
||||||
/// self-registration path, which provisions that User).
|
/// a local User to exist yet (used by the onboarding self-registration
|
||||||
async verifyAuthentikClaims(token: string): Promise<{
|
/// path, which provisions that User).
|
||||||
sub: string;
|
async verifyAuthentikClaims(
|
||||||
email: string;
|
token: string,
|
||||||
firstName: string;
|
): Promise<AuthentikClaims & { isLeitungsteam: boolean }> {
|
||||||
lastName: string;
|
|
||||||
}> {
|
|
||||||
const decoded = jwt.decode(token, { complete: true });
|
const decoded = jwt.decode(token, { complete: true });
|
||||||
const kid = decoded?.header.kid;
|
const kid = decoded?.header.kid;
|
||||||
if (!kid) {
|
if (!kid) {
|
||||||
@@ -50,6 +54,7 @@ export class TokenVerificationService {
|
|||||||
email?: string;
|
email?: string;
|
||||||
given_name?: string;
|
given_name?: string;
|
||||||
family_name?: string;
|
family_name?: string;
|
||||||
|
groups?: string[];
|
||||||
};
|
};
|
||||||
if (!payload.sub || !payload.email) {
|
if (!payload.sub || !payload.email) {
|
||||||
throw new UnauthorizedException('Authentik token missing subject or email');
|
throw new UnauthorizedException('Authentik token missing subject or email');
|
||||||
@@ -59,22 +64,19 @@ export class TokenVerificationService {
|
|||||||
email: payload.email,
|
email: payload.email,
|
||||||
firstName: payload.given_name ?? '',
|
firstName: payload.given_name ?? '',
|
||||||
lastName: payload.family_name ?? '',
|
lastName: payload.family_name ?? '',
|
||||||
|
isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
|
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
|
||||||
const claims = await this.verifyAuthentikClaims(token);
|
const { isLeitungsteam, ...claims } = await this.verifyAuthentikClaims(token);
|
||||||
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims);
|
const user = await resolveOrProvisionAuthentikUser(
|
||||||
return {
|
this.prisma,
|
||||||
userId: user.id,
|
this.sync,
|
||||||
authentikSub: user.authentikSub,
|
claims,
|
||||||
email: user.email,
|
isLeitungsteam,
|
||||||
memberships: user.memberships.map((m) => ({
|
);
|
||||||
kcId: m.kcId,
|
return toAuthenticatedUser(user);
|
||||||
gemeindeId: m.gemeindeId,
|
|
||||||
role: m.role,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyGuest(token: string): Promise<GuestJwtPayload> {
|
async verifyGuest(token: string): Promise<GuestJwtPayload> {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export class OnboardingService {
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
throw new UnauthorizedException('Missing Authentik bearer token');
|
throw new UnauthorizedException('Missing Authentik bearer token');
|
||||||
}
|
}
|
||||||
const claims = await this.tokens.verifyAuthentikClaims(token);
|
const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token);
|
||||||
|
|
||||||
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
|
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
|
||||||
if (!kc || !kc.isActive) {
|
if (!kc || !kc.isActive) {
|
||||||
@@ -53,7 +53,12 @@ export class OnboardingService {
|
|||||||
throw new BadRequestException('Gemeinde does not belong to this KC');
|
throw new BadRequestException('Gemeinde does not belong to this KC');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims);
|
const user = await resolveOrProvisionAuthentikUser(
|
||||||
|
this.prisma,
|
||||||
|
this.sync,
|
||||||
|
claims,
|
||||||
|
isLeitungsteam,
|
||||||
|
);
|
||||||
|
|
||||||
const existing = await this.prisma.membership.findUnique({
|
const existing = await this.prisma.membership.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
Reference in New Issue
Block a user