diff --git a/backend/src/app.ts b/backend/src/app.ts index 4b187dfd..0fea37f7 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,4 +1,4 @@ -import Fastify, { type FastifyInstance } from "fastify"; +import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify"; import cors from "@fastify/cors"; import cookie from "@fastify/cookie"; import rateLimit from "@fastify/rate-limit"; @@ -8,11 +8,11 @@ import type { AppConfig } from "./config.js"; import { ThtRunner } from "./tht/tht-runner.js"; import { PiProcessManager } from "./pi/pi-process-manager.js"; import { SseHub } from "./sse/sse-hub.js"; -import { authenticateSession } from "./auth/auth.js"; +import { authenticateSession, captureAuthConfigSnapshot, configuredOrigin } from "./auth/auth.js"; import type { PrincipalContext } from "./auth/principal.js"; import type { LoadedAuthConfig } from "./auth/types.js"; import { createCurrentLocalUserRegistryResolver, type LocalUserRegistry } from "./auth/local-registry.js"; -import { AuthSessionOperationalError, createFileAuthSessionStore, type AuthSessionStore } from "./auth/session-store.js"; +import { AuthSessionOperationalError, createFileAuthSessionStore, type AuthSessionStore, type AuthSessionValidity } from "./auth/session-store.js"; import { registerAuthRoutes } from "./auth/routes.js"; import { sessionRoutes } from "./routes/sessions.js"; import { sqlRoutes } from "./routes/sql.js"; @@ -56,6 +56,9 @@ export interface AppWithAuthSessionStore extends FastifyInstance { export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance { const app = Fastify({ logger: { level: "warn" }, disableRequestLogging: true }); + app.decorateRequest("authConfigSnapshot", undefined); + app.decorateRequest("authConfigSnapshotCaptured", false); + app.decorateRequest("authConfigSnapshotUnavailable", false); const isolatedTestRoot = process.env.VITEST === "true" ? join(tmpdir(), `thothii-workspace-secrets-vitest-${process.pid}`) : undefined; @@ -69,19 +72,18 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc const cookieAuth = config.authMode === "local" || config.authMode === "oidc"; app.register(cors, { - origin: cookieAuth - ? (origin, callback) => { - try { - const allowed = new URL(config.authentication?.current().value.publicUrl ?? "").origin; - const requested = origin === undefined ? undefined : new URL(origin).origin; - callback(null, requested === allowed ? allowed : false); - } catch { - callback(null, false); - } - } - : true, - credentials: cookieAuth, - methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + // The delegator runs at CORS's onRequest hook. It owns the one request-scoped config load + // which subsequent auth hooks and routes consume, including preflights that end here. + delegator: (request, callback) => { + const snapshot = captureAuthConfigSnapshot(request, config.authentication); + const origin = configuredOrigin(snapshot); + const snapshotUsesCookies = snapshot?.value.mode === "local" || snapshot?.value.mode === "oidc"; + callback(null, { + origin: snapshotUsesCookies && origin ? corsOrigin(request, origin) : cookieAuth ? false : true, + credentials: snapshotUsesCookies, + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + }); + }, }); // Cookie parsing and the rate-limit plugin must precede every auth/application route. app.register(cookie); @@ -167,6 +169,29 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc const resolveLocalUserRegistry = (loaded: LoadedAuthConfig) => { return deps?.localUserRegistry ?? localRegistryResolver?.resolve(loaded); }; + const localUserForSnapshot = async (loaded: LoadedAuthConfig, subject: string) => { + try { + if (loaded.value.mode !== "local") return { revision: loaded.revision, user: undefined }; + const registry = resolveLocalUserRegistry(loaded); + if (!registry) throw new AuthSessionOperationalError(); + const user = await registry.findBySubject(subject); + return { + revision: loaded.revision, + user: user === undefined ? undefined : { + enabled: user.enabled, + authRevision: user.authRevision, + roles: user.roles, + }, + }; + } catch (error) { + if (error instanceof AuthSessionOperationalError) throw error; + throw new AuthSessionOperationalError(); + } + }; + const sessionValidityForSnapshot = (loaded: LoadedAuthConfig): AuthSessionValidity => ({ + currentAuthConfigRevision: () => loaded.revision, + currentLocalUser: (subject) => localUserForSnapshot(loaded, subject), + }); const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc" ? createFileAuthSessionStore(config.authStateRoot, { currentAuthConfigRevision: () => { @@ -179,18 +204,8 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc currentLocalUser: async (subject) => { try { const loaded = config.authentication?.current(); - if (!loaded || loaded.value.mode !== "local") return { revision: "", user: undefined }; - const registry = resolveLocalUserRegistry(loaded); - if (!registry) throw new AuthSessionOperationalError(); - const user = await registry.findBySubject(subject); - return { - revision: loaded.revision, - user: user === undefined ? undefined : { - enabled: user.enabled, - authRevision: user.authRevision, - roles: user.roles, - }, - }; + if (!loaded) return { revision: "", user: undefined }; + return await localUserForSnapshot(loaded, subject); } catch (error) { if (error instanceof AuthSessionOperationalError) throw error; throw new AuthSessionOperationalError(); @@ -204,6 +219,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc publicExposure: config.publicExposure, authentication: config.authentication, sessionStore: authSessionStore, + sessionValidityForSnapshot, }); app.addHook("preHandler", (req, reply, done) => { if (isMaintenanceControl(req.url)) { @@ -284,6 +300,16 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc return app; } +function corsOrigin(request: FastifyRequest, expectedOrigin: string): string | false { + const supplied = request.headers.origin; + if (typeof supplied !== "string") return false; + try { + return new URL(supplied).origin === expectedOrigin ? expectedOrigin : false; + } catch { + return false; + } +} + function isLoopback(ip: string): boolean { return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"; } function isMaintenanceControl(url: string): boolean { return /^\/internal\/maintenance\/(?:activate|deactivate|status)(?:\?|$)/.test(url); diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts index 48035e96..e73008ac 100644 --- a/backend/src/auth/auth.ts +++ b/backend/src/auth/auth.ts @@ -1,8 +1,8 @@ import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from "fastify"; import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js"; import { rolesToPermissions } from "./config.js"; -import type { AuthenticationConfigProvider, AuthMode, AuthSessionRecord } from "./types.js"; -import { AuthSessionOperationalError, type AuthSessionStore } from "./session-store.js"; +import type { AuthenticationConfigProvider, AuthMode, AuthSessionRecord, LoadedAuthConfig } from "./types.js"; +import { AuthSessionOperationalError, type AuthSessionStore, type AuthSessionValidity } from "./session-store.js"; import { deriveCsrfToken, csrfTokensEqual } from "./csrf.js"; import { requireSameOriginOrNonBrowser } from "./authorization.js"; @@ -13,6 +13,10 @@ declare module "fastify" { /** Internal only: never serialize or write this opaque cookie token to logs. */ authSessionToken?: string; authPublicOrigin?: string; + /** One immutable configuration load for the whole request, including CORS. */ + authConfigSnapshot?: LoadedAuthConfig; + authConfigSnapshotCaptured?: boolean; + authConfigSnapshotUnavailable?: boolean; } } @@ -25,6 +29,22 @@ export interface AuthDependencies { publicExposure?: boolean; authentication?: AuthenticationConfigProvider; sessionStore?: AuthSessionStore; + sessionValidityForSnapshot?: (snapshot: LoadedAuthConfig) => AuthSessionValidity; +} + +/** Capture the authentication configuration once; CORS calls this before every other hook. */ +export function captureAuthConfigSnapshot( + request: FastifyRequest, + authentication: AuthenticationConfigProvider | undefined, +): LoadedAuthConfig | undefined { + if (request.authConfigSnapshotCaptured) return request.authConfigSnapshot; + request.authConfigSnapshotCaptured = true; + try { + request.authConfigSnapshot = authentication?.current(); + } catch { + request.authConfigSnapshotUnavailable = true; + } + return request.authConfigSnapshot; } export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposure = false) { @@ -59,6 +79,7 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl : undefined; const handle = async (request: FastifyRequest, reply: FastifyReply): Promise => { + const snapshot = captureAuthConfigSnapshot(request, deps.authentication); if (isPublicRoute(request)) return; if (legacy) { @@ -67,8 +88,8 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl return requireSameOriginOrNonBrowser(request, reply); } - const origin = configuredOrigin(deps.authentication); - if (!origin || !deps.sessionStore) { + const origin = configuredOrigin(snapshot); + if (!snapshot || !origin || !deps.sessionStore) { return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" }); } const token = readSessionCookie(request); @@ -76,7 +97,11 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl let session: AuthSessionRecord | undefined; try { - session = await deps.sessionStore.resolve(token); + session = await deps.sessionStore.resolve(token, undefined, deps.sessionValidityForSnapshot?.(snapshot)); + if (session && session.authConfigRevision !== snapshot.revision) { + try { await deps.sessionStore.revoke(token); } catch { /* the mismatch remains denied */ } + return authenticationRequired(reply); + } if (session) await deps.sessionStore.touch(token); } catch (error) { if (error instanceof AuthSessionOperationalError) { @@ -150,9 +175,9 @@ function csrfFailed(reply: FastifyReply): FastifyReply { return reply.code(403).send({ code: "csrf_failed", error: "Request origin validation failed" }); } -function configuredOrigin(authentication: AuthenticationConfigProvider | undefined): string | undefined { +export function configuredOrigin(snapshot: LoadedAuthConfig | undefined): string | undefined { try { - const publicUrl = authentication?.current().value.publicUrl; + const publicUrl = snapshot?.value.publicUrl; return publicUrl ? new URL(publicUrl).origin : undefined; } catch { return undefined; diff --git a/backend/src/auth/routes.ts b/backend/src/auth/routes.ts index 5e4834c2..85c3bdb2 100644 --- a/backend/src/auth/routes.ts +++ b/backend/src/auth/routes.ts @@ -4,7 +4,7 @@ import type { AuthenticationConfigProvider, LoadedAuthConfig } from "./types.js" import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js"; import type { AuthSessionStore } from "./session-store.js"; import { rolesToPermissions } from "./config.js"; -import { getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js"; +import { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js"; import { requirePermission, isPrincipalContext } from "./authorization.js"; import { deriveCsrfToken } from "./csrf.js"; import { verifyWithDummy } from "./password.js"; @@ -114,17 +114,15 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen const limiter = new LoginFailureLimiter(); const verificationGate = new VerificationGate(); - app.get("/auth/config", async (_request, reply) => { - try { - const mode = deps.authentication?.current().value.mode ?? deps.authMode; - return reply.send({ mode, localLogin: mode === "local", oidcLogin: false }); - } catch { - return unavailable(reply); - } + app.get("/auth/config", async (request, reply) => { + const snapshot = captureAuthConfigSnapshot(request, deps.authentication); + if (!snapshot) return unavailable(reply); + const mode = snapshot.value.mode; + return reply.send({ mode, localLogin: mode === "local", oidcLogin: false }); }); app.post("/auth/local/login", async (request, reply) => { - const configured = currentLocalConfig(deps); + const configured = currentLocalConfig(captureAuthConfigSnapshot(request, deps.authentication), deps); if (configured.kind === "unavailable") { return unavailableAfterDummy(verificationGate, argon2SafePassword(loginPayload(request).password), reply); } @@ -189,7 +187,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen if (!token || !deps.sessionStore) return unavailable(reply); try { await deps.sessionStore.revoke(token); - reply.clearCookie(sessionCookieName(), cookieOptions(currentSecure(deps), false)); + reply.clearCookie(sessionCookieName(), cookieOptions(currentSecure(request), false)); return reply.code(204).send(); } catch { return unavailable(reply); @@ -235,7 +233,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen }); } -function currentLocalConfig(deps: AuthRouteDependencies): +function currentLocalConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRouteDependencies): | { revision: string; origin: string; @@ -247,8 +245,8 @@ function currentLocalConfig(deps: AuthRouteDependencies): | { kind: "not_local" } | { kind: "unavailable" } { try { - const loaded = deps.authentication?.current(); - if (!loaded || loaded.value.mode !== "local") return { kind: "not_local" }; + if (!loaded) return { kind: "unavailable" }; + if (loaded.value.mode !== "local") return { kind: "not_local" }; const registry = deps.resolveLocalUserRegistry?.(loaded) ?? deps.localUserRegistry; if (!registry) return { kind: "unavailable" }; const url = new URL(loaded.value.publicUrl); @@ -265,8 +263,8 @@ function currentLocalConfig(deps: AuthRouteDependencies): } } -function currentSecure(deps: AuthRouteDependencies): boolean { - try { return new URL(deps.authentication?.current().value.publicUrl ?? "").protocol === "https:"; } catch { return false; } +function currentSecure(request: FastifyRequest): boolean { + return request.authConfigSnapshot?.value.publicUrl.startsWith("https:") ?? false; } function cookieOptions(secure: boolean, remembered: boolean) { diff --git a/backend/src/auth/session-store.ts b/backend/src/auth/session-store.ts index f8e5c6c0..01d659d6 100644 --- a/backend/src/auth/session-store.ts +++ b/backend/src/auth/session-store.ts @@ -105,7 +105,7 @@ export interface AuthSessionValidity { export interface AuthSessionStore { create(input: SessionCreateInput, now?: Date): Promise; - resolve(token: string, now?: Date): Promise; + resolve(token: string, now?: Date, validity?: AuthSessionValidity): Promise; touch(token: string, now?: Date): Promise; revoke(token: string): Promise; prune(now?: Date): Promise; @@ -801,7 +801,11 @@ export function createFileAuthSessionStore( throw invalid(); } - async function resolveSession(token: string, now = new Date()): Promise { + async function resolveSession( + token: string, + now = new Date(), + requestValidity = validity, + ): Promise { if (!canonicalRawValue(token)) return undefined; const nowMs = dateMilliseconds(now); const filename = digestFilename(token); @@ -816,7 +820,7 @@ export function createFileAuthSessionStore( return undefined; } try { - if (await recordIsCurrent(record, validity)) return record; + if (await recordIsCurrent(record, requestValidity)) return record; } catch (error) { if (error instanceof AuthSessionOperationalError) throw error; await bridge.remove(root, "sessions", filename); @@ -833,7 +837,7 @@ export function createFileAuthSessionStore( return undefined; } try { - if (await recordIsCurrent(trusted.value, validity)) return trusted.value; + if (await recordIsCurrent(trusted.value, requestValidity)) return trusted.value; } catch (error) { if (error instanceof AuthSessionOperationalError) throw error; removeTrusted(directories.sessions, filename, trusted.identity); diff --git a/backend/test/auth-password.test.ts b/backend/test/auth-password.test.ts index da249457..5dd95a46 100644 --- a/backend/test/auth-password.test.ts +++ b/backend/test/auth-password.test.ts @@ -97,6 +97,16 @@ describe("local Argon2id password verification", () => { expect(isValidPasswordHash(`$${algorithm}$${version}$${parameters}$${salt}$${digest}`)).toBe(true); }); + test("rejects raw-base64 PHCs with non-zero trailing bits", async () => { + const nonCanonicalSalt = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODx$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"; + const nonCanonicalDigest = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC5"; + + for (const phc of [nonCanonicalSalt, nonCanonicalDigest]) { + expect(isValidPasswordHash(phc)).toBe(false); + await expect(verifyPassword(vectors[0].password, phc)).resolves.toBe(false); + } + }); + test("surfaces a sanitized operational error when native Argon2 fails", async () => { argon2Spy.mockImplementationOnce((_algorithm, _parameters, callback) => { callback(new Error("native details must not leave the verifier")); diff --git a/backend/test/auth-request-snapshot.test.ts b/backend/test/auth-request-snapshot.test.ts new file mode 100644 index 00000000..a38ae9a1 --- /dev/null +++ b/backend/test/auth-request-snapshot.test.ts @@ -0,0 +1,181 @@ +import { afterEach, expect, test } from "vitest"; +import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stringify } from "yaml"; +import { buildApp, type AppWithAuthSessionStore } from "../src/app.js"; +import { loadAuthenticationConfig } from "../src/auth/config.js"; +import type { AuthenticationConfigProvider, LoadedAuthConfig } from "../src/auth/types.js"; +import { loadConfig } from "../src/config.js"; + +const password = "correct horse battery staple"; +const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"; +const originA = "http://127.0.0.1:8787"; +const originB = "http://127.0.0.1:8788"; +const userA = { id: "6ba7b810-9dad-4ed1-80b4-00c04fd430c8", username: "AdminA" }; +const userB = { id: "6ba7b811-9dad-4ed1-80b4-00c04fd430c8", username: "AdminB" }; +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +function localYaml(publicUrl: string, usersFile: string): string { + return stringify({ version: 1, mode: "local", publicUrl, local: { usersFile } }); +} + +function oidcYaml(publicUrl: string): string { + return stringify({ + version: 1, + mode: "oidc", + publicUrl, + oidc: { + issuer: "https://issuer.example.test/application/o/thothii/", + clientId: "thothii", + clientSecretRef: "THT_OIDC_CLIENT_SECRET", + scopes: ["openid"], + groupsClaim: "groups", + }, + groupCatalog: { + driver: "authentik", + baseUrl: "https://issuer.example.test", + apiTokenRef: "THT_AUTHENTIK_API_TOKEN", + }, + authorization: { groupRoles: { Users: ["user"], Admins: ["admin"] } }, + }); +} + +function usersYaml(user: typeof userA): string { + return [ + "version: 1", "users:", ` - id: ${user.id}`, ` username: ${user.username}`, + ` passwordHash: ${passwordHash}`, " roles:", " - admin", " enabled: true", " authRevision: 1", "", + ].join("\n"); +} + +function firstCookie(response: { headers: Record }): string { + const header = response.headers["set-cookie"]; + return (Array.isArray(header) ? header[0] : header)?.split(";", 1)[0] ?? ""; +} + +async function createFixture(first: "A" | "B", later: "A" | "B" | "oidc") { + const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-request-snapshot-")); + chmodSync(directory, 0o700); + const authA = join(directory, "auth-a.yaml"); + const authB = join(directory, "auth-b.yaml"); + const usersA = join(directory, "users-a.yaml"); + const usersB = join(directory, "users-b.yaml"); + writeFileSync(usersA, usersYaml(userA), { encoding: "utf8", mode: 0o600 }); + writeFileSync(usersB, usersYaml(userB), { encoding: "utf8", mode: 0o600 }); + writeFileSync(authA, localYaml(originA, "users-a.yaml"), { encoding: "utf8", mode: 0o600 }); + writeFileSync(authB, later === "oidc" ? oidcYaml(originB) : localYaml(originB, "users-b.yaml"), { encoding: "utf8", mode: 0o600 }); + for (const path of [authA, authB, usersA, usersB]) chmodSync(path, 0o600); + + const snapshots = { A: loadAuthenticationConfig(authA), B: loadAuthenticationConfig(authB) }; + let calls = 0; + const provider: AuthenticationConfigProvider = { + current: () => { + calls += 1; + return calls === 1 ? snapshots[first] : snapshots[later === "oidc" ? "B" : later]; + }, + }; + const config = loadConfig({ + THT_AUTH_CONFIG_FILE: authA, + THT_AUTH_STATE_ROOT: join(directory, "auth-state"), + THT_HARNESS_DIR: "/tmp/h", + }); + config.authentication = provider; + const app = buildApp(config) as AppWithAuthSessionStore; + cleanups.push(async () => { + await app.close(); + rmSync(directory, { recursive: true, force: true }); + }); + return { + app, + snapshots, + calls: () => calls, + resetCalls: () => { calls = 0; }, + userFor(snapshot: LoadedAuthConfig) { + return snapshot.sourcePath === authA ? userA : userB; + }, + }; +} + +test.each([ + { first: "A" as const, later: "B" as const }, + { first: "B" as const, later: "A" as const }, +])("a $later session cannot yield data under the replacement $first CORS snapshot", async ({ first, later }) => { + const fixture = await createFixture(first, later); + const requestSnapshot = fixture.snapshots[first]; + const replacementSnapshot = fixture.snapshots[later]; + const user = fixture.userFor(replacementSnapshot); + const created = await fixture.app.thothiiAuthSessionStore?.create({ + principal: { + issuer: "local", subject: user.id, displayName: user.username, roles: ["admin"], + permissions: ["session.use", "session.read_all", "session.manage_all", "settings.manage", "workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read"], + isAdmin: true, + }, + method: "local", + remembered: false, + userAuthRevision: 1, + authConfigRevision: replacementSnapshot.revision, + idleTtlMs: 60_000, + absoluteTtlMs: 60_000, + }); + expect(created).toBeDefined(); + + fixture.resetCalls(); + const response = await fixture.app.inject({ + method: "GET", + url: "/me", + headers: { cookie: `thothii_session=${created?.token}`, origin: requestSnapshot.value.publicUrl }, + }); + + expect(fixture.calls()).toBe(1); + expect(response.headers["access-control-allow-origin"]).toBe(new URL(requestSnapshot.value.publicUrl).origin); + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ code: "authentication_required", error: "Authentication is required" }); + expect(response.body).not.toContain(user.id); +}); + +test.each([ + { first: "A" as const, later: "B" as const }, + { first: "B" as const, later: "A" as const }, +])("local login uses and stamps its one $first request snapshot despite $later replacement", async ({ first, later }) => { + const fixture = await createFixture(first, later); + const snapshot = fixture.snapshots[first]; + const user = fixture.userFor(snapshot); + fixture.resetCalls(); + const response = await fixture.app.inject({ + method: "POST", + url: "/auth/local/login", + headers: { origin: snapshot.value.publicUrl, "sec-fetch-site": "same-origin" }, + payload: { username: user.username, password }, + }); + + expect(response.statusCode).toBe(200); + expect(fixture.calls()).toBe(1); + const token = firstCookie(response).split("=", 2)[1] ?? ""; + fixture.resetCalls(); + const record = await fixture.app.thothiiAuthSessionStore?.resolve(token); + expect(record).toMatchObject({ subject: user.id, authConfigRevision: snapshot.revision }); +}); + +test("auth config and valid preflight use the same first snapshot when the provider is replaced", async () => { + const fixture = await createFixture("A", "oidc"); + fixture.resetCalls(); + const preflight = await fixture.app.inject({ + method: "OPTIONS", + url: "/me", + headers: { origin: originA, "access-control-request-method": "GET" }, + }); + expect(preflight.statusCode).toBe(204); + expect(preflight.headers["access-control-allow-origin"]).toBe(originA); + expect(fixture.calls()).toBe(1); + + fixture.resetCalls(); + const response = await fixture.app.inject({ method: "GET", url: "/auth/config", headers: { origin: originA } }); + expect(response.statusCode).toBe(200); + expect(response.headers["access-control-allow-origin"]).toBe(originA); + expect(response.json()).toEqual({ mode: "local", localLogin: true, oidcLogin: false }); + expect(fixture.calls()).toBe(1); +}); diff --git a/tools/tht/internal/authconfig/password.go b/tools/tht/internal/authconfig/password.go index bc516334..b32f658a 100644 --- a/tools/tht/internal/authconfig/password.go +++ b/tools/tht/internal/authconfig/password.go @@ -20,7 +20,7 @@ const ( argon2SaltBytes = 16 argon2KeyBytes uint32 = 32 - maximumPHCBytes = 256 + maximumPHCBytes = 256 ) type argon2Parameters struct { @@ -130,8 +130,9 @@ func decodePHCBase64(value string, minimum, maximum int) ([]byte, bool) { if value == "" || strings.ContainsRune(value, '=') || len(value) > base64.RawStdEncoding.EncodedLen(maximum) || len(value) < base64.RawStdEncoding.EncodedLen(minimum)-1 { return nil, false } - decoded, err := base64.RawStdEncoding.DecodeString(value) - if err != nil || len(decoded) < minimum || len(decoded) > maximum { + encoding := base64.RawStdEncoding.Strict() + decoded, err := encoding.DecodeString(value) + if err != nil || len(decoded) < minimum || len(decoded) > maximum || encoding.EncodeToString(decoded) != value { return nil, false } return decoded, true diff --git a/tools/tht/internal/authconfig/password_test.go b/tools/tht/internal/authconfig/password_test.go index 119d9542..faf8d6da 100644 --- a/tools/tht/internal/authconfig/password_test.go +++ b/tools/tht/internal/authconfig/password_test.go @@ -57,14 +57,19 @@ func TestVerifyPasswordRejectsMalformedAndOversizedPHCBeforeHashing(t *testing.T "memory too large": "$argon2id$v=19$m=262145,t=1,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4", "passes too large": "$argon2id$v=19$m=65536,t=11,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4", "parallelism too large": "$argon2id$v=19$m=65536,t=3,p=5$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4", - "weak bounded policy": "$argon2id$v=19$m=8,t=1,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4", + "weak bounded policy": "$argon2id$v=19$m=8,t=1,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4", "short salt": "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0O$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4", "long digest": "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "noncanonical salt": "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODx$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4", + "noncanonical digest": "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC5", } { t.Run(name, func(t *testing.T) { if VerifyPassword(password, encoded) { t.Fatal("VerifyPassword() accepted an invalid PHC string") } + if err := validatePasswordHash(encoded); err == nil { + t.Fatal("validatePasswordHash() accepted an invalid PHC string") + } }) } }