From bb0f035de32a7a2bb01cf926bf6f7a30c2b9ce51 Mon Sep 17 00:00:00 2001 From: Vasileios Markopoulos Date: Wed, 14 Oct 2026 13:47:09 +0300 Subject: [PATCH 1/2] Attestation Based Client Authentication with optional WE BUILD profile checks --- .env.template | 5 + README.md | 20 ++ src/app.ts | 35 ++- src/config/index.ts | 25 +- src/config/oauth2clients.json | 2 +- src/oid4vci/preAuthorizedCodeHandler.ts | 2 +- ...stationBasedClientAuthenticationService.ts | 250 ++++++++++++++++ .../attestationClientAuth.integration.test.ts | 156 ++++++++++ test/integration/setup.ts | 2 + ...onBasedClientAuthenticationService.test.ts | 276 ++++++++++++++++++ test/unit/attestationTrustResolution.test.ts | 133 +++++++++ test/unit/config.test.ts | 78 ++++- test/unit/introspectionHelpers.test.ts | 10 +- 13 files changed, 986 insertions(+), 8 deletions(-) create mode 100644 src/services/attestationBasedClientAuthenticationService.ts create mode 100644 test/integration/attestationClientAuth.integration.test.ts create mode 100644 test/unit/attestationBasedClientAuthenticationService.test.ts create mode 100644 test/unit/attestationTrustResolution.test.ts diff --git a/.env.template b/.env.template index 4fb5630..c855fe4 100644 --- a/.env.template +++ b/.env.template @@ -15,3 +15,8 @@ USER_PASS_PID_DEMO_PASSWORD=vErY$3cure PRE_AUTHORIZED_CREDENTIAL_ISSUANCE=true PRE_AUTHORIZED_CODE_API_URL=http://localhost:8003/api PRE_AUTHORIZED_CODE_API_BEARER_TOKEN=secret-bearer-token +OPENID4VC_PROFILE=DEFAULT +ABCA_CLIENT_ATTESTATION_MAX_AGE=86400 +ABCA_CLIENT_ATTESTATION_POP_MAX_AGE=60 +ABCA_CHALLENGE_SECRET=replace-with-a-64-len-secret +WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS=http://localhost:8002 diff --git a/README.md b/README.md index bbb4396..abebd64 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Environment variables and how they are used: | Variable | Purpose | Default / Notes | | --- | --- | --- | +| `OPENID4VC_PROFILE` | Selects the ecosystem profile across wallet-as. | `DEFAULT` if unset. Supported values: `DEFAULT`, `WEBUILD`; see profiles below. | | `SERVICE_URL` | Full external URL for the AS (scheme/host and optional path prefix). | `http://localhost:6060` if unset. Example: If deployed under `/as`, set it to `https://issuer.example.com/as`. | | `WALLET_URL` | Redirect URI registered for the wallet client. | `http://localhost:3000` if unset. | | `OIDC_JWKS_PATH` | Optional override for the OIDC provider signing JWKS path used for ID tokens and JWT authorization responses. | Defaults to `./keys/oidc.jwks.json`| @@ -44,6 +45,25 @@ Environment variables and how they are used: | `AUTH_BROKER_SCOPE` | Space-separated scopes sent to external IdP authorize endpoint by `auth-broker`. | Default: `openid profile email`. | | `AUTH_BROKER_REDIRECT_URI` | Redirect URI handled by wallet-as auth-broker callback route. | Default: `SERVICE_URL + /interaction/authBroker/callback`. | | `AUTH_BROKER_SKIP_LOGOUT` | Skip external IdP logout even if IdP supports it in metadata in `auth-broker` callback flow. | Optional; set to `true` when logout should be skipped. | +| `ABCA_CLIENT_ATTESTATION_MAX_AGE` | Maximum attestation age in seconds under `DEFAULT`; if `iat` is absent, limits remaining validity instead. | Defaults to `86400`. Ignored under `WEBUILD`. | +| `ABCA_CLIENT_ATTESTATION_POP_MAX_AGE` | Maximum PoP age in seconds under `DEFAULT`. Future PoP issuance times are rejected. | Defaults to `60`. Ignored under `WEBUILD`. | +| `ABCA_CHALLENGE_SECRET` | Shared secret for Attestation-Based Client Authentication challenges. | Required: exactly 64 hexadecimal characters encoding 32 random bytes. | +| `WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS` | Comma-separated trusted origins for WIA status list requests under the WE BUILD profile. | Required at startup only when `OPENID4VC_PROFILE=WEBUILD`. HTTPS required; HTTP loopback allowed outside production. | + +Generate `ABCA_CHALLENGE_SECRET` once with `openssl rand -hex 32` and store it +in your local `.env` or deployment secret manager. Use the same value across all +AS replicas and restarts. Missing or malformed values prevent startup; there is +no generated fallback. Replacing the secret invalidates outstanding challenges, +so coordinate changes across replicas. Never commit the generated value. + +### Profiles + +Set `OPENID4VC_PROFILE` in `.env` to select the ecosystem requirements applied by wallet-as: + +| Value | Behavior | +| --- | --- | +| `DEFAULT` | Default when unset. Applies the configurable attestation and PoP maximum ages, without WE BUILD checks or WIA status-list requests. | +| `WEBUILD` | Adds required wallet fields, WIA lifetime below 24 hours when `iat` is present, WIA/DPoP key equality, and revocation-status checks. | ## Authenticators `wallet-as` supports explicit authenticator loading at startup. diff --git a/src/app.ts b/src/app.ts index 0ae971c..4bfe7cc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,10 @@ import { import { dataStoreClient } from "./stores/dataStoreClient"; import { createOidcValkeyAdapter } from "./stores/OidcValkeyAdapter"; import preAuthorizedCodeHandler from "./oid4vci/preAuthorizedCodeHandler"; +import { + getAttestationSignaturePublicKey, + assertAttestationJwtAndPop, +} from "./services/attestationBasedClientAuthenticationService"; export function createApp() { const app = express(); @@ -48,7 +52,15 @@ export function createApp() { adapter: createOidcValkeyAdapter(dataStoreClient), clients: oidClients, jwks: config.oidcJwks, - + enabledJWA: { + attestSigningAlgValues: ['ES256'] + }, + clientAuthMethods: [ + 'client_secret_basic', + 'client_secret_post', + 'attest_jwt_client_auth', + 'none', + ], discovery: { ...(config.preAuthorizedCredentialIssuance ? { "pre-authorized_grant_anonymous_access_supported": true, @@ -69,6 +81,13 @@ export function createApp() { } }, features: { + attestClientAuth: { + enabled: true, + challengeSecret: config.abcaChallengeSecret, + ack: 'draft-10', + getAttestationSignaturePublicKey, + assertAttestationJwtAndPop + }, devInteractions: { enabled: false }, introspection: { enabled: true, @@ -150,7 +169,19 @@ export function createApp() { ); routesRoot.post("/token", (req, _res, next) => { - if (req.body?.grant_type === "urn:ietf:params:oauth:grant-type:pre-authorized_code") { + const hasClientAuthentication = Boolean( + req.body?.client_id || + req.body?.client_secret || + req.body?.client_assertion || + req.headers.authorization || + req.headers["oauth-client-attestation"] || + req.headers["oauth-client-attestation-pop"] + ); + + if ( + req.body?.grant_type === "urn:ietf:params:oauth:grant-type:pre-authorized_code" + && !hasClientAuthentication + ) { req.body.client_id = "__pre-authorized_code_client__"; } diff --git a/src/config/index.ts b/src/config/index.ts index 3c73cca..cfc7af6 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -107,6 +107,24 @@ function loadOAuth2Clients(): oidc.ClientMetadata[] { return clientsSchema.parse(parsed) as oidc.ClientMetadata[]; } +const abcaChallengeSecretHex = process.env.ABCA_CHALLENGE_SECRET; +if ( + !abcaChallengeSecretHex + || abcaChallengeSecretHex.length !== 64 + || !/^[0-9a-fA-F]{64}$/.test(abcaChallengeSecretHex) +) { + throw new Error( + "ABCA_CHALLENGE_SECRET must contain exactly 64 hexadecimal characters (32 bytes)." + ); +} +const abcaChallengeSecret = Buffer.from(abcaChallengeSecretHex, "hex"); +const openid4vcProfile = z.enum(["DEFAULT", "WEBUILD"]).default("DEFAULT").parse(process.env.OPENID4VC_PROFILE); +const weBuildAbcaStatusListAllowedOrigins = (process.env.WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS || "") + .split(",").map((origin) => origin.trim()).filter(Boolean); +if (openid4vcProfile === "WEBUILD" && weBuildAbcaStatusListAllowedOrigins.length === 0) { + throw new Error("WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS is required when OPENID4VC_PROFILE=WEBUILD."); +} + export default { serviceUrl: serviceUrl, basePath: basePath, @@ -123,6 +141,8 @@ export default { accessToken: Number(process.env.ACCESS_TOKEN_TTL) || 30, refreshToken: Number(process.env.REFRESH_TOKEN_TTL) || 2592000, authorizationCode: Number(process.env.AUTHORIZATION_CODE_TTL) || 60, + clientAttestation: Number(process.env.ABCA_CLIENT_ATTESTATION_MAX_AGE) || 86400, + clientAttestationPoP: Number(process.env.ABCA_CLIENT_ATTESTATION_POP_MAX_AGE) || 60, }, demoUsername: process.env.USER_PASS_PID_DEMO_USERNAME || null, demoPassword: process.env.USER_PASS_PID_DEMO_PASSWORD || null, @@ -142,5 +162,8 @@ export default { preAuthorizedConsentClientIds: preAuthorizedConsentClientIds, preAuthorizedCredentialIssuance: process.env.PRE_AUTHORIZED_CREDENTIAL_ISSUANCE === 'true' || false, preAuthorizedCodeApiUrl: process.env.PRE_AUTHORIZED_CODE_API_URL || "", - preAuthorizedCodeApiBearerToken: process.env.PRE_AUTHORIZED_CODE_API_BEARER_TOKEN || "" + preAuthorizedCodeApiBearerToken: process.env.PRE_AUTHORIZED_CODE_API_BEARER_TOKEN || "", + abcaChallengeSecret, + openid4vcProfile, + weBuildAbcaStatusListAllowedOrigins, } diff --git a/src/config/oauth2clients.json b/src/config/oauth2clients.json index 57bcc8b..f0d9344 100644 --- a/src/config/oauth2clients.json +++ b/src/config/oauth2clients.json @@ -25,7 +25,7 @@ }, { "client_id": "1233", - "token_endpoint_auth_method": "none", + "token_endpoint_auth_method": "attest_jwt_client_auth", "redirect_uris": [ "http://localhost:3000/" ], diff --git a/src/oid4vci/preAuthorizedCodeHandler.ts b/src/oid4vci/preAuthorizedCodeHandler.ts index 835782a..7de6a8d 100644 --- a/src/oid4vci/preAuthorizedCodeHandler.ts +++ b/src/oid4vci/preAuthorizedCodeHandler.ts @@ -21,7 +21,7 @@ export default async function preAuthorizedCodeHandler(ctx: any) { validatePreAuthorizedCodeGrant(grant, tx_code); - const client = await provider.Client.find('__pre-authorized_code_client__'); + const client = ctx.oidc.client ?? await provider.Client.find('__pre-authorized_code_client__'); if (!client) { throw new errors.InvalidClient('Could not find pre-authorized_code client'); } diff --git a/src/services/attestationBasedClientAuthenticationService.ts b/src/services/attestationBasedClientAuthenticationService.ts new file mode 100644 index 0000000..54960fe --- /dev/null +++ b/src/services/attestationBasedClientAuthenticationService.ts @@ -0,0 +1,250 @@ +import { X509Certificate } from "node:crypto"; +import { inflateSync } from "node:zlib"; +import * as oidc from "oidc-provider"; +import { calculateJwkThumbprint, decodeProtectedHeader, exportJWK, importX509, jwtVerify, type JWK } from "jose"; +import config from "../config"; +import { verifyX5C } from "wallet-common"; + +const MAX_X5C_CERTIFICATES = 5; +const MAX_X5C_CERTIFICATE_LENGTH = 16 * 1024; +const BASE64_CERTIFICATE_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/; + +function parseX5C(value: unknown): string[] { + if ( + !Array.isArray(value) + || value.length === 0 + || value.length > MAX_X5C_CERTIFICATES + ) { + throw new oidc.errors.InvalidClientAttestation('invalid client attestation x5c chain'); + } + + return value.map((certificate) => { + if ( + typeof certificate !== 'string' + || certificate.length === 0 + || certificate.length > MAX_X5C_CERTIFICATE_LENGTH + || certificate.length % 4 !== 0 + || !BASE64_CERTIFICATE_PATTERN.test(certificate) + ) { + throw new oidc.errors.InvalidClientAttestation('invalid client attestation x5c certificate'); + } + + return certificate; + }); +} + +function certificateToPem(certificate: string): string { + const lines = certificate.match(/.{1,64}/g)?.join('\n') ?? certificate; + return `-----BEGIN CERTIFICATE-----\n${lines}\n-----END CERTIFICATE-----`; +} + +function assertNumericDate(value: unknown, claim: string): number { + if (!Number.isInteger(value) || typeof value !== 'number') { + throw new oidc.errors.InvalidClientAttestation(`${claim} must be an integer NumericDate`); + } + + return value; +} + +export async function getAttestationSignaturePublicKey( + _ctx: oidc.KoaContextWithOIDC, + header: oidc.UnknownObject, + _payload: oidc.UnknownObject, + _client: oidc.Client, +): Promise { + + if (header.alg !== 'ES256') { + throw new oidc.errors.InvalidClientAttestation('unsupported client attestation alg'); + } + const certificateChain = parseX5C(header.x5c); + try { + const leafCertificate = new X509Certificate( + Buffer.from(certificateChain[0], 'base64'), + ); + if (leafCertificate.ca) { + throw new Error('CA certificates cannot sign Wallet Instance Attestations'); + } + } catch { + throw new oidc.errors.InvalidClientAttestation('invalid client attestation signing certificate'); + } + + if (config.trustedRootCertificates.length === 0) { + throw new oidc.errors.InvalidClientAttestation('no Wallet Provider trust anchors configured'); + } + + let trusted: boolean; + try { + trusted = await verifyX5C(certificateChain, config.trustedRootCertificates); + } catch { + trusted = false; + } + if (!trusted) { + throw new oidc.errors.InvalidClientAttestation('untrusted client attestation x5c chain'); + } + +try { + return await exportJWK( + await importX509(certificateToPem(certificateChain[0]), header.alg), + ); + } catch { + throw new oidc.errors.InvalidClientAttestation('invalid client attestation signing certificate'); + } + +} + +export async function assertAttestationJwtAndPop( + ctx: oidc.KoaContextWithOIDC, + attestation: oidc.JWTVerificationResult, + _pop: oidc.JWTVerificationResult, + _client: oidc.Client, +): Promise { + const payload = attestation.payload; + const now = Math.floor(Date.now() / 1000); + // The provider validates NumericDate types. Draft 10 permits an omitted iat. + const issuedAt = payload.iat; + const expiresAt = assertNumericDate(payload.exp, "attestation exp"); + if (typeof issuedAt === "number" && (issuedAt > now || expiresAt <= issuedAt)) { + throw new oidc.errors.InvalidClientAttestation("attestation iat is in the future or exp is not after iat"); + } + if (ctx.oidc.route === "token" && !ctx.get("DPoP")) { + throw new oidc.errors.InvalidClientAttestation("DPoP is required for token requests"); + } + + if (config.openid4vcProfile === "WEBUILD") { + await assertAttestationJwtAndPopWeBuildProfile(ctx, attestation, _pop, _client); + } else { + if (typeof issuedAt === "number") { + if (now - issuedAt > config.ttl.clientAttestation) { + throw new oidc.errors.UseFreshAttestation("attestation is too old"); + } + } else if (expiresAt - now > config.ttl.clientAttestation) { + throw new oidc.errors.UseFreshAttestation("attestation exp is too far in the future"); + } + const popIssuedAt = _pop.payload.iat; + if (typeof popIssuedAt !== "number" || !Number.isFinite(popIssuedAt) + || popIssuedAt > now || now - popIssuedAt > config.ttl.clientAttestationPoP) { + throw new oidc.errors.InvalidClientAttestation("PoP iat is outside the allowed age window"); + } + } +} + +async function assertAttestationJwtAndPopWeBuildProfile( + ctx: oidc.KoaContextWithOIDC, + attestation: oidc.JWTVerificationResult, + _pop: oidc.JWTVerificationResult, + _client: oidc.Client, +): Promise { + const payload = attestation.payload; + for (const name of ["wallet_name", "wallet_version"]) { + if (typeof payload[name] !== "string" || payload[name].trim().length === 0) { + throw new oidc.errors.InvalidClientAttestation(`invalid ${name}`); + } + } + const certification = payload.wallet_solution_certification_information; + if ( + certification === undefined || certification === null + || (typeof certification === "string" && certification.trim().length === 0) + ) { + throw new oidc.errors.InvalidClientAttestation("wallet certification information missing"); + } + + const issuedAt = payload.iat; + const expiresAt = payload.exp as number; + if (typeof issuedAt === "number" && expiresAt - issuedAt >= 86400) { + throw new oidc.errors.InvalidClientAttestation("invalid WIA lifetime (must be below 24 hours)"); + } + + const dpop = ctx.get("DPoP"); + if (dpop) { + try { + const confirmation = payload.cnf as { jwk: JWK }; + const dpopKey = decodeProtectedHeader(dpop).jwk; + if (!dpopKey || await calculateJwkThumbprint(dpopKey) !== await calculateJwkThumbprint(confirmation.jwk)) { + throw new Error("key mismatch"); + } + } catch { + throw new oidc.errors.InvalidClientAttestation("DPoP key does not match WIA cnf.jwk"); + } + } + + await validateRevocationStatus(attestation); +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : undefined; +} + +async function validateRevocationStatus(attestation: oidc.JWTVerificationResult): Promise { + const status = asRecord(attestation.payload.client_status); + const reference = asRecord(asRecord(status?.status)?.status_list); + const now = Math.floor(Date.now() / 1000); + if ( + typeof status?.exp !== "number" || !Number.isSafeInteger(status.exp) + || status.exp - now < 31 * 86400 + || typeof reference?.uri !== "string" + || !Number.isSafeInteger(reference.idx) || (reference.idx as number) < 0 + ) { + throw new oidc.errors.InvalidClientAttestation("invalid client_status or maintenance period below 31 days"); + } + + try { + const url = new URL(reference.uri); + if ( + url.username || url.password || url.hash + || !config.weBuildAbcaStatusListAllowedOrigins.includes(url.origin) + || (url.protocol !== "https:" && !(process.env.NODE_ENV !== "production" + && url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) + ) { + throw new Error("status list origin is not allowed"); + } + const response = await fetch(url, { + headers: { Accept: "application/statuslist+jwt" }, + redirect: "error", + signal: AbortSignal.timeout(5000), + }); + if (!response.ok || !response.body) throw new Error("status list unavailable"); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > 256 * 1024) { + await reader.cancel(); + throw new Error("status list token too large"); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + // Minimal signer policy: the status list must use the WIA signing key. + const verified = await jwtVerify(Buffer.concat(chunks).toString("utf8"), attestation.key, { + algorithms: ["ES256"], typ: "statuslist+jwt", subject: reference.uri, + requiredClaims: ["sub", "iat", "status_list"], + }); + const list = asRecord(verified.payload.status_list); + const bits = list?.bits; + if ( + typeof verified.payload.iat !== "number" || verified.payload.iat > now + || (verified.payload.ttl !== undefined + && (typeof verified.payload.ttl !== "number" || verified.payload.ttl <= 0 + || now >= verified.payload.iat + verified.payload.ttl)) + || typeof bits !== "number" || ![1, 2, 4, 8].includes(bits) + || typeof list?.lst !== "string" || !/^[A-Za-z0-9_-]+$/.test(list.lst) + ) { + throw new Error("invalid status list payload"); + } + const bytes = inflateSync(Buffer.from(list.lst, "base64url"), { maxOutputLength: 1024 * 1024 }); + const index = reference.idx as number; + if (index >= bytes.length * (8 / bits)) throw new Error("status index out of bounds"); + const bitOffset = index * bits; + const value = (bytes[Math.floor(bitOffset / 8)] >> (bitOffset % 8)) & ((1 << bits) - 1); + if (value !== 0) throw new Error("WIA is revoked or not valid"); + } catch { + throw new oidc.errors.InvalidClientAttestation("client status could not be verified as valid"); + } +} diff --git a/test/integration/attestationClientAuth.integration.test.ts b/test/integration/attestationClientAuth.integration.test.ts new file mode 100644 index 0000000..4df3745 --- /dev/null +++ b/test/integration/attestationClientAuth.integration.test.ts @@ -0,0 +1,156 @@ +import { execFileSync } from "node:child_process"; +import { randomUUID, X509Certificate } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { calculateJwkThumbprint, exportJWK, generateKeyPair, importPKCS8, SignJWT } from "jose"; +import type * as oidc from "oidc-provider"; +import request from "supertest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const consumeCode = vi.hoisted(() => vi.fn()); +vi.mock("../../src/services/preAuthorizedCodeService", () => ({ consumePreAuthorizedCode: consumeCode })); + +describe("attestation client authentication through the AS", () => { + const issuer = "https://as.example.test"; + const clientId = `abca-test-${randomUUID()}`; + let directory: string; + let app: ReturnType["app"]; + let provider: oidc.Provider; + let config: typeof import("../../src/config").default; + let attesterKey: Awaited>; + let instanceKeys: Awaited>; + let leafBase64: string; + + function openssl(...args: string[]) { + execFileSync("openssl", args, { cwd: directory, stdio: "pipe" }); + } + + beforeAll(async () => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "wallet-as-abca-integration-")); + openssl("req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", + "-nodes", "-keyout", "root.key", "-out", "root.pem", "-days", "2", "-subj", "/CN=Test CA", + "-addext", "basicConstraints=critical,CA:TRUE", "-addext", "keyUsage=critical,keyCertSign,cRLSign"); + openssl("req", "-new", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", + "-nodes", "-keyout", "leaf.key", "-out", "leaf.csr", "-subj", "/CN=Test Attester"); + fs.writeFileSync(path.join(directory, "leaf.ext"), + "basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n"); + openssl("x509", "-req", "-in", "leaf.csr", "-CA", "root.pem", "-CAkey", "root.key", + "-CAcreateserial", "-out", "leaf.pem", "-days", "1", "-extfile", "leaf.ext"); + leafBase64 = new X509Certificate(fs.readFileSync(path.join(directory, "leaf.pem"))).raw.toString("base64"); + attesterKey = await importPKCS8(fs.readFileSync(path.join(directory, "leaf.key"), "utf8"), "ES256"); + instanceKeys = await generateKeyPair("ES256"); + + vi.stubEnv("SERVICE_URL", issuer); + vi.stubEnv("BASE_PATH", ""); + vi.stubEnv("AUTHENTICATOR", "user-pass-pid"); + vi.stubEnv("OPENID4VC_PROFILE", "DEFAULT"); + vi.stubEnv("PRE_AUTHORIZED_CREDENTIAL_ISSUANCE", "true"); + vi.resetModules(); + config = (await import("../../src/config")).default; + config.trustedRootCertificates = [fs.readFileSync(path.join(directory, "root.pem"), "utf8")]; + config.oidClients = [{ + client_id: clientId, + token_endpoint_auth_method: "attest_jwt_client_auth", + grant_types: ["urn:ietf:params:oauth:grant-type:pre-authorized_code"], + response_types: [], redirect_uris: ["https://wallet.example/callback"], + }]; + ({ app, provider } = (await import("../../src/app")).createApp()); + }); + + afterAll(async () => { + if (typeof app !== "undefined") await (await import("../../src/stores/dataStoreClient")).dataStoreClient.quit(); + vi.unstubAllEnvs(); + if (directory) fs.rmSync(directory, { recursive: true, force: true }); + }); + + beforeEach(() => { + consumeCode.mockReset().mockResolvedValue({ account_id: "abca-user", scope: "openid" }); + config.openid4vcProfile = "DEFAULT"; + }); + + async function attestation(key = attesterKey) { + return new SignJWT({ cnf: { jwk: await exportJWK(instanceKeys.publicKey) } }) + .setProtectedHeader({ alg: "ES256", typ: "oauth-client-attestation+jwt", x5c: [leafBase64] }) + .setSubject(clientId).setIssuedAt().setExpirationTime("5m").sign(key); + } + + async function pop(challenge?: string, audience = issuer) { + return new SignJWT(challenge ? { challenge } : {}) + .setProtectedHeader({ alg: "ES256", typ: "oauth-client-attestation-pop+jwt" }) + .setAudience(audience).setIssuedAt().setJti(randomUUID()).sign(instanceKeys.privateKey); + } + + async function tokenRequest(wia: string, proof: string) { + const dpop = await new SignJWT({ htm: "POST", htu: `${issuer}/token` }) + .setProtectedHeader({ alg: "ES256", typ: "dpop+jwt", jwk: await exportJWK(instanceKeys.publicKey) }) + .setIssuedAt().setJti(randomUUID()).sign(instanceKeys.privateKey); + return request(app).post("/token").set("Host", "as.example.test").set("X-Forwarded-Proto", "https") + .set("OAuth-Client-Attestation", wia).set("OAuth-Client-Attestation-PoP", proof).set("DPoP", dpop) + .type("form").send({ + grant_type: "urn:ietf:params:oauth:grant-type:pre-authorized_code", + "pre-authorized_code": "integration-code", + }); + } + + async function challenge() { + const discovery = await request(app).get("/.well-known/openid-configuration").expect(200); + expect(discovery.body.token_endpoint_auth_methods_supported).toContain("attest_jwt_client_auth"); + const response = await request(app).post(new URL(discovery.body.challenge_endpoint).pathname).expect(200); + expect(response.body.attestation_challenge).toEqual(expect.any(String)); + return response.body.attestation_challenge as string; + } + + it("challenges, then authenticates the attested client and issues its token", async () => { + const wia = await attestation(); + const first = await tokenRequest(wia, await pop()); + expect(first.status).toBe(400); + expect(first.body.error).toBe("use_attestation_challenge"); + expect(consumeCode).not.toHaveBeenCalled(); + const nextChallenge = first.headers["oauth-client-attestation-challenge"]; + expect(nextChallenge).toEqual(expect.any(String)); + + const response = await tokenRequest(wia, await pop(nextChallenge)); + expect(response.status).toBe(200); + expect(response.body.access_token).toEqual(expect.any(String)); + expect(consumeCode).toHaveBeenCalledOnce(); + const accessToken = await provider.AccessToken.find(response.body.access_token); + expect(accessToken?.clientId).toBe(clientId); + expect(accessToken?.jkt).toBe(await calculateJwkThumbprint(await exportJWK(instanceKeys.publicKey))); + }); + + it("rejects an attestation signed by a key different from its trusted certificate", async () => { + const wrongSigner = await generateKeyPair("ES256"); + const response = await tokenRequest(await attestation(wrongSigner.privateKey), await pop(await challenge())); + expect(response.status).toBe(401); + expect(response.body.error).toBe("invalid_client"); + expect(consumeCode).not.toHaveBeenCalled(); + }); + + it("rejects a PoP intended for another AS", async () => { + const response = await tokenRequest(await attestation(), await pop(await challenge(), "https://other-as.example")); + expect(response.status).toBe(401); + expect(response.body.error).toBe("invalid_client"); + expect(consumeCode).not.toHaveBeenCalled(); + }); + + it("rejects replay of a successfully authenticated PoP", async () => { + const wia = await attestation(); + const proof = await pop(await challenge()); + const first = await tokenRequest(wia, proof); + expect(first.status).toBe(200); + const replay = await tokenRequest(wia, proof); + expect(replay.status).toBe(401); + expect(replay.body.error).toBe("invalid_client"); + expect(consumeCode).toHaveBeenCalledOnce(); + }); + + it("runs the WE BUILD callback when that profile is selected", async () => { + config.openid4vcProfile = "WEBUILD"; + const response = await tokenRequest(await attestation(), await pop(await challenge())); + expect(response.status).toBe(400); + expect(response.body.error).toBe("invalid_client_attestation"); + expect(response.body.error_description).toContain("wallet_name"); + expect(consumeCode).not.toHaveBeenCalled(); + }); +}); diff --git a/test/integration/setup.ts b/test/integration/setup.ts index 8e2f620..bfc3b10 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -6,6 +6,8 @@ const VALKEY_PORT = 6379; const VALKEY_PASSWORD = "test-secret"; export default async function setup() { + // Deterministic test fixture only; never use this value in a deployment. + process.env.ABCA_CHALLENGE_SECRET = "ab".repeat(32); valkeyContainer = await new GenericContainer("valkey/valkey:latest") .withExposedPorts(VALKEY_PORT) .withCommand([ diff --git a/test/unit/attestationBasedClientAuthenticationService.test.ts b/test/unit/attestationBasedClientAuthenticationService.test.ts new file mode 100644 index 0000000..b03d7a2 --- /dev/null +++ b/test/unit/attestationBasedClientAuthenticationService.test.ts @@ -0,0 +1,276 @@ +import { deflateSync } from "node:zlib"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import type * as oidc from "oidc-provider"; +import { assertAttestationJwtAndPop } from "../../src/services/attestationBasedClientAuthenticationService"; +import config from "../../src/config"; + +vi.mock("../../src/config", () => ({ + default: { + openid4vcProfile: "WEBUILD", + ttl: { clientAttestation: 86400, clientAttestationPoP: 60 }, + weBuildAbcaStatusListAllowedOrigins: ["https://status.example"], + }, +})); + +const now = Math.floor(Date.now() / 1000); +const statusUri = "https://status.example/list"; +let signer: Awaited>; +let confirmation: Awaited>; +let attestation: oidc.JWTVerificationResult; +let ctx: oidc.KoaContextWithOIDC; +let dpop: string; +const fetchMock = vi.fn(); + +async function statusToken(value = 0, bits = 1, key = signer.privateKey) { + return new SignJWT({ + status_list: { bits, lst: deflateSync(Buffer.from([value])).toString("base64url") }, + ttl: 300, + }).setProtectedHeader({ alg: "ES256", typ: "statuslist+jwt" }) + .setSubject(statusUri).setIssuedAt(now).setExpirationTime(now + 300).sign(key); +} + +function popVerificationResult(iat: number): oidc.JWTVerificationResult { + return { + protectedHeader: { alg: "ES256", typ: "oauth-client-attestation-pop+jwt" }, + key: confirmation.publicKey, + payload: { iat }, + }; +} + +async function run() { + await assertAttestationJwtAndPop(ctx, attestation, popVerificationResult(now), {} as oidc.Client); +} + +describe("WIA validation", () => { + beforeEach(async () => { + config.openid4vcProfile = "WEBUILD"; + config.ttl.clientAttestation = 86400; + config.ttl.clientAttestationPoP = 60; + vi.useFakeTimers(); + vi.setSystemTime(now * 1000); + signer = await generateKeyPair("ES256"); + confirmation = await generateKeyPair("ES256"); + const jwk = await exportJWK(confirmation.publicKey); + dpop = await new SignJWT({}).setProtectedHeader({ alg: "ES256", typ: "dpop+jwt", jwk }) + .sign(confirmation.privateKey); + attestation = { + protectedHeader: { alg: "ES256" }, key: signer.publicKey, + payload: { + wallet_name: "Wallet", wallet_version: "1.0", + wallet_solution_certification_information: {}, + iat: now, exp: now + 3600, cnf: { jwk }, + client_status: { exp: now + 31 * 86400, status: { status_list: { uri: statusUri, idx: 0 } } }, + }, + }; + ctx = { get: () => dpop, oidc: { route: "token" } } as unknown as oidc.KoaContextWithOIDC; + fetchMock.mockReset().mockResolvedValue(new Response(await statusToken())); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("accepts a valid WIA without attestation aud, jti, or lifecycle tracking", async () => { + await expect(run()).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith(new URL(statusUri), expect.objectContaining({ redirect: "error" })); + }); + + it("skips all WE BUILD checks and status fetching when disabled", async () => { + config.openid4vcProfile = "DEFAULT"; + delete attestation.payload.wallet_name; + delete attestation.payload.wallet_version; + delete attestation.payload.wallet_solution_certification_information; + delete attestation.payload.client_status; + attestation.payload.exp = now + 86400; + dpop = await new SignJWT({}).setProtectedHeader({ alg: "ES256", jwk: await exportJWK(signer.publicKey) }) + .sign(signer.privateKey); + + await expect(run()).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("still requires DPoP at the token endpoint when the profile is disabled", async () => { + config.openid4vcProfile = "DEFAULT"; + dpop = ""; + await expect(run()).rejects.toMatchObject({ error_description: "DPoP is required for token requests" }); + }); + + it("still rejects future issuance times when the profile is disabled", async () => { + config.openid4vcProfile = "DEFAULT"; + attestation.payload.iat = now + 1; + await expect(run()).rejects.toMatchObject({ error: "invalid_client_attestation" }); + }); + + it("applies the configured attestation maximum age under DEFAULT", async () => { + config.openid4vcProfile = "DEFAULT"; + config.ttl.clientAttestation = 120; + attestation.payload.iat = now - 121; + await expect(run()).rejects.toMatchObject({ error: "use_fresh_attestation" }); + attestation.payload.iat = now - 120; + await expect(run()).resolves.toBeUndefined(); + }); + + it("bounds remaining validity when DEFAULT receives an attestation without iat", async () => { + config.openid4vcProfile = "DEFAULT"; + config.ttl.clientAttestation = 120; + delete attestation.payload.iat; + attestation.payload.exp = now + 121; + await expect(run()).rejects.toMatchObject({ error: "use_fresh_attestation" }); + attestation.payload.exp = now + 120; + await expect(run()).resolves.toBeUndefined(); + }); + + it("applies the configured PoP maximum age under DEFAULT", async () => { + config.openid4vcProfile = "DEFAULT"; + config.ttl.clientAttestationPoP = 10; + const validate = (iat: number) => assertAttestationJwtAndPop( + ctx, attestation, popVerificationResult(iat), {} as oidc.Client, + ); + await expect(validate(now - 11)).rejects.toMatchObject({ error: "invalid_client_attestation" }); + await expect(validate(now + 1)).rejects.toMatchObject({ error: "invalid_client_attestation" }); + await expect(validate(now - 10)).resolves.toBeUndefined(); + }); + + it("keeps WEBUILD policy independent of the configurable maximum ages", async () => { + config.ttl.clientAttestation = 1; + config.ttl.clientAttestationPoP = 1; + attestation.payload.iat = now - 60; + await expect(assertAttestationJwtAndPop( + ctx, attestation, popVerificationResult(now - 60), {} as oidc.Client, + )).resolves.toBeUndefined(); + }); + + it.each(["wallet_name", "wallet_version", "wallet_solution_certification_information"])( + "rejects a missing WIA field: %s", async (field) => { + delete attestation.payload[field]; + await expect(run()).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + + it.each([ + { iat: now + 0.5 }, { iat: now + 1 }, { exp: now }, { exp: now + 86400 }, + ])("rejects invalid temporal claims: %j", async (claims) => { + Object.assign(attestation.payload, claims); + await expect(run()).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("accepts an omitted attestation iat", async () => { + delete attestation.payload.iat; + await expect(run()).resolves.toBeUndefined(); + }); + + it("accepts a fractional attestation iat", async () => { + attestation.payload.iat = now - 0.5; + await expect(run()).resolves.toBeUndefined(); + }); + + it("leaves expiration validation to the provider", async () => { + attestation.payload.iat = now - 3600; + attestation.payload.exp = now - 61; + await expect(run()).resolves.toBeUndefined(); + }); + + it("requires DPoP at the token endpoint", async () => { + dpop = ""; + await expect(run()).rejects.toMatchObject({ error_description: expect.stringContaining("DPoP is required") }); + }); + + it("allows PAR without a DPoP header", async () => { + dpop = ""; + ctx = { get: () => dpop, oidc: { route: "pushed_authorization_request" } } as unknown as oidc.KoaContextWithOIDC; + await expect(run()).resolves.toBeUndefined(); + }); + + it("rejects a different DPoP key", async () => { + dpop = await new SignJWT({}).setProtectedHeader({ alg: "ES256", jwk: await exportJWK(signer.publicKey) }) + .sign(signer.privateKey); + await expect(run()).rejects.toMatchObject({ error_description: expect.stringContaining("DPoP key does not match") }); + }); + + it("rejects malformed DPoP", async () => { + dpop = "invalid"; + await expect(run()).rejects.toMatchObject({ error_description: expect.stringContaining("DPoP key does not match") }); + }); + + it("rejects a missing status reference", async () => { + delete attestation.payload.client_status; + await expect(run()).rejects.toMatchObject({ error_description: expect.stringContaining("invalid client_status") }); + }); + + it("requires at least 31 days of status maintenance", async () => { + (attestation.payload.client_status as { exp: number }).exp--; + await expect(run()).rejects.toMatchObject({ error_description: expect.stringContaining("maintenance period below 31 days") }); + }); + + it("does not fetch an unconfigured origin", async () => { + attestation.payload.client_status = { + exp: now + 31 * 86400, status: { status_list: { uri: "https://internal.example/list", idx: 0 } }, + }; + await expect(run()).rejects.toMatchObject({ error_description: expect.stringContaining("client status could not be verified") }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([1, 2, 4, 8])("rejects a nonzero status for %i-bit lists", async (bits) => { + fetchMock.mockResolvedValue(new Response(await statusToken(1, bits))); + await expect(run()).rejects.toMatchObject({ error_description: expect.stringContaining("client status could not be verified") }); + }); + + it("rejects a list signed by another key", async () => { + fetchMock.mockResolvedValue(new Response(await statusToken(0, 1, confirmation.privateKey))); + await expect(run()).rejects.toMatchObject({ error_description: expect.stringContaining("client status could not be verified") }); + }); + + it("checks the indexed entry rather than only the first status", async () => { + attestation.payload.client_status = { + exp: now + 31 * 86400, status: { status_list: { uri: statusUri, idx: 2 } }, + }; + fetchMock.mockResolvedValue(new Response(await statusToken(1 << 4, 2))); + await expect(run()).rejects.toMatchObject({ error: "invalid_client_attestation" }); + fetchMock.mockResolvedValue(new Response(await statusToken(1, 2))); + await expect(run()).resolves.toBeUndefined(); + }); + + it.each([ + { sub: "https://status.example/another-list", iat: now, exp: now + 300 }, + { sub: statusUri, iat: now - 600, exp: now - 1 }, + { sub: statusUri, iat: now + 1, exp: now + 300 }, + ])("rejects invalid status token claims: %j", async (claims) => { + const token = await new SignJWT({ + ...claims, + status_list: { bits: 1, lst: deflateSync(Buffer.from([0])).toString("base64url") }, + }).setProtectedHeader({ alg: "ES256", typ: "statuslist+jwt" }).sign(signer.privateKey); + fetchMock.mockResolvedValue(new Response(token)); + await expect(run()).rejects.toMatchObject({ error: "invalid_client_attestation" }); + }); + + it("rejects an oversized status token", async () => { + fetchMock.mockResolvedValue(new Response("a".repeat(256 * 1024 + 1))); + await expect(run()).rejects.toMatchObject({ error: "invalid_client_attestation" }); + }); + + it("limits decompressed status list size", async () => { + const token = await new SignJWT({ + status_list: { bits: 1, lst: deflateSync(Buffer.alloc(1024 * 1024 + 1)).toString("base64url") }, + }).setProtectedHeader({ alg: "ES256", typ: "statuslist+jwt" }) + .setSubject(statusUri).setIssuedAt(now).sign(signer.privateKey); + fetchMock.mockResolvedValue(new Response(token)); + await expect(run()).rejects.toMatchObject({ error: "invalid_client_attestation" }); + }); + + it("rejects an out-of-range status index", async () => { + attestation.payload.client_status = { + exp: now + 31 * 86400, status: { status_list: { uri: statusUri, idx: 8 } }, + }; + await expect(run()).rejects.toMatchObject({ error_description: expect.stringContaining("client status could not be verified") }); + }); + + it("fails closed when status retrieval fails", async () => { + fetchMock.mockRejectedValue(new Error("unavailable")); + await expect(run()).rejects.toMatchObject({ error_description: expect.stringContaining("client status could not be verified") }); + }); +}); diff --git a/test/unit/attestationTrustResolution.test.ts b/test/unit/attestationTrustResolution.test.ts new file mode 100644 index 0000000..7b36978 --- /dev/null +++ b/test/unit/attestationTrustResolution.test.ts @@ -0,0 +1,133 @@ +import { execFileSync } from "node:child_process"; +import { X509Certificate } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { importPKCS8, jwtVerify, SignJWT } from "jose"; +import type * as oidc from "oidc-provider"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const config = vi.hoisted(() => ({ trustedRootCertificates: [] as string[] })); +vi.mock("../../src/config", () => ({ default: config })); + +import { getAttestationSignaturePublicKey } from "../../src/services/attestationBasedClientAuthenticationService"; + +describe("attestation trust resolution", () => { + let directory: string; + let rootPem: string; + let otherRootPem: string; + let leafPem: string; + let leafKeyPem: string; + let leafBase64: string; + + function openssl(...args: string[]) { + execFileSync("openssl", args, { cwd: directory, stdio: "pipe" }); + } + + function resolve(x5c: unknown = [leafBase64], alg = "ES256") { + return getAttestationSignaturePublicKey( + {} as oidc.KoaContextWithOIDC, { alg, x5c }, {}, {} as oidc.Client, + ); + } + + beforeAll(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "wallet-as-trust-test-")); + for (const name of ["root", "other-root"]) { + openssl("req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", + "-nodes", "-keyout", `${name}.key`, "-out", `${name}.pem`, "-days", "30", + "-subj", `/CN=${name}`, "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign"); + } + openssl("req", "-new", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", + "-nodes", "-keyout", "leaf.key", "-out", "leaf.csr", "-subj", "/CN=Test Wallet Provider"); + fs.writeFileSync(path.join(directory, "leaf.ext"), + "basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n"); + openssl("x509", "-req", "-in", "leaf.csr", "-CA", "root.pem", "-CAkey", "root.key", + "-CAcreateserial", "-out", "leaf.pem", "-days", "1", "-extfile", "leaf.ext"); + rootPem = fs.readFileSync(path.join(directory, "root.pem"), "utf8"); + otherRootPem = fs.readFileSync(path.join(directory, "other-root.pem"), "utf8"); + leafPem = fs.readFileSync(path.join(directory, "leaf.pem"), "utf8"); + leafKeyPem = fs.readFileSync(path.join(directory, "leaf.key"), "utf8"); + leafBase64 = new X509Certificate(leafPem).raw.toString("base64"); + }); + + afterAll(() => { + if (directory) fs.rmSync(directory, { recursive: true, force: true }); + }); + + beforeEach(() => { + config.trustedRootCertificates = [rootPem]; + }); + + afterEach(() => vi.useRealTimers()); + + it("resolves a trusted leaf to its public JWK and verifies a signed JWT", async () => { + const jwk = await resolve(); + const expected = new X509Certificate(leafPem).publicKey.export({ format: "jwk" }); + expect(jwk).toMatchObject(expected); + expect(jwk).not.toHaveProperty("d"); + const token = await new SignJWT({ sub: "wallet" }) + .setProtectedHeader({ alg: "ES256", typ: "oauth-client-attestation+jwt", x5c: [leafBase64] }) + .sign(await importPKCS8(leafKeyPem, "ES256")); + const verified = await jwtVerify(token, (header) => getAttestationSignaturePublicKey( + {} as oidc.KoaContextWithOIDC, header, {}, {} as oidc.Client, + )); + expect(verified.payload.sub).toBe("wallet"); + expect(verified.key).toHaveProperty("type", "public"); + }); + + it("accepts the presented chain including its trusted root", async () => { + await expect(resolve([leafBase64, new X509Certificate(rootPem).raw.toString("base64")])) + .resolves.toMatchObject({ kty: "EC", crv: "P-256" }); + }); + + it.each([ + undefined, [], "not-an-array", [42], [""], ["!!!!"], ["abc"], + ["AAAA"], ["A".repeat(16 * 1024 + 4)], Array(6).fill("AAAA"), + ])("rejects missing, malformed, or oversized x5c (case %#)", async (x5c) => { + // Explicit undefined must not select resolve's default fixture. + await expect(getAttestationSignaturePublicKey( + {} as oidc.KoaContextWithOIDC, { alg: "ES256", x5c }, {}, {} as oidc.Client, + )).rejects.toMatchObject({ error: "invalid_client_attestation" }); + }); + + it("rejects unsupported signing algorithms", async () => { + await expect(resolve([leafBase64], "ES384")).rejects.toMatchObject({ + error_description: "unsupported client attestation alg", + }); + }); + + it("rejects a CA leaf even when that CA is trusted", async () => { + await expect(resolve([new X509Certificate(rootPem).raw.toString("base64")])) + .rejects.toMatchObject({ error_description: "invalid client attestation signing certificate" }); + }); + + it("rejects missing trust anchors", async () => { + config.trustedRootCertificates = []; + await expect(resolve()).rejects.toMatchObject({ error_description: "no Wallet Provider trust anchors configured" }); + }); + + it("rejects a chain rooted in an untrusted CA", async () => { + config.trustedRootCertificates = [otherRootPem]; + await expect(resolve()).rejects.toMatchObject({ error_description: "untrusted client attestation x5c chain" }); + }); + + it("rejects a certificate with a tampered issuer signature", async () => { + const tampered = Buffer.from(new X509Certificate(leafPem).raw); + tampered[tampered.length - 1] ^= 1; + await expect(resolve([tampered.toString("base64")])) + .rejects.toMatchObject({ error_description: "untrusted client attestation x5c chain" }); + }); + + it("rejects an expired leaf", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.parse(new X509Certificate(leafPem).validTo) + 1000)); + await expect(resolve()).rejects.toMatchObject({ error_description: "untrusted client attestation x5c chain" }); + }); + + it("rejects a not-yet-valid leaf", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.parse(new X509Certificate(leafPem).validFrom) - 1000)); + await expect(resolve()).rejects.toMatchObject({ error_description: "untrusted client attestation x5c chain" }); + }); +}); diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index bc1884b..bfcf73d 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -1,11 +1,22 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; describe("config", () => { const originalCwd = process.cwd(); + beforeEach(() => { + // Deterministic test fixture only; never use this value in a deployment. + vi.stubEnv("ABCA_CHALLENGE_SECRET", "ab".repeat(32)); + vi.stubEnv("OPENID4VC_PROFILE", "DEFAULT"); + vi.stubEnv("WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS", "https://status.example"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + function writeClientsFile(root: string, clients: unknown) { const configDir = path.join(root, "src/config"); fs.mkdirSync(configDir, { recursive: true }); @@ -123,6 +134,69 @@ describe("config", () => { ).rejects.toThrow(/redirect_uris/); }); + it.each([ + [undefined, "DEFAULT"], ["DEFAULT", "DEFAULT"], ["WEBUILD", "WEBUILD"], + ])("reads OPENID4VC_PROFILE=%s as %s", async (value, profile) => { + vi.stubEnv("OPENID4VC_PROFILE", value); + const { default: config } = await importConfigFromTempProject([]); + expect(config.openid4vcProfile).toBe(profile); + }); + + it.each(["", "typo", "true", "HAIP", "ETSI"])("rejects unsupported OPENID4VC_PROFILE: %s", async (value) => { + vi.stubEnv("OPENID4VC_PROFILE", value); + await expect(importConfigFromTempProject([])).rejects.toThrow(); + }); + + it.each([undefined, "", " , "])("requires status origins under WEBUILD (case %#)", async (value) => { + vi.stubEnv("OPENID4VC_PROFILE", "WEBUILD"); + vi.stubEnv("WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS", value); + await expect(importConfigFromTempProject([])).rejects.toThrow( + "WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS is required when OPENID4VC_PROFILE=WEBUILD." + ); + }); + + it("does not require status origins under DEFAULT", async () => { + vi.stubEnv("WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS", undefined); + const { default: config } = await importConfigFromTempProject([]); + expect(config.weBuildAbcaStatusListAllowedOrigins).toEqual([]); + }); + + it("reads the WE BUILD status origin list", async () => { + vi.stubEnv("OPENID4VC_PROFILE", "WEBUILD"); + vi.stubEnv("WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS", " https://one.example, https://two.example, "); + const { default: config } = await importConfigFromTempProject([]); + expect(config.weBuildAbcaStatusListAllowedOrigins).toEqual(["https://one.example", "https://two.example"]); + }); + + it("decodes the challenge secret into a stable 32-byte Buffer", async () => { + vi.stubEnv("ABCA_CHALLENGE_SECRET", "AB".repeat(32)); + const first = (await importConfigFromTempProject([])).default; + const second = (await importConfigFromTempProject([])).default; + + expect(Buffer.isBuffer(first.abcaChallengeSecret)).toBe(true); + expect(first.abcaChallengeSecret.length).toBe(32); + expect(first.abcaChallengeSecret).toEqual(Buffer.alloc(32, 0xab)); + expect(second.abcaChallengeSecret).toEqual(first.abcaChallengeSecret); + }); + + it.each([ + undefined, + "", + "ab".repeat(31), + "ab".repeat(33), + "a".repeat(63), + "gg".repeat(32), + `${"ab".repeat(31)}a!`, + ` ${"ab".repeat(32)}`, + `${"ab".repeat(32)}\n`, + ])("rejects a missing or malformed challenge secret (case %#)", async (secret) => { + vi.stubEnv("ABCA_CHALLENGE_SECRET", secret); + + await expect(importConfigFromTempProject([])).rejects.toThrow( + "ABCA_CHALLENGE_SECRET must contain exactly 64 hexadecimal characters (32 bytes)." + ); + }); + it("does not allow production builds to use an insecure Data Store", async () => { const originalEnv = process.env; process.env = { @@ -133,7 +207,7 @@ describe("config", () => { const exitSpy = vi .spyOn(process, "exit") - .mockImplementation((code?: number) => { + .mockImplementation((code) => { throw new Error(`process.exit:${code}`); }); const consoleSpy = vi diff --git a/test/unit/introspectionHelpers.test.ts b/test/unit/introspectionHelpers.test.ts index 77fa8b6..6841cf7 100644 --- a/test/unit/introspectionHelpers.test.ts +++ b/test/unit/introspectionHelpers.test.ts @@ -1,6 +1,14 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; describe("introspectionAllowedPolicy", () => { + beforeEach(() => { + // Deterministic test fixture only; never use this value in a deployment. + vi.stubEnv("ABCA_CHALLENGE_SECRET", "ab".repeat(32)); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); it("rejects non client_secret_basic auth", async () => { vi.resetModules(); const { introspectionAllowedPolicy } = await import( From 38fb6d9fc95bad721a91cb63d2a2f5293f5902ab Mon Sep 17 00:00:00 2001 From: Vasileios Markopoulos Date: Thu, 29 Oct 2026 16:29:40 +0200 Subject: [PATCH 2/2] Bump oidc-provider and enable DPoP --- .env.template | 1 + README.md | 59 ++++++++---------- package.json | 4 +- src/app.ts | 9 ++- src/config/index.ts | 22 +++++-- test/integration/setup.ts | 1 + test/unit/config.test.ts | 85 +++++++++++++++++++++----- test/unit/introspectionHelpers.test.ts | 1 + yarn.lock | 56 +++++++++++++---- 9 files changed, 169 insertions(+), 69 deletions(-) diff --git a/.env.template b/.env.template index c855fe4..a1a2e67 100644 --- a/.env.template +++ b/.env.template @@ -19,4 +19,5 @@ OPENID4VC_PROFILE=DEFAULT ABCA_CLIENT_ATTESTATION_MAX_AGE=86400 ABCA_CLIENT_ATTESTATION_POP_MAX_AGE=60 ABCA_CHALLENGE_SECRET=replace-with-a-64-len-secret +DPOP_NONCE_SECRET=replace-with-a-32+-len-secret WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS=http://localhost:8002 diff --git a/README.md b/README.md index 48df5fb..a50ce1d 100644 --- a/README.md +++ b/README.md @@ -27,38 +27,33 @@ Configuration is loaded from `.env` (see `.env.template`). Values are read via ` Environment variables and how they are used: -| Variable | Purpose | Default / Notes | -| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `OPENID4VC_PROFILE` | Selects the ecosystem profile across wallet-as. | `DEFAULT` if unset. Supported values: `DEFAULT`, `WEBUILD`; see profiles below. | -| `SERVICE_URL` | Full external URL for the AS (scheme/host and optional path prefix). | `http://localhost:6060` if unset. Example: If deployed under `/as`, set it to `https://issuer.example.com/as`. | -| `WALLET_URL` | Redirect URI registered for the wallet client. | `http://localhost:3000` if unset. | -| `OIDC_JWKS_PATH` | Optional override for the OIDC provider signing JWKS path used for ID tokens and JWT authorization responses. | Defaults to `./keys/oidc.jwks.json` | -| `INTROSPECTION_CLIENT` | Client ID allowed to introspect tokens. | If set with `INTROSPECTION_CLIENT_SECRET`, an extra client is registered. | -| `INTROSPECTION_CLIENT_SECRET` | Secret for the introspection client. | Required alongside `INTROSPECTION_CLIENT`. | -| `SCOPES` | Comma-separated list of supported scopes. | Split by `,` and passed to OIDC provider `scopes`. | -| `METADATA_URL` | Credential Issuer metadata URL. | Used during consent to fetch display metadata for requested scopes. | -| `ACCESS_TOKEN_TTL` | Access token TTL (seconds). | Default: `30` | -| `REFRESH_TOKEN_TTL` | Refresh token TTL (seconds). | Default: `2592000` | -| `BASE_PATH` | Optional base path for public, AS/OIDC, and related Express endpoints. | Empty by default. Supports values like `as` or `/as`; normalized to `/as`. | -| `AUTHENTICATOR` | Selected authenticator to load (single value). | Supported values: `user-pass-pid` or `auth-broker`. Defaults to `user-pass-pid` if unset. | -| `USER_PASS_PID_DEMO_USERNAME` | Demo username for the `user-pass-pid` login screen and demo account. | Used only when `AUTHENTICATOR=user-pass-pid`. | -| `USER_PASS_PID_DEMO_PASSWORD` | Demo password shown in `user-pass-pid` login form. | Used only for prefill; authentication does not check password. | -| `AUTH_BROKER_PROVIDER_URL` | External OIDC provider URL for the `auth-broker` authenticator. | Required when `auth-broker` is enabled. | -| `AUTH_BROKER_CLIENT_ID` | OIDC client ID for the `auth-broker` authenticator. | Required when `auth-broker` is enabled. | -| `AUTH_BROKER_CLIENT_SECRET` | OIDC client secret for the `auth-broker` authenticator. | Optional for public clients. | -| `AUTH_BROKER_SCOPE` | Space-separated scopes sent to external IdP authorize endpoint by `auth-broker`. | Default: `openid profile email`. | -| `AUTH_BROKER_REDIRECT_URI` | Redirect URI handled by wallet-as auth-broker callback route. | Default: `SERVICE_URL + /interaction/authBroker/callback`. | -| `AUTH_BROKER_SKIP_LOGOUT` | Skip external IdP logout even if IdP supports it in metadata in `auth-broker` callback flow. | Optional; set to `true` when logout should be skipped. | -| `ABCA_CLIENT_ATTESTATION_MAX_AGE` | Maximum attestation age in seconds under `DEFAULT`; if `iat` is absent, limits remaining validity instead. | Defaults to `86400`. Ignored under `WEBUILD`. | -| `ABCA_CLIENT_ATTESTATION_POP_MAX_AGE` | Maximum PoP age in seconds under `DEFAULT`. Future PoP issuance times are rejected. | Defaults to `60`. Ignored under `WEBUILD`. | -| `ABCA_CHALLENGE_SECRET` | Shared secret for Attestation-Based Client Authentication challenges. | Required: exactly 64 hexadecimal characters encoding 32 random bytes. | -| `WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS` | Comma-separated trusted origins for WIA status list requests under the WE BUILD profile. | Required at startup only when `OPENID4VC_PROFILE=WEBUILD`. HTTPS required; HTTP loopback allowed outside production. | - -Generate `ABCA_CHALLENGE_SECRET` once with `openssl rand -hex 32` and store it -in your local `.env` or deployment secret manager. Use the same value across all -AS replicas and restarts. Missing or malformed values prevent startup; there is -no generated fallback. Replacing the secret invalidates outstanding challenges, -so coordinate changes across replicas. Never commit the generated value. +| Variable | Purpose | Default / Notes | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OPENID4VC_PROFILE` | Selects the ecosystem profile across wallet-as. | `DEFAULT` if unset. Supported values: `DEFAULT`, `WEBUILD`; see profiles below. | +| `SERVICE_URL` | Full external URL for the AS (scheme/host and optional path prefix). | `http://localhost:6060` if unset. Example: If deployed under `/as`, set it to `https://issuer.example.com/as`. | +| `WALLET_URL` | Redirect URI registered for the wallet client. | `http://localhost:3000` if unset. | +| `OIDC_JWKS_PATH` | Optional override for the OIDC provider signing JWKS path used for ID tokens and JWT authorization responses. | Defaults to `./keys/oidc.jwks.json` | +| `INTROSPECTION_CLIENT` | Client ID allowed to introspect tokens. | If set with `INTROSPECTION_CLIENT_SECRET`, an extra client is registered. | +| `INTROSPECTION_CLIENT_SECRET` | Secret for the introspection client. | Required alongside `INTROSPECTION_CLIENT`. | +| `SCOPES` | Comma-separated list of supported scopes. | Split by `,` and passed to OIDC provider `scopes`. | +| `METADATA_URL` | Credential Issuer metadata URL. | Used during consent to fetch display metadata for requested scopes. | +| `ACCESS_TOKEN_TTL` | Access token TTL (seconds). | Default: `30` | +| `REFRESH_TOKEN_TTL` | Refresh token TTL (seconds). | Default: `2592000` | +| `BASE_PATH` | Optional base path for public, AS/OIDC, and related Express endpoints. | Empty by default. Supports values like `as` or `/as`; normalized to `/as`. | +| `AUTHENTICATOR` | Selected authenticator to load (single value). | Supported values: `user-pass-pid` or `auth-broker`. Defaults to `user-pass-pid` if unset. | +| `USER_PASS_PID_DEMO_USERNAME` | Demo username for the `user-pass-pid` login screen and demo account. | Used only when `AUTHENTICATOR=user-pass-pid`. | +| `USER_PASS_PID_DEMO_PASSWORD` | Demo password shown in `user-pass-pid` login form. | Used only for prefill; authentication does not check password. | +| `AUTH_BROKER_PROVIDER_URL` | External OIDC provider URL for the `auth-broker` authenticator. | Required when `auth-broker` is enabled. | +| `AUTH_BROKER_CLIENT_ID` | OIDC client ID for the `auth-broker` authenticator. | Required when `auth-broker` is enabled. | +| `AUTH_BROKER_CLIENT_SECRET` | OIDC client secret for the `auth-broker` authenticator. | Optional for public clients. | +| `AUTH_BROKER_SCOPE` | Space-separated scopes sent to external IdP authorize endpoint by `auth-broker`. | Default: `openid profile email`. | +| `AUTH_BROKER_REDIRECT_URI` | Redirect URI handled by wallet-as auth-broker callback route. | Default: `SERVICE_URL + /interaction/authBroker/callback`. | +| `AUTH_BROKER_SKIP_LOGOUT` | Skip external IdP logout even if IdP supports it in metadata in `auth-broker` callback flow. | Optional; set to `true` when logout should be skipped. | +| `ABCA_CLIENT_ATTESTATION_MAX_AGE` | Maximum attestation age in seconds under `DEFAULT`; if `iat` is absent, limits remaining validity instead. | Defaults to `86400`. Ignored under `WEBUILD`. | +| `ABCA_CLIENT_ATTESTATION_POP_MAX_AGE` | Maximum PoP age in seconds under `DEFAULT`. Future PoP issuance times are rejected. | Defaults to `60`. Ignored under `WEBUILD`. | +| `ABCA_CHALLENGE_SECRET` | Shared secret for Attestation-Based Client Authentication challenges. | Required: exactly 64 hexadecimal characters encoding 32 random bytes. `yarn setup` in the parent repo generates a random one. Otherwise, use `openssl rand -hex 32` to generate a random value. | +| `DPOP_NONCE_SECRET` | Shared secret used to generate and validate DPoP nonces. | Required: at least 32 characters. `yarn setup` in the parent repo generates a random one. Otherwise, use `openssl rand -hex 16` to generate a random value. | +| `WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS` | Comma-separated trusted origins for WIA status list requests under the WE BUILD profile. | Required at startup only when `OPENID4VC_PROFILE=WEBUILD`. HTTPS required; HTTP loopback allowed outside production. | ### Profiles diff --git a/package.json b/package.json index 37b9123..ffaa361 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@eslint/js": "^9.39.5", "@types/express": "^5.0.6", "@types/node": "^24.10.2", - "@types/oidc-provider": "^9.12.1", + "@types/oidc-provider": "^9.13.0", "@types/supertest": "^6.0.3", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", @@ -41,7 +41,7 @@ "dotenv": "^17.2.3", "express": "^5.2.1", "iovalkey": "^0.3.3", - "oidc-provider": "^9.12.2", + "oidc-provider": "^9.13.3", "openid-client": "^6.8.2", "pug": "^3.0.3", "wallet-common": "git+https://github.com/wwWallet/wallet-common.git#v1.0.0", diff --git a/src/app.ts b/src/app.ts index a9644a3..8387d80 100644 --- a/src/app.ts +++ b/src/app.ts @@ -54,6 +54,7 @@ export function createApp() { jwks: config.oidcJwks, enabledJWA: { attestSigningAlgValues: ['ES256'], + dPoPSigningAlgValues: ['ES256'], }, clientAuthMethods: [ 'client_secret_basic', @@ -88,11 +89,17 @@ export function createApp() { attestClientAuth: { enabled: true, challengeSecret: config.abcaChallengeSecret, - ack: 'draft-10', + ack: 'draft-12', getAttestationSignaturePublicKey, assertAttestationJwtAndPop, }, devInteractions: { enabled: false }, + dPoP: { + enabled: true, + nonceSecret: config.dpopNonceSecret, + requireNonce: () => false, + allowReplay: false, + }, introspection: { enabled: true, allowedPolicy: introspectionAllowedPolicy, diff --git a/src/config/index.ts b/src/config/index.ts index 4fc05e7..794d325 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type * as oidc from 'oidc-provider'; import { z } from 'zod'; +import crypto from 'node:crypto'; dotenv.config(); const serviceUrl = process.env.SERVICE_URL || 'http://localhost:6060'; @@ -41,9 +42,15 @@ const dataStorePassword = process.env.DATA_STORE_PASSWORD || null; if (process.env.NODE_ENV === 'production' && !dataStorePassword) { console.error(`FATAL: Insecure data store found in production.`); + process.exit(1); +} +const dpopNonceSecret = process.env.DPOP_NONCE_SECRET; +if (!dpopNonceSecret || dpopNonceSecret.length < 32) { + console.error(`FATAL: DPOP_NONCE_SECRET must be configured with at least 32 characters.`); process.exit(1); } +const dpopNonceSecretBytes = crypto.createHash('sha256').update(dpopNonceSecret).digest(); const certsDir = path.resolve(process.cwd(), './certs'); let trustedRootCertificates: string[] = []; @@ -99,9 +106,9 @@ function loadOAuth2Clients(): oidc.ClientMetadata[] { try { parsed = JSON.parse(fs.readFileSync(clientsFile, 'utf-8')); } catch (err) { - throw new Error(`Failed to parse OAuth2 clients file '${clientsFile}': ${err}`); + console.error(`FATAL: Failed to parse OAuth2 clients file '${clientsFile}': ${err}`); + process.exit(1); } - return clientsSchema.parse(parsed) as oidc.ClientMetadata[]; } @@ -111,9 +118,10 @@ if ( abcaChallengeSecretHex.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(abcaChallengeSecretHex) ) { - throw new Error( - 'ABCA_CHALLENGE_SECRET must contain exactly 64 hexadecimal characters (32 bytes).', + console.error( + `FATAL: ABCA_CHALLENGE_SECRET must contain exactly 64 hexadecimal characters (32 bytes).`, ); + process.exit(1); } const abcaChallengeSecret = Buffer.from(abcaChallengeSecretHex, 'hex'); const openid4vcProfile = z @@ -127,9 +135,10 @@ const weBuildAbcaStatusListAllowedOrigins = ( .map((origin) => origin.trim()) .filter(Boolean); if (openid4vcProfile === 'WEBUILD' && weBuildAbcaStatusListAllowedOrigins.length === 0) { - throw new Error( - 'WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS is required when OPENID4VC_PROFILE=WEBUILD.', + console.error( + `FATAL: WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS is required when OPENID4VC_PROFILE=WEBUILD.`, ); + process.exit(1); } export default { @@ -140,6 +149,7 @@ export default { dataStoreHost: dataStoreHost, dataStorePort: dataStorePort, dataStorePassword: dataStorePassword, + dpopNonceSecret: dpopNonceSecretBytes, oidClients: loadOAuth2Clients(), introspectionClient: process.env.INTROSPECTION_CLIENT || null, introspectionClientSecret: process.env.INTROSPECTION_CLIENT_SECRET || null, diff --git a/test/integration/setup.ts b/test/integration/setup.ts index 00c3851..79a152c 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -8,6 +8,7 @@ const VALKEY_PASSWORD = 'test-secret'; export default async function setup() { // Deterministic test fixture only; never use this value in a deployment. process.env.ABCA_CHALLENGE_SECRET = 'ab'.repeat(32); + process.env.DPOP_NONCE_SECRET = 'test-dpop-nonce-secret-0123456789ab'; valkeyContainer = await new GenericContainer('valkey/valkey:latest') .withExposedPorts(VALKEY_PORT) .withCommand(['valkey-server', '--requirepass', VALKEY_PASSWORD]) diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 3973fd8..751dabb 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -9,6 +10,7 @@ describe('config', () => { beforeEach(() => { // Deterministic test fixture only; never use this value in a deployment. vi.stubEnv('ABCA_CHALLENGE_SECRET', 'ab'.repeat(32)); + vi.stubEnv('DPOP_NONCE_SECRET', 'test-dpop-nonce-secret-0123456789ab'); vi.stubEnv('OPENID4VC_PROFILE', 'DEFAULT'); vi.stubEnv('WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS', 'https://status.example'); }); @@ -23,9 +25,10 @@ describe('config', () => { fs.writeFileSync(path.join(configDir, 'oauth2clients.json'), JSON.stringify(clients), 'utf-8'); } - async function importConfigFromTempProject(clients: unknown) { + async function importConfigFromTempProject(clients: unknown, prepare?: (root: string) => void) { const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-as-config-')); writeClientsFile(tempProject, clients); + prepare?.(tempProject); process.chdir(tempProject); vi.resetModules(); @@ -36,6 +39,24 @@ describe('config', () => { } } + async function expectFatalConfig(loadConfig: () => Promise, message: string | RegExp) { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit:${code}`); + }); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await expect(loadConfig()).rejects.toThrow('process.exit:1'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(consoleSpy).toHaveBeenCalledWith( + typeof message === 'string' ? message : expect.stringMatching(message), + ); + } finally { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + } + } + it('normalizes BASE_PATH and reads basic env vars', async () => { const originalEnv = process.env; process.env = { @@ -132,6 +153,21 @@ describe('config', () => { ).rejects.toThrow(/redirect_uris/); }); + it.each(['missing', 'malformed'])('exits when the OAuth2 clients file is %s', async (kind) => { + await expectFatalConfig( + () => + importConfigFromTempProject([], (root) => { + const clientsFile = path.join(root, 'src/config/oauth2clients.json'); + if (kind === 'missing') { + fs.unlinkSync(clientsFile); + } else { + fs.writeFileSync(clientsFile, '{', 'utf-8'); + } + }), + /^FATAL: Failed to parse OAuth2 clients file '.*oauth2clients\.json': /, + ); + }); + it.each([ [undefined, 'DEFAULT'], ['DEFAULT', 'DEFAULT'], @@ -155,8 +191,9 @@ describe('config', () => { async (value) => { vi.stubEnv('OPENID4VC_PROFILE', 'WEBUILD'); vi.stubEnv('WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS', value); - await expect(importConfigFromTempProject([])).rejects.toThrow( - 'WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS is required when OPENID4VC_PROFILE=WEBUILD.', + await expectFatalConfig( + () => importConfigFromTempProject([]), + 'FATAL: WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS is required when OPENID4VC_PROFILE=WEBUILD.', ); }, ); @@ -180,6 +217,29 @@ describe('config', () => { ]); }); + it.each([32, 33])('accepts a %i-character DPoP nonce secret', async (length) => { + const secret = 's'.repeat(length); + vi.stubEnv('DPOP_NONCE_SECRET', secret); + const first = (await importConfigFromTempProject([])).default; + const second = (await importConfigFromTempProject([])).default; + + expect(Buffer.isBuffer(first.dpopNonceSecret)).toBe(true); + expect(first.dpopNonceSecret.length).toBe(32); + expect(first.dpopNonceSecret).toEqual(createHash('sha256').update(secret).digest()); + expect(second.dpopNonceSecret).toEqual(first.dpopNonceSecret); + }); + + it.each([undefined, '', 's'.repeat(31)])( + 'rejects a missing or short DPoP nonce secret (case %#)', + async (secret) => { + vi.stubEnv('DPOP_NONCE_SECRET', secret); + await expectFatalConfig( + () => importConfigFromTempProject([]), + 'FATAL: DPOP_NONCE_SECRET must be configured with at least 32 characters.', + ); + }, + ); + it('decodes the challenge secret into a stable 32-byte Buffer', async () => { vi.stubEnv('ABCA_CHALLENGE_SECRET', 'AB'.repeat(32)); const first = (await importConfigFromTempProject([])).default; @@ -204,8 +264,9 @@ describe('config', () => { ])('rejects a missing or malformed challenge secret (case %#)', async (secret) => { vi.stubEnv('ABCA_CHALLENGE_SECRET', secret); - await expect(importConfigFromTempProject([])).rejects.toThrow( - 'ABCA_CHALLENGE_SECRET must contain exactly 64 hexadecimal characters (32 bytes).', + await expectFatalConfig( + () => importConfigFromTempProject([]), + 'FATAL: ABCA_CHALLENGE_SECRET must contain exactly 64 hexadecimal characters (32 bytes).', ); }); @@ -217,21 +278,15 @@ describe('config', () => { DATA_STORE_PASSWORD: undefined, }; - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit:${code}`); - }); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.resetModules(); try { - await expect(import('../../src/config')).rejects.toThrow('process.exit:1'); - expect(exitSpy).toHaveBeenCalledWith(1); - expect(consoleSpy).toHaveBeenCalledWith('FATAL: Insecure data store found in production.'); + await expectFatalConfig( + () => import('../../src/config'), + 'FATAL: Insecure data store found in production.', + ); } finally { process.env = originalEnv; - exitSpy.mockRestore(); - consoleSpy.mockRestore(); } }); }); diff --git a/test/unit/introspectionHelpers.test.ts b/test/unit/introspectionHelpers.test.ts index e6ad633..f5d9457 100644 --- a/test/unit/introspectionHelpers.test.ts +++ b/test/unit/introspectionHelpers.test.ts @@ -4,6 +4,7 @@ describe('introspectionAllowedPolicy', () => { beforeEach(() => { // Deterministic test fixture only; never use this value in a deployment. vi.stubEnv('ABCA_CHALLENGE_SECRET', 'ab'.repeat(32)); + vi.stubEnv('DPOP_NONCE_SECRET', 'test-dpop-nonce-secret-0123456789ab'); }); afterEach(() => { diff --git a/yarn.lock b/yarn.lock index 98991ea..a905927 100644 --- a/yarn.lock +++ b/yarn.lock @@ -768,10 +768,10 @@ dependencies: undici-types "~7.18.0" -"@types/oidc-provider@^9.12.1": - version "9.12.1" - resolved "https://registry.yarnpkg.com/@types/oidc-provider/-/oidc-provider-9.12.1.tgz#33b95f8d8ab0e036dd0bf573343c9683af338a1c" - integrity sha512-qRJksCw0I+VloInJxMV/4L4gLL5Qrws9Np9+rW+MZeBKauEz5WqKO99OVCpnj4LKB/o1+utkHoQq1c3GBhuE5g== +"@types/oidc-provider@^9.13.0": + version "9.13.0" + resolved "https://registry.yarnpkg.com/@types/oidc-provider/-/oidc-provider-9.13.0.tgz#3d8037e57e30f3bb153f3f7fc77b7ecb63775db3" + integrity sha512-Ucf4oqIvxHHAM7TRH0mh6Yzixjfx+rBDuCxVBDnMw8ebMxboi4vwDUTG2nIdjRXBYUYcHADP6q7QUghxES7Xig== dependencies: "@types/keygrip" "*" "@types/koa" "*" @@ -2439,11 +2439,16 @@ jose@^5.9.6: resolved "https://registry.yarnpkg.com/jose/-/jose-5.10.0.tgz#c37346a099d6467c401351a9a0c2161e0f52c4be" integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg== -jose@^6.1.3, jose@^6.2.10: +jose@^6.1.3: version "6.2.12" resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.12.tgz#65663e146edd010b98ece83f81737d3aa95fd0d7" integrity sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw== +jose@^6.2.13: + version "6.2.14" + resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.14.tgz#f275808d68c833735d484a96606e7e5d937bc03e" + integrity sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A== + js-base64@^3.7.6: version "3.7.8" resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.8.tgz#af44496bc09fa178ed9c4adf67eb2b46f5c6d2a4" @@ -2836,13 +2841,13 @@ obug@^2.1.1: resolved "https://registry.yarnpkg.com/obug/-/obug-2.2.1.tgz#9c453efeeda822050eb9774242e969c784d64d78" integrity sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q== -oidc-provider@^9.12.2: - version "9.12.2" - resolved "https://registry.yarnpkg.com/oidc-provider/-/oidc-provider-9.12.2.tgz#24137fc1c45e975ebff8bcfac70608703fc4d077" - integrity sha512-UaqeVpeijTocxVbDowPqPKloGjb49bxRSzHfChyI86teR+6xxoiI1V5jv8CHL2AIUmA2rg0xTZcYaC9GguympA== +oidc-provider@^9.13.3: + version "9.13.3" + resolved "https://registry.yarnpkg.com/oidc-provider/-/oidc-provider-9.13.3.tgz#ed3ab01776bccc06a4896c0ae33aa4e49726443e" + integrity sha512-WmNDX9dLeItBmIqGqspmX5u6Cc7VVMrKUw2InX2T16+bwUJ8m/N5rJ3Ed+YKxE5aZqLp4YqwEi6kMJx4Ui5ffw== dependencies: debug "^4.4.3" - jose "^6.2.10" + jose "^6.2.13" koa "^3.2.1" on-finished@^2.4.1: @@ -3544,7 +3549,16 @@ streamx@^2.12.5, streamx@^2.15.0, streamx@^2.25.0: fast-fifo "^1.3.2" text-decoder "^1.1.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -3576,7 +3590,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4019,7 +4040,16 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==