From 2b618c5a0f6f07045700cfe7146ca517fb5f78ab Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 18 Aug 2026 06:33:48 +0200 Subject: [PATCH] test(auth): harden Task 15 OIDC smoke evidence --- backend/test/fixtures/oidc-provider.mjs | 209 ++++++++++++++--- backend/test/oidc-provider-fixture.test.ts | 254 +++++++++++++++++++++ frontend/e2e/auth.spec.ts | 27 +++ frontend/e2e/fixtures/auth-stack.mjs | 177 ++++++++++---- scripts/unified-deployment-smoke.sh | 173 ++++++++++++++ 5 files changed, 758 insertions(+), 82 deletions(-) create mode 100644 backend/test/oidc-provider-fixture.test.ts diff --git a/backend/test/fixtures/oidc-provider.mjs b/backend/test/fixtures/oidc-provider.mjs index 6729e000..86fc58f9 100644 --- a/backend/test/fixtures/oidc-provider.mjs +++ b/backend/test/fixtures/oidc-provider.mjs @@ -7,7 +7,13 @@ * needed to trust the provider from a spawned backend process. */ import { spawnSync } from "node:child_process"; -import { createHash, generateKeyPairSync, randomBytes, sign as signRsa } from "node:crypto"; +import { + createHash, + generateKeyPairSync, + randomBytes, + sign as signRsa, + timingSafeEqual, +} from "node:crypto"; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:https"; import { tmpdir } from "node:os"; @@ -17,6 +23,9 @@ import { fileURLToPath } from "node:url"; const LOOPBACK_HOST = "127.0.0.1"; const ISSUER_PATH = "/application/o/thothii"; const MAX_BODY_BYTES = 32 * 1024; +const MAX_STATE_TTL_MS = 5 * 60 * 1_000; +const MAX_STATE_LIMIT = 1_024; +const MAX_DEVICE_POLLS = 32; const VALID_IDENTITIES = new Set([ "ordinary", "admin", @@ -39,6 +48,43 @@ function safeError(code) { return new Error(code); } +function boundedInteger(value, fallback, maximum, code) { + const selected = value ?? fallback; + if (!Number.isSafeInteger(selected) || selected < 1 || selected > maximum) throw safeError(code); + return selected; +} + +function boundedNonNegativeInteger(value, fallback, maximum, code) { + const selected = value ?? fallback; + if (!Number.isSafeInteger(selected) || selected < 0 || selected > maximum) throw safeError(code); + return selected; +} + +function controlledString(value, code) { + if (typeof value !== "string" || value.length < 1 || value.length > 512 || /[\r\n]/u.test(value)) { + throw safeError(code); + } + return value; +} + +function exactParameter(values, name) { + const matches = values.getAll(name); + return matches.length === 1 && matches[0].length > 0 ? matches[0] : undefined; +} + +function secretMatches(actual, expected) { + if (typeof actual !== "string") return false; + const actualDigest = createHash("sha256").update(actual).digest(); + const expectedDigest = createHash("sha256").update(expected).digest(); + return timingSafeEqual(actualDigest, expectedDigest); +} + +function pruneExpired(records, currentTime) { + for (const [key, record] of records) { + if (record.expiresAt <= currentTime) records.delete(key); + } +} + function ensurePrivateDirectory(directory) { mkdirSync(directory, { recursive: true, mode: 0o700 }); chmodSync(directory, 0o700); @@ -85,9 +131,9 @@ async function requestBody(request) { return Buffer.concat(chunks).toString("utf8"); } -function jwt(privateKey, issuer, audience, nonce, identity) { +function jwt(privateKey, issuer, audience, nonce, identity, currentTime) { const claims = identityClaims[identity]; - const now = Math.floor(Date.now() / 1_000); + const now = Math.floor(currentTime / 1_000); const payload = { iss: issuer, sub: claims.subject, @@ -118,6 +164,42 @@ function authorizationIdentity(identity) { export async function startFakeOidcProvider(options = {}) { const host = options.host ?? LOOPBACK_HOST; if (host !== LOOPBACK_HOST) throw safeError("oidc_fixture_loopback_required"); + const registration = options.registration; + if (!registration || typeof registration !== "object") throw safeError("oidc_fixture_registration_required"); + const clientId = controlledString(registration.clientId, "oidc_fixture_client_id_invalid"); + const clientSecret = controlledString(registration.clientSecret, "oidc_fixture_client_secret_invalid"); + const redirectUri = controlledString(registration.redirectUri, "oidc_fixture_redirect_invalid"); + let parsedRedirect; + try { + parsedRedirect = new URL(redirectUri); + } catch { + throw safeError("oidc_fixture_redirect_invalid"); + } + if (parsedRedirect.protocol !== "http:" || parsedRedirect.hostname !== LOOPBACK_HOST + || parsedRedirect.username || parsedRedirect.password || parsedRedirect.hash) { + throw safeError("oidc_fixture_redirect_invalid"); + } + const apiToken = controlledString(options.apiToken, "oidc_fixture_api_token_required"); + const now = options.now ?? Date.now; + if (typeof now !== "function") throw safeError("oidc_fixture_clock_invalid"); + const authorizationStateTtlMs = boundedInteger( + options.authorizationStateTtlMs, 60_000, MAX_STATE_TTL_MS, "oidc_fixture_authorization_ttl_invalid", + ); + const authorizationStateLimit = boundedInteger( + options.authorizationStateLimit, 64, MAX_STATE_LIMIT, "oidc_fixture_authorization_limit_invalid", + ); + const deviceStateTtlMs = boundedInteger( + options.deviceStateTtlMs, 60_000, MAX_STATE_TTL_MS, "oidc_fixture_device_ttl_invalid", + ); + const deviceStateLimit = boundedInteger( + options.deviceStateLimit, 32, MAX_STATE_LIMIT, "oidc_fixture_device_limit_invalid", + ); + const devicePendingPolls = boundedNonNegativeInteger( + options.devicePendingPolls, 0, MAX_DEVICE_POLLS, "oidc_fixture_device_pending_polls_invalid", + ); + const devicePollLimit = boundedInteger( + options.devicePollLimit, 5, MAX_DEVICE_POLLS, "oidc_fixture_device_poll_limit_invalid", + ); const ownsDirectory = options.directory === undefined; const directory = options.directory ?? mkdtempSync(join(tmpdir(), "thothii-oidc-fixture-")); ensurePrivateDirectory(directory); @@ -134,6 +216,19 @@ export async function startFakeOidcProvider(options = {}) { let issuer = undefined; let baseUrl = undefined; + function currentTime() { + const value = now(); + if (!Number.isSafeInteger(value) || value < 0) throw safeError("oidc_fixture_clock_invalid"); + return value; + } + + function authenticateClient(request, values) { + if (request.headers.authorization !== undefined) return false; + const suppliedClientId = exactParameter(values, "client_id"); + const suppliedClientSecret = exactParameter(values, "client_secret"); + return secretMatches(suppliedClientId, clientId) && secretMatches(suppliedClientSecret, clientSecret); + } + const server = createServer({ key: certificate.key, cert: certificate.cert }, async (request, reply) => { try { if (!issuer || !baseUrl || !request.url) { @@ -154,7 +249,7 @@ export async function startFakeOidcProvider(options = {}) { response_types_supported: ["code"], subject_types_supported: ["public"], grant_types_supported: ["authorization_code", "urn:ietf:params:oauth:grant-type:device_code"], - token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], + token_endpoint_auth_methods_supported: ["client_secret_post"], code_challenge_methods_supported: ["S256"], id_token_signing_alg_values_supported: ["RS256"], }); @@ -165,33 +260,33 @@ export async function startFakeOidcProvider(options = {}) { return; } if (request.method === "GET" && path === `${ISSUER_PATH}/authorize`) { - const redirectUri = url.searchParams.get("redirect_uri"); - const clientId = url.searchParams.get("client_id"); - const state = url.searchParams.get("state"); - const nonce = url.searchParams.get("nonce"); - const challenge = url.searchParams.get("code_challenge"); - if (url.searchParams.get("response_type") !== "code" || !redirectUri || !clientId || !state || !nonce - || !challenge || url.searchParams.get("code_challenge_method") !== "S256") { + const suppliedRedirectUri = exactParameter(url.searchParams, "redirect_uri"); + const suppliedClientId = exactParameter(url.searchParams, "client_id"); + const state = exactParameter(url.searchParams, "state"); + const nonce = exactParameter(url.searchParams, "nonce"); + const challenge = exactParameter(url.searchParams, "code_challenge"); + if (exactParameter(url.searchParams, "response_type") !== "code" + || !secretMatches(suppliedRedirectUri, redirectUri) + || !secretMatches(suppliedClientId, clientId) || !state || !nonce || !challenge + || exactParameter(url.searchParams, "code_challenge_method") !== "S256") { sendJson(reply, 400, { error: "invalid_request" }); return; } - let callback; - try { - callback = new URL(redirectUri); - if (callback.protocol !== "http:" || callback.hostname !== LOOPBACK_HOST || callback.username || callback.password) { - throw safeError("oidc_fixture_redirect_invalid"); - } - } catch { - sendJson(reply, 400, { error: "invalid_request" }); + const reservationTime = currentTime(); + pruneExpired(authorizations, reservationTime); + if (authorizations.size >= authorizationStateLimit) { + sendJson(reply, 503, { error: "temporarily_unavailable" }); return; } + const callback = new URL(redirectUri); const code = randomBytes(32).toString("base64url"); authorizations.set(code, { challenge, clientId, + redirectUri, identity: activeIdentity, nonce, - used: false, + expiresAt: reservationTime + authorizationStateTtlMs, }); lastAuthorization = { identity: activeIdentity, codeChallengeMethod: "S256", pkceVerified: false }; callback.searchParams.set("code", code); @@ -201,48 +296,80 @@ export async function startFakeOidcProvider(options = {}) { } if (request.method === "POST" && path === `${ISSUER_PATH}/device_authorization`) { const values = new URLSearchParams(await requestBody(request)); - const clientId = values.get("client_id"); - if (!clientId) { - sendJson(reply, 400, { error: "invalid_request" }); + if (!authenticateClient(request, values)) { + sendJson(reply, 401, { error: "invalid_client" }); + return; + } + const reservationTime = currentTime(); + pruneExpired(deviceCodes, reservationTime); + if (deviceCodes.size >= deviceStateLimit) { + sendJson(reply, 503, { error: "temporarily_unavailable" }); return; } const deviceCode = randomBytes(32).toString("base64url"); const userCode = "FIXTURE-CODE"; - deviceCodes.set(deviceCode, { clientId, identity: activeIdentity, nonce: "device", used: false }); + deviceCodes.set(deviceCode, { + clientId, + identity: activeIdentity, + nonce: "device", + expiresAt: reservationTime + deviceStateTtlMs, + polls: 0, + }); sendJson(reply, 200, { device_code: deviceCode, user_code: userCode, verification_uri: `${issuer}device`, verification_uri_complete: `${issuer}device?user_code=${userCode}`, - expires_in: 60, + expires_in: Math.max(1, Math.floor(deviceStateTtlMs / 1_000)), interval: 1, }); return; } if (request.method === "POST" && path === `${ISSUER_PATH}/token`) { const values = new URLSearchParams(await requestBody(request)); - const grantType = values.get("grant_type"); + if (!authenticateClient(request, values)) { + sendJson(reply, 401, { error: "invalid_client" }); + return; + } + const tokenTime = currentTime(); + pruneExpired(authorizations, tokenTime); + pruneExpired(deviceCodes, tokenTime); + const grantType = exactParameter(values, "grant_type"); let record; if (grantType === "authorization_code") { - const code = values.get("code") ?? ""; + const code = exactParameter(values, "code") ?? ""; record = authorizations.get(code); - const verifier = values.get("code_verifier") ?? ""; - const verified = record !== undefined && !record.used - && createHash("sha256").update(verifier).digest("base64url") === record.challenge; + if (record !== undefined) authorizations.delete(code); + const verifier = exactParameter(values, "code_verifier") ?? ""; + const suppliedRedirectUri = exactParameter(values, "redirect_uri"); + const suppliedClientId = exactParameter(values, "client_id"); + const verified = record !== undefined + && secretMatches(suppliedClientId, record.clientId) + && secretMatches(suppliedRedirectUri, record.redirectUri) + && secretMatches(createHash("sha256").update(verifier).digest("base64url"), record.challenge); if (!verified) { sendJson(reply, 400, { error: "invalid_grant" }); return; } - record.used = true; if (lastAuthorization) lastAuthorization = { ...lastAuthorization, pkceVerified: true }; } else if (grantType === "urn:ietf:params:oauth:grant-type:device_code") { - const deviceCode = values.get("device_code") ?? ""; + const deviceCode = exactParameter(values, "device_code") ?? ""; record = deviceCodes.get(deviceCode); - if (record === undefined || record.used) { + if (record === undefined) { sendJson(reply, 400, { error: "invalid_grant" }); return; } - record.used = true; + record.polls += 1; + if (record.polls >= devicePollLimit && record.polls <= devicePendingPolls) { + deviceCodes.delete(deviceCode); + sendJson(reply, 400, { error: "expired_token" }); + return; + } + if (record.polls <= devicePendingPolls) { + sendJson(reply, 400, { error: "authorization_pending" }); + return; + } + deviceCodes.delete(deviceCode); } else { sendJson(reply, 400, { error: "unsupported_grant_type" }); return; @@ -251,12 +378,13 @@ export async function startFakeOidcProvider(options = {}) { access_token: randomBytes(32).toString("base64url"), token_type: "Bearer", expires_in: 60, - id_token: jwt(privateKey, issuer, record.clientId, record.nonce, record.identity), + id_token: jwt(privateKey, issuer, record.clientId, record.nonce, record.identity, tokenTime), }); return; } if (request.method === "GET" && path === "/api/v3/core/groups/") { - if (!request.headers.authorization?.startsWith("Bearer ")) { + const authorization = request.headers.authorization; + if (!secretMatches(authorization, `Bearer ${apiToken}`)) { sendJson(reply, 401, { detail: "authentication required" }); return; } @@ -317,7 +445,14 @@ export async function startFakeOidcProvider(options = {}) { const currentFile = fileURLToPath(import.meta.url); if (process.argv[1] && resolve(process.argv[1]) === currentFile) { - const provider = await startFakeOidcProvider(); + const provider = await startFakeOidcProvider({ + registration: { + clientId: "fixture-standalone-client", + clientSecret: "fixture-standalone-secret-not-production", + redirectUri: "http://127.0.0.1:8787/api/auth/oidc/callback", + }, + apiToken: "fixture-standalone-api-token-not-production", + }); process.stdout.write('{"status":"ready"}\n'); const close = async () => { await provider.close(); diff --git a/backend/test/oidc-provider-fixture.test.ts b/backend/test/oidc-provider-fixture.test.ts new file mode 100644 index 00000000..a127fa07 --- /dev/null +++ b/backend/test/oidc-provider-fixture.test.ts @@ -0,0 +1,254 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { request as httpsRequest } from "node:https"; +import { afterEach, describe, expect, test } from "vitest"; +import { startFakeOidcProvider } from "./fixtures/oidc-provider.mjs"; + +const registration = Object.freeze({ + clientId: "fixture-client", + clientSecret: "fixture-client-secret-not-production", + redirectUri: "http://127.0.0.1:8787/api/auth/oidc/callback", +}); +const apiToken = "fixture-api-token-not-production"; +const verifier = "fixture-pkce-verifier-0123456789-abcdefghijklmnopqrstuvwxyz"; +const challenge = createHash("sha256").update(verifier).digest("base64url"); + +let provider; + +afterEach(async () => { + await provider?.close(); + provider = undefined; +}); + +async function start(options = {}) { + provider = await startFakeOidcProvider({ registration, apiToken, ...options }); + return provider; +} + +function exchange(target, options = {}) { + return new Promise((resolve, reject) => { + const body = options.body ?? ""; + const request = httpsRequest(target, { + method: options.method ?? "GET", + ca: readFileSync(provider.caFile), + headers: { + accept: "application/json", + ...(body.length === 0 ? {} : { + "content-length": String(Buffer.byteLength(body)), + "content-type": "application/x-www-form-urlencoded", + }), + ...options.headers, + }, + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.once("error", reject); + response.once("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + const parsed = text.length === 0 ? {} : JSON.parse(text); + if (parsed && typeof parsed === "object") { + if (Object.hasOwn(parsed, "access_token")) parsed.access_token = "[redacted]"; + if (Object.hasOwn(parsed, "id_token")) parsed.id_token = "[redacted]"; + } + resolve({ + status: response.statusCode ?? 0, + location: response.headers.location, + body: parsed, + }); + }); + }); + request.once("error", reject); + request.end(body); + }); +} + +function form(entries) { + return new URLSearchParams(entries).toString(); +} + +async function authorize(overrides = {}) { + const target = new URL(`${provider.issuer}authorize`); + const values = { + response_type: "code", + client_id: registration.clientId, + redirect_uri: registration.redirectUri, + state: "fixture-state", + nonce: "fixture-nonce", + code_challenge: challenge, + code_challenge_method: "S256", + ...overrides, + }; + for (const [name, value] of Object.entries(values)) target.searchParams.set(name, value); + return exchange(target); +} + +function codeFrom(response) { + return new URL(response.location).searchParams.get("code"); +} + +function tokenBody(code, overrides = {}) { + return form({ + grant_type: "authorization_code", + client_id: registration.clientId, + client_secret: registration.clientSecret, + code, + redirect_uri: registration.redirectUri, + code_verifier: verifier, + ...overrides, + }); +} + +function deviceAuthorizationBody(overrides = {}) { + return form({ + client_id: registration.clientId, + client_secret: registration.clientSecret, + ...overrides, + }); +} + +function deviceTokenBody(deviceCode, overrides = {}) { + return form({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: registration.clientId, + client_secret: registration.clientSecret, + device_code: deviceCode, + ...overrides, + }); +} + +describe("loopback OIDC fixture security contract", () => { + test("advertises only production client_secret_post and rejects missing, wrong, basic, or duplicate client credentials", async () => { + await start(); + const discovery = await exchange(`${provider.issuer}.well-known/openid-configuration`); + expect(discovery.body.token_endpoint_auth_methods_supported).toEqual(["client_secret_post"]); + + const endpoint = `${provider.issuer}token`; + const requests = [ + form({ grant_type: "authorization_code", code: "unknown" }), + form({ grant_type: "authorization_code", code: "unknown", client_id: registration.clientId, client_secret: "wrong" }), + `${form({ grant_type: "authorization_code", code: "unknown", client_id: registration.clientId, client_secret: registration.clientSecret })}&client_secret=duplicate`, + `${form({ grant_type: "authorization_code", code: "unknown", client_id: registration.clientId, client_secret: registration.clientSecret })}&client_id=duplicate`, + form({ grant_type: "authorization_code", code: "unknown", client_id: "wrong", client_secret: registration.clientSecret }), + ]; + for (const body of requests) { + const response = await exchange(endpoint, { method: "POST", body }); + expect(response).toMatchObject({ status: 401, body: { error: "invalid_client" } }); + } + const basic = Buffer.from(`${registration.clientId}:${registration.clientSecret}`).toString("base64"); + await expect(exchange(endpoint, { + method: "POST", + body: form({ grant_type: "authorization_code", code: "unknown" }), + headers: { authorization: `Basic ${basic}` }, + })).resolves.toMatchObject({ status: 401, body: { error: "invalid_client" } }); + + await expect(exchange(endpoint, { + method: "POST", + body: tokenBody("unknown"), + })).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); + }); + + test("requires the exact Authentik bearer token", async () => { + await start(); + const target = `${provider.baseUrl}/api/v3/core/groups/?name=fixture-users`; + await expect(exchange(target)).resolves.toMatchObject({ status: 401 }); + await expect(exchange(target, { headers: { authorization: "Bearer wrong" } })) + .resolves.toMatchObject({ status: 401 }); + await expect(exchange(target, { headers: { authorization: `Bearer ${apiToken}` } })) + .resolves.toMatchObject({ status: 200, body: { results: [{ name: "fixture-users" }] } }); + }); + + test("binds authorization codes to registration, redirect URI, and PKCE and consumes terminal attempts", async () => { + await start(); + await expect(authorize({ client_id: "wrong" })).resolves.toMatchObject({ status: 400 }); + await expect(authorize({ redirect_uri: "http://127.0.0.1:8787/wrong" })).resolves.toMatchObject({ status: 400 }); + + const redirectCode = codeFrom(await authorize()); + await expect(exchange(`${provider.issuer}token`, { + method: "POST", + body: tokenBody(redirectCode, { redirect_uri: "http://127.0.0.1:8787/wrong" }), + })).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); + await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(redirectCode) })) + .resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); + + const pkceCode = codeFrom(await authorize()); + await expect(exchange(`${provider.issuer}token`, { + method: "POST", + body: tokenBody(pkceCode, { code_verifier: "wrong-verifier" }), + })).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); + await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(pkceCode) })) + .resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); + + const successfulCode = codeFrom(await authorize()); + await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(successfulCode) })) + .resolves.toMatchObject({ status: 200 }); + await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(successfulCode) })) + .resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); + }); + + test("prunes expired authorization state before enforcing a fail-closed cardinality cap", async () => { + let now = 1_000; + await start({ now: () => now, authorizationStateTtlMs: 1_000, authorizationStateLimit: 1 }); + const first = await authorize(); + expect(first.status).toBe(302); + await expect(authorize({ state: "capacity" })) + .resolves.toMatchObject({ status: 503, body: { error: "temporarily_unavailable" } }); + now += 1_001; + await expect(authorize({ state: "after-expiry" })).resolves.toMatchObject({ status: 302 }); + await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(codeFrom(first)) })) + .resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); + }); + + test("bounds device state, polling, expiry, and replay", async () => { + let now = 5_000; + await start({ + now: () => now, + deviceStateTtlMs: 1_000, + deviceStateLimit: 1, + devicePendingPolls: 1, + devicePollLimit: 3, + }); + const device = await exchange(`${provider.issuer}device_authorization`, { + method: "POST", body: deviceAuthorizationBody(), + }); + expect(device.status).toBe(200); + await expect(exchange(`${provider.issuer}device_authorization`, { + method: "POST", body: deviceAuthorizationBody(), + })).resolves.toMatchObject({ status: 503, body: { error: "temporarily_unavailable" } }); + await expect(exchange(`${provider.issuer}token`, { + method: "POST", body: deviceTokenBody(device.body.device_code), + })).resolves.toMatchObject({ status: 400, body: { error: "authorization_pending" } }); + await expect(exchange(`${provider.issuer}token`, { + method: "POST", body: deviceTokenBody(device.body.device_code), + })).resolves.toMatchObject({ status: 200 }); + await expect(exchange(`${provider.issuer}token`, { + method: "POST", body: deviceTokenBody(device.body.device_code), + })).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); + + const expired = await exchange(`${provider.issuer}device_authorization`, { + method: "POST", body: deviceAuthorizationBody(), + }); + now += 1_001; + await expect(exchange(`${provider.issuer}device_authorization`, { + method: "POST", body: deviceAuthorizationBody(), + })).resolves.toMatchObject({ status: 200 }); + await expect(exchange(`${provider.issuer}token`, { + method: "POST", body: deviceTokenBody(expired.body.device_code), + })).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); + }); + + test("deletes a device grant when its polling limit is exhausted", async () => { + await start({ devicePendingPolls: 10, devicePollLimit: 2 }); + const device = await exchange(`${provider.issuer}device_authorization`, { + method: "POST", body: deviceAuthorizationBody(), + }); + await expect(exchange(`${provider.issuer}token`, { + method: "POST", body: deviceTokenBody(device.body.device_code), + })).resolves.toMatchObject({ status: 400, body: { error: "authorization_pending" } }); + await expect(exchange(`${provider.issuer}token`, { + method: "POST", body: deviceTokenBody(device.body.device_code), + })).resolves.toMatchObject({ status: 400, body: { error: "expired_token" } }); + await expect(exchange(`${provider.issuer}token`, { + method: "POST", body: deviceTokenBody(device.body.device_code), + })).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); + }); +}); diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 8cd8889d..c21e199e 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -25,6 +25,33 @@ test("the loopback fixture exposes signed OIDC discovery, device authorization, }); }); +test("the production OIDC client rejects a wrong registration and wrong mounted client secret", async ({ page }) => { + await stack.useOidcMode("ordinary", "wrong-client-id"); + await page.goto(stack.publicUrl); + await signInWithOidc(page); + await expect(page.locator("body")).toContainText("invalid_request"); + await expect(page.getByTestId("app-shell")).toHaveCount(0); + + await stack.useOidcMode("ordinary", "wrong-client-secret"); + await page.goto(stack.publicUrl); + await signInWithOidc(page); + await expectOidcCallbackDenied(page); +}); + +test("production diagnostics reject a wrong group token and pass with the correct mounted secrets", async () => { + await stack.useOidcMode("ordinary", "wrong-api-token"); + await expect(stack.authDiagnostics()).resolves.toMatchObject({ + status: 1, + report: { ready: false, checks: [{ code: "oidc_group_catalog_unauthorized" }] }, + }); + + await stack.useOidcMode("ordinary", "correct"); + await expect(stack.authDiagnostics()).resolves.toMatchObject({ + status: 0, + report: { ready: true, checks: [{ code: "auth_ready" }] }, + }); +}); + async function expectShell(page: Page): Promise { await expect(page.getByTestId("app-shell")).toBeVisible({ timeout: 30_000 }); } diff --git a/frontend/e2e/fixtures/auth-stack.mjs b/frontend/e2e/fixtures/auth-stack.mjs index 9fd1fc20..c12454ba 100644 --- a/frontend/e2e/fixtures/auth-stack.mjs +++ b/frontend/e2e/fixtures/auth-stack.mjs @@ -17,6 +17,15 @@ const harnessRoot = join(repositoryRoot, "harness"); const thtRoot = join(repositoryRoot, "tools", "tht"); const fakePi = join(frontendRoot, "e2e", "fixtures", "fake-pi.mjs"); const fakeTht = join(frontendRoot, "e2e", "fixtures", "fake-tht.mjs"); +const FIXTURE_CLIENT_ID = "thothii-e2e-client"; +const FIXTURE_CLIENT_SECRET = "e2e-client-secret-not-a-production-secret"; +const FIXTURE_API_TOKEN = "e2e-group-catalog-token-not-a-production-secret"; +const WRONG_CLIENT_ID = "thothii-e2e-unregistered-client"; +const WRONG_CLIENT_SECRET = "e2e-wrong-client-secret-not-production"; +const WRONG_API_TOKEN = "e2e-wrong-api-token-not-production"; +const OIDC_CREDENTIAL_VARIANTS = new Set([ + "correct", "wrong-client-id", "wrong-client-secret", "wrong-api-token", +]); function safeError(code) { return new Error(code); @@ -247,6 +256,48 @@ function managedProcess(command, args, options) { }; } +function oneShotJson(command, args, options) { + return new Promise((resolveCommand) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "ignore"], + }); + let output = ""; + let outputValid = true; + let settled = false; + const finish = (status) => { + if (settled) return; + settled = true; + clearTimeout(timer); + let report; + try { + report = outputValid ? JSON.parse(output) : undefined; + } catch { + report = undefined; + } + resolveCommand({ + status: Number.isInteger(status) ? status : 1, + report: report && typeof report === "object" + ? report + : { ready: false, mode: "none", checks: [{ code: "fixture_command_invalid" }] }, + }); + }; + child.stdout?.on("data", (chunk) => { + if (!outputValid) return; + output += String(chunk); + if (Buffer.byteLength(output) > 64 * 1024) { + output = ""; + outputValid = false; + child.kill("SIGKILL"); + } + }); + child.once("error", () => finish(1)); + child.once("exit", (status) => finish(status)); + const timer = setTimeout(() => child.kill("SIGKILL"), 35_000); + }); +} + async function waitForOk(url, processHandle) { for (let attempt = 0; attempt < 300; attempt += 1) { if (processHandle.exited()) { @@ -303,7 +354,15 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {} const publicUrl = `http://127.0.0.1:${frontendPort}`; const backendUrl = `http://127.0.0.1:${backendPort}`; const workspace = withF1Workspace ? await prepareF1Workspace(root) : undefined; - const provider = await startFakeOidcProvider({ directory: providerRoot }); + const provider = await startFakeOidcProvider({ + directory: providerRoot, + registration: { + clientId: FIXTURE_CLIENT_ID, + clientSecret: FIXTURE_CLIENT_SECRET, + redirectUri: new URL("/api/auth/oidc/callback", publicUrl).href, + }, + apiToken: FIXTURE_API_TOKEN, + }); await buildAuthenticationStorageBridge(authStorageBinary); const localPassword = "e2e-local-password"; const passwordHash = await testPasswordHash(localPassword); @@ -334,11 +393,17 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {} }, ], })); - writeSecure(secretsFile, [ - "THT_OIDC_CLIENT_SECRET=e2e-client-secret-not-a-production-secret", - "THT_AUTHENTIK_API_TOKEN=e2e-group-catalog-token-not-a-production-secret", - "", - ].join("\n")); + function writeOidcSecrets(variant) { + if (!OIDC_CREDENTIAL_VARIANTS.has(variant)) throw safeError("e2e_oidc_credential_variant_invalid"); + const selectedClientSecret = variant === "wrong-client-secret" ? WRONG_CLIENT_SECRET : FIXTURE_CLIENT_SECRET; + const selectedApiToken = variant === "wrong-api-token" ? WRONG_API_TOKEN : FIXTURE_API_TOKEN; + writeSecure(secretsFile, [ + `THT_OIDC_CLIENT_SECRET=${selectedClientSecret}`, + `THT_AUTHENTIK_API_TOKEN=${selectedApiToken}`, + "", + ].join("\n")); + } + writeOidcSecrets("correct"); if (workspace) { writeSecure(fixtureDwhPasswordFile, "fixture-password-not-a-secret\n"); writeSecure(fixtureDwhCaFile, "fixture-ca-not-a-production-certificate\n"); @@ -378,7 +443,7 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {} }, local: { usersFile: "users.yaml" }, }); - const oidcConfig = () => ({ + const oidcConfig = (variant = "correct") => ({ version: 1, mode: "oidc", publicUrl, @@ -389,7 +454,7 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {} }, oidc: { issuer: provider.issuer, - clientId: "thothii-e2e-client", + clientId: variant === "wrong-client-id" ? WRONG_CLIENT_ID : FIXTURE_CLIENT_ID, clientSecretRef: "THT_OIDC_CLIENT_SECRET", scopes: ["openid", "profile", "groups"], groupsClaim: "groups", @@ -407,42 +472,46 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {} }, }); + function backendEnvironment() { + return cleanBackendEnvironment({ + NODE_ENV: "test", + HOST: "127.0.0.1", + PORT: String(backendPort), + PI_BIN: fakePi, + THT_BIN: fakeTht, + THT_AUTH_STORAGE_BIN: authStorageBinary, + THT_HARNESS_DIR: harnessRoot, + THT_AUTH_CONFIG_FILE: authConfigFile, + THT_AUTH_STATE_ROOT: stateRoot, + THT_SECRETS_FILE: secretsFile, + NODE_EXTRA_CA_CERTS: provider.caFile, + SETTINGS_FILE: settingsFile, + THT_MAINTENANCE_FILE: maintenanceFile, + THT_WORKSPACE_REGISTRY_ROOT: registryRoot, + THT_WORKSPACE_SECRET_STORE_ROOT: workspaceSecretRoot, + THT_WORKSPACE_SECRET_RUNTIME_ROOT: workspaceRuntimeRoot, + THT_WORKSPACE_INSTALLATION_ID: "e2e", + THT_DATA_ROOT: join(root, "data"), + ...(qdrant ? { THT_INTERNAL_QDRANT_URL: qdrant.baseUrl } : {}), + ...(workspace ? { + THT_WORKSPACE_GIT_REMOTE: workspace.remote, + THT_WORKSPACE_GIT_BRANCH: "main", + THT_WORKSPACE_SECRET_ROOTS: fixtureSecretRoot, + THT_WS_FIXTURE_WORKSPACE_DWH_TRANSPORT: "postgres_direct", + THT_WS_FIXTURE_WORKSPACE_DWH_HOST: "127.0.0.1", + THT_WS_FIXTURE_WORKSPACE_DWH_PORT: "5432", + THT_WS_FIXTURE_WORKSPACE_DWH_USER: "fixture", + THT_WS_FIXTURE_WORKSPACE_DWH_PASSWORD_FILE: fixtureDwhPasswordFile, + THT_WS_FIXTURE_WORKSPACE_DWH_TLS_CA_FILE: fixtureDwhCaFile, + } : {}), + }); + } + async function startBackend() { if (mode === undefined) throw safeError("e2e_auth_mode_not_configured"); backend = managedProcess(join(backendRoot, "node_modules", ".bin", "tsx"), ["src/server.ts"], { cwd: backendRoot, - env: cleanBackendEnvironment({ - NODE_ENV: "test", - HOST: "127.0.0.1", - PORT: String(backendPort), - PI_BIN: fakePi, - THT_BIN: fakeTht, - THT_AUTH_STORAGE_BIN: authStorageBinary, - THT_HARNESS_DIR: harnessRoot, - THT_AUTH_CONFIG_FILE: authConfigFile, - THT_AUTH_STATE_ROOT: stateRoot, - THT_SECRETS_FILE: secretsFile, - NODE_EXTRA_CA_CERTS: provider.caFile, - SETTINGS_FILE: settingsFile, - THT_MAINTENANCE_FILE: maintenanceFile, - THT_WORKSPACE_REGISTRY_ROOT: registryRoot, - THT_WORKSPACE_SECRET_STORE_ROOT: workspaceSecretRoot, - THT_WORKSPACE_SECRET_RUNTIME_ROOT: workspaceRuntimeRoot, - THT_WORKSPACE_INSTALLATION_ID: "e2e", - THT_DATA_ROOT: join(root, "data"), - ...(qdrant ? { THT_INTERNAL_QDRANT_URL: qdrant.baseUrl } : {}), - ...(workspace ? { - THT_WORKSPACE_GIT_REMOTE: workspace.remote, - THT_WORKSPACE_GIT_BRANCH: "main", - THT_WORKSPACE_SECRET_ROOTS: fixtureSecretRoot, - THT_WS_FIXTURE_WORKSPACE_DWH_TRANSPORT: "postgres_direct", - THT_WS_FIXTURE_WORKSPACE_DWH_HOST: "127.0.0.1", - THT_WS_FIXTURE_WORKSPACE_DWH_PORT: "5432", - THT_WS_FIXTURE_WORKSPACE_DWH_USER: "fixture", - THT_WS_FIXTURE_WORKSPACE_DWH_PASSWORD_FILE: fixtureDwhPasswordFile, - THT_WS_FIXTURE_WORKSPACE_DWH_TLS_CA_FILE: fixtureDwhCaFile, - } : {}), - }), + env: backendEnvironment(), }); await waitForOk(`${backendUrl}/health`, backend); } @@ -468,15 +537,23 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {} const jwks = await providerJson(`${provider.issuer}jwks`, provider.caFile); const device = await providerJson(`${provider.issuer}device_authorization`, provider.caFile, { method: "POST", - body: "client_id=thothii-e2e-client", + body: new URLSearchParams({ + client_id: FIXTURE_CLIENT_ID, + client_secret: FIXTURE_CLIENT_SECRET, + }).toString(), }); const deviceCode = typeof device.body?.device_code === "string" ? device.body.device_code : ""; const deviceToken = deviceCode.length === 0 ? { status: 0, body: {} } : await providerJson(`${provider.issuer}token`, provider.caFile, { method: "POST", - body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=${encodeURIComponent(deviceCode)}`, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: FIXTURE_CLIENT_ID, + client_secret: FIXTURE_CLIENT_SECRET, + device_code: deviceCode, + }).toString(), }); const groups = await providerJson(`${provider.baseUrl}/api/v3/core/groups/?name=fixture-users`, provider.caFile, { - headers: { authorization: "Bearer e2e-fixture" }, + headers: { authorization: `Bearer ${FIXTURE_API_TOKEN}` }, }); return { discovery: discovery.status === 200 && discovery.body?.issuer === provider.issuer, @@ -495,12 +572,22 @@ export async function createAuthenticationStack({ withF1Workspace = false } = {} mode = "local"; await restartBackend(); }, - async useOidcMode(identity) { + async useOidcMode(identity, variant = "correct") { + if (!OIDC_CREDENTIAL_VARIANTS.has(variant)) throw safeError("e2e_oidc_credential_variant_invalid"); provider.setIdentity(identity); - writeSecure(authConfigFile, JSON.stringify(oidcConfig())); + writeOidcSecrets(variant); + writeSecure(authConfigFile, JSON.stringify(oidcConfig(variant))); mode = "oidc"; await restartBackend(); }, + async authDiagnostics() { + if (mode !== "oidc") throw safeError("e2e_oidc_mode_required"); + return oneShotJson( + join(backendRoot, "node_modules", ".bin", "tsx"), + ["src/auth/diagnostic-command.ts", "--json"], + { cwd: backendRoot, env: backendEnvironment() }, + ); + }, restartBackend, async close() { await backend?.close(); diff --git a/scripts/unified-deployment-smoke.sh b/scripts/unified-deployment-smoke.sh index 2c53b83e..36e1498e 100755 --- a/scripts/unified-deployment-smoke.sh +++ b/scripts/unified-deployment-smoke.sh @@ -29,6 +29,98 @@ task13_sha256_text() { fi } +task13_record_image_evidence() { + local reference="$1" role="$2" image_id verified_id repo_digests + [[ "$role" =~ ^[a-z0-9-]+$ ]] || task13_fail "invalid image evidence role" + image_id="$(docker image inspect --format '{{.Id}}' "$reference")" + [[ "$image_id" =~ ^sha256:[0-9a-f]{64}$ ]] \ + || task13_fail "Docker image identity was not resolved" + verified_id="$(docker image inspect --format '{{.Id}}' "$image_id")" + [[ "$verified_id" == "$image_id" ]] \ + || task13_fail "Docker image identity changed during evidence capture" + if ! repo_digests="$({ docker image inspect --format '{{json .RepoDigests}}' "$image_id"; } | node -e ' + const fs = require("node:fs"); + let value = JSON.parse(fs.readFileSync(0, "utf8")); + if (value === null) value = []; + if (!Array.isArray(value)) process.exit(1); + const digests = []; + for (const item of value) { + if (typeof item !== "string" || !/@sha256:[0-9a-f]{64}$/.test(item)) process.exit(1); + digests.push(item.slice(item.lastIndexOf("@") + 1)); + } + process.stdout.write(JSON.stringify([...new Set(digests)].sort())); + ')"; then + task13_fail "Docker repository digest evidence was invalid" + return 1 + fi + printf '%s\t%s\t%s\n' "$image_id" "$role" "$repo_digests" >>"$TASK13_IMAGE_EVIDENCE_RECORDS" +} + +task13_record_project_image_evidence() { + local container_id image_id count=0 + while IFS= read -r container_id; do + [[ -n "$container_id" ]] || continue + image_id="$(docker container inspect --format '{{.Image}}' "$container_id")" + task13_record_image_evidence "$image_id" compose-runtime + count=$((count + 1)) + done < <(docker container ls -aq --filter "label=com.docker.compose.project=$TASK13_PROJECT") + [[ "$count" -gt 0 ]] || task13_fail "no Compose runtime images were available for evidence capture" +} + +task13_write_image_evidence() { + local image_id verified_id output_dir temporary + while IFS= read -r image_id; do + [[ -n "$image_id" ]] || continue + verified_id="$(docker image inspect --format '{{.Id}}' "$image_id")" + [[ "$verified_id" == "$image_id" ]] \ + || task13_fail "Docker image identity was unavailable before cleanup" + done < <(cut -f1 "$TASK13_IMAGE_EVIDENCE_RECORDS" | LC_ALL=C sort -u) + + output_dir="$(dirname "$TASK13_IMAGE_EVIDENCE_OUTPUT")" + mkdir -p "$output_dir" + temporary="$(mktemp "$output_dir/.unified-docker-images.XXXXXX")" + if ! node - "$TASK13_IMAGE_EVIDENCE_RECORDS" "$TASK13_SOURCE_COMMIT" "$TASK13_RUN_ID" >"$temporary" <<'NODE' +const fs = require("node:fs"); +const [recordsFile, sourceCommit, runId] = process.argv.slice(2); +if (!/^[0-9a-f]{40}$/.test(sourceCommit) || !/^[0-9A-Za-z-]+$/.test(runId)) process.exit(1); +const images = new Map(); +for (const line of fs.readFileSync(recordsFile, "utf8").split("\n").filter(Boolean)) { + const fields = line.split("\t"); + if (fields.length !== 3) process.exit(1); + const [id, role, encodedDigests] = fields; + if (!/^sha256:[0-9a-f]{64}$/.test(id) || !/^[a-z0-9-]+$/.test(role)) process.exit(1); + const repoDigests = JSON.parse(encodedDigests); + if (!Array.isArray(repoDigests) + || repoDigests.some((digest) => typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest))) { + process.exit(1); + } + const current = images.get(id) ?? { id, roles: new Set(), repo_digests: new Set() }; + current.roles.add(role); + for (const digest of repoDigests) current.repo_digests.add(digest); + images.set(id, current); +} +if (images.size === 0) process.exit(1); +const document = { + gate: "unified-deployment-smoke", + source_commit: sourceCommit, + run_id: runId, + images: [...images.values()].sort((left, right) => left.id.localeCompare(right.id)).map((image) => ({ + id: image.id, + roles: [...image.roles].sort(), + repo_digests: [...image.repo_digests].sort(), + })), +}; +process.stdout.write(`${JSON.stringify(document, null, 2)}\n`); +NODE + then + rm -f "$temporary" + task13_fail "could not serialize sanitized Docker image evidence" + return 1 + fi + chmod 0600 "$temporary" + mv "$temporary" "$TASK13_IMAGE_EVIDENCE_OUTPUT" +} + task13_registry_filesystem_fingerprint() { python3 - "$1" <<'PY' import hashlib @@ -1470,6 +1562,7 @@ task13_prepare_bad_candidate() { TASK13_BAD_CANDIDATE_ID="$(docker image inspect --format '{{.Id}}' "$TASK13_BAD_CANDIDATE_IMAGE")" [[ "$TASK13_BAD_CANDIDATE_ID" =~ ^sha256:[0-9a-f]{64}$ ]] \ || task13_fail "bad candidate image identity was not resolved" + task13_record_image_evidence "$TASK13_BAD_CANDIDATE_IMAGE" rollback-candidate task13_run_logged "prove bad candidate exits" docker run \ --name "$TASK13_BAD_CANDIDATE_CONTAINER" \ --label "io.thothii.task13.run=$TASK13_RUN_ID" \ @@ -1934,6 +2027,67 @@ task13_self_test_transaction_image_cleanup() { rm -f "$calls" "$foreign_error" } +task13_self_test_image_evidence() ( + local fixture records output + fixture="$(mktemp -d "${TMPDIR:-/tmp}/thothii-task15-image-evidence.XXXXXX")" + records="$fixture/records.tsv" + output="$fixture/images.json" + trap 'rm -rf "$fixture"' EXIT + : >"$records" + TASK13_RUN_ID="task15-image-run" + TASK13_SOURCE_COMMIT="0123456789abcdef0123456789abcdef01234567" + TASK13_IMAGE_EVIDENCE_RECORDS="$records" + TASK13_IMAGE_EVIDENCE_OUTPUT="$output" + TASK13_PROJECT="task15-image-project" + + docker() { + case "$*" in + "container ls -aq --filter label=com.docker.compose.project=task15-image-project") + printf '%s\n' container-one container-two + ;; + "container inspect --format {{.Image}} container-one") + printf '%s\n' 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + ;; + "container inspect --format {{.Image}} container-two") + printf '%s\n' 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + ;; + "image inspect --format {{.Id}} fixture-candidate@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc") + printf '%s\n' 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' + ;; + "image inspect --format {{.Id}} sha256:"*) + printf '%s\n' "${*: -1}" + ;; + "image inspect --format {{json .RepoDigests}} sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + printf '%s\n' '["fixture/core@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"]' + ;; + "image inspect --format {{json .RepoDigests}} sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + printf '%s\n' '[]' + ;; + "image inspect --format {{json .RepoDigests}} sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc") + printf '%s\n' '["fixture/candidate@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]' + ;; + *) task13_fail "unexpected image evidence Docker command" ;; + esac + } + + task13_record_project_image_evidence + task13_record_image_evidence \ + 'fixture-candidate@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' \ + 'rollback-candidate' + task13_write_image_evidence + unset -f docker + + node - "$output" <<'NODE' +const manifest = JSON.parse(require("node:fs").readFileSync(process.argv[2], "utf8")); +if (manifest.source_commit !== "0123456789abcdef0123456789abcdef01234567" + || manifest.run_id !== "task15-image-run" || manifest.images.length !== 3) process.exit(1); +const ids = manifest.images.map((image) => image.id); +if (new Set(ids).size !== 3 || ids.some((id) => !/^sha256:[0-9a-f]{64}$/.test(id))) process.exit(1); +if (!manifest.images.some((image) => image.roles.includes("rollback-candidate") + && image.repo_digests[0] === "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) process.exit(1); +NODE +) + task13_self_test_rollback_fixture_contract() { [[ "${TASK13_BAD_CANDIDATE_BEHAVIOR:-}" == stopped ]] \ || task13_fail "rollback candidate is not declared as guaranteed stopped" @@ -2181,6 +2335,7 @@ task13_self_test() { task13_self_test_cleanup_ownership task13_self_test_image_cleanup_ownership task13_self_test_transaction_image_cleanup + task13_self_test_image_evidence task13_self_test_rollback_fixture_contract task13_self_test_runtime_binding_fixture task13_self_test_server_runtime_binding_fixture @@ -2202,6 +2357,7 @@ task13_self_test_case() { runtime-bindings) task13_self_test_runtime_binding_fixture ;; server-bindings) task13_self_test_server_runtime_binding_fixture ;; cleanup) task13_self_test_stopped_project_containers ;; + image-evidence) task13_self_test_image_evidence ;; timeout-group) task13_self_test_timeout_process_group ;; timeout-nested) task13_self_test_nested_timeout_process_group ;; timeout-public) task13_self_test_public_timeout_contract ;; @@ -2258,6 +2414,7 @@ task13_fixtures_only() { } task13_initialize() { + local source_status umask 077 TASK13_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" TASK13_TMP_PARENT="$(cd "${TMPDIR:-/tmp}" && pwd -P)" @@ -2269,6 +2426,14 @@ task13_initialize() { trap 'task13_cleanup $?' EXIT trap 'exit 130' INT TERM HUP TASK13_RUN_ID="$(date -u +%Y%m%d%H%M%S)-$$-${RANDOM:-0}" + TASK13_SOURCE_COMMIT="$(git -C "$TASK13_ROOT" rev-parse --verify HEAD)" + [[ "$TASK13_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || task13_fail "source commit was not resolved" + source_status="$(git -C "$TASK13_ROOT" status --porcelain --untracked-files=normal \ + | sed -e '/^?? \.playwright-cli\/$/d' -e '/^?? \.thothctl\/$/d')" + [[ -z "$source_status" ]] || task13_fail "source must be clean for image traceability" + TASK13_IMAGE_EVIDENCE_RECORDS="$TASK13_TMP/image-evidence.tsv" + : >"$TASK13_IMAGE_EVIDENCE_RECORDS" + TASK13_IMAGE_EVIDENCE_OUTPUT="$TASK13_ROOT/.artifacts/task-15/unified-docker-images.json" TASK13_PROFILE="local" TASK13_INSTALLATION="$TASK13_TMP/thothii-installation.yaml" TASK13_PROJECT="thothii-$(task13_sha256_text "$TASK13_INSTALLATION" | cut -c1-12)" @@ -2339,6 +2504,8 @@ task13_smoke_main() { task13_build_tht task13_configure_local_authentication task13_start_stack + task13_record_project_image_evidence + task13_record_image_evidence "$TASK13_CORE_IMAGE" fixture-runtime task13_assert_project_ownership task13_assert_built_image_ownership task13_assert_runtime @@ -2348,6 +2515,8 @@ task13_smoke_main() { task13_registry_lifecycle fi task13_update_rollback + task13_record_project_image_evidence + task13_write_image_evidence printf 'Task 13 %s deployment smoke passed.\n' "$mode" } @@ -2360,10 +2529,14 @@ task13_server_smoke_main() { task13_build_tht task13_configure_server_oidc_authentication task13_start_server_stack + task13_record_project_image_evidence + task13_record_image_evidence "$TASK13_CORE_IMAGE" fixture-runtime task13_assert_project_ownership task13_assert_built_image_ownership task13_assert_server_runtime task13_assert_server_oidc_restore_verification + task13_record_project_image_evidence + task13_write_image_evidence printf 'Task 13 Linux server deployment smoke passed.\n' }