External authentication (ADR 0021) built on jose (#188's vetted library) plus fetch — no new dependency enters the supply chain for a security base function. Discovery-configured; ID tokens validate against the IdP's JWKS under an explicit RS256/ES256 allowlist with issuer, audience, expiry and nonce binding. State, nonce and the PKCE verifier travel in a signed HttpOnly Lax cookie keyed by a dedicated HKDF purpose (oidc-state, ADR 0020). Deploy-level configuration (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/SCOPES/ PROVIDER_LABEL): who authenticates users is a platform decision. The login page discovers the provider via GET /auth/methods and renders the SSO button (i18n de+en). Identities use the existing slot (provider oidc:<issuer>, subject from the token). First login creates the account just-in-time — ACTIVE and mail-verified only when the IdP asserts a verified address. An existing local account is NEVER adopted silently by e-mail (account-takeover path): login refuses with oidc_link_required and the owner links explicitly via GET /auth/oidc/link (audited auth.identity_linked, catalogue v1.3). Sessions come from the one existing session service. Tests run the full flow against a protocol-faithful fake IdP: PKCE verifier at the token endpoint, JIT creation incl. personal pond, invalid state/nonce/signature/issuer/audience/expiry each rejected, the linking refusal and the explicit link flow. Verified end-to-end against a real Keycloak 26.0 (repeatable procedure documented in security.md §External authentication). Refs #214. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
107 lines
3.5 KiB
TypeScript
107 lines
3.5 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
/**
|
|
* Auth-related schemas shared between api (runtime validation) and web
|
|
* (form validation). Field error messages are i18n keys resolved by the
|
|
* client; the api returns them inside ApiErrorBody.details.
|
|
*/
|
|
|
|
export const usernameSchema = z
|
|
.string()
|
|
.min(3, 'validation.username.tooShort')
|
|
.max(32, 'validation.username.tooLong')
|
|
.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i, 'validation.username.charset');
|
|
|
|
/**
|
|
* Password policy per issue #13: length over composition rules, plus a
|
|
* small blocklist of the most common passwords (full breach-list checks
|
|
* are deliberately out of scope for v1).
|
|
*/
|
|
const COMMON_PASSWORDS = new Set([
|
|
'1234567890',
|
|
'qwertyuiop',
|
|
'password12',
|
|
'password123',
|
|
'passwort123',
|
|
'1q2w3e4r5t',
|
|
'iloveyou12',
|
|
'sonnenschein',
|
|
'schalke04!',
|
|
'aaaaaaaaaa',
|
|
'1234567890a',
|
|
'qwertz1234',
|
|
]);
|
|
|
|
export const passwordSchema = z
|
|
.string()
|
|
.min(10, 'validation.password.tooShort')
|
|
.max(128, 'validation.password.tooLong')
|
|
.refine((value) => !COMMON_PASSWORDS.has(value.toLowerCase()), 'validation.password.tooCommon');
|
|
|
|
export const signupInputSchema = z.object({
|
|
username: usernameSchema,
|
|
email: z.string().email('validation.email.invalid').max(254),
|
|
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80),
|
|
password: passwordSchema,
|
|
locale: z.enum(['de', 'en']).default('en'),
|
|
});
|
|
export type SignupInput = z.infer<typeof signupInputSchema>;
|
|
/** Form-side type: locale is optional before Zod applies its default. */
|
|
export type SignupFormInput = z.input<typeof signupInputSchema>;
|
|
|
|
export const loginInputSchema = z.object({
|
|
usernameOrEmail: z.string().min(1, 'validation.required'),
|
|
password: z.string().min(1, 'validation.required'),
|
|
});
|
|
export type LoginInput = z.infer<typeof loginInputSchema>;
|
|
|
|
export const verifyEmailInputSchema = z.object({ token: z.string().min(16).max(256) });
|
|
export const resendVerificationInputSchema = z.object({
|
|
email: z.string().email('validation.email.invalid'),
|
|
});
|
|
export const forgotPasswordInputSchema = z.object({
|
|
email: z.string().email('validation.email.invalid'),
|
|
});
|
|
export const resetPasswordInputSchema = z.object({
|
|
token: z.string().min(16).max(256),
|
|
password: passwordSchema,
|
|
});
|
|
/** Form-side schema for the reset page (token travels via URL, not form). */
|
|
export const resetPasswordFormSchema = z.object({ password: passwordSchema });
|
|
export const updateProfileInputSchema = z.object({
|
|
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80).optional(),
|
|
locale: z.enum(['de', 'en']).optional(),
|
|
/** Auto-watch preferences (issue #93). */
|
|
autoWatchOwnPages: z.boolean().optional(),
|
|
autoWatchOnComment: z.boolean().optional(),
|
|
/** E-mail digest cadence (issue #95). */
|
|
digestFrequency: z.enum(['hourly', 'daily', 'off']).optional(),
|
|
});
|
|
export const changePasswordInputSchema = z.object({
|
|
currentPassword: z.string().min(1, 'validation.required'),
|
|
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;
|
|
username: string;
|
|
email: string;
|
|
displayName: string;
|
|
locale: 'de' | 'en';
|
|
isSiteAdmin: boolean;
|
|
autoWatchOwnPages: boolean;
|
|
autoWatchOnComment: boolean;
|
|
digestFrequency: 'hourly' | 'daily' | 'off';
|
|
}
|