diff --git a/api/.env.example b/api/.env.example index 5a00628e..12b0350a 100644 --- a/api/.env.example +++ b/api/.env.example @@ -44,6 +44,12 @@ RACKULA_AUTH_SESSION_COOKIE_SAMESITE=lax # Keycloak: # RACKULA_OIDC_ISSUER=https://keycloak.example.com/realms/your-realm # +# Microsoft Entra ID (single-tenant): +# RACKULA_OIDC_ISSUER=https://login.microsoftonline.com//v2.0 +# +# Microsoft Entra ID (multi-tenant/common): +# RACKULA_OIDC_ISSUER=https://login.microsoftonline.com/common/v2.0 +# # Generic OIDC Provider: # RACKULA_OIDC_ISSUER=https://your-provider.example.com/.well-known/openid-configuration diff --git a/api/src/app.ts b/api/src/app.ts index 438b5218..54fc3a45 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -5,6 +5,7 @@ import { bodyLimit } from "hono/body-limit"; import layouts from "./routes/layouts"; import assets from "./routes/assets"; import { + createSignedAuthSessionToken, createAuthGateMiddleware, createCsrfProtectionMiddleware, createExpiredAuthSessionCookieHeader, @@ -13,6 +14,7 @@ import { invalidateAuthSession, resolveAuthenticatedSessionClaims, resolveApiSecurityConfig, + verifySignedAuthSessionToken, type AuthSessionClaims, type EnvMap, } from "./security"; @@ -23,6 +25,7 @@ import { configureAuthLogHashKey, safeLogAuthEvent } from "./auth-logger"; const DEFAULT_MAX_ASSET_SIZE = 5 * 1024 * 1024; // 5MB const DEFAULT_MAX_LAYOUT_SIZE = 1 * 1024 * 1024; // 1MB +const OIDC_PROVIDER_ID = "oidc"; const HEALTH_RESPONSE = { ok: true, status: "ok", @@ -37,6 +40,176 @@ type AppEnv = { }; }; +type BetterAuthSessionLike = { + session: { + id?: string; + createdAt?: Date | string | number; + expiresAt?: Date | string | number; + }; + user: { + id?: string | null; + email?: string | null; + }; +}; + +type BetterAuthSessionApiResult = { + headers?: Headers; + response?: BetterAuthSessionLike | null; +}; + +function normalizeNextPath(next: string | undefined): string { + if (!next) { + return "/"; + } + + const trimmed = next.trim(); + if (!trimmed.startsWith("/")) { + return "/"; + } + + if (trimmed.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { + return "/"; + } + + return trimmed; +} + +function readSetCookieHeaders(headers: Headers | undefined): string[] { + if (!headers) { + return []; + } + + const withGetSetCookie = headers as Headers & { + getSetCookie?: () => string[]; + }; + if (typeof withGetSetCookie.getSetCookie === "function") { + return withGetSetCookie.getSetCookie(); + } + + const rawSetCookie = headers.get("set-cookie"); + return rawSetCookie ? [rawSetCookie] : []; +} + +function appendSetCookieHeaders( + c: Context, + headers: Headers | undefined, +): void { + for (const setCookieHeader of readSetCookieHeaders(headers)) { + c.header("Set-Cookie", setCookieHeader, { append: true }); + } +} + +function toEpochSeconds( + value: Date | string | number | undefined, +): number | null { + if (value instanceof Date) { + const epochSeconds = Math.floor(value.getTime() / 1000); + return Number.isFinite(epochSeconds) ? epochSeconds : null; + } + + if (typeof value === "string") { + const epochSeconds = Math.floor(new Date(value).getTime() / 1000); + return Number.isFinite(epochSeconds) ? epochSeconds : null; + } + + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return null; + } + // Handle millisecond timestamps (common for JS Date.getTime()) as well as second timestamps. + const seconds = value > 1e11 ? Math.floor(value / 1000) : Math.floor(value); + return Number.isFinite(seconds) ? seconds : null; + } + + return null; +} + +function mapFallbackSessionClaims( + session: BetterAuthSessionLike, + authSessionConfig: { + authSessionGeneration: number; + authSessionIdleTimeoutSeconds: number; + }, +): AuthSessionClaims | null { + const sessionId = session.session.id?.trim(); + if (!sessionId) { + return null; + } + + const issuedAt = toEpochSeconds(session.session.createdAt); + const expiresAt = toEpochSeconds(session.session.expiresAt); + if (!issuedAt || !expiresAt || expiresAt <= issuedAt) { + return null; + } + + // Use persisted creation metadata as fallback idle-timeout source of truth. + // Do not derive idle expiry from request-time "now", which permits silent extension. + const idleExpiresAt = Math.min( + expiresAt, + issuedAt + authSessionConfig.authSessionIdleTimeoutSeconds, + ); + if (idleExpiresAt <= issuedAt) { + return null; + } + + const fallbackSubject = + session.user.email?.trim() || session.user.id?.trim() || "oidc-user"; + if (fallbackSubject === "oidc-user") { + console.warn( + "auth: OIDC session missing user identity (email and id), using generic subject", + ); + } + // MVP: all authenticated users get admin role. Role-based access control + // (viewer, editor) will be added when RACKULA_OIDC_ROLE_CLAIM is implemented. + return { + sub: fallbackSubject, + sid: sessionId, + iat: issuedAt, + exp: expiresAt, + idleExp: idleExpiresAt, + generation: authSessionConfig.authSessionGeneration, + role: "admin", + }; +} + +function validateFallbackSessionClaims( + claims: AuthSessionClaims, + authSessionConfig: { + authSessionSecret?: string; + authSessionGeneration: number; + authSessionMaxAgeSeconds: number; + authSessionIdleTimeoutSeconds: number; + }, +): AuthSessionClaims | null { + if (!authSessionConfig.authSessionSecret) { + return null; + } + + try { + const token = createSignedAuthSessionToken( + claims, + authSessionConfig.authSessionSecret, + { + sessionGeneration: authSessionConfig.authSessionGeneration, + sessionMaxAgeSeconds: authSessionConfig.authSessionMaxAgeSeconds, + sessionIdleTimeoutSeconds: + authSessionConfig.authSessionIdleTimeoutSeconds, + }, + ); + + return verifySignedAuthSessionToken( + token, + authSessionConfig.authSessionSecret, + { + expectedGeneration: authSessionConfig.authSessionGeneration, + maxSessionMaxAgeSeconds: authSessionConfig.authSessionMaxAgeSeconds, + }, + ); + } catch { + return null; + } +} + export function createApp(env: EnvMap = process.env): Hono { const app = new Hono(); const securityConfig = resolveApiSecurityConfig(env); @@ -72,8 +245,9 @@ export function createApp(env: EnvMap = process.env): Hono { // Better Auth instance — created with validated session secret const auth = securityConfig.authSessionSecret - ? createAuth(securityConfig.authSessionSecret) + ? createAuth(securityConfig.authSessionSecret, env) : undefined; + const authApi = (auth?.api ?? {}) as Record; const authSessionConfig = { authEnabled: securityConfig.authEnabled, @@ -86,13 +260,51 @@ export function createApp(env: EnvMap = process.env): Hono { authSessionMaxAgeSeconds: securityConfig.authSessionMaxAgeSeconds, }; + const resolveFallbackClaims = async ( + requestHeaders: Headers, + ): Promise => { + const getSession = authApi.getSession as + | ((options: { + headers: Headers; + returnHeaders: boolean; + }) => Promise) + | undefined; + + if (typeof getSession !== "function") { + return null; + } + + try { + const fallbackSessionResult = await getSession({ + headers: requestHeaders, + returnHeaders: true, + }); + + const mappedFallbackClaims = fallbackSessionResult.response + ? mapFallbackSessionClaims( + fallbackSessionResult.response, + authSessionConfig, + ) + : null; + return mappedFallbackClaims + ? validateFallbackSessionClaims(mappedFallbackClaims, authSessionConfig) + : null; + } catch (error) { + console.debug("auth: fallback session check failed", error); + return null; + } + }; + if (securityConfig.authEnabled) { app.use( "*", - createAuthGateMiddleware({ - ...authSessionConfig, - authLoginPath: securityConfig.authLoginPath, - }), + createAuthGateMiddleware( + { + ...authSessionConfig, + authLoginPath: securityConfig.authLoginPath, + }, + (request) => resolveFallbackClaims(request.headers), + ), ); } @@ -106,27 +318,18 @@ export function createApp(env: EnvMap = process.env): Hono { }), ); - /** Sets canonical auth context consumed by authorization middleware. */ - const setCanonicalAuthContext = ( - c: Context, - claims: AuthSessionClaims, - ): void => { - c.set("authSubject", claims.sub); - c.set("authClaims", claims); - }; - if (securityConfig.authEnabled) { - const authApi = (auth?.api ?? {}) as Record; const authPlugins = Array.isArray(auth?.options?.plugins) ? auth?.options?.plugins : []; const oidcApiAvailable = Boolean(auth) && + securityConfig.authMode === "oidc" && authPlugins.some( (plugin) => (plugin as { id?: unknown }).id === "generic-oauth", ) && - typeof authApi.signInSocial === "function" && - typeof authApi.callbackOAuth === "function"; + typeof authApi.signInWithOAuth2 === "function" && + typeof authApi.oAuth2Callback === "function"; const authUnavailableRouteHandler = (c: Context) => c.json( @@ -138,67 +341,145 @@ export function createApp(env: EnvMap = process.env): Hono { 501, ); - const authLoginRouteHandler = (c: Context) => { + const authLoginRouteHandler = async (c: Context) => { if (!oidcApiAvailable) { return authUnavailableRouteHandler(c); } - const loginTarget = new URL("/api/auth/sign-in/social", c.req.url); - loginTarget.searchParams.set("provider", "oidc"); - return c.redirect(`${loginTarget.pathname}${loginTarget.search}`); + try { + const signInWithOAuth2 = authApi.signInWithOAuth2 as (options: { + headers: Headers; + body: { + providerId: string; + callbackURL: string; + }; + returnHeaders: boolean; + }) => Promise<{ headers?: Headers; response?: { url?: string } }>; + + const signInResult = await signInWithOAuth2({ + headers: c.req.raw.headers, + body: { + providerId: OIDC_PROVIDER_ID, + callbackURL: normalizeNextPath(c.req.query("next")), + }, + returnHeaders: true, + }); + + appendSetCookieHeaders(c, signInResult.headers); + + const redirectUrl = signInResult.response?.url; + if (!redirectUrl) { + throw new Error("OIDC provider did not return an authorization URL."); + } + + return c.redirect(redirectUrl, 302); + } catch (error) { + console.error("OIDC login initiation failed:", error); + return c.json( + { + error: "Authentication failed", + message: "Unable to initiate OIDC login.", + }, + 502, + ); + } }; - const authCallbackRouteHandler = (c: Context) => { + const authCallbackRouteHandler = async (c: Context) => { if (!oidcApiAvailable) { return authUnavailableRouteHandler(c); } - const requestUrl = new URL(c.req.url); - const callbackTarget = new URL("/api/auth/callback/oidc", c.req.url); - callbackTarget.search = requestUrl.search; - return c.redirect(`${callbackTarget.pathname}${callbackTarget.search}`); + const callbackUrl = new URL(c.req.url); + callbackUrl.pathname = "/api/auth/oauth2/callback/oidc"; + const proxyRequest = new Request(callbackUrl.toString(), { + method: c.req.raw.method, + headers: c.req.raw.headers, + }); + return auth!.handler(proxyRequest); }; - const authCheckRouteHandler = (c: Context) => { - const claims = resolveAuthenticatedSessionClaims( + const authCheckRouteHandler = async (c: Context) => { + const signedClaims = resolveAuthenticatedSessionClaims( c.req.raw, authSessionConfig, ); - if (!claims) { - safeLogAuthEvent("auth.session.invalid", c.req.raw, { - reason: "missing or invalid session cookie", - }); - return c.json( - { - error: "Unauthorized", - message: "Authentication required.", - }, - 401, + + if (signedClaims) { + c.set("authSubject", signedClaims.sub); + c.set("authClaims", signedClaims); + + const refreshedCookie = createRefreshedAuthSessionCookieHeader( + signedClaims, + authSessionConfig, ); - } + if (refreshedCookie) { + c.header("Set-Cookie", refreshedCookie, { append: true }); + } - setCanonicalAuthContext(c, claims); + return c.body(null, 204); + } - const refreshedCookie = createRefreshedAuthSessionCookieHeader( - claims, - authSessionConfig, - ); - if (refreshedCookie) { - c.header("Set-Cookie", refreshedCookie, { append: true }); + const fallbackClaims = await resolveFallbackClaims(c.req.raw.headers); + if (fallbackClaims) { + c.set("authSubject", fallbackClaims.sub); + c.set("authClaims", fallbackClaims); + return c.body(null, 204); } - return c.body(null, 204); + safeLogAuthEvent("auth.session.invalid", c.req.raw, { + reason: "missing or invalid session cookie", + }); + return c.json( + { + error: "Unauthorized", + message: "Authentication required.", + }, + 401, + ); }; - const authLogoutRouteHandler = (c: Context) => { - const claims = resolveAuthenticatedSessionClaims( + const authLogoutRouteHandler = async (c: Context) => { + const signedClaims = resolveAuthenticatedSessionClaims( c.req.raw, authSessionConfig, ); - if (claims) { - setCanonicalAuthContext(c, claims); - invalidateAuthSession(claims.sid, claims.exp); - safeLogAuthEvent("auth.logout", c.req.raw, { subject: claims.sub }); + let logoutSubject: string | undefined = signedClaims?.sub; + + if (signedClaims) { + c.set("authSubject", signedClaims.sub); + c.set("authClaims", signedClaims); + invalidateAuthSession(signedClaims.sid, signedClaims.exp); + } + + const fallbackClaims = await resolveFallbackClaims(c.req.raw.headers); + if (fallbackClaims) { + invalidateAuthSession(fallbackClaims.sid, fallbackClaims.exp); + if (!logoutSubject) { + logoutSubject = fallbackClaims.sub; + } + } + + const signOut = authApi.signOut as + | ((options: { + headers: Headers; + returnHeaders: boolean; + }) => Promise<{ headers?: Headers }>) + | undefined; + if (typeof signOut === "function") { + try { + const signOutResult = await signOut({ + headers: c.req.raw.headers, + returnHeaders: true, + }); + appendSetCookieHeaders(c, signOutResult.headers); + } catch (error) { + console.debug("auth: provider sign-out failed", error); + } + } + + if (logoutSubject) { + safeLogAuthEvent("auth.logout", c.req.raw, { subject: logoutSubject }); } c.header( diff --git a/api/src/auth/config.ts b/api/src/auth/config.ts index 24acd64f..1767b735 100644 --- a/api/src/auth/config.ts +++ b/api/src/auth/config.ts @@ -1,5 +1,298 @@ import { betterAuth } from "better-auth"; import { genericOAuth } from "better-auth/plugins"; +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; + +import type { EnvMap } from "../security"; + +const DEFAULT_BASE_URL = "http://localhost:3000"; +const DEFAULT_AUTH_SESSION_COOKIE_NAME = "rackula_auth_session"; +const DEFAULT_OIDC_SCOPES = ["openid", "profile", "email"]; +const OIDC_DISCOVERY_PATH = "/.well-known/openid-configuration"; + +interface OidcDiscoveryDocument { + issuer: string; + jwksUri: string; +} + +interface VerifiedOidcUserInfo { + id: string; + name?: string; + email: string; + image?: string; + emailVerified: boolean; +} + +function readEnv(env: EnvMap, key: string): string | undefined { + const value = env[key]?.trim(); + return value && value.length > 0 ? value : undefined; +} + +function parseOidcScopes(raw: string | undefined): string[] { + if (!raw) { + return [...DEFAULT_OIDC_SCOPES]; + } + + const scopes = [ + ...new Set(raw.split(/[,\s]+/).map((scope) => scope.trim())), + ].filter((scope) => scope.length > 0); + + if (scopes.length === 0) { + return [...DEFAULT_OIDC_SCOPES]; + } + + if (!scopes.includes("openid")) { + scopes.unshift("openid"); + } + + return scopes; +} + +function parseOptionalBoolean(value: string | undefined): boolean | undefined { + if (value === undefined) { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + + if (normalized === "false") { + return false; + } + + throw new Error( + 'RACKULA_AUTH_SESSION_COOKIE_SECURE must be either "true" or "false" when set.', + ); +} + +function parseAbsoluteUrl(value: string, envName: string): URL { + try { + return new URL(value); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`${envName} must be a valid absolute URL. ${reason}`, { + cause: error, + }); + } +} + +function resolveOidcDiscoveryUrl(env: EnvMap): string | undefined { + const discovery = readEnv(env, "RACKULA_OIDC_DISCOVERY_URL"); + if (discovery) { + return parseAbsoluteUrl(discovery, "RACKULA_OIDC_DISCOVERY_URL").toString(); + } + + const issuer = readEnv(env, "RACKULA_OIDC_ISSUER"); + if (!issuer) { + return undefined; + } + + const issuerUrl = parseAbsoluteUrl(issuer, "RACKULA_OIDC_ISSUER"); + const normalizedPath = issuerUrl.pathname.replace(/\/+$/, ""); + if (normalizedPath.endsWith(OIDC_DISCOVERY_PATH)) { + issuerUrl.pathname = normalizedPath; + issuerUrl.search = ""; + issuerUrl.hash = ""; + return issuerUrl.toString(); + } + + issuerUrl.pathname = `${normalizedPath}${OIDC_DISCOVERY_PATH}`; + issuerUrl.search = ""; + issuerUrl.hash = ""; + return issuerUrl.toString(); +} + +function normalizeIssuerUrl(value: string): string { + const issuerUrl = new URL(value); + issuerUrl.search = ""; + issuerUrl.hash = ""; + const normalizedPath = issuerUrl.pathname.replace(/\/+$/, ""); + issuerUrl.pathname = normalizedPath.length > 0 ? normalizedPath : "/"; + return issuerUrl.toString(); +} + +function isMicrosoftEntraCommonIssuerMatch( + expectedIssuer: string, + discoveryIssuer: string, +): boolean { + try { + const expected = new URL(expectedIssuer); + const discovery = new URL(discoveryIssuer); + const expectedPath = expected.pathname.replace(/\/+$/, ""); + const discoveryPath = discovery.pathname.replace(/\/+$/, ""); + + if ( + expected.protocol !== discovery.protocol || + expected.hostname !== discovery.hostname || + expected.port !== discovery.port + ) { + return false; + } + + if (expected.hostname !== "login.microsoftonline.com") { + return false; + } + + if (!/^\/common\/v2\.0$/i.test(expectedPath)) { + return false; + } + + return /^\/[^/]+\/v2\.0$/i.test(discoveryPath); + } catch { + return false; + } +} + +function issuerMatchesExpected( + expectedIssuer: string | undefined, + discoveryIssuer: string, +): boolean { + if (!expectedIssuer) { + return true; + } + + if (expectedIssuer === discoveryIssuer) { + return true; + } + + return isMicrosoftEntraCommonIssuerMatch(expectedIssuer, discoveryIssuer); +} + +async function fetchOidcDiscoveryDocument( + discoveryUrl: string, + expectedIssuer: string | undefined, +): Promise { + const response = await fetch(discoveryUrl, { + method: "GET", + headers: { + Accept: "application/json", + }, + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + throw new Error( + `OIDC discovery request failed (${response.status} ${response.statusText}).`, + ); + } + + const parsed = (await response.json()) as Record; + const issuerValue = parsed.issuer; + const jwksUriValue = parsed.jwks_uri; + if (typeof issuerValue !== "string" || issuerValue.trim().length === 0) { + throw new Error("OIDC discovery response missing issuer."); + } + + if (typeof jwksUriValue !== "string" || jwksUriValue.trim().length === 0) { + throw new Error("OIDC discovery response missing jwks_uri."); + } + + const issuer = normalizeIssuerUrl(issuerValue); + if (!issuerMatchesExpected(expectedIssuer, issuer)) { + throw new Error( + "OIDC discovery issuer does not match RACKULA_OIDC_ISSUER.", + ); + } + + return { + issuer, + jwksUri: parseAbsoluteUrl( + jwksUriValue, + "OIDC discovery jwks_uri", + ).toString(), + }; +} + +function mapVerifiedOidcPayload( + payload: JWTPayload, +): VerifiedOidcUserInfo | null { + const subjectValue = payload.sub; + const emailValue = payload.email; + const nameClaim = payload["name"]; + const preferredUsernameClaim = payload["preferred_username"]; + const pictureClaim = payload["picture"]; + const emailVerifiedClaim = payload["email_verified"]; + if (typeof subjectValue !== "string" || subjectValue.trim().length === 0) { + return null; + } + + if (typeof emailValue !== "string" || emailValue.trim().length === 0) { + return null; + } + + const nameValue = + typeof nameClaim === "string" && nameClaim.trim().length > 0 + ? nameClaim.trim() + : typeof preferredUsernameClaim === "string" && + preferredUsernameClaim.trim().length > 0 + ? preferredUsernameClaim.trim() + : emailValue.trim(); + + const imageValue = + typeof pictureClaim === "string" && pictureClaim.trim().length > 0 + ? pictureClaim.trim() + : undefined; + + return { + id: subjectValue.trim(), + name: nameValue, + email: emailValue.trim().toLowerCase(), + image: imageValue, + emailVerified: emailVerifiedClaim === true, + }; +} + +function createOidcUserInfoResolver(options: { + discoveryUrl: string; + clientId: string; + expectedIssuer?: string; +}) { + let discoveryPromise: Promise | undefined; + let jwks: ReturnType | undefined; + + async function resolveDiscovery(): Promise { + if (!discoveryPromise) { + discoveryPromise = (async () => { + try { + return await fetchOidcDiscoveryDocument( + options.discoveryUrl, + options.expectedIssuer, + ); + } catch (error) { + discoveryPromise = undefined; + jwks = undefined; + throw error; + } + })(); + } + + return discoveryPromise; + } + + return async (tokens: { idToken?: string | undefined }) => { + const idToken = tokens.idToken?.trim(); + if (!idToken) { + return null; + } + + try { + const discovery = await resolveDiscovery(); + if (!jwks) { + jwks = createRemoteJWKSet(new URL(discovery.jwksUri)); + } + + const { payload } = await jwtVerify(idToken, jwks, { + issuer: discovery.issuer, + audience: options.clientId, + }); + + return mapVerifiedOidcPayload(payload); + } catch (error) { + console.warn("OIDC ID token validation failed:", error); + return null; + } + }; +} /** * Better Auth configuration with stateless (cookie-only) sessions and optional OIDC. @@ -15,47 +308,70 @@ import { genericOAuth } from "better-auth/plugins"; * Environment variables: * - RACKULA_AUTH_SESSION_SECRET: HMAC secret for signing session cookies (required, min 32 chars) * - RACKULA_OIDC_ISSUER: OIDC provider base URL (e.g. https://auth.example.com/application/o/rackula/) + * - RACKULA_OIDC_DISCOVERY_URL: Explicit OIDC discovery document URL (optional override) * - RACKULA_OIDC_CLIENT_ID: OAuth client ID * - RACKULA_OIDC_CLIENT_SECRET: OAuth client secret - * - RACKULA_OIDC_REDIRECT_URI: OAuth callback URL (optional, defaults to {baseURL}/api/auth/oauth2/callback/oidc) + * - RACKULA_OIDC_REDIRECT_URI: OAuth callback URL (optional) + * - RACKULA_OIDC_SCOPES: Optional scopes (comma or space-separated), defaults to openid profile email * - RACKULA_BASE_URL: Base URL for callback construction (defaults to http://localhost:3000) */ -export function createAuth(secret: string) { +export function createAuth(secret: string, env: EnvMap = process.env) { if (!secret) { throw new Error( "Auth session secret is required. Set RACKULA_AUTH_SESSION_SECRET.", ); } - const oidcClientId = process.env.RACKULA_OIDC_CLIENT_ID?.trim(); - const oidcClientSecret = process.env.RACKULA_OIDC_CLIENT_SECRET?.trim(); - const oidcConfigured = Boolean(oidcClientId && oidcClientSecret); - - const plugins = oidcConfigured - ? [ - genericOAuth({ - config: [ - { - providerId: "oidc", - clientId: oidcClientId!, - clientSecret: oidcClientSecret!, - discoveryUrl: process.env.RACKULA_OIDC_ISSUER - ? `${process.env.RACKULA_OIDC_ISSUER.replace(/\/$/, "")}/.well-known/openid-configuration` + const oidcClientId = readEnv(env, "RACKULA_OIDC_CLIENT_ID"); + const oidcClientSecret = readEnv(env, "RACKULA_OIDC_CLIENT_SECRET"); + const oidcDiscoveryUrl = resolveOidcDiscoveryUrl(env); + const oidcIssuer = readEnv(env, "RACKULA_OIDC_ISSUER"); + const oidcScopes = parseOidcScopes(readEnv(env, "RACKULA_OIDC_SCOPES")); + const authSessionCookieName = + readEnv(env, "RACKULA_AUTH_SESSION_COOKIE_NAME") || + DEFAULT_AUTH_SESSION_COOKIE_NAME; + const baseURL = readEnv(env, "RACKULA_BASE_URL") || DEFAULT_BASE_URL; + const authSessionCookieSecure = + parseOptionalBoolean(readEnv(env, "RACKULA_AUTH_SESSION_COOKIE_SECURE")) ?? + env.NODE_ENV === "production"; + + const plugins = []; + if (oidcClientId && oidcClientSecret) { + if (!oidcDiscoveryUrl) { + throw new Error( + "OIDC is enabled but no discovery URL is configured. Set RACKULA_OIDC_DISCOVERY_URL or RACKULA_OIDC_ISSUER.", + ); + } + + plugins.push( + genericOAuth({ + config: [ + { + providerId: "oidc", + clientId: oidcClientId, + clientSecret: oidcClientSecret, + discoveryUrl: oidcDiscoveryUrl, + scopes: oidcScopes, + pkce: true, + redirectURI: readEnv(env, "RACKULA_OIDC_REDIRECT_URI"), + getUserInfo: createOidcUserInfoResolver({ + discoveryUrl: oidcDiscoveryUrl, + clientId: oidcClientId, + expectedIssuer: oidcIssuer + ? normalizeIssuerUrl(oidcIssuer) : undefined, - scopes: ["openid", "profile", "email"], - pkce: true, - redirectURI: process.env.RACKULA_OIDC_REDIRECT_URI || undefined, - }, - ], - }), - ] - : []; + }), + }, + ], + }), + ); + } return betterAuth({ // Omitting database config enables stateless mode (cookie-only sessions) // Session data stored in signed cookies, no database queries for validation secret, - baseURL: process.env.RACKULA_BASE_URL || "http://localhost:3000", + baseURL, session: { // 12 hours session lifetime (shorter than Better Auth default of 7 days) @@ -72,10 +388,16 @@ export function createAuth(secret: string) { }, advanced: { + useSecureCookies: authSessionCookieSecure, + cookies: { + session_token: { + name: authSessionCookieName, + }, + }, defaultCookieAttributes: { httpOnly: true, // Prevent XSS access to cookie sameSite: "lax", // CSRF protection - secure: process.env.NODE_ENV === "production", // HTTPS only in production + secure: authSessionCookieSecure, // domain: '.racku.la' // Uncomment if using subdomains }, }, diff --git a/api/src/oidc.integration.test.ts b/api/src/oidc.integration.test.ts new file mode 100644 index 00000000..74a7abd9 --- /dev/null +++ b/api/src/oidc.integration.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it } from "bun:test"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { createApp } from "./app"; +import type { EnvMap } from "./security"; + +const TEST_AUTH_SECRET = "rackula-auth-session-secret-for-tests-0123456789"; +const ENTRA_COMMON_ISSUER = "https://login.microsoftonline.com/common/v2.0"; +const ENTRA_TENANT_ISSUER = + "https://login.microsoftonline.com/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/v2.0"; +const ENTRA_DISCOVERY_URL = + "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration"; +const ENTRA_AUTHORIZATION_URL = + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; +const ENTRA_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; +const ENTRA_JWKS_URL = "https://login.microsoftonline.com/common/discovery/v2.0/keys"; + +function buildOidcEnv(overrides: EnvMap = {}): EnvMap { + return { + NODE_ENV: "test", + RACKULA_AUTH_MODE: "oidc", + RACKULA_AUTH_SESSION_SECRET: TEST_AUTH_SECRET, + CORS_ORIGIN: "https://rack.example.com", + RACKULA_BASE_URL: "https://rack.example.com", + RACKULA_OIDC_ISSUER: ENTRA_COMMON_ISSUER, + RACKULA_OIDC_CLIENT_ID: "rackula-web", + RACKULA_OIDC_CLIENT_SECRET: "oidc-client-secret", + RACKULA_OIDC_REDIRECT_URI: "https://rack.example.com/auth/callback", + ...overrides, + }; +} + +function readSetCookies(headers: Headers): string[] { + const withGetSetCookie = headers as Headers & { getSetCookie: () => string[] }; + try { + const setCookies = withGetSetCookie.getSetCookie(); + if (Array.isArray(setCookies)) { + return setCookies; + } + } catch { + // Fall through to standard header handling. + } + + const raw = headers.get("set-cookie"); + return raw ? [raw] : []; +} + +function cookieHeaderFromSetCookies(setCookies: string[]): string { + return setCookies + .map((cookie) => cookie.split(";", 1)[0] ?? "") + .filter((cookie) => cookie.length > 0) + .join("; "); +} + +function mergeCookieHeaders(...cookieHeaders: Array): string { + return cookieHeaders.filter((value): value is string => Boolean(value)).join("; "); +} + +async function createSignedIdToken(overrides: { + audience?: string | string[]; + issuer?: string; +} = {}): Promise<{ token: string; publicJwk: JsonWebKey }> { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const publicJwk = await exportJWK(publicKey); + publicJwk.kid = "rackula-test-kid"; + publicJwk.alg = "RS256"; + publicJwk.use = "sig"; + + const nowSeconds = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ + email: "admin@example.com", + name: "Rackula Admin", + email_verified: true, + }) + .setProtectedHeader({ + alg: "RS256", + kid: "rackula-test-kid", + typ: "JWT", + }) + .setIssuer(overrides.issuer ?? ENTRA_TENANT_ISSUER) + .setAudience(overrides.audience ?? "rackula-web") + .setSubject("entra-user-123") + .setIssuedAt(nowSeconds) + .setExpirationTime(nowSeconds + 3600) + .sign(privateKey); + + return { token, publicJwk }; +} + +async function installMockOidcFetch(options: { + idTokenAudience?: string | string[]; + idTokenIssuer?: string; + failTokenExchange?: boolean; +} = {}): Promise<{ restore: () => void }> { + const originalFetch = globalThis.fetch; + const signedIdToken = await createSignedIdToken({ + audience: options.idTokenAudience, + issuer: options.idTokenIssuer, + }); + + const mockFetch = async (...args: Parameters): Promise => { + const [input, init] = args; + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + + if (url === ENTRA_DISCOVERY_URL) { + return new Response( + JSON.stringify({ + issuer: ENTRA_TENANT_ISSUER, + authorization_endpoint: ENTRA_AUTHORIZATION_URL, + token_endpoint: ENTRA_TOKEN_URL, + jwks_uri: ENTRA_JWKS_URL, + userinfo_endpoint: "https://graph.microsoft.com/oidc/userinfo", + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ); + } + + if (url === ENTRA_JWKS_URL) { + return new Response( + JSON.stringify({ + keys: [signedIdToken.publicJwk], + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ); + } + + if (url === ENTRA_TOKEN_URL) { + if (options.failTokenExchange) { + return new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "authorization code is invalid", + }), + { + status: 400, + headers: { + "content-type": "application/json", + }, + }, + ); + } + + return new Response( + JSON.stringify({ + access_token: "entra-access-token", + token_type: "Bearer", + expires_in: 3600, + id_token: signedIdToken.token, + scope: "openid profile email", + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ); + } + + return originalFetch(input, init); + }; + + const patchedFetch = Object.assign(mockFetch, { + preconnect: (originalFetch as typeof fetch & { preconnect?: typeof fetch }).preconnect, + }) as typeof fetch; + globalThis.fetch = patchedFetch; + + return { + restore: () => { + globalThis.fetch = originalFetch; + }, + }; +} + +describe("OIDC integration", () => { + async function completeOidcLogin(app: ReturnType): Promise { + const loginResponse = await app.request("/auth/login?next=%2Fdashboard"); + expect(loginResponse.status).toBe(302); + + const loginUrl = new URL(loginResponse.headers.get("location")!); + const state = loginUrl.searchParams.get("state"); + expect(state).not.toBeNull(); + + const loginCookieHeader = cookieHeaderFromSetCookies( + readSetCookies(loginResponse.headers), + ); + + const callbackResponse = await app.request( + `/auth/callback?code=entra-code&state=${encodeURIComponent(state!)}`, + { + headers: { + Cookie: loginCookieHeader, + }, + }, + ); + + expect(callbackResponse.status).toBe(302); + const callbackCookies = readSetCookies(callbackResponse.headers); + expect(callbackCookies.some((cookie) => cookie.includes("rackula_auth_session="))).toBe( + true, + ); + + return mergeCookieHeaders( + loginCookieHeader, + cookieHeaderFromSetCookies(callbackCookies), + ); + } + + it("accepts Entra common issuer config when discovery returns tenant issuer", async () => { + const mock = await installMockOidcFetch(); + try { + const app = createApp(buildOidcEnv()); + const authedCookieHeader = await completeOidcLogin(app); + + const checkResponse = await app.request("/auth/check", { + headers: { + Cookie: authedCookieHeader, + Origin: "https://rack.example.com", + }, + }); + expect(checkResponse.status).toBe(204); + } finally { + mock.restore(); + } + }); + + it("rejects callback when token audience does not match client id", async () => { + const mock = await installMockOidcFetch({ + idTokenAudience: "wrong-client-id", + }); + try { + const app = createApp(buildOidcEnv()); + + const loginResponse = await app.request("/auth/login?next=%2Fdashboard"); + expect(loginResponse.status).toBe(302); + const loginUrl = new URL(loginResponse.headers.get("location")!); + const state = loginUrl.searchParams.get("state"); + expect(state).not.toBeNull(); + const loginCookieHeader = cookieHeaderFromSetCookies( + readSetCookies(loginResponse.headers), + ); + + const callbackResponse = await app.request( + `/auth/callback?code=entra-code&state=${encodeURIComponent(state!)}`, + { + headers: { + Cookie: loginCookieHeader, + }, + }, + ); + + expect(callbackResponse.status).toBe(302); + expect(callbackResponse.headers.get("location")).toContain("user_info_is_missing"); + + const callbackCookies = readSetCookies(callbackResponse.headers); + expect(callbackCookies.some((cookie) => cookie.includes("rackula_auth_session="))).toBe( + false, + ); + } finally { + mock.restore(); + } + }); + + it("enforces fallback idle timeout based on persisted session metadata", async () => { + const mock = await installMockOidcFetch(); + const originalNow = Date.now; + try { + const app = createApp( + buildOidcEnv({ + RACKULA_AUTH_SESSION_IDLE_TIMEOUT_SECONDS: "60", + }), + ); + + const authedCookieHeader = await completeOidcLogin(app); + const baselineNow = originalNow(); + + Date.now = () => baselineNow + 1_000; + const firstCheck = await app.request("/auth/check", { + headers: { + Cookie: authedCookieHeader, + Origin: "https://rack.example.com", + }, + }); + expect(firstCheck.status).toBe(204); + + Date.now = () => baselineNow + 65_000; + const expiredCheck = await app.request("/auth/check", { + headers: { + Cookie: authedCookieHeader, + Origin: "https://rack.example.com", + }, + }); + expect(expiredCheck.status).toBe(401); + expect(await expiredCheck.json()).toEqual({ + error: "Unauthorized", + message: "Authentication required.", + }); + } finally { + Date.now = originalNow; + mock.restore(); + } + }); + + it("does not extend fallback idle expiry just by repeated auth checks", async () => { + const mock = await installMockOidcFetch(); + const originalNow = Date.now; + try { + const app = createApp( + buildOidcEnv({ + RACKULA_AUTH_SESSION_IDLE_TIMEOUT_SECONDS: "60", + }), + ); + + const authedCookieHeader = await completeOidcLogin(app); + const baselineNow = originalNow(); + + Date.now = () => baselineNow + 1_000; + const firstCheck = await app.request("/auth/check", { + headers: { + Cookie: authedCookieHeader, + Origin: "https://rack.example.com", + }, + }); + expect(firstCheck.status).toBe(204); + + Date.now = () => baselineNow + 20_000; + const secondCheck = await app.request("/auth/check", { + headers: { + Cookie: authedCookieHeader, + Origin: "https://rack.example.com", + }, + }); + expect(secondCheck.status).toBe(204); + + Date.now = () => baselineNow + 65_000; + const thirdCheck = await app.request("/auth/check", { + headers: { + Cookie: authedCookieHeader, + Origin: "https://rack.example.com", + }, + }); + expect(thirdCheck.status).toBe(401); + } finally { + Date.now = originalNow; + mock.restore(); + } + }); + + it("invalidates fallback OIDC sessions on logout and blocks replay", async () => { + const mock = await installMockOidcFetch(); + try { + const app = createApp(buildOidcEnv()); + const authedCookieHeader = await completeOidcLogin(app); + + const beforeLogout = await app.request("/auth/check", { + headers: { + Cookie: authedCookieHeader, + Origin: "https://rack.example.com", + }, + }); + expect(beforeLogout.status).toBe(204); + + const logoutResponse = await app.request("/auth/logout", { + method: "POST", + headers: { + Cookie: authedCookieHeader, + Origin: "https://rack.example.com", + }, + }); + expect(logoutResponse.status).toBe(204); + + const replayResponse = await app.request("/auth/check", { + headers: { + Cookie: authedCookieHeader, + Origin: "https://rack.example.com", + }, + }); + expect(replayResponse.status).toBe(401); + } finally { + mock.restore(); + } + }); +}); diff --git a/api/src/security.ts b/api/src/security.ts index 573d7a37..020f7f10 100644 --- a/api/src/security.ts +++ b/api/src/security.ts @@ -1086,6 +1086,9 @@ export function createAuthGateMiddleware( | "authSessionGeneration" | "authSessionMaxAgeSeconds" >, + resolveFallbackClaims?: ( + request: Request, + ) => Promise, ): MiddlewareHandler { return async (c, next): Promise => { if (!securityConfig.authEnabled) { @@ -1099,13 +1102,16 @@ export function createAuthGateMiddleware( return; } - const claims = resolveAuthenticatedSessionClaims(c.req.raw, securityConfig); - if (claims) { - c.set("authSubject", claims.sub); - c.set("authClaims", claims); + const signedClaims = resolveAuthenticatedSessionClaims( + c.req.raw, + securityConfig, + ); + if (signedClaims) { + c.set("authSubject", signedClaims.sub); + c.set("authClaims", signedClaims); const refreshedCookie = createRefreshedAuthSessionCookieHeader( - claims, + signedClaims, securityConfig, ); if (refreshedCookie) { @@ -1116,6 +1122,16 @@ export function createAuthGateMiddleware( return; } + if (resolveFallbackClaims) { + const fallbackClaims = await resolveFallbackClaims(c.req.raw); + if (fallbackClaims) { + c.set("authSubject", fallbackClaims.sub); + c.set("authClaims", fallbackClaims); + await next(); + return; + } + } + safeLogAuthEvent("auth.session.invalid", c.req.raw, { reason: "missing or invalid session cookie", }); diff --git a/docs/deployment/AUTHENTICATION.md b/docs/deployment/AUTHENTICATION.md index 291483bd..eb05ff7d 100644 --- a/docs/deployment/AUTHENTICATION.md +++ b/docs/deployment/AUTHENTICATION.md @@ -248,6 +248,12 @@ RACKULA_OIDC_ISSUER=https://authelia.example.com # Keycloak RACKULA_OIDC_ISSUER=https://keycloak.example.com/realms/homelab + +# Microsoft Entra ID (single tenant) +RACKULA_OIDC_ISSUER=https://login.microsoftonline.com//v2.0 + +# Microsoft Entra ID (multi-tenant) +RACKULA_OIDC_ISSUER=https://login.microsoftonline.com/common/v2.0 ``` ### Step 4: Restart Rackula API @@ -561,9 +567,12 @@ Maintain separate configurations for development and production: - Authentik: `https://authentik.example.com/application/o/rackula/` - Authelia: `https://authelia.example.com` - Keycloak: `https://keycloak.example.com/realms/{realm-name}` -2. Check IdP application exists and is enabled -3. Verify client ID matches: `RACKULA_OIDC_CLIENT_ID` = IdP client ID -4. Check IdP logs for more specific error details + - Entra (single tenant): `https://login.microsoftonline.com//v2.0` + - Entra (multi-tenant): `https://login.microsoftonline.com/common/v2.0` +2. For Entra `common`, ensure discovery issuer and token issuer use the tenant-specific `...//v2.0` value after login (Rackula accepts this when `RACKULA_OIDC_ISSUER` is `common`). +3. Check IdP application exists and is enabled +4. Verify client ID matches: `RACKULA_OIDC_CLIENT_ID` = IdP client ID +5. Check IdP logs for more specific error details ### Session cookie not set in browser