diff --git a/apps/api/package.json b/apps/api/package.json index 989f75d..fe6753f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -30,6 +30,7 @@ "fflate": "^0.8.3", "fractional-indexing": "^4.0.0", "i18next": "^26.3.4", + "jose": "^6.2.4", "jsdom": "^26.1.0", "multer": "^2.1.1", "nestjs-pino": "^4.3.0", diff --git a/apps/api/src/audit/audit-actions.ts b/apps/api/src/audit/audit-actions.ts index 5156293..ac09c72 100644 --- a/apps/api/src/audit/audit-actions.ts +++ b/apps/api/src/audit/audit-actions.ts @@ -17,6 +17,7 @@ export const AUDIT_EVENTS = { 'api.write': { severity: 'info' }, 'audit.pruned': { severity: 'info' }, 'auth.email_verified': { severity: 'info' }, + 'auth.identity_linked': { severity: 'notice' }, 'auth.login_failed': { severity: 'warning' }, 'auth.login_succeeded': { severity: 'info' }, 'auth.password_reset': { severity: 'notice' }, diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 2d2ab64..2ad68cf 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common'; import { + AuthMethodsView, CurrentUser as CurrentUserShape, LoginInput, SignupInput, @@ -26,6 +27,7 @@ import { toCurrentUser, } from './auth.guard'; import { AuthService } from './auth.service'; +import { OidcService } from './oidc.service'; import { SessionsService, sessionAbsoluteMs } from './sessions.service'; @AuthenticatedOnly() // routes reachable without a session opt out via @Public @@ -36,6 +38,7 @@ export class AuthController { private readonly sessions: SessionsService, private readonly config: AppConfig, private readonly settings: InstanceSettingsService, + private readonly oidc: OidcService, ) {} /** Public: the SPA hides the signup route while registration is closed. */ @@ -45,6 +48,18 @@ export class AuthController { return { mode: await this.settings.get('auth.registrationMode') }; } + /** Public: what the login screen offers (issue #214) — the local form + * and/or the deploy-configured OIDC provider. */ + @SetupExempt() + @Public() + @Get('methods') + methods(): AuthMethodsView { + return { + local: true, + oidc: this.oidc.enabled ? { label: this.oidc.providerLabel } : null, + }; + } + @Public() @Post('signup') @HttpCode(201) diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 0ad630a..75b32b7 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -8,18 +8,21 @@ import { AuthController } from './auth.controller'; import { AuthGuard } from './auth.guard'; import { AuthService } from './auth.service'; import { AuthTokensService } from './auth-tokens.service'; +import { OidcController } from './oidc.controller'; +import { OidcService } from './oidc.service'; import { SessionsModule } from './sessions.module'; @Module({ imports: [UsersModule, MailModule, SessionsModule, PondsModule], - controllers: [AuthController], + controllers: [AuthController, OidcController], providers: [ AuthService, AuthTokensService, + OidcService, // Global default-protected: every route needs a session unless it // opts out with @Public(). { provide: APP_GUARD, useClass: AuthGuard }, ], - exports: [AuthTokensService, AuthService], + exports: [AuthTokensService, AuthService, OidcService], }) export class AuthModule {} diff --git a/apps/api/src/auth/oidc.controller.ts b/apps/api/src/auth/oidc.controller.ts new file mode 100644 index 0000000..ac02c01 --- /dev/null +++ b/apps/api/src/auth/oidc.controller.ts @@ -0,0 +1,106 @@ +import { Controller, Get, Query, Req, Res } from '@nestjs/common'; +import type { Response } from 'express'; + +import { AppConfig } from '../config/app-config.service'; +import { AuthenticatedOnly } from '../permissions/permission.decorators'; +import { RateLimit } from '../rate-limit/rate-limit.guard'; + +import { AuthedRequest, Public, setSessionCookie } from './auth.guard'; +import { OidcService } from './oidc.service'; +import { sessionAbsoluteMs } from './sessions.service'; + +/** Carries state+nonce+PKCE verifier across the IdP round-trip — signed + * (purpose-derived key), HttpOnly, Lax so the top-level callback + * navigation still sends it, and 10 minutes short-lived. */ +const OIDC_STATE_COOKIE = 'dt_oidc'; + +/** + * OIDC endpoints (issue #214, ADR 0021). Browser-navigation shaped: `login` + * and `link` answer 302 to the IdP, the callback lands back here and + * redirects into the SPA — errors become `/login?error=` so the SPA + * can translate them. + */ +@AuthenticatedOnly() +@Controller('auth/oidc') +export class OidcController { + constructor( + private readonly oidc: OidcService, + private readonly config: AppConfig, + ) {} + + private stateCookie(response: Response, value: string): void { + response.cookie(OIDC_STATE_COOKIE, value, { + httpOnly: true, + sameSite: 'lax', + secure: this.config.env.NODE_ENV === 'production', + maxAge: 10 * 60 * 1000, + path: '/', + }); + } + + @Public() + @Get('login') + @RateLimit({ scope: 'oidc-login', limit: 30, windowSeconds: 60 }) + async login(@Res() response: Response): Promise { + this.oidc.assertEnabled(); + const { url, stateToken } = await this.oidc.beginLogin(); + this.stateCookie(response, stateToken); + response.redirect(url); + } + + /** The deliberate account-linking flow (ADR 0021 §2): only a logged-in + * user attaches an IdP identity to their own account. */ + @Get('link') + @RateLimit({ scope: 'oidc-login', limit: 30, windowSeconds: 60 }) + async link(@Req() request: AuthedRequest, @Res() response: Response): Promise { + this.oidc.assertEnabled(); + const { url, stateToken } = await this.oidc.beginLogin(request.user!.id); + this.stateCookie(response, stateToken); + response.redirect(url); + } + + @Public() + @Get('callback') + @RateLimit({ scope: 'oidc-callback', limit: 30, windowSeconds: 60 }) + async callback( + @Query('code') code: string | undefined, + @Query('state') state: string | undefined, + @Query('error') idpError: string | undefined, + @Req() request: AuthedRequest, + @Res() response: Response, + ): Promise { + this.oidc.assertEnabled(); + const base = this.config.env.APP_BASE_URL; + response.clearCookie(OIDC_STATE_COOKIE, { path: '/' }); + const stateToken = (request.cookies as Record | undefined)?.[OIDC_STATE_COOKIE]; + if (idpError || !code || !state || !stateToken) { + response.redirect(`${base}/login?error=oidc_cancelled`); + return; + } + try { + const result = await this.oidc.completeLogin( + code, + state, + stateToken, + request.headers['user-agent'], + ); + if (result.linked) { + response.redirect(`${base}/settings?oidc=linked`); + return; + } + setSessionCookie( + response, + result.sessionToken!, + this.config.env.NODE_ENV === 'production', + sessionAbsoluteMs(this.config.env), + ); + response.redirect(`${base}/`); + } catch (error) { + const code_ = + typeof (error as { response?: { code?: string } })?.response?.code === 'string' + ? (error as { response: { code: string } }).response.code + : 'oidc_failed'; + response.redirect(`${base}/login?error=${encodeURIComponent(code_)}`); + } + } +} diff --git a/apps/api/src/auth/oidc.e2e.db.test.ts b/apps/api/src/auth/oidc.e2e.db.test.ts new file mode 100644 index 0000000..6a27881 --- /dev/null +++ b/apps/api/src/auth/oidc.e2e.db.test.ts @@ -0,0 +1,351 @@ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { SignJWT, exportJWK, generateKeyPair, type JWTPayload } from 'jose'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +/** + * OIDC Authorization Code + PKCE against a local fake IdP (issue #214, + * ADR 0021): discovery, JWKS-validated ID tokens, state/nonce binding, PKCE + * verifier at the token endpoint, JIT account creation, the documented + * refusal to link silently by e-mail, and the explicit link flow. The fake + * IdP is protocol-shaped exactly like Keycloak's endpoints — the Keycloak + * verification itself is a manual procedure (security.md §External + * authentication). + */ +describe.skipIf(!hasTestDb)('oidc login (e2e, issue #214)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let idp: Server; + let issuer: string; + const suffix = uniqueSuffix(); + + let signingKey: CryptoKey; + let publicJwk: Record; + let wrongKey: CryptoKey; + /** What the fake token endpoint returns next (set per test). */ + let nextIdToken: (() => Promise) | null = null; + /** The last body the token endpoint received (PKCE assertions). */ + let lastTokenRequest: URLSearchParams | null = null; + + const api = () => request(app.getHttpServer()); + + async function mintIdToken( + claims: JWTPayload, + options: { key?: CryptoKey; expired?: boolean } = {}, + ): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ ...claims }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuedAt(options.expired ? now - 7200 : now) + .setExpirationTime(options.expired ? now - 3600 : now + 300) + .sign(options.key ?? signingKey); + } + + /** Runs /auth/oidc/login and returns the pieces the callback needs. */ + async function beginLogin(cookie?: string) { + const req = api().get('/api/v1/auth/oidc/login'); + const res = await (cookie ? req.set('Cookie', cookie) : req).expect(302); + const url = new URL(res.headers.location!); + const stateCookie = (res.headers['set-cookie'] as unknown as string[]) + .find((c) => c.startsWith('dt_oidc='))! + .split(';')[0]!; + return { + state: url.searchParams.get('state')!, + nonce: url.searchParams.get('nonce')!, + challenge: url.searchParams.get('code_challenge')!, + stateCookie, + authorizeUrl: url, + }; + } + + async function callback(state: string, stateCookie: string) { + return api() + .get(`/api/v1/auth/oidc/callback?code=fake-code&state=${encodeURIComponent(state)}`) + .set('Cookie', stateCookie); + } + + function redirectTarget(res: request.Response): string { + return res.headers.location!; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + let signingPublic: CryptoKey; + ({ privateKey: signingKey, publicKey: signingPublic } = await generateKeyPair('RS256', { + extractable: true, + })); + ({ privateKey: wrongKey } = await generateKeyPair('RS256', { extractable: true })); + publicJwk = { ...(await exportJWK(signingPublic)), kid: 'test-key', alg: 'RS256' }; + + idp = createServer((req, res) => { + void (async () => { + if (req.url === '/.well-known/openid-configuration') { + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/jwks`, + }), + ); + return; + } + if (req.url === '/jwks') { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ keys: [publicJwk] })); + return; + } + if (req.url === '/token') { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + void (async () => { + lastTokenRequest = new URLSearchParams(body); + res.setHeader('content-type', 'application/json'); + if (!nextIdToken) { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'invalid_grant' })); + return; + } + res.end(JSON.stringify({ id_token: await nextIdToken(), token_type: 'Bearer' })); + })(); + }); + return; + } + res.statusCode = 404; + res.end(); + })(); + }); + await new Promise((resolve) => idp.listen(0, '127.0.0.1', resolve)); + issuer = `http://127.0.0.1:${(idp.address() as AddressInfo).port}`; + + process.env.OIDC_ISSUER = issuer; + process.env.OIDC_CLIENT_ID = 'dorfteich-test'; + process.env.OIDC_PROVIDER_LABEL = 'Fake IdP'; + app = await createTestApp(); + }); + + afterAll(async () => { + delete process.env.OIDC_ISSUER; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_PROVIDER_LABEL; + await new Promise((resolve) => idp.close(() => resolve())); + await prisma.userIdentity.deleteMany({ where: { provider: `oidc:${issuer}` } }); + await prisma.page.deleteMany({ + where: { pond: { owner: { email: { contains: `${suffix}@idp.example` } } } }, + }); + await prisma.roleGrant.deleteMany({ + where: { pond: { owner: { email: { contains: `${suffix}@idp.example` } } } }, + }); + await prisma.pond.deleteMany({ + where: { owner: { email: { contains: `${suffix}@idp.example` } } }, + }); + await prisma.user.deleteMany({ where: { email: { contains: `${suffix}@idp.example` } } }); + await prisma.user.deleteMany({ where: { username: { contains: `local-${suffix}` } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('advertises the provider on /auth/methods', async () => { + const res = await api().get('/api/v1/auth/methods').expect(200); + expect(res.body).toEqual({ local: true, oidc: { label: 'Fake IdP' } }); + }); + + it('logs in end to end: PKCE at the token endpoint, JIT user, identity, personal pond, session', async () => { + const { state, nonce, challenge, stateCookie, authorizeUrl } = await beginLogin(); + expect(authorizeUrl.searchParams.get('code_challenge_method')).toBe('S256'); + expect(authorizeUrl.searchParams.get('client_id')).toBe('dorfteich-test'); + + nextIdToken = () => + mintIdToken({ + iss: issuer, + aud: 'dorfteich-test', + sub: `subject-${suffix}`, + nonce, + email: `nadia-${suffix}@idp.example`, + email_verified: true, + preferred_username: `nadia-${suffix}`, + name: 'Nadia IdP', + }); + const res = await callback(state, stateCookie); + expect(res.status).toBe(302); + expect(redirectTarget(res)).toMatch(/\/$/); + const session = sessionCookieOf(res); + expect(session).toContain('dt_session='); + + // PKCE: the verifier travelled to the token endpoint and matches the + // challenge from the authorize redirect. + expect(lastTokenRequest?.get('grant_type')).toBe('authorization_code'); + const verifier = lastTokenRequest?.get('code_verifier'); + expect(verifier).toBeTruthy(); + const { createHash } = await import('node:crypto'); + expect(createHash('sha256').update(verifier!).digest('base64url')).toBe(challenge); + + const user = await prisma.user.findUnique({ + where: { email: `nadia-${suffix}@idp.example` }, + }); + expect(user).toMatchObject({ status: 'ACTIVE', displayName: 'Nadia IdP' }); + const identity = await prisma.userIdentity.findUnique({ + where: { + provider_subject: { provider: `oidc:${issuer}`, subject: `subject-${suffix}` }, + }, + }); + expect(identity?.userId).toBe(user!.id); + const personal = await prisma.pond.findFirst({ + where: { ownerId: user!.id, type: 'PERSONAL' }, + }); + expect(personal).not.toBeNull(); + + const me = await api().get('/api/v1/auth/me').set('Cookie', session).expect(200); + expect(me.body.email).toBe(`nadia-${suffix}@idp.example`); + }); + + it('reuses the existing account on the next login of the same subject', async () => { + const before = await prisma.user.count({ where: { email: { contains: `${suffix}@idp` } } }); + const { state, nonce, stateCookie } = await beginLogin(); + nextIdToken = () => + mintIdToken({ + iss: issuer, + aud: 'dorfteich-test', + sub: `subject-${suffix}`, + nonce, + email: `nadia-${suffix}@idp.example`, + email_verified: true, + }); + const res = await callback(state, stateCookie); + expect(res.status).toBe(302); + expect(redirectTarget(res)).toMatch(/\/$/); + const after = await prisma.user.count({ where: { email: { contains: `${suffix}@idp` } } }); + expect(after).toBe(before); + }); + + it('rejects a wrong state, a foreign nonce, a bad signature, wrong issuer/audience and an expired token', async () => { + // Wrong state: cookie from one round, state from nowhere. + const first = await beginLogin(); + const bad = await callback('not-the-state', first.stateCookie); + expect(redirectTarget(bad)).toContain('error=oidc_state_invalid'); + + const cases: { + claims: (nonce: string) => JWTPayload; + options?: { key?: CryptoKey; expired?: boolean }; + }[] = [ + // Foreign nonce. + { claims: () => baseClaims('other-nonce') }, + // Signature from the wrong key. + { claims: (n) => baseClaims(n), options: { key: wrongKey } }, + // Wrong issuer. + { claims: (n) => ({ ...baseClaims(n), iss: 'https://evil.example' }) }, + // Wrong audience. + { claims: (n) => ({ ...baseClaims(n), aud: 'someone-else' }) }, + // Expired. + { claims: (n) => baseClaims(n), options: { expired: true } }, + ]; + function baseClaims(nonce: string): JWTPayload { + return { + iss: issuer, + aud: 'dorfteich-test', + sub: `reject-${suffix}`, + nonce, + email: `reject-${suffix}@idp.example`, + email_verified: true, + }; + } + for (const testCase of cases) { + const { state, nonce, stateCookie } = await beginLogin(); + nextIdToken = () => mintIdToken(testCase.claims(nonce), testCase.options); + const res = await callback(state, stateCookie); + expect(redirectTarget(res)).toContain('error=oidc_token_invalid'); + } + // None of the rejected attempts created anything. + expect( + await prisma.user.findUnique({ where: { email: `reject-${suffix}@idp.example` } }), + ).toBeNull(); + }); + + it('refuses to adopt an existing local account by e-mail — and links it via the explicit flow', async () => { + const users = app.get(UsersService); + const password = 'lokales konto 123'; + const local = await users.createUser({ + username: `local-${suffix}`, + email: `local-${suffix}@idp.example`, + displayName: 'Local User', + password, + locale: 'en', + }); + await users.markEmailVerified(local.id); + + // Silent adoption refused (ADR 0021 §2 — account-takeover path). + const attempt = await beginLogin(); + nextIdToken = () => + mintIdToken({ + iss: issuer, + aud: 'dorfteich-test', + sub: `local-subject-${suffix}`, + nonce: attempt.nonce, + email: `local-${suffix}@idp.example`, + email_verified: true, + }); + const refused = await callback(attempt.state, attempt.stateCookie); + expect(redirectTarget(refused)).toContain('error=oidc_link_required'); + + // The explicit link flow, from a logged-in session. + const login = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: `local-${suffix}`, password }) + .expect(200); + const sessionCookie = sessionCookieOf(login); + const linkRes = await api() + .get('/api/v1/auth/oidc/link') + .set('Cookie', sessionCookie) + .expect(302); + const linkUrl = new URL(linkRes.headers.location!); + const linkState = linkUrl.searchParams.get('state')!; + const linkNonce = linkUrl.searchParams.get('nonce')!; + const linkCookie = (linkRes.headers['set-cookie'] as unknown as string[]) + .find((c) => c.startsWith('dt_oidc='))! + .split(';')[0]!; + nextIdToken = () => + mintIdToken({ + iss: issuer, + aud: 'dorfteich-test', + sub: `local-subject-${suffix}`, + nonce: linkNonce, + email: `local-${suffix}@idp.example`, + email_verified: true, + }); + const linked = await callback(linkState, linkCookie); + expect(redirectTarget(linked)).toContain('oidc=linked'); + const identity = await prisma.userIdentity.findUnique({ + where: { + provider_subject: { provider: `oidc:${issuer}`, subject: `local-subject-${suffix}` }, + }, + }); + expect(identity?.userId).toBe(local.id); + + // From now on the IdP login lands in the linked account. + const again = await beginLogin(); + nextIdToken = () => + mintIdToken({ + iss: issuer, + aud: 'dorfteich-test', + sub: `local-subject-${suffix}`, + nonce: again.nonce, + email: `local-${suffix}@idp.example`, + email_verified: true, + }); + const res = await callback(again.state, again.stateCookie); + const session = sessionCookieOf(res); + const me = await api().get('/api/v1/auth/me').set('Cookie', session).expect(200); + expect(me.body.id).toBe(local.id); + }); +}); diff --git a/apps/api/src/auth/oidc.service.ts b/apps/api/src/auth/oidc.service.ts new file mode 100644 index 0000000..6c17ffb --- /dev/null +++ b/apps/api/src/auth/oidc.service.ts @@ -0,0 +1,353 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { slugify } from '@dorfteich/shared'; +import { deriveTokenKey } from '@dorfteich/shared/token-crypto'; +import { User } from '@prisma/client'; +import { SignJWT, createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; +import { PinoLogger } from 'nestjs-pino'; + +import { AuditService } from '../audit/audit.service'; +import { AppConfig } from '../config/app-config.service'; +import { PondsService } from '../ponds/ponds.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { UsersService } from '../users/users.service'; + +import { SessionsService } from './sessions.service'; + +/** The state cookie's signed payload lives this long — ample for one + * round-trip to the IdP's login form. */ +const STATE_TTL_SECONDS = 10 * 60; + +/** Explicit asymmetric allowlist for ID-token signatures (no HS*, no + * `none`): Keycloak's default RS256 plus the common EC profile. */ +const ID_TOKEN_ALGORITHMS = ['RS256', 'ES256']; + +/** What we mint into the signed, HttpOnly state cookie before redirecting + * to the IdP: CSRF binding (`state`), replay binding (`nonce`), the PKCE + * verifier, and — for the deliberate account-linking flow — the session + * user the new identity must attach to. */ +interface OidcStateClaims extends JWTPayload { + state: string; + nonce: string; + codeVerifier: string; + linkUserId?: string; +} + +interface DiscoveryDocument { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + jwks_uri: string; + end_session_endpoint?: string; +} + +/** + * OIDC Authorization Code with PKCE (issue #214, ADR 0021). Deliberately + * built on `jose` (the vetted library from #188) plus `fetch` — no new + * dependency enters the supply chain for a security base function. + * Discovery-based: nothing here is Keycloak-specific; Keycloak is the + * reference IdP the flow is verified against (procedure in + * `docs/architecture/security.md` §External authentication). + * + * Identity linking follows ADR 0021 §2: `provider = "oidc:"`, + * `subject` from the token. An existing local account is NEVER linked + * silently by e-mail — that would be an account-takeover path. Instead the + * login is refused with `oidc_link_required`, and the user (logged in + * locally) links explicitly via `GET /auth/oidc/link`. + */ +@Injectable() +export class OidcService { + private discoveryCache: DiscoveryDocument | null = null; + private jwks: ReturnType | null = null; + + constructor( + private readonly prisma: PrismaService, + private readonly users: UsersService, + private readonly sessions: SessionsService, + private readonly ponds: PondsService, + private readonly audit: AuditService, + private readonly config: AppConfig, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(OidcService.name); + } + + /** OIDC is a deploy-level decision (ADR 0021): enabled iff issuer and + * client id are configured. */ + get enabled(): boolean { + return Boolean(this.config.env.OIDC_ISSUER && this.config.env.OIDC_CLIENT_ID); + } + + get providerLabel(): string { + return this.config.env.OIDC_PROVIDER_LABEL; + } + + private get issuer(): string { + return this.config.env.OIDC_ISSUER!; + } + + private get clientId(): string { + return this.config.env.OIDC_CLIENT_ID!; + } + + private get redirectUri(): string { + return `${this.config.env.APP_BASE_URL}/api/v1/auth/oidc/callback`; + } + + /** The identity provider key: one issuer, one provider namespace. */ + private get provider(): string { + return `oidc:${this.issuer}`; + } + + assertEnabled(): void { + // 404, not 403: consistent with the instance switches (`api.enabled` + // et al.) — an unconfigured surface hides its existence. + if (!this.enabled) throw new NotFoundException(); + } + + private async discover(): Promise { + if (this.discoveryCache) return this.discoveryCache; + const url = `${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`; + const response = await fetch(url).catch(() => null); + if (!response?.ok) { + throw new ServiceUnavailableException({ code: 'oidc_discovery_failed' }); + } + const doc = (await response.json()) as DiscoveryDocument; + if (doc.issuer !== this.issuer) { + // RFC 8414 §3.3: the advertised issuer must match the configured one. + throw new ServiceUnavailableException({ code: 'oidc_discovery_failed' }); + } + this.discoveryCache = doc; + this.jwks = createRemoteJWKSet(new URL(doc.jwks_uri)); + return doc; + } + + /** Builds the IdP redirect plus the signed state-cookie value. */ + async beginLogin(linkUserId?: string): Promise<{ url: string; stateToken: string }> { + const doc = await this.discover(); + const state = randomBytes(24).toString('base64url'); + const nonce = randomBytes(24).toString('base64url'); + const codeVerifier = randomBytes(48).toString('base64url'); + const challenge = createHash('sha256').update(codeVerifier).digest('base64url'); + + const url = new URL(doc.authorization_endpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', this.clientId); + url.searchParams.set('redirect_uri', this.redirectUri); + url.searchParams.set('scope', this.config.env.OIDC_SCOPES); + url.searchParams.set('state', state); + url.searchParams.set('nonce', nonce); + url.searchParams.set('code_challenge', challenge); + url.searchParams.set('code_challenge_method', 'S256'); + + const now = Math.floor(Date.now() / 1000); + const claims: OidcStateClaims = { state, nonce, codeVerifier }; + if (linkUserId) claims.linkUserId = linkUserId; + const stateToken = await new SignJWT({ ...claims }) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuedAt(now) + .setExpirationTime(now + STATE_TTL_SECONDS) + .sign(deriveTokenKey(this.config.env.COLLAB_TOKEN_SECRET, 'oidc-state')); + + return { url: url.toString(), stateToken }; + } + + private async verifyStateToken(stateToken: string): Promise { + try { + const { payload } = await jwtVerify( + stateToken, + deriveTokenKey(this.config.env.COLLAB_TOKEN_SECRET, 'oidc-state'), + { algorithms: ['HS256'] }, + ); + if (typeof payload.state !== 'string' || typeof payload.nonce !== 'string') throw new Error(); + if (typeof payload.codeVerifier !== 'string') throw new Error(); + return payload as OidcStateClaims; + } catch { + throw new BadRequestException({ code: 'oidc_state_invalid' }); + } + } + + /** + * The callback half: state check, code exchange, ID-token validation + * (signature via JWKS, issuer, audience, expiry — and the nonce binding), + * then identity resolution. Returns the session token to set plus where + * the SPA should land. + */ + async completeLogin( + code: string, + state: string, + stateToken: string, + userAgent: string | undefined, + ): Promise<{ sessionToken: string | null; linked: boolean }> { + const doc = await this.discover(); + const stored = await this.verifyStateToken(stateToken); + if (state !== stored.state) { + throw new BadRequestException({ code: 'oidc_state_invalid' }); + } + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: this.redirectUri, + client_id: this.clientId, + code_verifier: stored.codeVerifier, + }); + // Confidential client: secret via client_secret_post (Keycloak default + // accepts it); a public client authenticates with PKCE alone. + if (this.config.env.OIDC_CLIENT_SECRET) { + body.set('client_secret', this.config.env.OIDC_CLIENT_SECRET); + } + const tokenResponse = await fetch(doc.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }).catch(() => null); + if (!tokenResponse?.ok) { + this.logger.warn({ status: tokenResponse?.status }, 'oidc: code exchange failed'); + throw new BadRequestException({ code: 'oidc_exchange_failed' }); + } + const tokens = (await tokenResponse.json()) as { id_token?: string }; + if (!tokens.id_token) throw new BadRequestException({ code: 'oidc_exchange_failed' }); + + let payload: JWTPayload; + try { + ({ payload } = await jwtVerify(tokens.id_token, this.jwks!, { + issuer: this.issuer, + audience: this.clientId, + algorithms: ID_TOKEN_ALGORITHMS, + })); + } catch (error) { + this.logger.warn({ err: error }, 'oidc: id token rejected'); + throw new BadRequestException({ code: 'oidc_token_invalid' }); + } + if (typeof payload.nonce !== 'string' || payload.nonce !== stored.nonce) { + throw new BadRequestException({ code: 'oidc_token_invalid' }); + } + if (typeof payload.sub !== 'string' || payload.sub.length === 0) { + throw new BadRequestException({ code: 'oidc_token_invalid' }); + } + + if (stored.linkUserId) { + await this.linkIdentity(stored.linkUserId, payload.sub); + return { sessionToken: null, linked: true }; + } + + const user = await this.resolveUser(payload); + if (user.status === 'DISABLED') { + throw new BadRequestException({ code: 'account_disabled' }); + } + const sessionToken = await this.sessions.create(user.id, userAgent); + await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); + await this.audit.record({ + action: 'auth.login_succeeded', + actorId: user.id, + details: { provider: this.provider }, + }); + return { sessionToken, linked: false }; + } + + /** The deliberate linking rule (ADR 0021 §2): only an authenticated user + * links an IdP identity to their own account — never automatic by mail. */ + private async linkIdentity(userId: string, subject: string): Promise { + const existing = await this.prisma.userIdentity.findUnique({ + where: { provider_subject: { provider: this.provider, subject } }, + }); + if (existing && existing.userId !== userId) { + throw new ConflictException({ code: 'oidc_identity_taken' }); + } + if (!existing) { + await this.prisma.userIdentity.create({ + data: { userId, provider: this.provider, subject }, + }); + await this.audit.record({ + action: 'auth.identity_linked', + actorId: userId, + details: { provider: this.provider }, + }); + } + } + + private async resolveUser(payload: JWTPayload): Promise { + const identity = await this.prisma.userIdentity.findUnique({ + where: { provider_subject: { provider: this.provider, subject: payload.sub! } }, + }); + if (identity) { + const user = await this.users.findById(identity.userId); + if (!user) throw new BadRequestException({ code: 'oidc_token_invalid' }); + return user; + } + + // First login of this subject: just-in-time creation. The IdP owns the + // account lifecycle (ADR 0021), so the account arrives ACTIVE and + // mail-verified — provided the IdP says the address is verified. + const email = typeof payload.email === 'string' ? payload.email.toLowerCase() : null; + if (!email) throw new BadRequestException({ code: 'oidc_email_missing' }); + if (payload.email_verified === false) { + throw new BadRequestException({ code: 'oidc_email_unverified' }); + } + const clash = await this.users.findByEmail(email); + if (clash) { + // The documented refusal: the local owner of this address must link + // explicitly (GET /auth/oidc/link) — silent adoption would be an + // account-takeover path (ADR 0021 §2). + throw new ConflictException({ code: 'oidc_link_required' }); + } + + const preferred = + typeof payload.preferred_username === 'string' && payload.preferred_username + ? payload.preferred_username + : email.split('@')[0]!; + const displayName = + typeof payload.name === 'string' && payload.name.trim() ? payload.name.trim() : preferred; + const username = await this.uniqueUsername(slugify(preferred) || 'user'); + + const user = await this.prisma.$transaction(async (tx) => { + const created = await tx.user.create({ + data: { + username, + email, + displayName, + locale: 'en', + status: 'ACTIVE', + emailVerifiedAt: new Date(), + }, + }); + await tx.userIdentity.create({ + data: { userId: created.id, provider: this.provider, subject: payload.sub! }, + }); + return created; + }); + // Same invariant as e-mail verification: every active account owns a + // personal pond (idempotent). + await this.ponds.ensurePersonalPond(user); + await this.audit.record({ + action: 'auth.signup', + actorId: user.id, + details: { provider: this.provider }, + }); + return user; + } + + private async uniqueUsername(base: string): Promise { + const taken = new Set( + ( + await this.prisma.user.findMany({ + where: { OR: [{ username: base }, { username: { startsWith: `${base}-` } }] }, + select: { username: true }, + }) + ).map((row) => row.username), + ); + if (!taken.has(base)) return base; + for (let n = 2; ; n += 1) { + const candidate = `${base}-${n}`; + if (!taken.has(candidate)) return candidate; + } + } +} diff --git a/apps/web/src/pages/auth/LoginPage.tsx b/apps/web/src/pages/auth/LoginPage.tsx index 08d8596..406f56a 100644 --- a/apps/web/src/pages/auth/LoginPage.tsx +++ b/apps/web/src/pages/auth/LoginPage.tsx @@ -1,5 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { LoginInput, loginInputSchema } from '@dorfteich/shared'; +import { AuthMethodsView, LoginInput, loginInputSchema } from '@dorfteich/shared'; +import { useQuery } from '@tanstack/react-query'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -7,7 +8,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../../auth/auth-context'; import { Field, FormError } from '../../components/forms'; -import { ApiError, apiPost } from '../../lib/api'; +import { ApiError, apiGet, apiPost } from '../../lib/api'; import { useDocumentTitle } from '../../lib/use-document-title'; export function LoginPage(): React.JSX.Element { @@ -21,6 +22,16 @@ export function LoginPage(): React.JSX.Element { const form = useForm({ resolver: zodResolver(loginInputSchema) }); + // Which sign-in paths this deployment offers (issue #214): the SSO button + // appears only when OIDC is configured server-side. + const methods = useQuery({ + queryKey: ['auth-methods'], + queryFn: () => apiGet('/auth/methods'), + }); + // An OIDC callback failure lands back here as ?error= (full-page + // redirect flow — no SPA state survives the IdP round-trip). + const oidcError = params.get('error'); + const onSubmit = form.handleSubmit(async (input) => { setError(null); try { @@ -45,6 +56,23 @@ export function LoginPage(): React.JSX.Element { return (

{t('auth:login.title')}

+ {oidcError && ( +

+ {t([`auth:oidc.errors.${oidcError}`, 'auth:oidc.errors.oidc_failed'])} +

+ )} + {methods.data?.oidc && ( + <> + {/* Full-page navigation on purpose: the OIDC flow is a redirect + chain the SPA cannot ride along on. */} + + {t('auth:oidc.signIn', { provider: methods.data.oidc.label })} + + + + )}
{unverified && !resent && ( diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index eee2963..21ff5c9 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -782,6 +782,23 @@ button { font-size: 0.95rem; } +/* OIDC sign-in (issue #214): the SSO button is a full-width link styled as + a button; the separator sits between it and the local form. */ +.button--block { + display: block; + width: 100%; + text-align: center; + text-decoration: none; + box-sizing: border-box; +} + +.auth-card__separator { + margin: var(--space-4) 0; + text-align: center; + color: var(--color-text-muted); + font-size: 0.9rem; +} + /* Legal pages + footer (issue #82) */ .app-footer { display: flex; diff --git a/docs/architecture/adr/0021-external-authentication.md b/docs/architecture/adr/0021-external-authentication.md index e045a2a..52e877d 100644 --- a/docs/architecture/adr/0021-external-authentication.md +++ b/docs/architecture/adr/0021-external-authentication.md @@ -46,6 +46,27 @@ expect the application to trust a header or a client certificate. 6. **No MFA, no password policy engine of our own** (ADR 0019). Both are the IdP's. +## Decisions taken in #214 + +- **Implementation on `jose` + `fetch`** — the vetted JWT library from + #188 plus the platform HTTP client. `openid-client` was rejected: no new + dependency enters the supply chain for a security base function, and the + code path (discovery, authorize URL, code exchange, JWKS validation) is + small enough to own. +- **ID-token algorithms**: explicit `RS256`/`ES256` allowlist; HS* and + `none` can never verify. +- **Client authentication**: `client_secret_post` when a secret is + configured; a public client runs on PKCE alone (PKCE is always sent). +- **No IdP-initiated single logout**: sessions are short-bounded (#190), + the leaver case is covered by claim-mapping revocation (#217) and the + disable flag. Front-channel logout would add an unauthenticated, + spoofable endpoint for marginal gain. +- **JIT accounts** arrive ACTIVE and mail-verified, but only when the IdP + asserts a verified address (`email_verified` must not be false; missing + e-mail refuses the login). The linking refusal (`oidc_link_required`) + plus the explicit `GET /auth/oidc/link` flow (audited + `auth.identity_linked`) is the documented linking rule. + ## Consequences - Bootstrapping needs a documented answer: the first-run wizard creates a diff --git a/docs/architecture/audit-events.md b/docs/architecture/audit-events.md index 8e2e695..4adf246 100644 --- a/docs/architecture/audit-events.md +++ b/docs/architecture/audit-events.md @@ -1,7 +1,8 @@ # Audit event catalogue -**Catalogue version 1.2 (2026-07-31; 1.2 adds `read_trail.pruned`, -issue #224; 1.1 added `page.classification_*`, issue #205).** +**Catalogue version 1.3 (2026-07-31; 1.3 adds `auth.identity_linked`, +issue #214; 1.2 added `read_trail.pruned`, issue #224; 1.1 added +`page.classification_*`, issue #205).** This is the operator-facing contract for the audit trail: every event id the application can emit, with its trigger, severity, actor/target @@ -62,13 +63,14 @@ failure), `warning` = feeds detection (suspicious or destructive), ### Authentication (`auth.*`) -| Id | Trigger | Severity | Actor | Target | Fields | -| ---------------------- | ------------------------------------- | -------- | ------------------------------------ | ------ | ------ | -| `auth.signup` | Account created via self-registration | info | the new user | — | — | -| `auth.email_verified` | E-mail double-opt-in completed | info | the verified user | — | — | -| `auth.login_failed` | Login rejected (bad credentials) | warning | matched user, `null` if unknown name | — | — | -| `auth.login_succeeded` | Session created | info | the user | — | — | -| `auth.password_reset` | Password changed via reset token | notice | the user | — | — | +| Id | Trigger | Severity | Actor | Target | Fields | +| ---------------------- | ----------------------------------------------------------------------------------- | -------- | ------------------------------------ | ------ | ------------------------------------- | +| `auth.signup` | Account created via self-registration or OIDC just-in-time (issue #214) | info | the new user | — | `provider` (optional; absent = local) | +| `auth.email_verified` | E-mail double-opt-in completed | info | the verified user | — | — | +| `auth.login_failed` | Login rejected (bad credentials) | warning | matched user, `null` if unknown name | — | — | +| `auth.login_succeeded` | Session created | info | the user | — | `provider` (optional; absent = local) | +| `auth.password_reset` | Password changed via reset token | notice | the user | — | — | +| `auth.identity_linked` | OIDC identity linked to an existing account via the explicit link flow (issue #214) | notice | the linking user | — | `provider` | ### Access & membership (`grant.*`, `member.*`) diff --git a/docs/architecture/security.md b/docs/architecture/security.md index 5d0a780..72f9cec 100644 --- a/docs/architecture/security.md +++ b/docs/architecture/security.md @@ -43,6 +43,47 @@ or sloppy plugin authors, compromised dependencies. - Self-registration can be disabled instance-wide; personal-pond quotas (editors/readers/ponds/storage) bound the blast radius of spam accounts. +## External authentication (OIDC, issue #214, ADR 0021) + +- **Authorization Code + PKCE**, discovery-configured, ID tokens validated + against the IdP's JWKS with an explicit `RS256`/`ES256` allowlist — + built on `jose` (the vetted library from #188) plus `fetch`, so no new + dependency enters the supply chain for a security base function. + Nothing is IdP-specific; Keycloak is the reference IdP. +- **Deploy-level configuration** (who authenticates users is a platform + decision, not a Site-Admin setting): `OIDC_ISSUER`, `OIDC_CLIENT_ID`, + optional `OIDC_CLIENT_SECRET` (public client uses PKCE alone), + `OIDC_SCOPES` (default `openid profile email`), `OIDC_PROVIDER_LABEL` + (login-button text). Enabled iff issuer + client id are set; the login + page discovers this via `GET /auth/methods`. +- **State, nonce and the PKCE verifier** travel in a signed, HttpOnly, + SameSite=Lax cookie (10 min TTL) whose key is HKDF-derived for the + dedicated `oidc-state` purpose (ADR 0020) — the callback binds the + IdP's `state` and the ID token's `nonce` to exactly that browser. +- **Identities**: `provider = "oidc:"`, `subject` from the token. + First login creates the account just-in-time (ACTIVE, mail verified — + refused if the IdP does not supply a verified address). An existing + local account with the same address is NEVER adopted silently (that is + an account-takeover path): the login is refused with + `oidc_link_required`, and the owner links explicitly via + `GET /auth/oidc/link` from a logged-in session (audited as + `auth.identity_linked`). +- **One session mechanism**: OIDC produces the same server-side session + as the password login (#190 bounds apply). IdP-initiated single logout + is deliberately NOT implemented: sessions are short-bounded, and the + claim-mapping revocation path (#217) plus the account-disable flag + cover the leaver case — recorded in ADR 0021. +- **Keycloak verification procedure** (repeatable): run + `docker run --name keycloak-local -p 8089:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.0 start-dev`; + via `kcadm.sh`: create realm `dorfteich`, a public client + `dorfteich-web` with redirect URI + `/api/v1/auth/oidc/callback`, and a user with password + + verified mail. Start the api with the OIDC variables pointing at + `http://localhost:8089/realms/dorfteich`, then drive + `GET /auth/oidc/login` → Keycloak form login → callback with a cookie + jar (curl suffices) and confirm `GET /auth/me` returns the + just-in-time account. Last verified 2026-07-31 against Keycloak 26.0. + ## Authorization - Single resolution algorithm (`permissions.md`) in `packages/shared`, diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index bfe503f..06c1e9f 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -36,7 +36,7 @@ Stattdessen: **delegieren und dokumentieren.** _Meilenstein: `M27 — VS-NfD: external authentication`_ -- [ ] OIDC Authorization Code + PKCE gegen `UserIdentity.provider` (ADR 0007 +- [x] OIDC Authorization Code + PKCE gegen `UserIdentity.provider` (ADR 0007 ausbauen), Keycloak als Referenz-IdP · 5–6 AT · #214 - [ ] Alternativpfad vertrauenswürdiger Reverse-Proxy-Header bzw. mTLS- Client-Zertifikat · 2 AT · #215 diff --git a/docs/vs-nfd/50-haertungsleitfaden.md b/docs/vs-nfd/50-haertungsleitfaden.md index 445e9e3..e4c51da 100644 --- a/docs/vs-nfd/50-haertungsleitfaden.md +++ b/docs/vs-nfd/50-haertungsleitfaden.md @@ -46,14 +46,15 @@ Settings-Cache ist in-process (operations.md). ### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI) -| Variable | Referenzwert | Warum | -| ----------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). | -| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. | -| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. | -| `SMTP_HOST` etc. | **unkonfiguriert lassen** (oder internes Relay) | ohne SMTP verlassen keinerlei Inhaltstitel die Instanz per Mail (Digest-Restrisiko I-23 entfällt vollständig). Konsequenz ehrlich benannt: dann gibt es keine Verifikations- und Passwort-Reset-Mails — Kontenpflege läuft über den Site-Admin. Wer Mail braucht, nutzt ein internes Relay und akzeptiert I-23 (Restrisikoliste). | -| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). | -| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. | +| Variable | Referenzwert | Warum | +| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). | +| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. | +| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. | +| `SMTP_HOST` etc. | **unkonfiguriert lassen** (oder internes Relay) | ohne SMTP verlassen keinerlei Inhaltstitel die Instanz per Mail (Digest-Restrisiko I-23 entfällt vollständig). Konsequenz ehrlich benannt: dann gibt es keine Verifikations- und Passwort-Reset-Mails — Kontenpflege läuft über den Site-Admin. Wer Mail braucht, nutzt ein internes Relay und akzeptiert I-23 (Restrisikoliste). | +| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). | +| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. | +| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_SCOPES`, `OIDC_PROVIDER_LABEL` | IdP der Behörde konfigurieren | Fremdauthentisierung (#214, ADR 0021): Authorization Code + PKCE gegen den IdP der Umgebung; deploy-seitig, weil die Authentisierungshoheit Plattformsache ist. Erst-Login legt Konten just-in-time an; bestehende lokale Konten werden NIE stillschweigend per E-Mail übernommen (expliziter Link-Flow). Konfigurationsdetails: security.md §External authentication. | ### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR) diff --git a/packages/shared/i18n/de/auth.json b/packages/shared/i18n/de/auth.json index e2ce098..cbbd906 100644 --- a/packages/shared/i18n/de/auth.json +++ b/packages/shared/i18n/de/auth.json @@ -66,5 +66,22 @@ "logout": "Abmelden", "login": "Anmelden", "signup": "Registrieren" + }, + "oidc": { + "signIn": "Anmelden mit {{provider}}", + "or": "oder", + "errors": { + "oidc_failed": "Die Anmeldung über Single Sign-on ist fehlgeschlagen. Bitte erneut versuchen.", + "oidc_cancelled": "Die Single-Sign-on-Anmeldung wurde abgebrochen.", + "oidc_state_invalid": "Der Anmeldeversuch ist abgelaufen. Bitte erneut versuchen.", + "oidc_exchange_failed": "Der Identitätsanbieter hat die Anmeldung abgelehnt. Bitte erneut versuchen.", + "oidc_token_invalid": "Die Antwort des Identitätsanbieters konnte nicht geprüft werden.", + "oidc_discovery_failed": "Der Identitätsanbieter ist derzeit nicht erreichbar.", + "oidc_email_missing": "Der Identitätsanbieter hat keine E-Mail-Adresse übermittelt.", + "oidc_email_unverified": "Deine E-Mail-Adresse ist beim Identitätsanbieter nicht bestätigt.", + "oidc_link_required": "Ein Konto mit dieser E-Mail-Adresse existiert bereits. Melde dich mit Passwort an und verknüpfe Single Sign-on in den Einstellungen.", + "oidc_identity_taken": "Diese Single-Sign-on-Identität ist bereits mit einem anderen Konto verknüpft.", + "account_disabled": "Dieses Konto ist deaktiviert." + } } } diff --git a/packages/shared/i18n/en/auth.json b/packages/shared/i18n/en/auth.json index 54e0373..c8f865e 100644 --- a/packages/shared/i18n/en/auth.json +++ b/packages/shared/i18n/en/auth.json @@ -66,5 +66,22 @@ "logout": "Sign out", "login": "Sign in", "signup": "Register" + }, + "oidc": { + "signIn": "Sign in with {{provider}}", + "or": "or", + "errors": { + "oidc_failed": "Single sign-on failed. Please try again.", + "oidc_cancelled": "Single sign-on was cancelled.", + "oidc_state_invalid": "The sign-on attempt expired. Please try again.", + "oidc_exchange_failed": "The identity provider rejected the sign-on. Please try again.", + "oidc_token_invalid": "The identity provider's response could not be verified.", + "oidc_discovery_failed": "The identity provider is currently unreachable.", + "oidc_email_missing": "The identity provider did not supply an e-mail address.", + "oidc_email_unverified": "Your e-mail address is not verified at the identity provider.", + "oidc_link_required": "An account with this e-mail address already exists. Sign in with your password and link single sign-on in your settings.", + "oidc_identity_taken": "This single sign-on identity is already linked to another account.", + "account_disabled": "This account is disabled." + } } } diff --git a/packages/shared/src/auth.ts b/packages/shared/src/auth.ts index 5465014..c871d01 100644 --- a/packages/shared/src/auth.ts +++ b/packages/shared/src/auth.ts @@ -82,6 +82,16 @@ export const changePasswordInputSchema = z.object({ newPassword: passwordSchema, }); +/** + * What the login screen may offer (issue #214, ADR 0021): the local + * password form and/or the deploy-configured OIDC provider. `local` becomes + * switchable with #216 (`AUTH_LOCAL_ENABLED`). + */ +export interface AuthMethodsView { + local: boolean; + oidc: { label: string } | null; +} + /** Public shape of the signed-in user, returned by /auth/me. */ export interface CurrentUser { id: string; diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index 1993132..2d10890 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -154,6 +154,20 @@ export const apiEnvSchema = z.object({ SETUP_INSTANCE_NAME: z.string().optional(), SETUP_DEFAULT_LOCALE: z.enum(['de', 'en']).optional(), SETUP_REGISTRATION_MODE: z.enum(['open', 'closed']).optional(), + /** + * External authentication via OIDC (ADR 0021, issue #214). Deploy-level + * on purpose: who authenticates users is a platform decision, not a + * runtime setting a Site Admin can flip. OIDC is enabled when ISSUER and + * CLIENT_ID are both set; configuration is discovery-based + * (`/.well-known/openid-configuration`). The client secret is + * optional — a public client uses PKCE alone (always sent regardless). + */ + OIDC_ISSUER: z.string().url().optional(), + OIDC_CLIENT_ID: z.string().min(1).optional(), + OIDC_CLIENT_SECRET: z.string().min(1).optional(), + OIDC_SCOPES: z.string().min(1).default('openid profile email'), + /** Button label the login page shows, e.g. the agency SSO's name. */ + OIDC_PROVIDER_LABEL: z.string().min(1).default('Single Sign-On'), }); export type ApiEnv = z.infer; diff --git a/packages/shared/src/token-crypto.ts b/packages/shared/src/token-crypto.ts index 4df5add..c0938e0 100644 --- a/packages/shared/src/token-crypto.ts +++ b/packages/shared/src/token-crypto.ts @@ -30,7 +30,7 @@ import { */ /** Every purpose a subkey is derived for. Add here — never reuse a key. */ -export type TokenPurpose = 'collab' | 'unsubscribe'; +export type TokenPurpose = 'collab' | 'unsubscribe' | 'oidc-state'; /** Fixed HKDF salt: domain-separates this application's key hierarchy. */ const HKDF_SALT = 'dorfteich-token-keys'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db78446..8778ffe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: i18next: specifier: ^26.3.4 version: 26.3.4(typescript@5.9.3) + jose: + specifier: ^6.2.4 + version: 6.2.4 jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -4951,9 +4954,6 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.3: - resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} - jose@6.2.4: resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} @@ -8450,7 +8450,7 @@ snapshots: express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) hono: 4.12.29 - jose: 6.2.3 + jose: 6.2.4 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -11486,8 +11486,6 @@ snapshots: jiti@2.7.0: {} - jose@6.2.3: {} - jose@6.2.4: {} jotai-scope@0.7.2(jotai@2.11.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7):