#215: trusted reverse-proxy header / mTLS client-certificate path #283
@ -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",
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {}
|
||||
|
||||
106
apps/api/src/auth/oidc.controller.ts
Normal file
106
apps/api/src/auth/oidc.controller.ts
Normal file
@ -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=<code>` 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.oidc.assertEnabled();
|
||||
const base = this.config.env.APP_BASE_URL;
|
||||
response.clearCookie(OIDC_STATE_COOKIE, { path: '/' });
|
||||
const stateToken = (request.cookies as Record<string, string> | 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_)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
351
apps/api/src/auth/oidc.e2e.db.test.ts
Normal file
351
apps/api/src/auth/oidc.e2e.db.test.ts
Normal file
@ -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<string, unknown>;
|
||||
let wrongKey: CryptoKey;
|
||||
/** What the fake token endpoint returns next (set per test). */
|
||||
let nextIdToken: (() => Promise<string>) | 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<string> {
|
||||
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<void>((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<void>((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);
|
||||
});
|
||||
});
|
||||
353
apps/api/src/auth/oidc.service.ts
Normal file
353
apps/api/src/auth/oidc.service.ts
Normal file
@ -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:<issuer>"`,
|
||||
* `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<typeof createRemoteJWKSet> | 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<DiscoveryDocument> {
|
||||
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<OidcStateClaims> {
|
||||
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<void> {
|
||||
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<User> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<LoginInput>({ 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<AuthMethodsView>('/auth/methods'),
|
||||
});
|
||||
// An OIDC callback failure lands back here as ?error=<code> (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 (
|
||||
<div className="auth-card">
|
||||
<h1>{t('auth:login.title')}</h1>
|
||||
{oidcError && (
|
||||
<p className="form-banner form-banner--error" role="alert">
|
||||
{t([`auth:oidc.errors.${oidcError}`, 'auth:oidc.errors.oidc_failed'])}
|
||||
</p>
|
||||
)}
|
||||
{methods.data?.oidc && (
|
||||
<>
|
||||
{/* Full-page navigation on purpose: the OIDC flow is a redirect
|
||||
chain the SPA cannot ride along on. */}
|
||||
<a className="button button--block" href="/api/v1/auth/oidc/login">
|
||||
{t('auth:oidc.signIn', { provider: methods.data.oidc.label })}
|
||||
</a>
|
||||
<p className="auth-card__separator" aria-hidden="true">
|
||||
{t('auth:oidc.or')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<FormError error={error} />
|
||||
{unverified && !resent && (
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -63,12 +64,13 @@ 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.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 | — | — |
|
||||
| `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.*`)
|
||||
|
||||
|
||||
@ -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:<issuer>"`, `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
|
||||
`<APP_BASE_URL>/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`,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -47,13 +47,14 @@ 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. |
|
||||
| `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)
|
||||
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
* (`<issuer>/.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<typeof apiEnvSchema>;
|
||||
|
||||
@ -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';
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user