#215: trusted reverse-proxy header / mTLS client-certificate path #283

Merged
fable-5 merged 2 commits from issue-215-proxy-auth into main 2026-07-31 13:35:49 +02:00
20 changed files with 1025 additions and 29 deletions
Showing only changes of commit 5796b7a5dd - Show all commits

View File

@ -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",

View File

@ -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' },

View File

@ -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)

View File

@ -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 {}

View 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_)}`);
}
}
}

View 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);
});
});

View 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;
}
}
}

View File

@ -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 && (

View File

@ -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;

View File

@ -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

View File

@ -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.*`)

View File

@ -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`,

View File

@ -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 · 56 AT · #214
- [ ] Alternativpfad vertrauenswürdiger Reverse-Proxy-Header bzw. mTLS-
Client-Zertifikat · 2 AT · #215

View File

@ -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)

View File

@ -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."
}
}
}

View File

@ -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."
}
}
}

View File

@ -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;

View File

@ -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>;

View File

@ -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
View File

@ -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):