From c8c116434f236cce9308366d546d0efaa7109604 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 14 Aug 2026 13:02:33 -0400 Subject: [PATCH 01/37] feat(security): OIDC identity token verification for trusted publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core of #2171: verify a CI identity token against an issuer's published signing keys and match it to a trust policy's claim constraints. No storage or operation wiring yet — this is the layer those sit on. security/oidcTrust/claims.ts is pure: claim normalization (deriving workflow_path from workflow_ref so a tag release can pin the workflow file without knowing the tag), exact/any-of matching that denies on an absent claim, and write-time validation requiring a repository pin, a workflow pin, and a ref-or-environment gate. That last requirement is what npm's repository+filename model lacks: without it, anyone who can push a branch can add the trusted workflow to it and mint a token. security/oidcTrust/jwks.ts fetches keys with the conservatism the unauthenticated exchange endpoint demands: https only, bounded body and time, discovery-issuer cross-check, asymmetric keys only, concurrent loads collapsed, and a rate limit on the refetch an unrecognized kid triggers. The rate-limit clock is kept outside the cache entry so a successful fetch does not reset it — and so a genuine key rotation is picked up on first use rather than after the window. security/oidcTrust/index.ts verifies signature, issuer, and audience, and additionally requires exp, a bounded lifetime, and jti (a token that cannot be identified cannot be replay-protected). Rejection reasons go to the log, never to the caller. Co-Authored-By: Claude Opus 5 --- security/oidcTrust/claims.ts | 154 ++++++++++ security/oidcTrust/index.ts | 118 ++++++++ security/oidcTrust/jwks.ts | 227 ++++++++++++++ security/oidcTrust/types.ts | 35 +++ unitTests/security/oidcTrust/claims.test.js | 286 ++++++++++++++++++ unitTests/security/oidcTrust/jwks.test.js | 218 +++++++++++++ .../oidcTrust/verifyIdentityToken.test.js | 181 +++++++++++ 7 files changed, 1219 insertions(+) create mode 100644 security/oidcTrust/claims.ts create mode 100644 security/oidcTrust/index.ts create mode 100644 security/oidcTrust/jwks.ts create mode 100644 security/oidcTrust/types.ts create mode 100644 unitTests/security/oidcTrust/claims.test.js create mode 100644 unitTests/security/oidcTrust/jwks.test.js create mode 100644 unitTests/security/oidcTrust/verifyIdentityToken.test.js diff --git a/security/oidcTrust/claims.ts b/security/oidcTrust/claims.ts new file mode 100644 index 000000000..1cadf8f0b --- /dev/null +++ b/security/oidcTrust/claims.ts @@ -0,0 +1,154 @@ +/** + * Claim normalization, matching, and policy validation for OIDC trusted publishing (#2171). + * + * Pure — no network, no storage — so the rules that decide whether an external CI run may act as a + * Harper user can be exercised directly. + */ + +import { ClientError } from '../../utility/errors/hdbError.ts'; +import type { ClaimConstraint, TokenClaims } from './types.ts'; + +/** + * A ref-qualified workflow reference is `//@`, and the ref is always a full + * `refs/...` name. Splitting on `@refs/` rather than the first or last `@` is exact: a path segment + * cannot contain `/`, so `@refs/` cannot occur inside the path portion. + */ +const REF_QUALIFIER = '@refs/'; + +/** + * Claims that pin *which repository* minted the token. `repository_id` is an immutable numeric id: + * it survives renames and cannot be re-acquired by a new owner of a recycled org name, so it is the + * pin to prefer. `repository_owner` is deliberately absent — it identifies an org, not a repository, + * and would let any repo in the org match. + */ +export const REPOSITORY_PIN_CLAIMS = ['repository_id', 'repository']; + +/** Claims that pin *which workflow file* ran. */ +export const WORKFLOW_PIN_CLAIMS = ['workflow_ref', 'workflow_path', 'job_workflow_ref', 'job_workflow_path']; + +/** + * Claims that pin the run to a specific ref, or to an environment whose protection rules gate it. + * Without one of these, any branch that can be pushed to the repository can run the trusted workflow + * and mint a token — the weakness in trusting repository + workflow filename alone. + * + * `ref_type` is not here on purpose: `ref_type: tag` still admits any tag, which anyone with push + * access can create. A tag-triggered release should pin `environment` and lean on GitHub's + * environment protection (required reviewers, tag deployment rules) for the gate. + */ +export const REF_GATE_CLAIMS = ['workflow_ref', 'job_workflow_ref', 'ref', 'environment']; + +/** + * Splits the ref off a workflow reference, yielding the workflow path alone. Returns undefined when + * the value isn't a ref-qualified reference, so a caller never matches against a guess. + */ +export function splitWorkflowPath(workflowRef: unknown): string | undefined { + if (typeof workflowRef !== 'string') return undefined; + const qualifierIndex = workflowRef.indexOf(REF_QUALIFIER); + return qualifierIndex === -1 ? undefined : workflowRef.slice(0, qualifierIndex); +} + +/** + * Adds derived claims to a verified token's payload. + * + * `workflow_path` / `job_workflow_path` are the workflow reference with the ref removed. They exist + * so a policy can pin the workflow *file* while gating the ref some other way — a tag-triggered + * release cannot pin `workflow_ref`, because the tag is not known when the policy is written. + * + * Derived entries are computed last so a claim actually present in the token cannot be displaced by + * one we synthesized. + */ +export function normalizeTokenClaims(payload: TokenClaims): TokenClaims { + const claims: TokenClaims = { ...payload }; + const workflowPath = splitWorkflowPath(payload.workflow_ref); + const jobWorkflowPath = splitWorkflowPath(payload.job_workflow_ref); + if (workflowPath !== undefined && claims.workflow_path === undefined) claims.workflow_path = workflowPath; + if (jobWorkflowPath !== undefined && claims.job_workflow_path === undefined) { + claims.job_workflow_path = jobWorkflowPath; + } + return claims; +} + +/** + * Claim values arrive as strings, but an issuer is free to encode a numeric id as a JSON number. + * Anything else (boolean, object, array, null) is not a value we will compare. + */ +function claimToString(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return undefined; +} + +/** + * Matches normalized token claims against a policy's constraints. Returns undefined on a match, or a + * short reason for the first failure — for the log, not for the caller: a client that learns *which* + * constraint failed can enumerate a policy one claim at a time. + * + * Every constraint must be satisfied, and a constrained claim that is absent from the token is a + * failure rather than a pass, so a policy cannot be weakened by an issuer dropping a claim. + */ +export function matchTrustPolicyClaims( + claims: TokenClaims, + policyClaims: Record +): string | undefined { + const constraints = Object.entries(policyClaims); + // A policy with no constraints would match every token from the issuer. validateTrustPolicyClaims + // rejects that at write time; this is the matching-side backstop for a policy stored before (or + // around) that validation. + if (constraints.length === 0) return 'policy constrains no claims'; + + for (const [claimName, constraint] of constraints) { + const actual = claimToString(claims[claimName]); + if (actual === undefined || actual === '') return `token has no usable ${claimName} claim`; + const accepted = Array.isArray(constraint) ? constraint : [constraint]; + if (!accepted.includes(actual)) return `${claimName} does not match the policy`; + } + return undefined; +} + +function describeUnpinned(claimNames: string[]): string { + return claimNames.join(', '); +} + +/** + * Validates a policy's claim constraints at write time. Throws ClientError describing the first + * problem; the caller is an administrator, so these messages are meant to be read. + * + * The three structural requirements exist because each guards a distinct way a policy can be + * accidentally broad: no repository pin admits any repository, no workflow pin admits any workflow + * in the repository, and no ref gate admits any branch that can be pushed. + */ +export function validateTrustPolicyClaims( + policyClaims: unknown +): asserts policyClaims is Record { + if (!policyClaims || typeof policyClaims !== 'object' || Array.isArray(policyClaims)) { + throw new ClientError('claims must be an object of claim constraints'); + } + + const entries = Object.entries(policyClaims as Record); + if (entries.length === 0) throw new ClientError('claims must constrain at least one claim'); + + for (const [claimName, constraint] of entries) { + const values = Array.isArray(constraint) ? constraint : [constraint]; + if (values.length === 0) throw new ClientError(`claims.${claimName} must accept at least one value`); + for (const value of values) { + if (typeof value !== 'string' || value === '') { + throw new ClientError(`claims.${claimName} must be a non-empty string or an array of non-empty strings`); + } + } + } + + const constrained = new Set(entries.map(([claimName]) => claimName)); + if (!REPOSITORY_PIN_CLAIMS.some((claimName) => constrained.has(claimName))) { + throw new ClientError( + `claims must pin the repository with one of: ${describeUnpinned(REPOSITORY_PIN_CLAIMS)} (repository_id is immutable and survives renames)` + ); + } + if (!WORKFLOW_PIN_CLAIMS.some((claimName) => constrained.has(claimName))) { + throw new ClientError(`claims must pin the workflow with one of: ${describeUnpinned(WORKFLOW_PIN_CLAIMS)}`); + } + if (!REF_GATE_CLAIMS.some((claimName) => constrained.has(claimName))) { + throw new ClientError( + `claims must gate the ref with one of: ${describeUnpinned(REF_GATE_CLAIMS)} — otherwise any branch that can be pushed to the repository can run the workflow and mint a token` + ); + } +} diff --git a/security/oidcTrust/index.ts b/security/oidcTrust/index.ts new file mode 100644 index 000000000..e0b83e6b0 --- /dev/null +++ b/security/oidcTrust/index.ts @@ -0,0 +1,118 @@ +/** + * Identity-token verification for OIDC trusted publishing (#2171). + * + * Verifies that a token was signed by the configured issuer and is addressed to this instance. + * Matching the token against a trust policy's claim constraints is separate (see claims.ts), because + * one verification serves every policy sharing an issuer and audience. + */ + +import jwt, { type Algorithm, type JwtPayload } from 'jsonwebtoken'; +import type { KeyObject } from 'node:crypto'; +import { ClientError } from '../../utility/errors/hdbError.ts'; +import { loggerWithTag } from '../../utility/logging/logger.ts'; +import { getSigningKey as defaultGetSigningKey, normalizeIssuer } from './jwks.ts'; +import { normalizeTokenClaims } from './claims.ts'; +import type { TokenClaims } from './types.ts'; + +const logger = loggerWithTag('oidc-trust'); + +/** + * Asymmetric signatures only. Passing this to jwt.verify is what prevents algorithm confusion: an + * `alg: none` token, or one signed with HMAC using a public key as the secret, is rejected before + * the signature is considered. + */ +const ALLOWED_ALGORITHMS: Algorithm[] = [ + 'RS256', + 'RS384', + 'RS512', + 'ES256', + 'ES384', + 'ES512', + 'PS256', + 'PS384', + 'PS512', +]; + +/** Leeway for clock skew between the runner, the issuer, and this instance. */ +const CLOCK_TOLERANCE_SECONDS = 60; + +/** + * Ceiling on a token's own declared lifetime. CI identity tokens are minted per job and live minutes + * — a correctly-signed token claiming a far-future expiry is not something we should honor for that + * long, whatever the issuer intended. + */ +const MAX_TOKEN_LIFETIME_SECONDS = 3_600; + +export interface VerifyTokenTarget { + issuer: string; + audience: string; +} + +export interface VerifyTokenOptions { + /** Overridable for tests; defaults to the network-backed JWKS lookup. */ + getSigningKey?: (issuer: string, kid: unknown) => Promise; + /** Seconds since the epoch to evaluate expiry against; defaults to the real clock. */ + clockTimestamp?: number; +} + +/** + * Fails verification. The reason is logged but never returned: the exchange endpoint is + * unauthenticated, and a caller told exactly which check failed can probe a policy one claim at a + * time. + */ +function rejectToken(detail: string): never { + logger.warn?.(`Rejecting identity token: ${detail}`); + throw new ClientError('Identity token was rejected', 401); +} + +/** + * Verifies a CI identity token against one issuer/audience pair and returns its normalized claims. + * + * The audience check is not incidental: an issuer's default audience is shared by every repository + * under an owner, so a token minted for some other service would otherwise be replayable here. + */ +export async function verifyIdentityToken( + token: unknown, + target: VerifyTokenTarget, + options: VerifyTokenOptions = {} +): Promise { + if (typeof token !== 'string' || token === '') throw new ClientError('token is required'); + const issuer = normalizeIssuer(target.issuer); + if (typeof target.audience !== 'string' || target.audience === '') { + throw new ClientError('audience is required'); + } + + const decoded = jwt.decode(token, { complete: true }); + if (!decoded) rejectToken('token is not a well-formed JWT'); + + // Check the algorithm before resolving a key so a garbage header costs no outbound request. + const algorithm = decoded.header.alg as Algorithm; + if (!ALLOWED_ALGORITHMS.includes(algorithm)) rejectToken(`unsupported algorithm ${decoded.header.alg}`); + + const getSigningKey = options.getSigningKey ?? defaultGetSigningKey; + const key = await getSigningKey(issuer, decoded.header.kid); + + let payload: JwtPayload; + try { + payload = jwt.verify(token, key, { + algorithms: ALLOWED_ALGORITHMS, + issuer, + audience: target.audience, + clockTolerance: CLOCK_TOLERANCE_SECONDS, + ...(options.clockTimestamp === undefined ? {} : { clockTimestamp: options.clockTimestamp }), + }) as JwtPayload; + } catch (error) { + rejectToken((error as Error).message); + } + + // jsonwebtoken only enforces `exp` when it is present, so a token without one never expires. + if (typeof payload.exp !== 'number') rejectToken('token has no exp claim'); + if (typeof payload.iat === 'number' && payload.exp - payload.iat > MAX_TOKEN_LIFETIME_SECONDS) { + rejectToken(`token lifetime exceeds ${MAX_TOKEN_LIFETIME_SECONDS}s`); + } + // The exchange records `jti` to block replay within the token's window; a token we cannot identify + // is one we cannot replay-protect, so it is not one we will accept. + if (typeof payload.jti !== 'string' || payload.jti === '') rejectToken('token has no jti claim'); + + return normalizeTokenClaims(payload as TokenClaims); +} diff --git a/security/oidcTrust/jwks.ts b/security/oidcTrust/jwks.ts new file mode 100644 index 000000000..99346a774 --- /dev/null +++ b/security/oidcTrust/jwks.ts @@ -0,0 +1,227 @@ +/** + * OIDC discovery and JWKS retrieval for trusted publishing (#2171). + * + * The issuer's signing keys are the root of trust for an exchanged token, so this module is + * deliberately conservative: HTTPS only, bounded response size, bounded fetch time, and a rate limit + * on the refetch that an unrecognized `kid` triggers. That last one matters because the exchange + * endpoint is unauthenticated — without it, a stream of forged `kid`s becomes one outbound fetch per + * request, against the issuer and on Harper's own event loop. + */ + +import { createPublicKey, type KeyObject } from 'node:crypto'; +import { loggerWithTag } from '../../utility/logging/logger.ts'; +import { ClientError, ServerError } from '../../utility/errors/hdbError.ts'; + +const logger = loggerWithTag('oidc-trust'); + +const DISCOVERY_PATH = '/.well-known/openid-configuration'; +/** How long a fetched key set is served without revalidation. */ +const JWKS_CACHE_TTL_MS = 3_600_000; +/** Floor between refetches triggered by an unrecognized `kid`. */ +const MIN_REFETCH_INTERVAL_MS = 60_000; +/** + * How long a cached key set may still be used after a refetch fails. Issuers rotate signing keys + * rarely, so a network blip should not break deploys — but an unbounded fallback would keep honoring + * a key set long after a key was pulled. + */ +const STALE_KEY_GRACE_MS = 86_400_000; +const FETCH_TIMEOUT_MS = 5_000; +const MAX_RESPONSE_BYTES = 1_048_576; +/** + * Asymmetric key types only. An `oct` (symmetric) key in a JWKS is the setup for the classic + * algorithm-confusion attack, where a public value is replayed as an HMAC secret. + */ +const SUPPORTED_KEY_TYPES = ['RSA', 'EC']; + +interface IssuerKeys { + keys: Map; + fetchedAt: number; +} + +const issuerKeyCache = new Map(); +const inFlightLoads = new Map>(); +/** + * When an unrecognized `kid` last drove a refetch, per issuer. Kept outside the cache entry on + * purpose: the entry is replaced by every successful fetch, and a rate limit that resets whenever it + * fires is not a rate limit. + */ +const unknownKidRefetchAt = new Map(); + +/** Drops all cached key sets. Exported for tests and for an operator forcing a re-read. */ +export function clearJwksCache(): void { + issuerKeyCache.clear(); + inFlightLoads.clear(); + unknownKidRefetchAt.clear(); +} + +/** + * Validates and canonicalizes an issuer URL. The result is both the cache key and the discovery + * base, so it has to be stable: a policy stored with a trailing slash and one without must not end + * up as two entries pointing at the same issuer. + */ +export function normalizeIssuer(issuer: unknown): string { + if (typeof issuer !== 'string' || issuer === '') throw new ClientError('issuer is required'); + let url: URL; + try { + url = new URL(issuer); + } catch { + throw new ClientError(`issuer is not a valid URL: ${issuer}`); + } + if (url.protocol !== 'https:') throw new ClientError('issuer must be an https URL'); + if (url.search !== '' || url.hash !== '') throw new ClientError('issuer must not carry a query or fragment'); + return url.origin + (url.pathname === '/' ? '' : url.pathname.replace(/\/$/, '')); +} + +/** + * Reads a JSON response with a hard byte ceiling. `content-length` is checked first as a cheap + * rejection, then the body is counted as it streams, because the header is advisory and a hostile + * endpoint can simply omit it. + */ +async function readBoundedJson(response: Response, url: string): Promise { + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { + throw new ServerError(`Response from ${url} exceeds ${MAX_RESPONSE_BYTES} bytes`); + } + const chunks: Buffer[] = []; + let total = 0; + if (response.body) { + for await (const chunk of response.body as unknown as AsyncIterable) { + total += chunk.length; + if (total > MAX_RESPONSE_BYTES) { + throw new ServerError(`Response from ${url} exceeds ${MAX_RESPONSE_BYTES} bytes`); + } + chunks.push(Buffer.from(chunk)); + } + } + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch (error) { + throw new ServerError(`Response from ${url} is not valid JSON: ${(error as Error).message}`); + } +} + +async function fetchJson(url: string): Promise { + let response: Response; + try { + response = await fetch(url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { accept: 'application/json' }, + redirect: 'error', + }); + } catch (error) { + throw new ServerError(`Could not reach ${url}: ${(error as Error).message}`); + } + if (!response.ok) throw new ServerError(`${url} responded ${response.status}`); + return readBoundedJson(response, url); +} + +/** + * Resolves the issuer's `jwks_uri` via OIDC discovery. The discovery document's own `issuer` must + * equal the one we asked about — the spec requires it, and it is what stops a misdirected discovery + * document from quietly re-pointing an issuer we trust. + */ +async function discoverJwksUri(issuer: string): Promise { + const document = await fetchJson(issuer + DISCOVERY_PATH); + // normalizeIssuer raises a ClientError, which is the wrong shape for a malformed *server* + // response — an unparseable `issuer` here is the issuer misbehaving, not the caller. + let declaredIssuer: string | undefined; + try { + declaredIssuer = normalizeIssuer(document?.issuer); + } catch { + declaredIssuer = undefined; + } + if (declaredIssuer !== issuer) { + throw new ServerError(`Discovery document at ${issuer} declares a different issuer`); + } + const jwksUri = document?.jwks_uri; + if (typeof jwksUri !== 'string' || !jwksUri.startsWith('https://')) { + throw new ServerError(`Discovery document at ${issuer} has no https jwks_uri`); + } + return jwksUri; +} + +/** + * Converts a JWK to a usable public key, or returns undefined for one we will not honor. A single + * unusable entry must not poison the whole set: issuers publish keys for other purposes, and future + * key types should degrade to "not usable here" rather than to a failed fetch. + */ +function toSigningKey(jwk: any): KeyObject | undefined { + if (!jwk || typeof jwk !== 'object') return undefined; + if (typeof jwk.kid !== 'string' || jwk.kid === '') return undefined; + if (jwk.use !== undefined && jwk.use !== 'sig') return undefined; + if (!SUPPORTED_KEY_TYPES.includes(jwk.kty)) return undefined; + try { + return createPublicKey({ key: jwk, format: 'jwk' }); + } catch (error) { + logger.warn?.(`Skipping unusable JWK ${jwk.kid}: ${(error as Error).message}`); + return undefined; + } +} + +async function fetchIssuerKeys(issuer: string): Promise { + const jwksUri = await discoverJwksUri(issuer); + const jwks = await fetchJson(jwksUri); + if (!Array.isArray(jwks?.keys)) throw new ServerError(`JWKS at ${jwksUri} has no keys array`); + + const keys = new Map(); + for (const jwk of jwks.keys) { + const key = toSigningKey(jwk); + if (key) keys.set(jwk.kid, key); + } + if (keys.size === 0) throw new ServerError(`JWKS at ${jwksUri} contains no usable signing keys`); + + const entry: IssuerKeys = { keys, fetchedAt: Date.now() }; + issuerKeyCache.set(issuer, entry); + logger.debug?.(`Loaded ${keys.size} signing key(s) for ${issuer}`); + return entry; +} + +/** Loads an issuer's keys, collapsing concurrent callers onto one fetch. */ +function loadIssuerKeys(issuer: string): Promise { + const existing = inFlightLoads.get(issuer); + if (existing) return existing; + + const load = fetchIssuerKeys(issuer).finally(() => inFlightLoads.delete(issuer)); + inFlightLoads.set(issuer, load); + return load; +} + +/** + * Resolves the public key an issuer used to sign a token, by `kid`. + * + * An unrecognized `kid` against a fresh cache means either a genuine key rotation or a forged + * header. Both look identical from here, so the refetch that distinguishes them is rate-limited + * rather than unconditional. + */ +export async function getSigningKey(issuer: string, kid: unknown): Promise { + if (typeof kid !== 'string' || kid === '') throw new ClientError('Token has no key id', 401); + const normalizedIssuer = normalizeIssuer(issuer); + const now = Date.now(); + const cached = issuerKeyCache.get(normalizedIssuer); + + if (cached && now - cached.fetchedAt < JWKS_CACHE_TTL_MS) { + const key = cached.keys.get(kid); + if (key) return key; + // Unknown kid against a still-fresh set: refetch once, then hold the line for the window. The + // timestamp is recorded before the fetch so a failing issuer is rate-limited like a succeeding one. + if (now - (unknownKidRefetchAt.get(normalizedIssuer) ?? 0) < MIN_REFETCH_INTERVAL_MS) { + throw new ClientError('Token signing key is not recognized', 401); + } + unknownKidRefetchAt.set(normalizedIssuer, now); + } + + let refreshed: IssuerKeys; + try { + refreshed = await loadIssuerKeys(normalizedIssuer); + } catch (error) { + // Serve a still-recent cached key rather than failing an exchange on a transient outage. + const staleKey = cached && now - cached.fetchedAt < STALE_KEY_GRACE_MS ? cached.keys.get(kid) : undefined; + if (!staleKey) throw error; + logger.warn?.(`Using cached signing key for ${normalizedIssuer}; refresh failed: ${(error as Error).message}`); + return staleKey; + } + + const key = refreshed.keys.get(kid); + if (!key) throw new ClientError('Token signing key is not recognized', 401); + return key; +} diff --git a/security/oidcTrust/types.ts b/security/oidcTrust/types.ts new file mode 100644 index 000000000..9daab86cc --- /dev/null +++ b/security/oidcTrust/types.ts @@ -0,0 +1,35 @@ +/** + * Types for OIDC trusted publishing (#2171). + */ + +/** A claim constraint: one accepted value, or a set of accepted values. */ +export type ClaimConstraint = string | string[]; + +/** + * A stored trust policy. Matching a policy lets an external CI run act as `user` without holding + * any Harper credential — so every field here is load-bearing, and `claims` is validated at write + * time (see validateTrustPolicyClaims) rather than trusted as written. + */ +export interface OidcTrustPolicy { + /** Caller-supplied identifier, and the handle used to revoke. */ + id: string; + /** Expected `iss`. Also the base for OIDC discovery. */ + issuer: string; + /** + * Expected `aud`. Must identify *this* instance: the issuer's default audience is shared by + * every repository under an owner, so without an instance-specific audience a token minted for + * an unrelated service is replayable here. + */ + audience: string; + /** Claim constraints, matched against the normalized token claims. */ + claims: Record; + /** Harper user the exchanged token authenticates as. */ + user: string; + /** When set, the minted token is usable only for these operations. */ + operations?: string[]; + /** Defaults to true; false keeps the policy for reference without honoring it. */ + enabled?: boolean; +} + +/** Claims carried by a verified identity token, plus the derived entries normalizeTokenClaims adds. */ +export type TokenClaims = Record; diff --git a/unitTests/security/oidcTrust/claims.test.js b/unitTests/security/oidcTrust/claims.test.js new file mode 100644 index 000000000..84f6f1b99 --- /dev/null +++ b/unitTests/security/oidcTrust/claims.test.js @@ -0,0 +1,286 @@ +'use strict'; + +const assert = require('node:assert'); +const { + splitWorkflowPath, + normalizeTokenClaims, + matchTrustPolicyClaims, + validateTrustPolicyClaims, +} = require('#src/security/oidcTrust/claims'); + +// A representative GitHub Actions identity token payload, as emitted for a push to main running a +// job that declares `environment: production`. +const GITHUB_CLAIMS = Object.freeze({ + iss: 'https://token.actions.githubusercontent.com', + aud: 'https://my-instance.harperdb.io:9925/', + sub: 'repo:HarperFast/my-app:environment:production', + repository: 'HarperFast/my-app', + repository_id: '67890', + repository_owner: 'HarperFast', + repository_owner_id: '12345', + workflow_ref: 'HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main', + job_workflow_ref: 'HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main', + environment: 'production', + ref: 'refs/heads/main', + ref_type: 'branch', + event_name: 'push', + runner_environment: 'github-hosted', + jti: 'e5f7a0c2-0000-4000-8000-000000000001', +}); + +// The smallest policy that passes validation, reused as a base so each validation test varies one thing. +const VALID_POLICY_CLAIMS = Object.freeze({ + repository_id: '67890', + workflow_ref: 'HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main', +}); + +describe('oidcTrust claims', () => { + describe('splitWorkflowPath', () => { + it('strips the ref from a workflow reference', () => { + assert.strictEqual( + splitWorkflowPath('HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main'), + 'HarperFast/my-app/.github/workflows/deploy.yml' + ); + }); + + it('splits on the ref qualifier, not on an @ inside the branch name', () => { + assert.strictEqual( + splitWorkflowPath('HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/release@2'), + 'HarperFast/my-app/.github/workflows/deploy.yml' + ); + }); + + it('handles a tag ref', () => { + assert.strictEqual( + splitWorkflowPath('HarperFast/my-app/.github/workflows/release.yml@refs/tags/v1.2.3'), + 'HarperFast/my-app/.github/workflows/release.yml' + ); + }); + + it('returns undefined rather than guessing when the value is not ref-qualified', () => { + assert.strictEqual(splitWorkflowPath('HarperFast/my-app/.github/workflows/deploy.yml'), undefined); + assert.strictEqual(splitWorkflowPath('no-at-sign'), undefined); + }); + + it('returns undefined for non-string input', () => { + assert.strictEqual(splitWorkflowPath(undefined), undefined); + assert.strictEqual(splitWorkflowPath(null), undefined); + assert.strictEqual(splitWorkflowPath(42), undefined); + }); + }); + + describe('normalizeTokenClaims', () => { + it('derives workflow_path and job_workflow_path', () => { + const claims = normalizeTokenClaims(GITHUB_CLAIMS); + assert.strictEqual(claims.workflow_path, 'HarperFast/my-app/.github/workflows/deploy.yml'); + assert.strictEqual(claims.job_workflow_path, 'HarperFast/my-app/.github/workflows/deploy.yml'); + }); + + it('preserves the original claims', () => { + const claims = normalizeTokenClaims(GITHUB_CLAIMS); + assert.strictEqual(claims.repository_id, '67890'); + assert.strictEqual(claims.environment, 'production'); + assert.strictEqual(claims.workflow_ref, GITHUB_CLAIMS.workflow_ref); + }); + + it('does not mutate the input', () => { + const payload = { ...GITHUB_CLAIMS }; + normalizeTokenClaims(payload); + assert.strictEqual(payload.workflow_path, undefined); + }); + + it('omits a derived claim when the reference is not ref-qualified', () => { + const claims = normalizeTokenClaims({ workflow_ref: 'owner/repo/.github/workflows/deploy.yml' }); + assert.strictEqual(claims.workflow_path, undefined); + }); + + // An issuer that one day emits workflow_path itself must win over our derivation, otherwise a + // policy written against the real claim would be matched against a value we invented. + it('does not displace a claim the token already carries', () => { + const claims = normalizeTokenClaims({ + workflow_ref: 'owner/repo/.github/workflows/deploy.yml@refs/heads/main', + workflow_path: 'issuer-supplied', + }); + assert.strictEqual(claims.workflow_path, 'issuer-supplied'); + }); + }); + + describe('matchTrustPolicyClaims', () => { + it('matches when every constraint is satisfied', () => { + const claims = normalizeTokenClaims(GITHUB_CLAIMS); + const reason = matchTrustPolicyClaims(claims, { + repository_id: '67890', + repository_owner_id: '12345', + workflow_ref: GITHUB_CLAIMS.workflow_ref, + environment: 'production', + runner_environment: 'github-hosted', + }); + assert.strictEqual(reason, undefined); + }); + + it('accepts any value from a set', () => { + const claims = normalizeTokenClaims(GITHUB_CLAIMS); + const reason = matchTrustPolicyClaims(claims, { + repository_id: '67890', + workflow_ref: GITHUB_CLAIMS.workflow_ref, + event_name: ['push', 'workflow_dispatch'], + }); + assert.strictEqual(reason, undefined); + }); + + it('rejects a value outside the set', () => { + const claims = normalizeTokenClaims(GITHUB_CLAIMS); + const reason = matchTrustPolicyClaims(claims, { + repository_id: '67890', + event_name: ['workflow_dispatch', 'schedule'], + }); + assert.ok(reason, 'expected a mismatch reason'); + assert.match(reason, /event_name/); + }); + + // The central fail-closed property: a constrained claim the token does not carry must deny, + // so a policy cannot be silently weakened by an issuer that stops emitting a claim. + it('rejects when a constrained claim is absent from the token', () => { + const { environment: _environment, ...withoutEnvironment } = GITHUB_CLAIMS; + const claims = normalizeTokenClaims(withoutEnvironment); + const reason = matchTrustPolicyClaims(claims, { + repository_id: '67890', + environment: 'production', + }); + assert.ok(reason, 'expected a mismatch reason'); + assert.match(reason, /environment/); + }); + + it('rejects an empty-string claim value', () => { + const claims = normalizeTokenClaims({ ...GITHUB_CLAIMS, environment: '' }); + const reason = matchTrustPolicyClaims(claims, { environment: 'production' }); + assert.ok(reason, 'expected a mismatch reason'); + }); + + it('rejects a policy that constrains nothing', () => { + const claims = normalizeTokenClaims(GITHUB_CLAIMS); + const reason = matchTrustPolicyClaims(claims, {}); + assert.ok(reason, 'expected a rejection for an unconstrained policy'); + }); + + it('compares a numerically-encoded claim as a string', () => { + const claims = normalizeTokenClaims({ ...GITHUB_CLAIMS, repository_id: 67890 }); + assert.strictEqual(matchTrustPolicyClaims(claims, { repository_id: '67890' }), undefined); + }); + + it('refuses to compare a non-scalar claim', () => { + for (const value of [true, { nested: 'object' }, ['array'], null]) { + const claims = normalizeTokenClaims({ ...GITHUB_CLAIMS, environment: value }); + assert.ok( + matchTrustPolicyClaims(claims, { environment: 'production' }), + `expected rejection for ${JSON.stringify(value)}` + ); + } + }); + + it('matches on the derived workflow_path so a tag release can pin the file', () => { + const tagRun = normalizeTokenClaims({ + ...GITHUB_CLAIMS, + workflow_ref: 'HarperFast/my-app/.github/workflows/release.yml@refs/tags/v1.2.3', + ref: 'refs/tags/v1.2.3', + ref_type: 'tag', + }); + const reason = matchTrustPolicyClaims(tagRun, { + repository_id: '67890', + workflow_path: 'HarperFast/my-app/.github/workflows/release.yml', + environment: 'production', + }); + assert.strictEqual(reason, undefined); + }); + }); + + describe('validateTrustPolicyClaims', () => { + it('accepts a repository pin plus a ref-qualified workflow pin', () => { + assert.doesNotThrow(() => validateTrustPolicyClaims({ ...VALID_POLICY_CLAIMS })); + }); + + it('accepts a workflow path pinned by an environment gate', () => { + assert.doesNotThrow(() => + validateTrustPolicyClaims({ + repository_id: '67890', + workflow_path: 'HarperFast/my-app/.github/workflows/release.yml', + environment: 'production', + }) + ); + }); + + it('rejects a non-object', () => { + for (const value of [undefined, null, 'claims', 42, ['repository_id']]) { + assert.throws(() => validateTrustPolicyClaims(value), /claims must be an object/); + } + }); + + it('rejects an empty object', () => { + assert.throws(() => validateTrustPolicyClaims({}), /at least one claim/); + }); + + it('rejects an empty accepted-value set', () => { + assert.throws(() => validateTrustPolicyClaims({ ...VALID_POLICY_CLAIMS, event_name: [] }), /at least one value/); + }); + + it('rejects non-string and empty-string values', () => { + assert.throws(() => validateTrustPolicyClaims({ ...VALID_POLICY_CLAIMS, environment: '' }), /non-empty/); + assert.throws(() => validateTrustPolicyClaims({ ...VALID_POLICY_CLAIMS, environment: 42 }), /non-empty/); + assert.throws(() => validateTrustPolicyClaims({ ...VALID_POLICY_CLAIMS, event_name: ['push', ''] }), /non-empty/); + }); + + // repository_owner identifies an org, not a repository — pinning it would admit every repo in + // the org, which is exactly the over-broad policy this validation exists to prevent. + it('rejects a policy with no repository pin', () => { + assert.throws( + () => + validateTrustPolicyClaims({ + repository_owner: 'HarperFast', + workflow_ref: VALID_POLICY_CLAIMS.workflow_ref, + }), + /pin the repository/ + ); + }); + + it('rejects a policy with no workflow pin', () => { + assert.throws( + () => validateTrustPolicyClaims({ repository_id: '67890', environment: 'production' }), + /pin the workflow/ + ); + }); + + // The npm-style "repository + workflow filename" policy: any branch that can be pushed can run + // the workflow and mint a token. Refuse it unless something gates the ref. + it('rejects a workflow pin with no ref gate', () => { + assert.throws( + () => + validateTrustPolicyClaims({ + repository_id: '67890', + workflow_path: 'HarperFast/my-app/.github/workflows/deploy.yml', + }), + /gate the ref/ + ); + }); + + it('does not accept ref_type alone as a ref gate', () => { + assert.throws( + () => + validateTrustPolicyClaims({ + repository_id: '67890', + workflow_path: 'HarperFast/my-app/.github/workflows/release.yml', + ref_type: 'tag', + }), + /gate the ref/ + ); + }); + + it('treats a ref-qualified workflow_ref as both the workflow pin and the ref gate', () => { + assert.doesNotThrow(() => + validateTrustPolicyClaims({ + repository: 'HarperFast/my-app', + job_workflow_ref: VALID_POLICY_CLAIMS.workflow_ref, + }) + ); + }); + }); +}); diff --git a/unitTests/security/oidcTrust/jwks.test.js b/unitTests/security/oidcTrust/jwks.test.js new file mode 100644 index 000000000..7f95d756f --- /dev/null +++ b/unitTests/security/oidcTrust/jwks.test.js @@ -0,0 +1,218 @@ +'use strict'; + +const assert = require('node:assert'); +const { generateKeyPairSync, createPublicKey } = require('node:crypto'); +const { getSigningKey, normalizeIssuer, clearJwksCache } = require('#src/security/oidcTrust/jwks'); + +const ISSUER = 'https://token.actions.githubusercontent.com'; +const JWKS_URI = 'https://token.actions.githubusercontent.com/.well-known/jwks'; +const DISCOVERY_URI = ISSUER + '/.well-known/openid-configuration'; + +describe('oidcTrust jwks', () => { + describe('normalizeIssuer', () => { + it('drops a trailing slash so one issuer is one cache entry', () => { + assert.strictEqual(normalizeIssuer(ISSUER + '/'), ISSUER); + assert.strictEqual(normalizeIssuer(ISSUER), ISSUER); + }); + + it('keeps a path-qualified issuer', () => { + assert.strictEqual(normalizeIssuer('https://gitlab.example.com/oidc/'), 'https://gitlab.example.com/oidc'); + }); + + it('requires https', () => { + assert.throws(() => normalizeIssuer('http://token.actions.githubusercontent.com'), /https/); + }); + + it('rejects a query or fragment', () => { + assert.throws(() => normalizeIssuer(ISSUER + '?a=b'), /query or fragment/); + assert.throws(() => normalizeIssuer(ISSUER + '#frag'), /query or fragment/); + }); + + it('rejects values that are not URLs', () => { + for (const value of ['', undefined, null, 42, 'token.actions.githubusercontent.com']) { + assert.throws(() => normalizeIssuer(value), `expected rejection for ${JSON.stringify(value)}`); + } + }); + }); + + describe('getSigningKey', () => { + let realFetch; + let requestLog; + let respond; + let signingJwk; + + before(() => { + const { publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + signingJwk = { ...createPublicKey(publicKey).export({ format: 'jwk' }), kid: 'key-1', use: 'sig', alg: 'RS256' }; + }); + + beforeEach(() => { + clearJwksCache(); + requestLog = []; + realFetch = globalThis.fetch; + // Route fetch through a per-test responder so the real caching, discovery, and bounding + // logic runs — only the network is displaced. + globalThis.fetch = async (url) => { + requestLog.push(String(url)); + return respond(String(url)); + }; + respond = (url) => json(url === DISCOVERY_URI ? discoveryDocument() : jwksDocument([signingJwk])); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + clearJwksCache(); + }); + + function json(body, init) { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + ...init, + }); + } + + function discoveryDocument(overrides = {}) { + return { issuer: ISSUER, jwks_uri: JWKS_URI, ...overrides }; + } + + function jwksDocument(keys) { + return { keys }; + } + + it('discovers the JWKS and resolves a key by kid', async () => { + const key = await getSigningKey(ISSUER, 'key-1'); + assert.strictEqual(key.type, 'public'); + assert.deepStrictEqual(requestLog, [DISCOVERY_URI, JWKS_URI]); + }); + + it('serves later lookups from cache', async () => { + await getSigningKey(ISSUER, 'key-1'); + await getSigningKey(ISSUER, 'key-1'); + assert.strictEqual(requestLog.length, 2, 'expected no refetch for a cached kid'); + }); + + it('treats a trailing slash as the same issuer', async () => { + await getSigningKey(ISSUER, 'key-1'); + await getSigningKey(ISSUER + '/', 'key-1'); + assert.strictEqual(requestLog.length, 2, 'expected the normalized issuer to hit the same cache entry'); + }); + + it('collapses concurrent lookups onto one fetch', async () => { + const keys = await Promise.all([ + getSigningKey(ISSUER, 'key-1'), + getSigningKey(ISSUER, 'key-1'), + getSigningKey(ISSUER, 'key-1'), + ]); + assert.strictEqual(keys.length, 3); + assert.deepStrictEqual(requestLog, [DISCOVERY_URI, JWKS_URI]); + }); + + // An unknown kid is either a rotation or a forgery. The first triggers one refetch; repeats + // inside the rate-limit window must not, or the unauthenticated exchange endpoint becomes a + // way to drive outbound requests. + it('refetches once for an unknown kid, then rate-limits', async () => { + await getSigningKey(ISSUER, 'key-1'); + assert.strictEqual(requestLog.length, 2); + + await assert.rejects(() => getSigningKey(ISSUER, 'forged-kid'), /not recognized/); + assert.strictEqual(requestLog.length, 4, 'expected one refetch for the unknown kid'); + + await assert.rejects(() => getSigningKey(ISSUER, 'forged-kid'), /not recognized/); + await assert.rejects(() => getSigningKey(ISSUER, 'another-forged-kid'), /not recognized/); + assert.strictEqual(requestLog.length, 4, 'expected no further refetches inside the window'); + }); + + it('picks up a rotated key when the issuer publishes it', async () => { + await getSigningKey(ISSUER, 'key-1'); + const rotated = { ...signingJwk, kid: 'key-2' }; + respond = (url) => json(url === DISCOVERY_URI ? discoveryDocument() : jwksDocument([signingJwk, rotated])); + const key = await getSigningKey(ISSUER, 'key-2'); + assert.strictEqual(key.type, 'public'); + }); + + it('falls back to a cached key when a refresh fails', async () => { + await getSigningKey(ISSUER, 'key-1'); + respond = () => { + throw new Error('network down'); + }; + // The unknown kid forces a refresh, which fails; the still-cached kid must survive it. + await assert.rejects(() => getSigningKey(ISSUER, 'forged-kid')); + const key = await getSigningKey(ISSUER, 'key-1'); + assert.strictEqual(key.type, 'public'); + }); + + it('rejects a discovery document that declares a different issuer', async () => { + respond = (url) => + json( + url === DISCOVERY_URI ? discoveryDocument({ issuer: 'https://evil.example.com' }) : jwksDocument([signingJwk]) + ); + await assert.rejects(() => getSigningKey(ISSUER, 'key-1'), /different issuer/); + }); + + it('rejects a non-https jwks_uri', async () => { + respond = (url) => + json(url === DISCOVERY_URI ? discoveryDocument({ jwks_uri: 'http://insecure.example/jwks' }) : {}); + await assert.rejects(() => getSigningKey(ISSUER, 'key-1'), /https jwks_uri/); + }); + + // A symmetric key in a JWKS is the setup for algorithm confusion; it must never become a + // candidate signing key. + it('ignores symmetric and non-signing keys', async () => { + respond = (url) => + json( + url === DISCOVERY_URI + ? discoveryDocument() + : jwksDocument([ + { kty: 'oct', kid: 'symmetric', k: 'c2VjcmV0' }, + { ...signingJwk, kid: 'encryption-only', use: 'enc' }, + signingJwk, + ]) + ); + await assert.rejects(() => getSigningKey(ISSUER, 'symmetric'), /not recognized/); + await assert.rejects(() => getSigningKey(ISSUER, 'encryption-only'), /not recognized/); + assert.strictEqual((await getSigningKey(ISSUER, 'key-1')).type, 'public'); + }); + + it('rejects a JWKS with no usable keys', async () => { + respond = (url) => + json(url === DISCOVERY_URI ? discoveryDocument() : jwksDocument([{ kty: 'oct', kid: 'x', k: 'c2VjcmV0' }])); + await assert.rejects(() => getSigningKey(ISSUER, 'key-1'), /no usable signing keys/); + }); + + it('rejects a non-JSON response', async () => { + respond = () => json('not json'); + await assert.rejects(() => getSigningKey(ISSUER, 'key-1'), /not valid JSON/); + }); + + it('rejects an error status', async () => { + respond = () => new Response('nope', { status: 503 }); + await assert.rejects(() => getSigningKey(ISSUER, 'key-1'), /responded 503/); + }); + + it('rejects an oversized response body', async () => { + respond = () => json('x'.repeat(1_100_000)); + await assert.rejects(() => getSigningKey(ISSUER, 'key-1'), /exceeds/); + }); + + it('rejects an oversized declared content-length without reading the body', async () => { + respond = () => + new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json', 'content-length': '99999999' }, + }); + await assert.rejects(() => getSigningKey(ISSUER, 'key-1'), /exceeds/); + }); + + it('requires a key id', async () => { + for (const kid of ['', undefined, null, 42]) { + await assert.rejects(() => getSigningKey(ISSUER, kid), /no key id/); + } + assert.deepStrictEqual(requestLog, [], 'expected no fetch for a token with no kid'); + }); + }); +}); diff --git a/unitTests/security/oidcTrust/verifyIdentityToken.test.js b/unitTests/security/oidcTrust/verifyIdentityToken.test.js new file mode 100644 index 000000000..077195c3f --- /dev/null +++ b/unitTests/security/oidcTrust/verifyIdentityToken.test.js @@ -0,0 +1,181 @@ +'use strict'; + +const assert = require('node:assert'); +const { generateKeyPairSync, createPublicKey } = require('node:crypto'); +const jwt = require('jsonwebtoken'); +const { verifyIdentityToken } = require('#src/security/oidcTrust/index'); + +const ISSUER = 'https://token.actions.githubusercontent.com'; +const AUDIENCE = 'https://my-instance.harperdb.io:9925/'; +const SIGNING_KID = 'test-signing-key'; + +// Fixed clock so expiry assertions do not depend on how long the suite takes to run. +const NOW_SECONDS = 1_800_000_000; + +describe('verifyIdentityToken', () => { + let privateKey; + let publicKey; + let otherPrivateKey; + + before(() => { + const pair = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + privateKey = pair.privateKey; + publicKey = pair.publicKey; + otherPrivateKey = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }).privateKey; + }); + + // Stands in for the JWKS lookup so these tests exercise the real verification path without network. + function lookupKey(issuer, kid) { + assert.strictEqual(issuer, ISSUER, 'expected the normalized issuer'); + if (kid !== SIGNING_KID) return Promise.reject(new Error(`unknown kid ${kid}`)); + return Promise.resolve(createPublicKey(publicKey)); + } + + function claimsFor(overrides = {}) { + return { + iss: ISSUER, + aud: AUDIENCE, + sub: 'repo:HarperFast/my-app:environment:production', + jti: 'token-id-1', + iat: NOW_SECONDS, + exp: NOW_SECONDS + 300, + repository: 'HarperFast/my-app', + repository_id: '67890', + workflow_ref: 'HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main', + environment: 'production', + ...overrides, + }; + } + + function sign(claims, { key = privateKey, algorithm = 'RS256', kid = SIGNING_KID } = {}) { + return jwt.sign(claims, key, { algorithm, header: { alg: algorithm, kid } }); + } + + function verify(token, options = {}) { + return verifyIdentityToken( + token, + { issuer: ISSUER, audience: AUDIENCE }, + { getSigningKey: lookupKey, clockTimestamp: NOW_SECONDS, ...options } + ); + } + + async function assertRejected(token, options) { + await assert.rejects( + () => verify(token, options), + (error) => { + assert.strictEqual(error.statusCode, 401); + // The reason belongs in the log, not in a response to an unauthenticated caller. + assert.strictEqual(error.message, 'Identity token was rejected'); + return true; + } + ); + } + + it('accepts a well-formed token and returns normalized claims', async () => { + const claims = await verify(sign(claimsFor())); + assert.strictEqual(claims.repository_id, '67890'); + assert.strictEqual(claims.environment, 'production'); + assert.strictEqual(claims.workflow_path, 'HarperFast/my-app/.github/workflows/deploy.yml'); + }); + + it('tolerates modest clock skew', async () => { + const token = sign(claimsFor()); + // 30s past expiry is inside the 60s tolerance. + const claims = await verify(token, { clockTimestamp: NOW_SECONDS + 330 }); + assert.strictEqual(claims.repository_id, '67890'); + }); + + it('rejects an expired token', async () => { + await assertRejected(sign(claimsFor()), { clockTimestamp: NOW_SECONDS + 3600 }); + }); + + it('rejects a token issued for another audience', async () => { + await assertRejected(sign(claimsFor({ aud: 'https://someone-elses-service.example/' }))); + }); + + it('rejects a token from another issuer', async () => { + await assertRejected(sign(claimsFor({ iss: 'https://gitlab.example.com' }))); + }); + + it('rejects a token signed by a key we do not trust', async () => { + await assertRejected(sign(claimsFor(), { key: otherPrivateKey })); + }); + + // Algorithm confusion: the public key is public, so a token HMAC-signed with it must never verify. + it('rejects an HMAC-signed token', async () => { + const token = jwt.sign(claimsFor(), publicKey, { + algorithm: 'HS256', + header: { alg: 'HS256', kid: SIGNING_KID }, + }); + await assertRejected(token); + }); + + it('rejects an unsigned token', async () => { + const token = jwt.sign(claimsFor(), '', { algorithm: 'none', header: { alg: 'none', kid: SIGNING_KID } }); + await assertRejected(token); + }); + + it('rejects a tampered payload', async () => { + const [header, , signature] = sign(claimsFor()).split('.'); + const forged = Buffer.from(JSON.stringify(claimsFor({ repository_id: '99999' }))).toString('base64url'); + await assertRejected(`${header}.${forged}.${signature}`); + }); + + // jsonwebtoken only enforces exp when it is present, so a token without one would never expire. + it('rejects a token with no exp claim', async () => { + const { exp: _exp, ...withoutExp } = claimsFor(); + await assertRejected(sign(withoutExp)); + }); + + it('rejects a token whose declared lifetime exceeds the ceiling', async () => { + await assertRejected(sign(claimsFor({ exp: NOW_SECONDS + 86_400 }))); + }); + + // Without a jti the exchange cannot record the token as spent, so it cannot be replay-protected. + it('rejects a token with no jti claim', async () => { + const { jti: _jti, ...withoutJti } = claimsFor(); + await assertRejected(sign(withoutJti)); + }); + + it('rejects a token whose kid is unknown to the key lookup', async () => { + await assert.rejects(() => verify(sign(claimsFor(), { kid: 'rotated-away' })), /unknown kid/); + }); + + it('rejects malformed input', async () => { + for (const value of ['', 'not-a-jwt', null, undefined, 42]) { + await assert.rejects(() => verify(value), 'expected rejection for ' + JSON.stringify(value)); + } + }); + + it('requires an https issuer', async () => { + await assert.rejects( + () => + verifyIdentityToken( + sign(claimsFor()), + { issuer: 'http://token.actions.githubusercontent.com', audience: AUDIENCE }, + { getSigningKey: lookupKey, clockTimestamp: NOW_SECONDS } + ), + /https/ + ); + }); + + it('requires an audience', async () => { + await assert.rejects( + () => + verifyIdentityToken( + sign(claimsFor()), + { issuer: ISSUER, audience: '' }, + { getSigningKey: lookupKey, clockTimestamp: NOW_SECONDS } + ), + /audience is required/ + ); + }); +}); From 97e0902bb2b881cf52b22896f6116ac3594edab9 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 14 Aug 2026 13:17:30 -0400 Subject: [PATCH 02/37] feat(security): hdb_oidc_trust table and trust policy operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage and administration for #2171: add_oidc_trust / list_oidc_trust / drop_oidc_trust over a new system.hdb_oidc_trust table, following the three-touchpoint pattern DESIGN.md documents for a new system table (schema entry, SYSTEM_TABLE_NAMES, upgrade directive). The directive is tagged 5.3.0 to match the release that ships these operations — a later tag would never fire on the upgrade path and leave the table missing. A policy names a Harper user and deliberately carries no operation allowlist of its own: least privilege is that user's role, and a second authorization mechanism running alongside roles is one more place for the two to disagree. Notes for review: - add_oidc_trust rejects an issuer's default audience (https://github.com/), which every repository under an owner shares. Accepting it is the one configuration mistake that makes the audience check meaningless. - User existence is checked against the users cache, not findAndValidateUser: with validatePassword false that returns a bare { username } for an unknown user, so it cannot answer the question. - Handlers enforce super_user directly as well as via requiredPermissions, matching secretOperations — a role's `operations` allowlist can otherwise delegate an SU-only operation. - Naming a super_user returns a warning rather than an error. An admin may mean it; it just should not be silent. - The ops join secrets in the MCP DEFAULT_EXCLUDED set. list_oidc_trust matches the `list_*` glob, and the policy set names exactly which repository and workflow are worth compromising. Co-Authored-By: Claude Opus 5 --- components/mcp/tools/operations.ts | 8 + json/systemSchema.json | 38 +++ security/oidcTrust/trustPolicyOperations.ts | 193 ++++++++++++++ security/oidcTrust/types.ts | 10 +- server/serverHelpers/serverUtilities.ts | 10 + .../components/mcp/tools/operations.test.js | 19 +- .../oidcTrust/trustPolicyOperations.test.js | 248 ++++++++++++++++++ upgrade/directives/5-3-0.ts | 84 ++++++ upgrade/directives/directivesController.ts | 3 +- utility/hdbTerms.ts | 4 + utility/operation_authorization.ts | 17 ++ 11 files changed, 623 insertions(+), 11 deletions(-) create mode 100644 security/oidcTrust/trustPolicyOperations.ts create mode 100644 unitTests/security/oidcTrust/trustPolicyOperations.test.js create mode 100644 upgrade/directives/5-3-0.ts diff --git a/components/mcp/tools/operations.ts b/components/mcp/tools/operations.ts index 5572b7950..f8fb1c721 100644 --- a/components/mcp/tools/operations.ts +++ b/components/mcp/tools/operations.ts @@ -144,6 +144,11 @@ export const DEFAULT_ALLOW: readonly string[] = [ * `mcp.operations.allow` (an explicit allow list replaces the default and is * not filtered by this set), where the audit-log redaction of * `value`/`values`/`envelope` applies. + * + * OIDC trust policies (#2171) are excluded on the same grounds and then some: a + * policy grants an external CI workflow the right to authenticate as a Harper + * user, so reading the set tells an attacker exactly which repository and + * workflow to compromise, and writing one is a way to grant itself access. */ export const DEFAULT_EXCLUDED: ReadonlySet = new Set([ 'set_secret', @@ -152,6 +157,9 @@ export const DEFAULT_EXCLUDED: ReadonlySet = new Set([ 'list_secrets', 'delete_secret', 'get_secrets_public_key', + 'add_oidc_trust', + 'list_oidc_trust', + 'drop_oidc_trust', ]); /** diff --git a/json/systemSchema.json b/json/systemSchema.json index 6f44989b5..812c6ea12 100644 --- a/json/systemSchema.json +++ b/json/systemSchema.json @@ -472,5 +472,43 @@ "attribute": "__updatedtime__" } ] + }, + "hdb_oidc_trust": { + "hash_attribute": "id", + "name": "hdb_oidc_trust", + "schema": "system", + "audit": true, + "attributes": [ + { + "attribute": "id" + }, + { + "attribute": "issuer" + }, + { + "attribute": "audience" + }, + { + "attribute": "claims" + }, + { + "attribute": "user" + }, + { + "attribute": "enabled" + }, + { + "attribute": "description" + }, + { + "attribute": "updated_by" + }, + { + "attribute": "__createdtime__" + }, + { + "attribute": "__updatedtime__" + } + ] } } diff --git a/security/oidcTrust/trustPolicyOperations.ts b/security/oidcTrust/trustPolicyOperations.ts new file mode 100644 index 000000000..0077734f1 --- /dev/null +++ b/security/oidcTrust/trustPolicyOperations.ts @@ -0,0 +1,193 @@ +'use strict'; + +// Operations against system.hdb_oidc_trust — the trust policies for OIDC trusted publishing (#2171). +// +// A policy lets an external CI run authenticate as a Harper user without holding any Harper +// credential, so these are super_user only. Following components/secretOperations.ts, super_user is +// enforced in-handler as well as via requiredPermissions: a role's `operations` allowlist can +// otherwise delegate an SU-only operation (see the gate-2 bypass in utility/operation_authorization.ts). +// +// Rows reach peers through normal system-table replication; these operations deliberately do not +// call replicateOperation, which would double-apply on top of it. + +import Joi from 'joi'; +import { databases } from '../../resources/databases.ts'; +import * as terms from '../../utility/hdbTerms.ts'; +import { ClientError, hdbErrors } from '../../utility/errors/hdbError.ts'; +import { validateBySchema } from '../../validation/validationWrapper.ts'; +import { getUsersWithRolesCache } from '../user.ts'; +import { validateTrustPolicyClaims } from './claims.ts'; +import { normalizeIssuer } from './jwks.ts'; +import type { OidcTrustPolicy } from './types.ts'; + +const { HTTP_STATUS_CODES } = hdbErrors; +const OIDC_TRUST_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TRUST_TABLE_NAME; + +/** + * GitHub's default audience is the repository owner's URL, which every repository under that owner + * shares. Accepting it would make a token minted by any repo in the org valid here, which is the one + * mistake this field exists to prevent. + */ +const SHARED_DEFAULT_AUDIENCE = /^https:\/\/github\.com\/[^/]+\/?$/i; + +const POLICY_ID = Joi.string() + .min(1) + .max(128) + .pattern(/^[\w.-]+$/) + .required() + .messages({ 'string.pattern.base': "'id' may contain only letters, numbers, '_', '-', and '.'" }); + +function requireSuperUser(req: any): void { + if (!req?.hdb_user?.role?.permission?.super_user) { + throw new ClientError( + `Operation '${req?.operation}' is restricted to super_user roles`, + HTTP_STATUS_CODES.FORBIDDEN + ); + } +} + +function validate(validation: any): void { + if (validation) throw new ClientError(validation.message); +} + +function trustTable() { + const table = (databases as any).system?.[OIDC_TRUST_TABLE]; + if (!table) { + throw new ClientError( + `OIDC trust policies are not initialized on this node (system.${OIDC_TRUST_TABLE} missing). ` + + `Run upgrade or restart the server to provision the table.` + ); + } + return table; +} + +/** + * Rebuild a plain record from a stored row's known attributes. Never spread rows — RecordObject + * prototype fields don't survive a spread reliably (see DESIGN.md). + */ +function toRecord(row: any): OidcTrustPolicy & Record { + return { + id: row.id, + issuer: row.issuer, + audience: row.audience, + claims: row.claims ?? {}, + user: row.user, + enabled: row.enabled !== false, + description: row.description ?? null, + updated_by: row.updated_by ?? null, + __createdtime__: row.__createdtime__, + __updatedtime__: row.__updatedtime__, + } as any; +} + +/** + * Reads every enabled policy. The set is small and administrator-managed, so a scan is cheaper than + * maintaining an index — and it keeps the matching order deterministic (by id) rather than + * dependent on an index's iteration order. + */ +export async function loadEnabledPolicies(): Promise { + const table = trustTable(); + const policies: OidcTrustPolicy[] = []; + for await (const row of table.search([])) { + if (row.enabled === false) continue; + policies.push(toRecord(row)); + } + policies.sort((a, b) => String(a.id).localeCompare(String(b.id))); + return policies; +} + +/** + * Creates or replaces a trust policy. + * + * Replace rather than merge: a partial update to a claim set is how an over-broad policy gets + * created by accident, and the whole point of `claims` is that every constraint in it was written + * deliberately. + */ +export async function addOidcTrust(req: any) { + requireSuperUser(req); + validate( + validateBySchema( + req, + Joi.object({ + id: POLICY_ID, + issuer: Joi.string().min(1).max(512).required(), + audience: Joi.string().min(1).max(512).required(), + claims: Joi.object().min(1).required(), + user: Joi.string().min(1).max(512).required(), + enabled: Joi.boolean(), + description: Joi.string().allow('').max(1024), + }).unknown(true) + ) + ); + + const issuer = normalizeIssuer(req.issuer); + if (SHARED_DEFAULT_AUDIENCE.test(req.audience)) { + throw new ClientError( + `'audience' must identify this instance, not '${req.audience}' — an issuer's default audience is ` + + `shared by every repository under an owner, so a token minted for any of them would be accepted here. ` + + `Use the instance URL the CI client targets.` + ); + } + // Throws ClientError naming the first structural problem. + validateTrustPolicyClaims(req.claims); + + // Resolve the target user now: a policy pointing at a user that does not exist would fail only at + // exchange time, in CI, with nothing to point at. Read the users cache directly rather than + // findAndValidateUser — with validatePassword false, that returns a bare `{ username }` for an + // unknown user instead of failing, so it cannot answer "does this user exist". + const users = await getUsersWithRolesCache(); + const targetUser = users?.get(req.user); + if (!targetUser) { + throw new ClientError(`No such user '${req.user}'; create the user before granting it to a workflow`); + } + if (targetUser.active === false) { + throw new ClientError(`User '${req.user}' is inactive; a policy naming it could never authenticate`); + } + + const table = trustTable(); + await table.put({ + id: req.id, + issuer, + audience: req.audience, + claims: req.claims, + user: req.user, + enabled: req.enabled !== false, + description: req.description ?? null, + updated_by: req.hdb_user?.username ?? null, + }); + + const result: Record = { message: `Successfully set OIDC trust policy '${req.id}'` }; + // Not an error — an admin may genuinely want this — but a policy that hands super_user to a + // workflow deserves to be said out loud rather than discovered later. + if (targetUser.role?.permission?.super_user) { + result.warning = + `Policy '${req.id}' authenticates as '${req.user}', which is a super_user. Any run matching this ` + + `policy gains full administrative access; consider a user whose role grants only the operations CI needs.`; + } + return result; +} + +export async function listOidcTrust(req: any) { + requireSuperUser(req); + + const table = trustTable(); + const policies: unknown[] = []; + for await (const row of table.search([])) { + policies.push(toRecord(row)); + } + policies.sort((a: any, b: any) => String(a.id).localeCompare(String(b.id))); + return { policies }; +} + +export async function dropOidcTrust(req: any) { + requireSuperUser(req); + validate(validateBySchema(req, Joi.object({ id: POLICY_ID }).unknown(true))); + + const table = trustTable(); + const row = await table.get(req.id); + if (!row) { + throw new ClientError(`No OIDC trust policy found with id '${req.id}'`, HTTP_STATUS_CODES.NOT_FOUND); + } + await table.delete(req.id); + return { message: `Successfully dropped OIDC trust policy '${req.id}'` }; +} diff --git a/security/oidcTrust/types.ts b/security/oidcTrust/types.ts index 9daab86cc..c54a76ebf 100644 --- a/security/oidcTrust/types.ts +++ b/security/oidcTrust/types.ts @@ -23,12 +23,16 @@ export interface OidcTrustPolicy { audience: string; /** Claim constraints, matched against the normalized token claims. */ claims: Record; - /** Harper user the exchanged token authenticates as. */ + /** + * Harper user the exchanged token authenticates as. Least privilege is this user's role — the + * policy deliberately carries no operation allowlist of its own, because a second authorization + * mechanism running alongside roles is one more place for the two to disagree. + */ user: string; - /** When set, the minted token is usable only for these operations. */ - operations?: string[]; /** Defaults to true; false keeps the policy for reference without honoring it. */ enabled?: boolean; + /** Free-text note for whoever reads `list_oidc_trust` a year from now. */ + description?: string; } /** Claims carried by a verified identity token, plus the derived entries normalizeTokenClaims adds. */ diff --git a/server/serverHelpers/serverUtilities.ts b/server/serverHelpers/serverUtilities.ts index 6ee01ef59..5a90e96d5 100644 --- a/server/serverHelpers/serverUtilities.ts +++ b/server/serverHelpers/serverUtilities.ts @@ -41,6 +41,7 @@ import * as status from '../status/index.ts'; import * as regDeprecated from '../../resources/registrationDeprecated.ts'; import * as deploymentOperations from '../../components/deploymentOperations.ts'; import * as secretOperations from '../../components/secretOperations.ts'; +import * as trustPolicyOperations from '../../security/oidcTrust/trustPolicyOperations.ts'; import { contextStorage } from '../../resources/transaction.ts'; import { isMainThread } from 'node:worker_threads'; import { @@ -581,6 +582,15 @@ function initializeOperationFunctionMap(): Map { } }); - it('excludes ALL secret-store operations from the default-allow surface', () => { - // list_secrets matches the `list_*` glob and get_secrets_public_key looks like a safe - // getter, but the secrets store is key custody management — never default-exposed to an - // LLM surface. DEFAULT_EXCLUDED pins every secret op, present and future-glob-matching. + it('excludes ALL credential-management operations from the default-allow surface', () => { + // list_secrets / list_oidc_trust match the `list_*` glob and get_secrets_public_key looks + // like a safe getter, but both stores are credential management — never default-exposed to + // an LLM surface. The secrets store is key custody; an OIDC trust policy grants an external + // workflow the right to authenticate as a Harper user, so reading the set names the + // repository worth compromising and writing one is a way to grant access. + // DEFAULT_EXCLUDED pins each op, present and future-glob-matching. const secretOps = [ 'set_secret', 'grant_secret', @@ -138,15 +141,17 @@ describe('mcp/tools/operations — registration', () => { 'delete_secret', 'get_secrets_public_key', ]; - assert.deepEqual([...DEFAULT_EXCLUDED].sort(), [...secretOps].sort()); + const oidcTrustOps = ['add_oidc_trust', 'list_oidc_trust', 'drop_oidc_trust']; + const credentialOps = [...secretOps, ...oidcTrustOps]; + assert.deepEqual([...DEFAULT_EXCLUDED].sort(), [...credentialOps].sort()); _setOperationFunctionMapForTest( - makeOpMap([...secretOps.map((name) => [name, async () => ({})]), ['list_users', async () => ({})]]) + makeOpMap([...credentialOps.map((name) => [name, async () => ({})]), ['list_users', async () => ({})]]) ); registerOperationsTools(); const { tools } = listTools({ user: SUPER, profile: 'operations', sessionId: 's', limit: 200 }); const names = new Set(tools.map((t) => t.name)); - for (const op of secretOps) { + for (const op of credentialOps) { assert.ok(!names.has(op), `${op} must not be on the default MCP surface`); } assert.ok(names.has('list_users'), 'list_* glob still works for non-excluded ops'); diff --git a/unitTests/security/oidcTrust/trustPolicyOperations.test.js b/unitTests/security/oidcTrust/trustPolicyOperations.test.js new file mode 100644 index 000000000..0b8dd4eea --- /dev/null +++ b/unitTests/security/oidcTrust/trustPolicyOperations.test.js @@ -0,0 +1,248 @@ +'use strict'; + +// Op-flow tests for the hdb_oidc_trust operations, following the secretOperations.test.js pattern: +// a Map-backed mock table on databases.system plus a seeded users cache, so the real handlers run +// without stubs. + +const assert = require('node:assert'); +const testUtils = require('../../testUtils.js'); +testUtils.preTestPrep(); + +const { + addOidcTrust, + listOidcTrust, + dropOidcTrust, + loadEnabledPolicies, +} = require('#src/security/oidcTrust/trustPolicyOperations'); +const { databases } = require('#src/resources/databases'); +const { setUsersWithRolesCache } = require('#src/security/user'); +const terms = require('#src/utility/hdbTerms'); + +const OIDC_TRUST_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TRUST_TABLE_NAME; +const ISSUER = 'https://token.actions.githubusercontent.com'; +const AUDIENCE = 'https://my-instance.harperdb.io:9925/'; + +const VALID_CLAIMS = { + repository_id: '67890', + workflow_ref: 'HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main', +}; + +function installMockTable() { + const rows = new Map(); + const mock = { + rows, + async get(id) { + return rows.get(id); + }, + async put(row) { + rows.set(row.id, row); + }, + async delete(id) { + return rows.delete(id); + }, + search() { + return (async function* () { + yield* rows.values(); + })(); + }, + }; + if (!databases.system) databases.system = {}; + const prior = databases.system[OIDC_TRUST_TABLE]; + databases.system[OIDC_TRUST_TABLE] = mock; + return { + mock, + restore() { + if (databases.system) databases.system[OIDC_TRUST_TABLE] = prior; + }, + }; +} + +function seedUsers() { + const users = new Map(); + users.set('ci-deploy', { + username: 'ci-deploy', + active: true, + role: { role: 'deployer', permission: { super_user: false } }, + }); + users.set('admin', { username: 'admin', active: true, role: { role: 'su', permission: { super_user: true } } }); + users.set('retired', { username: 'retired', active: false, role: { role: 'deployer', permission: {} } }); + return setUsersWithRolesCache(users); +} + +const su = (op, body = {}) => ({ + operation: op, + hdb_user: { username: 'admin', role: { permission: { super_user: true } } }, + ...body, +}); +// A role whose `operations` allowlist names the op: the in-handler check must still refuse. +const delegated = (op, body = {}) => ({ + operation: op, + hdb_user: { username: 'joe', role: { permission: { super_user: false, operations: [op] } } }, + ...body, +}); + +function validPolicy(overrides = {}) { + return { + id: 'my-app-prod', + issuer: ISSUER, + audience: AUDIENCE, + claims: { ...VALID_CLAIMS }, + user: 'ci-deploy', + ...overrides, + }; +} + +describe('oidcTrust trustPolicyOperations', () => { + let installed; + + beforeEach(async () => { + installed = installMockTable(); + await seedUsers(); + }); + + afterEach(() => { + installed.restore(); + }); + + describe('super_user enforcement (in-handler, allowlist-proof)', () => { + const cases = [ + ['add_oidc_trust', addOidcTrust, validPolicy()], + ['list_oidc_trust', listOidcTrust, {}], + ['drop_oidc_trust', dropOidcTrust, { id: 'my-app-prod' }], + ]; + for (const [name, handler, body] of cases) { + it(`${name} refuses a non-super_user even when the role allowlists it`, async () => { + await assert.rejects(() => handler(delegated(name, body)), /restricted to super_user/); + }); + } + }); + + describe('addOidcTrust', () => { + it('stores a policy', async () => { + const result = await addOidcTrust(su('add_oidc_trust', validPolicy())); + assert.match(result.message, /my-app-prod/); + assert.strictEqual(result.warning, undefined); + + const stored = installed.mock.rows.get('my-app-prod'); + assert.strictEqual(stored.issuer, ISSUER); + assert.strictEqual(stored.user, 'ci-deploy'); + assert.strictEqual(stored.enabled, true); + assert.strictEqual(stored.updated_by, 'admin'); + assert.deepStrictEqual(stored.claims, VALID_CLAIMS); + }); + + it('normalizes the issuer so one issuer is one spelling', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy({ issuer: ISSUER + '/' }))); + assert.strictEqual(installed.mock.rows.get('my-app-prod').issuer, ISSUER); + }); + + it('replaces rather than merges, so a narrowed claim set actually narrows', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy({ claims: { ...VALID_CLAIMS, environment: 'staging' } }))); + await addOidcTrust(su('add_oidc_trust', validPolicy())); + assert.deepStrictEqual(installed.mock.rows.get('my-app-prod').claims, VALID_CLAIMS); + }); + + // The default GitHub audience is shared by every repo under an owner; accepting it would make a + // token minted by any of them valid here. + it('rejects an issuer default audience', async () => { + for (const audience of ['https://github.com/HarperFast', 'https://github.com/HarperFast/']) { + await assert.rejects( + () => addOidcTrust(su('add_oidc_trust', validPolicy({ audience }))), + /must identify this instance/ + ); + } + }); + + it('rejects a non-https issuer', async () => { + await assert.rejects( + () => addOidcTrust(su('add_oidc_trust', validPolicy({ issuer: 'http://token.actions.githubusercontent.com' }))), + /https/ + ); + }); + + // Delegated to validateTrustPolicyClaims, which has its own coverage; this asserts the handler + // actually calls it rather than storing whatever it is given. + it('rejects an over-broad claim set', async () => { + await assert.rejects( + () => addOidcTrust(su('add_oidc_trust', validPolicy({ claims: { repository_id: '67890' } }))), + /pin the workflow/ + ); + assert.strictEqual(installed.mock.rows.size, 0, 'expected nothing stored'); + }); + + it('rejects a policy naming a user that does not exist', async () => { + await assert.rejects( + () => addOidcTrust(su('add_oidc_trust', validPolicy({ user: 'ghost' }))), + /No such user 'ghost'/ + ); + }); + + it('rejects a policy naming an inactive user', async () => { + await assert.rejects(() => addOidcTrust(su('add_oidc_trust', validPolicy({ user: 'retired' }))), /inactive/); + }); + + it('warns when the policy hands a workflow super_user', async () => { + const result = await addOidcTrust(su('add_oidc_trust', validPolicy({ user: 'admin' }))); + assert.match(result.warning, /super_user/); + // Still stored — an admin may mean it; it just should not be silent. + assert.strictEqual(installed.mock.rows.get('my-app-prod').user, 'admin'); + }); + + it('rejects a malformed id', async () => { + for (const id of ['', 'has spaces', 'has/slash', 'x'.repeat(129)]) { + await assert.rejects(() => addOidcTrust(su('add_oidc_trust', validPolicy({ id })))); + } + }); + }); + + describe('listOidcTrust', () => { + it('returns policies sorted by id', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy({ id: 'zulu' }))); + await addOidcTrust(su('add_oidc_trust', validPolicy({ id: 'alpha' }))); + const { policies } = await listOidcTrust(su('list_oidc_trust')); + assert.deepStrictEqual( + policies.map((policy) => policy.id), + ['alpha', 'zulu'] + ); + }); + + it('includes disabled policies', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy({ enabled: false }))); + const { policies } = await listOidcTrust(su('list_oidc_trust')); + assert.strictEqual(policies.length, 1); + assert.strictEqual(policies[0].enabled, false); + }); + }); + + describe('dropOidcTrust', () => { + it('removes a policy', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy())); + const result = await dropOidcTrust(su('drop_oidc_trust', { id: 'my-app-prod' })); + assert.match(result.message, /my-app-prod/); + assert.strictEqual(installed.mock.rows.size, 0); + }); + + it('reports a missing policy as not found', async () => { + await assert.rejects( + () => dropOidcTrust(su('drop_oidc_trust', { id: 'never-existed' })), + (error) => { + assert.strictEqual(error.statusCode, 404); + return true; + } + ); + }); + }); + + describe('loadEnabledPolicies', () => { + it('skips disabled policies and sorts by id', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy({ id: 'zulu' }))); + await addOidcTrust(su('add_oidc_trust', validPolicy({ id: 'alpha' }))); + await addOidcTrust(su('add_oidc_trust', validPolicy({ id: 'disabled', enabled: false }))); + const policies = await loadEnabledPolicies(); + assert.deepStrictEqual( + policies.map((policy) => policy.id), + ['alpha', 'zulu'] + ); + }); + }); +}); diff --git a/upgrade/directives/5-3-0.ts b/upgrade/directives/5-3-0.ts new file mode 100644 index 000000000..cf605e894 --- /dev/null +++ b/upgrade/directives/5-3-0.ts @@ -0,0 +1,84 @@ +'use strict'; + +// 5.3.0 — introduces system.hdb_oidc_trust for OIDC trusted publishing (#2171). +// +// Fresh installs get the table automatically via utility/mount_hdb.ts (which iterates +// json/systemSchema.json on first boot). This directive handles the upgrade path: existing +// installs that already have a system schema need the new table added explicitly. +// +// IMPORTANT: this directive must be versioned to the first release that ships the trust-policy +// operations depending on the table. Directives only run when +// current_version < directive_version <= upgrade_version (see +// directivesController.getVersionsForUpgrade), so tagging it for a later release than the +// dependent code means it never fires and the table is missing on upgraded installs — +// exchange_oidc_token would then fail on every node that upgraded rather than installed fresh +// (see the mis-tagging history documented in 5-1-0.ts). + +import { databases } from '../../resources/databases.ts'; +import systemSchema from '../../json/systemSchema.json'; +import * as terms from '../../utility/hdbTerms.ts'; +import * as initPaths from '../../dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js'; +import bridge from '../../dataLayer/harperBridge/harperBridge.ts'; +import hdbLogger from '../../utility/logging/harper_logger.ts'; + +const OIDC_TRUST_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TRUST_TABLE_NAME; + +async function createHdbOidcTrustIfMissing() { + if (databases.system?.[OIDC_TRUST_TABLE]) { + hdbLogger.info(`system.${OIDC_TRUST_TABLE} already exists; skipping create.`); + await patchHdbOidcTrustIsHashAttribute(); + return; + } + + hdbLogger.info(`Creating system.${OIDC_TRUST_TABLE} table for OIDC trusted publishing.`); + + const CreateTableObject = + require('../../dataLayer/CreateTableObject').default || require('../../dataLayer/CreateTableObject'); + const schema = (systemSchema as any)[OIDC_TRUST_TABLE]; + if (!schema) { + throw new Error(`systemSchema.${OIDC_TRUST_TABLE} is missing; cannot run 5.3.0 directive.`); + } + + initPaths.initSystemSchemaPaths(terms.SYSTEM_SCHEMA_NAME, OIDC_TRUST_TABLE); + const createTable = new (CreateTableObject as any)(terms.SYSTEM_SCHEMA_NAME, OIDC_TRUST_TABLE, schema.hash_attribute); + createTable.attributes = schema.attributes; + const primaryKeyAttribute = createTable.attributes.find(({ attribute }) => attribute === schema.hash_attribute); + if (primaryKeyAttribute) primaryKeyAttribute.isPrimaryKey = true; + // Must match `"audit": true` in systemSchema.json, or the fresh-install and upgrade paths diverge. + createTable.audit = true; + + await bridge.createTable(OIDC_TRUST_TABLE, createTable); + await patchHdbOidcTrustIsHashAttribute(); +} + +/** + * Ensure the hdb_oidc_trust __dbis__ primary-key entry carries is_hash_attribute: true. + * + * harperdb@4.x reads is_hash_attribute from __dbis__ to derive the LMDB DBI open flags; without it + * the DBI is opened with the opposite flags (DUPSORT set) and LMDB throws MDB_INCOMPATIBLE, breaking + * downgrade — the same guard 5-1-0.ts and 5-2-0.ts apply to their tables. Idempotent: no-op when the + * field is already set. + */ +async function patchHdbOidcTrustIsHashAttribute() { + const systemTable = (databases as any).system?.[OIDC_TRUST_TABLE]; + if (!systemTable?.dbisDB) return; + + const dbiName = `${OIDC_TRUST_TABLE}/`; + const primaryAttr = systemTable.dbisDB.getSync(dbiName); + if (!primaryAttr || primaryAttr.is_hash_attribute) return; // already correct + + primaryAttr.is_hash_attribute = true; + await systemTable.dbisDB.put(dbiName, primaryAttr); + hdbLogger.info( + `Patched system.${OIDC_TRUST_TABLE} __dbis__ entry with is_hash_attribute=true for harperdb@4.x downgrade compatibility.` + ); +} + +const directive530 = { + version: '5.3.0', + description: 'create system.hdb_oidc_trust table for OIDC trusted publishing', + sync_functions: [] as Array<() => unknown>, + async_functions: [createHdbOidcTrustIfMissing] as Array<() => Promise>, +}; + +export default [directive530]; diff --git a/upgrade/directives/directivesController.ts b/upgrade/directives/directivesController.ts index 4674e0923..ed1775fbf 100644 --- a/upgrade/directives/directivesController.ts +++ b/upgrade/directives/directivesController.ts @@ -11,13 +11,14 @@ import * as hdbTerms from '../../utility/hdbTerms.ts'; import hdbLog from '../../utility/logging/harper_logger.ts'; import directive510 from './5-1-0.ts'; import directive520 from './5-2-0.ts'; +import directive530 from './5-3-0.ts'; const { DATA_VERSION, UPGRADE_VERSION } = hdbTerms.UPGRADE_JSON_FIELD_NAMES_ENUM as any; let versions: any = new Map(); // All directive modules export an array of { version, sync_functions, async_functions }. // New directives must be imported above and registered here. -for (const directive of [...directive510, ...directive520]) { +for (const directive of [...directive510, ...directive520, ...directive530]) { versions.set(directive.version, directive); } diff --git a/utility/hdbTerms.ts b/utility/hdbTerms.ts index 1c9ab2141..d57eb4fb7 100644 --- a/utility/hdbTerms.ts +++ b/utility/hdbTerms.ts @@ -211,6 +211,7 @@ export const SYSTEM_TABLE_NAMES = { DEPLOYMENT_TABLE_NAME: 'hdb_deployment', AGENT_SESSION_TABLE_NAME: 'hdb_agent_session', SECRET_TABLE_NAME: 'hdb_secret', + OIDC_TRUST_TABLE_NAME: 'hdb_oidc_trust', } as const; /** Hash attribute for the system info table */ @@ -348,6 +349,9 @@ export const OPERATIONS_ENUM = { LIST_SECRETS: 'list_secrets', DELETE_SECRET: 'delete_secret', GET_SECRETS_PUBLIC_KEY: 'get_secrets_public_key', + ADD_OIDC_TRUST: 'add_oidc_trust', + LIST_OIDC_TRUST: 'list_oidc_trust', + DROP_OIDC_TRUST: 'drop_oidc_trust', GET_DEPLOYMENT_PAYLOAD: 'get_deployment_payload', DELETE_DEPLOYMENT_PAYLOAD: 'delete_deployment_payload', AGENT_PROMPT: 'agent_prompt', diff --git a/utility/operation_authorization.ts b/utility/operation_authorization.ts index 148fe26bf..e1a763e42 100644 --- a/utility/operation_authorization.ts +++ b/utility/operation_authorization.ts @@ -47,6 +47,7 @@ import { handleHDBError, hdbErrors } from '../utility/errors/hdbError.ts'; import * as regDeprecated from '../resources/registrationDeprecated.ts'; import * as deploymentOperations from '../components/deploymentOperations.ts'; import * as secretOperations from '../components/secretOperations.ts'; +import * as trustPolicyOperations from '../security/oidcTrust/trustPolicyOperations.ts'; const requiredPermissions = new Map(); const DELETE_PERM = 'delete'; @@ -359,6 +360,22 @@ requiredPermissions.set( new (permission as any)(true, [], terms.OPERATIONS_ENUM.GET_SECRETS_PUBLIC_KEY) ); +// OIDC trust policies (#2171). A policy lets an external CI run authenticate as a Harper user, so +// these are SU-only; the handlers ALSO enforce super_user directly so they cannot be delegated +// through a role's `operations` allowlist (gate-2 bypass below). +requiredPermissions.set( + trustPolicyOperations.addOidcTrust.name, + new (permission as any)(true, [], terms.OPERATIONS_ENUM.ADD_OIDC_TRUST) +); +requiredPermissions.set( + trustPolicyOperations.listOidcTrust.name, + new (permission as any)(true, [], terms.OPERATIONS_ENUM.LIST_OIDC_TRUST) +); +requiredPermissions.set( + trustPolicyOperations.dropOidcTrust.name, + new (permission as any)(true, [], terms.OPERATIONS_ENUM.DROP_OIDC_TRUST) +); + //Below are functions that are currently open to all roles requiredPermissions.set(regDeprecated.getRegistrationInfo.name, new (permission as any)(false, [])); requiredPermissions.set(user.userInfo.name, new (permission as any)(false, [], terms.OPERATIONS_ENUM.USER_INFO)); From 63e026c2a31d131ee9e2f94169a40626f7d36d84 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 14 Aug 2026 13:29:20 -0400 Subject: [PATCH 03/37] =?UTF-8?q?feat(security):=20exchange=5Foidc=5Ftoken?= =?UTF-8?q?=20=E2=80=94=20mint=20a=20token=20from=20a=20CI=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the server half of #2171. A runner posts its provider's identity token; if it verifies against an enabled trust policy, Harper returns a one-hour operation token for the user that policy names. The operation is unauthenticated because it *is* the authentication, the same way create_authentication_tokens is against a password. createOperationToken is new in tokenAuthentication.ts because createTokens could not be used: it overwrites hdb_user.refresh_token as a side effect, so minting for CI would silently revoke whatever credential that user already held (#2018) — the exact problem this feature exists to remove. Notes for review: - Every rejection returns the same message and status; the reason goes to the log. The endpoint is unauthenticated, so a caller told which check failed can enumerate a policy one claim at a time. - Replay: hdb_oidc_token_use records issuer|jti with expiresAt set past the token's own expiry, so it stays proportional to in-flight tokens. The get-then-put is not atomic and does not pretend to be — see the comment on getTokenUseTable for why the concurrent race is tolerable (it is not a privilege escalation) and what it does stop. - The use is recorded *before* minting. A failure after recording costs a CI re-run; the reverse ordering would leave a spendable token behind. - The user is resolved before the token is spent, so a policy naming a deleted or deactivated user fails without burning a token the runner cannot re-mint. Deactivating a user stops its workflows. - Policy selection iterates enabled policies for the token's issuer, first match by id. Signature verification is memoized per audience so N policies sharing one audience cost one verification. Co-Authored-By: Claude Opus 5 --- components/mcp/tools/operations.ts | 1 + security/oidcTrust/tokenExchange.ts | 205 +++++++++++ security/tokenAuthentication.ts | 26 ++ server/serverHelpers/serverHandlers.js | 3 + server/serverHelpers/serverUtilities.ts | 10 +- .../components/mcp/tools/operations.test.js | 4 +- .../security/oidcTrust/tokenExchange.test.js | 331 ++++++++++++++++++ utility/hdbTerms.ts | 1 + utility/operation_authorization.ts | 7 + 9 files changed, 586 insertions(+), 2 deletions(-) create mode 100644 security/oidcTrust/tokenExchange.ts create mode 100644 unitTests/security/oidcTrust/tokenExchange.test.js diff --git a/components/mcp/tools/operations.ts b/components/mcp/tools/operations.ts index f8fb1c721..e6d8ea04e 100644 --- a/components/mcp/tools/operations.ts +++ b/components/mcp/tools/operations.ts @@ -160,6 +160,7 @@ export const DEFAULT_EXCLUDED: ReadonlySet = new Set([ 'add_oidc_trust', 'list_oidc_trust', 'drop_oidc_trust', + 'exchange_oidc_token', ]); /** diff --git a/security/oidcTrust/tokenExchange.ts b/security/oidcTrust/tokenExchange.ts new file mode 100644 index 000000000..3fe5b66a8 --- /dev/null +++ b/security/oidcTrust/tokenExchange.ts @@ -0,0 +1,205 @@ +'use strict'; + +// exchange_oidc_token — the unauthenticated half of OIDC trusted publishing (#2171). +// +// A CI runner presents an identity token minted by its provider. If the token verifies against a +// stored trust policy, Harper mints a short-lived operation token for the user that policy names. +// This is the only unauthenticated operation that yields a credential, so it fails closed and tells +// the caller as little as possible: every rejection is the same message, and the reason goes to the +// log. A caller who learns *which* check failed can enumerate a policy one claim at a time. + +import jwt from 'jsonwebtoken'; +import Joi from 'joi'; +import { databases, table, type Table } from '../../resources/databases.ts'; +import { ClientError } from '../../utility/errors/hdbError.ts'; +import { validateBySchema } from '../../validation/validationWrapper.ts'; +import { loggerWithTag } from '../../utility/logging/logger.ts'; +import { getUsersWithRolesCache } from '../user.ts'; +import { createOperationToken } from '../tokenAuthentication.ts'; +import { verifyIdentityToken } from './index.ts'; +import { matchTrustPolicyClaims } from './claims.ts'; +import { normalizeIssuer } from './jwks.ts'; +import { loadEnabledPolicies } from './trustPolicyOperations.ts'; +import type { OidcTrustPolicy, TokenClaims } from './types.ts'; + +const logger = loggerWithTag('oidc-trust'); + +/** + * Lifetime of the minted operation token. Long enough to cover a slow deploy without the client + * re-authenticating mid-run, short enough that the credential is worthless by the time it could + * surface in a log. Compare the 30-day refresh token this replaces. + */ +const EXCHANGED_TOKEN_LIFETIME_SECONDS = 3600; + +/** Padding on the replay record so it outlives the token by more than the verifier's clock leeway. */ +const REPLAY_RECORD_PADDING_MS = 120_000; + +/** Bounds the token we are willing to even parse; real identity tokens are ~1-2 KB. */ +const MAX_TOKEN_LENGTH = 8192; + +const TOKEN_USE_TABLE = 'hdb_oidc_token_use'; + +/** + * Records which identity tokens have been spent, keyed by issuer and `jti`. Rows expire with the + * token itself (`expiresAt`), so the table stays proportional to in-flight tokens rather than to + * deploy history. Replicated like other system tables, which extends the check across the cluster — + * though replication is asynchronous, so two truly simultaneous replays against different nodes can + * still both land. That race is not a privilege escalation: whoever holds the token could obtain one + * operation token regardless. What this stops is the realistic case — a token that leaks after a + * legitimate run and is reused while still inside its window. + */ +function getTokenUseTable(): any { + // table() both creates and registers into `databases.system`, so the lookup finds it on every + // call after the first. Untyped at the call sites, matching components/secretOperations.ts: the + // typed `put` overload takes an explicit target, and these callers use the record form. + return ( + (databases as any).system?.[TOKEN_USE_TABLE] ?? + table({ + table: TOKEN_USE_TABLE, + database: 'system', + attributes: [ + { name: 'id', isPrimaryKey: true }, + { name: 'policy_id' }, + { name: 'used_at' }, + { name: 'expiresAt', expiresAt: true, indexed: true }, + ], + }) + ); +} + +/** Every rejection looks identical to the caller; the reason is for the operator reading the log. */ +function rejectExchange(detail: string): never { + logger.warn?.(`Rejecting OIDC token exchange: ${detail}`); + throw new ClientError('Identity token was rejected', 401); +} + +function describeRun(claims: TokenClaims): string { + const parts = [ + claims.repository, + claims.workflow_ref ?? claims.workflow_path, + claims.environment && `environment=${claims.environment}`, + claims.run_id && `run=${claims.run_id}`, + claims.actor && `actor=${claims.actor}`, + ]; + return parts.filter(Boolean).join(' '); +} + +/** + * Verifies each candidate policy's audience at most once. Policies for one instance normally share + * an audience, so this is usually a single verification; grouping keeps it that way rather than + * re-verifying the signature per policy. + */ +async function findMatchingPolicy( + token: string, + issuer: string, + policies: OidcTrustPolicy[] +): Promise<{ policy: OidcTrustPolicy; claims: TokenClaims } | undefined> { + const verifiedByAudience = new Map(); + + for (const policy of policies) { + if (!verifiedByAudience.has(policy.audience)) { + try { + verifiedByAudience.set( + policy.audience, + await verifyIdentityToken(token, { issuer, audience: policy.audience }) + ); + } catch (error) { + // verifyIdentityToken already logged the reason. + verifiedByAudience.set(policy.audience, undefined); + void error; + } + } + const claims = verifiedByAudience.get(policy.audience); + if (!claims) continue; + + const mismatch = matchTrustPolicyClaims(claims, policy.claims); + if (mismatch) { + logger.debug?.(`Trust policy '${policy.id}' did not match: ${mismatch}`); + continue; + } + return { policy, claims }; + } + return undefined; +} + +/** + * Marks an identity token as spent, rejecting one already recorded. + * + * Recorded before the token is minted, not after: if minting fails the credential is burned, which + * costs a CI re-run. The reverse ordering would let a failure leave a spendable token behind. + * + * The get-then-put is not atomic. Harper's optimistic concurrency may serialize it in practice, but + * this deliberately does not depend on that — see getTokenUseTable for why the race is tolerable. + */ +async function recordTokenUse(issuer: string, claims: TokenClaims, policyId: string): Promise { + const useTable = getTokenUseTable(); + const id = `${issuer}|${claims.jti}`; + if (await useTable.get(id)) rejectExchange(`token ${claims.jti} has already been exchanged`); + + await useTable.put({ + id, + policy_id: policyId, + used_at: Date.now(), + expiresAt: (claims.exp as number) * 1000 + REPLAY_RECORD_PADDING_MS, + }); +} + +/** + * Exchanges a CI identity token for a short-lived Harper operation token. + * + * Unauthenticated by design — this operation *is* the authentication, the same way + * create_authentication_tokens is. + */ +export async function exchangeOidcToken(req: any) { + const validation = validateBySchema( + req, + Joi.object({ token: Joi.string().min(1).max(MAX_TOKEN_LENGTH).required() }).unknown(true) + ); + if (validation) throw new ClientError(validation.message); + + // Read `iss` without verifying, only to select candidate policies. Nothing is trusted from this + // decode: the issuer it names must match a stored policy, and the signature is then checked + // against that policy's issuer. + const unverified = jwt.decode(req.token, { complete: true }); + let issuer: string; + try { + issuer = normalizeIssuer((unverified?.payload as any)?.iss); + } catch { + rejectExchange('token has no usable iss claim'); + } + + const policies = (await loadEnabledPolicies()).filter((policy) => policy.issuer === issuer); + if (policies.length === 0) rejectExchange(`no enabled trust policy for issuer ${issuer}`); + + const matched = await findMatchingPolicy(req.token, issuer, policies); + if (!matched) rejectExchange(`no trust policy matched a token from ${issuer}`); + const { policy, claims } = matched; + + // Resolve the user before spending the token, so a policy pointing at a deleted or deactivated + // user fails without burning a token the runner cannot re-mint. + const users = await getUsersWithRolesCache(); + const user = users?.get(policy.user); + if (!user) rejectExchange(`trust policy '${policy.id}' names user '${policy.user}', which does not exist`); + if (user.active === false) rejectExchange(`trust policy '${policy.id}' names inactive user '${policy.user}'`); + + await recordTokenUse(issuer, claims, policy.id); + + const operationToken = await createOperationToken( + { username: user.username, super_user: user.role?.permission?.super_user === true }, + EXCHANGED_TOKEN_LIFETIME_SECONDS + ); + + // The audit trail for a credential handed to an external system: which policy, which user, and + // which run presented the token. + // TODO(#2171): route this through AuthAuditLog once the operation handler has request context. + logger.info?.( + `OIDC exchange: policy '${policy.id}' authenticated '${user.username}' for ${describeRun(claims)} (jti ${claims.jti})` + ); + + return { + operation_token: operationToken, + expires_in: EXCHANGED_TOKEN_LIFETIME_SECONDS, + username: user.username, + policy: policy.id, + }; +} diff --git a/security/tokenAuthentication.ts b/security/tokenAuthentication.ts index 0191f906a..c724d97d9 100644 --- a/security/tokenAuthentication.ts +++ b/security/tokenAuthentication.ts @@ -246,6 +246,32 @@ export async function refreshOperationToken(tokenObj: TokenObject): Promise { + const keys: JWTRSAKeys = await getJWTRSAKeys(); + return jwt.sign( + { username: user.username, super_user: user.super_user }, + { key: keys.privateKey, passphrase: keys.passphrase } satisfies Secret, + { + expiresIn, + algorithm: RSA_ALGORITHM, + subject: TOKEN_TYPE.OPERATION, + } satisfies SignOptions + ); +} + export async function validateOperationToken(token: string): Promise { return validateToken(token, TOKEN_TYPE.OPERATION); } diff --git a/server/serverHelpers/serverHandlers.js b/server/serverHelpers/serverHandlers.js index f38dd65f5..4d621f114 100644 --- a/server/serverHelpers/serverHandlers.js +++ b/server/serverHelpers/serverHandlers.js @@ -41,6 +41,9 @@ const NO_AUTH_OPERATIONS = [ terms.OPERATIONS_ENUM.CREATE_AUTHENTICATION_TOKENS, terms.OPERATIONS_ENUM.LOGIN, terms.OPERATIONS_ENUM.LOGOUT, + // The OIDC exchange *is* the authentication (#2171): the caller proves identity with a token + // from a trusted issuer, not with Harper credentials it does not have. + terms.OPERATIONS_ENUM.EXCHANGE_OIDC_TOKEN, ]; const UNSAFE_REQUEST_BODY_PROPERTIES = ['__proto__', 'constructor', 'prototype']; diff --git a/server/serverHelpers/serverUtilities.ts b/server/serverHelpers/serverUtilities.ts index 5a90e96d5..2998ffcae 100644 --- a/server/serverHelpers/serverUtilities.ts +++ b/server/serverHelpers/serverUtilities.ts @@ -42,6 +42,7 @@ import * as regDeprecated from '../../resources/registrationDeprecated.ts'; import * as deploymentOperations from '../../components/deploymentOperations.ts'; import * as secretOperations from '../../components/secretOperations.ts'; import * as trustPolicyOperations from '../../security/oidcTrust/trustPolicyOperations.ts'; +import * as tokenExchange from '../../security/oidcTrust/tokenExchange.ts'; import { contextStorage } from '../../resources/transaction.ts'; import { isMainThread } from 'node:worker_threads'; import { @@ -256,7 +257,10 @@ export function chooseOperation(json: OperationRequestBody, bypassAuth = false) !bypassAuth && json.operation !== terms.OPERATIONS_ENUM.CREATE_AUTHENTICATION_TOKENS && json.operation !== terms.OPERATIONS_ENUM.LOGIN && - json.operation !== terms.OPERATIONS_ENUM.LOGOUT + json.operation !== terms.OPERATIONS_ENUM.LOGOUT && + // Same rationale: the OIDC exchange authenticates its own caller (#2171), so there is no + // hdb_user for verifyPerms to check against. + json.operation !== terms.OPERATIONS_ENUM.EXCHANGE_OIDC_TOKEN ) { const functionToCheck = job_operation_function === undefined ? operation_function : job_operation_function; const operation_json = json.search_operation ? json.search_operation : json; @@ -591,6 +595,10 @@ function initializeOperationFunctionMap(): Map { 'delete_secret', 'get_secrets_public_key', ]; - const oidcTrustOps = ['add_oidc_trust', 'list_oidc_trust', 'drop_oidc_trust']; + // exchange_oidc_token matches no DEFAULT_ALLOW glob today, but it is the operation that hands + // out a credential — pinned here so a future glob cannot quietly pull it onto the surface. + const oidcTrustOps = ['add_oidc_trust', 'list_oidc_trust', 'drop_oidc_trust', 'exchange_oidc_token']; const credentialOps = [...secretOps, ...oidcTrustOps]; assert.deepEqual([...DEFAULT_EXCLUDED].sort(), [...credentialOps].sort()); diff --git a/unitTests/security/oidcTrust/tokenExchange.test.js b/unitTests/security/oidcTrust/tokenExchange.test.js new file mode 100644 index 000000000..72815cc2f --- /dev/null +++ b/unitTests/security/oidcTrust/tokenExchange.test.js @@ -0,0 +1,331 @@ +'use strict'; + +// End-to-end tests for exchange_oidc_token: a real signed identity token, served through a real +// JWKS fetch, verified and matched against real trust policies, exchanged for a real operation +// token. Only two things are displaced — the network (a fetch responder) and the two system tables +// (Map-backed mocks on databases.system). + +const assert = require('node:assert'); +const testUtils = require('../../testUtils.js'); +testUtils.preTestPrep(); + +const fs = require('node:fs'); +const path = require('node:path'); +const jwt = require('jsonwebtoken'); +const { generateKeyPairSync, createPublicKey } = require('node:crypto'); +const { exchangeOidcToken } = require('#src/security/oidcTrust/tokenExchange'); +const { addOidcTrust } = require('#src/security/oidcTrust/trustPolicyOperations'); +const { clearJwksCache } = require('#src/security/oidcTrust/jwks'); +const { validateOperationToken, clearJWTRSAKeysCache, decodeJWT } = require('#src/security/tokenAuthentication'); +const { databases } = require('#src/resources/databases'); +const { setUsersWithRolesCache } = require('#src/security/user'); +const env = require('#src/utility/environment/environmentManager'); +const terms = require('#src/utility/hdbTerms'); + +const TRUST_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TRUST_TABLE_NAME; +const TOKEN_USE_TABLE = 'hdb_oidc_token_use'; +const ISSUER = 'https://token.actions.githubusercontent.com'; +const JWKS_URI = ISSUER + '/.well-known/jwks'; +const DISCOVERY_URI = ISSUER + '/.well-known/openid-configuration'; +const AUDIENCE = 'https://my-instance.harperdb.io:9925/'; +const WORKFLOW_REF = 'HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main'; +const OIDC_KID = 'gh-signing-key'; + +let issuerPrivateKey; +let signingJwk; + +/** Writes the RSA keys createOperationToken signs with into the isolated test base path. */ +function installJwtSigningKeys() { + const passphrase = 'test-passphrase'; + const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem', cipher: 'aes-256-cbc', passphrase }, + }); + const keysDir = path.join(env.getHdbBasePath(), terms.LICENSE_KEY_DIR_NAME); + fs.mkdirSync(keysDir, { recursive: true }); + fs.writeFileSync(path.join(keysDir, terms.JWT_ENUM.JWT_PRIVATE_KEY_NAME), privateKey); + fs.writeFileSync(path.join(keysDir, terms.JWT_ENUM.JWT_PUBLIC_KEY_NAME), publicKey); + fs.writeFileSync(path.join(keysDir, terms.JWT_ENUM.JWT_PASSPHRASE_NAME), passphrase); + clearJWTRSAKeysCache(); +} + +function installMockTable(name, primaryKey) { + const rows = new Map(); + const mock = { + rows, + async get(id) { + return rows.get(id); + }, + async put(row) { + rows.set(row[primaryKey], row); + }, + async delete(id) { + return rows.delete(id); + }, + search() { + return (async function* () { + yield* rows.values(); + })(); + }, + }; + if (!databases.system) databases.system = {}; + const prior = databases.system[name]; + databases.system[name] = mock; + return { mock, restore: () => (databases.system[name] = prior) }; +} + +function seedUsers() { + const users = new Map(); + users.set('ci-deploy', { + username: 'ci-deploy', + active: true, + role: { role: 'deployer', permission: { super_user: false } }, + }); + users.set('admin', { username: 'admin', active: true, role: { role: 'su', permission: { super_user: true } } }); + users.set('retired', { username: 'retired', active: false, role: { role: 'deployer', permission: {} } }); + return setUsersWithRolesCache(users); +} + +const asAdmin = (body) => ({ + operation: 'add_oidc_trust', + hdb_user: { username: 'admin', role: { permission: { super_user: true } } }, + ...body, +}); + +describe('exchangeOidcToken', () => { + let trustTable; + let useTable; + let realFetch; + let tokenCounter = 0; + + before(() => { + installJwtSigningKeys(); + const pair = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + issuerPrivateKey = pair.privateKey; + signingJwk = { + ...createPublicKey(pair.publicKey).export({ format: 'jwk' }), + kid: OIDC_KID, + use: 'sig', + alg: 'RS256', + }; + }); + + beforeEach(async () => { + clearJwksCache(); + trustTable = installMockTable(TRUST_TABLE, 'id'); + useTable = installMockTable(TOKEN_USE_TABLE, 'id'); + await seedUsers(); + realFetch = globalThis.fetch; + globalThis.fetch = async (url) => + new Response( + JSON.stringify(String(url) === DISCOVERY_URI ? { issuer: ISSUER, jwks_uri: JWKS_URI } : { keys: [signingJwk] }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + trustTable.restore(); + useTable.restore(); + }); + + function identityToken(overrides = {}) { + const now = Math.floor(Date.now() / 1000); + return jwt.sign( + { + iss: ISSUER, + aud: AUDIENCE, + sub: 'repo:HarperFast/my-app:environment:production', + jti: `token-${++tokenCounter}`, + iat: now, + exp: now + 300, + repository: 'HarperFast/my-app', + repository_id: '67890', + repository_owner_id: '12345', + workflow_ref: WORKFLOW_REF, + environment: 'production', + ref: 'refs/heads/main', + event_name: 'push', + runner_environment: 'github-hosted', + run_id: '99', + actor: 'octocat', + ...overrides, + }, + issuerPrivateKey, + { algorithm: 'RS256', header: { alg: 'RS256', kid: OIDC_KID } } + ); + } + + function addPolicy(overrides = {}) { + return addOidcTrust( + asAdmin({ + id: 'my-app-prod', + issuer: ISSUER, + audience: AUDIENCE, + claims: { repository_id: '67890', workflow_ref: WORKFLOW_REF }, + user: 'ci-deploy', + ...overrides, + }) + ); + } + + async function assertRejected(promise) { + await assert.rejects(promise, (error) => { + assert.strictEqual(error.statusCode, 401); + // One message for every failure: a caller told which check failed can enumerate a policy. + assert.strictEqual(error.message, 'Identity token was rejected'); + return true; + }); + } + + it('exchanges a matching token for a usable operation token', async () => { + await addPolicy(); + const result = await exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() }); + + assert.strictEqual(result.username, 'ci-deploy'); + assert.strictEqual(result.policy, 'my-app-prod'); + assert.strictEqual(result.expires_in, 3600); + + const user = await validateOperationToken(result.operation_token); + assert.strictEqual(user.username, 'ci-deploy'); + }); + + // The whole point of trusted publishing: CI ends up holding nothing durable, and the user's + // existing refresh credential is not rotated out from under whoever holds it (#2018). + it('mints no refresh token', async () => { + await addPolicy(); + const result = await exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() }); + assert.strictEqual(result.refresh_token, undefined); + assert.ok(decodeJWT(result.operation_token).exp, 'expected a bounded lifetime'); + }); + + it('refuses to spend the same token twice', async () => { + await addPolicy(); + const token = identityToken(); + await exchangeOidcToken({ operation: 'exchange_oidc_token', token }); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token })); + }); + + it('records the spent token against the policy, expiring with the token', async () => { + await addPolicy(); + const token = identityToken(); + await exchangeOidcToken({ operation: 'exchange_oidc_token', token }); + + const [record] = [...useTable.mock.rows.values()]; + assert.strictEqual(record.policy_id, 'my-app-prod'); + const tokenExpiryMs = decodeJWT(token).exp * 1000; + assert.ok(record.expiresAt > tokenExpiryMs, 'record must outlive the token it guards'); + }); + + it('rejects a token minted for a different audience', async () => { + await addPolicy(); + await assertRejected( + exchangeOidcToken({ + operation: 'exchange_oidc_token', + token: identityToken({ aud: 'https://github.com/HarperFast' }), + }) + ); + }); + + it('rejects a run from a branch the policy does not name', async () => { + await addPolicy(); + await assertRejected( + exchangeOidcToken({ + operation: 'exchange_oidc_token', + token: identityToken({ + workflow_ref: 'HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/attacker', + ref: 'refs/heads/attacker', + }), + }) + ); + }); + + it('rejects a run from a different repository', async () => { + await addPolicy(); + await assertRejected( + exchangeOidcToken({ + operation: 'exchange_oidc_token', + token: identityToken({ repository_id: '11111', repository: 'attacker/evil' }), + }) + ); + }); + + it('rejects when no policy exists for the issuer', async () => { + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() })); + }); + + it('ignores a disabled policy', async () => { + await addPolicy({ enabled: false }); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() })); + }); + + it('selects the first matching policy by id when several match', async () => { + await addPolicy({ id: 'zulu', user: 'admin' }); + await addPolicy({ id: 'alpha', user: 'ci-deploy' }); + const result = await exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() }); + assert.strictEqual(result.policy, 'alpha'); + assert.strictEqual(result.username, 'ci-deploy'); + }); + + it('rejects a policy naming a user that no longer exists, without spending the token', async () => { + await addPolicy(); + await setUsersWithRolesCache(new Map()); + const token = identityToken(); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token })); + assert.strictEqual(useTable.mock.rows.size, 0, 'a token the runner cannot re-mint must not be burned'); + }); + + // add_oidc_trust refuses an inactive user outright, so the case that reaches here is a user + // deactivated after the policy was written — deactivating a user must stop its workflows. + it('rejects a policy whose user has since been deactivated', async () => { + await addPolicy(); + const users = new Map(); + users.set('ci-deploy', { username: 'ci-deploy', active: false, role: { role: 'deployer', permission: {} } }); + await setUsersWithRolesCache(users); + + const token = identityToken(); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token })); + assert.strictEqual(useTable.mock.rows.size, 0, 'a token the runner cannot re-mint must not be burned'); + }); + + it('rejects an expired token', async () => { + await addPolicy(); + const past = Math.floor(Date.now() / 1000) - 7200; + await assertRejected( + exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken({ iat: past, exp: past + 300 }) }) + ); + }); + + it('rejects a token signed by a key the issuer does not publish', async () => { + await addPolicy(); + const { privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const forged = jwt.sign( + { iss: ISSUER, aud: AUDIENCE, jti: 'forged', exp: Math.floor(Date.now() / 1000) + 300 }, + privateKey, + { + algorithm: 'RS256', + header: { alg: 'RS256', kid: OIDC_KID }, + } + ); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: forged })); + }); + + it('rejects malformed input', async () => { + await addPolicy(); + for (const token of ['not-a-jwt', 'a.b.c']) { + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token })); + } + for (const token of ['', undefined, 42, { nested: true }]) { + await assert.rejects(() => exchangeOidcToken({ operation: 'exchange_oidc_token', token })); + } + }); +}); diff --git a/utility/hdbTerms.ts b/utility/hdbTerms.ts index d57eb4fb7..6914813a6 100644 --- a/utility/hdbTerms.ts +++ b/utility/hdbTerms.ts @@ -352,6 +352,7 @@ export const OPERATIONS_ENUM = { ADD_OIDC_TRUST: 'add_oidc_trust', LIST_OIDC_TRUST: 'list_oidc_trust', DROP_OIDC_TRUST: 'drop_oidc_trust', + EXCHANGE_OIDC_TOKEN: 'exchange_oidc_token', GET_DEPLOYMENT_PAYLOAD: 'get_deployment_payload', DELETE_DEPLOYMENT_PAYLOAD: 'delete_deployment_payload', AGENT_PROMPT: 'agent_prompt', diff --git a/utility/operation_authorization.ts b/utility/operation_authorization.ts index e1a763e42..a87efd0a9 100644 --- a/utility/operation_authorization.ts +++ b/utility/operation_authorization.ts @@ -48,6 +48,7 @@ import * as regDeprecated from '../resources/registrationDeprecated.ts'; import * as deploymentOperations from '../components/deploymentOperations.ts'; import * as secretOperations from '../components/secretOperations.ts'; import * as trustPolicyOperations from '../security/oidcTrust/trustPolicyOperations.ts'; +import * as tokenExchange from '../security/oidcTrust/tokenExchange.ts'; const requiredPermissions = new Map(); const DELETE_PERM = 'delete'; @@ -375,6 +376,12 @@ requiredPermissions.set( trustPolicyOperations.dropOidcTrust.name, new (permission as any)(true, [], terms.OPERATIONS_ENUM.DROP_OIDC_TRUST) ); +// The exchange is unauthenticated by design — it authenticates its own caller against a trust +// policy, the way create_authentication_tokens does against a password. +requiredPermissions.set( + tokenExchange.exchangeOidcToken.name, + new (permission as any)(false, [], terms.OPERATIONS_ENUM.EXCHANGE_OIDC_TOKEN) +); //Below are functions that are currently open to all roles requiredPermissions.set(regDeprecated.getRegistrationInfo.name, new (permission as any)(false, [])); From 1f27a6c31336aef41d8c739f9be5eec19de27e4d Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 14 Aug 2026 13:34:10 -0400 Subject: [PATCH 04/37] feat(cli): exchange a CI identity for a Harper token automatically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes #2171. On a runner that offers an OIDC identity token, the CLI asks the provider for one addressed to this instance and trades it via exchange_oidc_token — so a GitHub Actions deploy needs `id-token: write` and a target URL, and no secret at all. Ranked below every configured credential (env tokens, saved login), not above. Adding `id-token: write` to a workflow that still sets HARPER_CLI_REFRESH_TOKEN must not silently change which identity deploys; the ambient credential is the fallback, not the override. Notes for review: - The audience sent to GitHub is the resolved target, not the provider default — that default is shared by every repository under an owner, and is what makes a token replayable at an unrelated service. - Detection requires BOTH ACTIONS_ID_TOKEN_REQUEST_URL and _TOKEN. Their absence means the workflow did not grant `id-token: write`, which is an answer rather than a failure to report. - Failures are reported and swallowed. This is the last credential source before the request goes out unauthenticated, and the resulting 401 says nothing useful, so a 401 from the exchange prints what the operator can actually inspect (list_oidc_trust, the audience). - Local (no-target) operations never reach the exchange, same as the env-var tokens: bypassLocalAuth only applies with no Authorization header, so attaching one opts out of the domain socket's trust. DESIGN.md gains a section on the layering and the three constraints that look like choices but are not. Co-Authored-By: Claude Opus 5 --- DESIGN.md | 19 +++ bin/ciIdentityToken.ts | 104 ++++++++++++++++ bin/cliOperations.ts | 12 +- unitTests/bin/ciIdentityToken.test.js | 171 ++++++++++++++++++++++++++ unitTests/bin/cliOperations.test.js | 153 +++++++++++++++++++++++ 5 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 bin/ciIdentityToken.ts create mode 100644 unitTests/bin/ciIdentityToken.test.js diff --git a/DESIGN.md b/DESIGN.md index 8b8a19dcb..4663c3a2b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -190,6 +190,25 @@ System tables replicate by default. To opt out, add the name to `NON_REPLICATING If the table needs `audit: true`, set it both in the schema (for fresh installs) **and** on the `CreateTableObject` instance in the directive (for upgrades) — otherwise the two paths diverge. +## OIDC trusted publishing (`security/oidcTrust/`) + +`exchange_oidc_token` lets a CI runner authenticate with no stored Harper credential (#2171): it presents an identity token minted by its provider, and gets back a one-hour operation token for the user a stored trust policy names. It is in `NO_AUTH_OPERATIONS` because it _is_ the authentication, the same way `create_authentication_tokens` is against a password — the same three wiring points apply (`serverHandlers.js` `NO_AUTH_OPERATIONS`, the `verifyPerms` bypass in `serverUtilities.ts`, and a `permission(false, [])` registration). + +The layering is deliberate and worth preserving: + +- `claims.ts` is pure — normalization, matching, and write-time policy validation. It never touches the network or storage, so the rules that decide whether a workflow may act as a user are directly testable. +- `jwks.ts` owns issuer keys. The rate-limit clock for unknown-`kid` refetches lives _outside_ the cache entry: a successful fetch replaces the entry, and a rate limit that resets whenever it fires is not a rate limit. Keeping it separate also means a genuine key rotation is picked up on first use rather than after the window. +- `index.ts` verifies signature/issuer/audience and additionally requires `exp`, a bounded lifetime, and `jti`. +- `tokenExchange.ts` selects a policy and mints. Verification is memoized per audience, so N policies sharing one audience cost one signature check. + +Three constraints that look like choices but are not: + +1. **Every rejection returns the same message.** The endpoint is unauthenticated; a caller told which check failed can enumerate a policy one claim at a time. Reasons go to the `oidc-trust` logger. +2. **A policy must gate the ref.** `validateTrustPolicyClaims` rejects a policy pinning only repository + workflow, because anyone who can push a branch could then add that workflow to it and mint a token. This is stricter than npm's trusted-publishing model, which mitigates the same hole with environment protection instead. +3. **`createOperationToken`, not `createTokens`.** `createTokens` overwrites `hdb_user.refresh_token` as a side effect, so minting for CI would silently revoke whatever credential that user already held (#2018) — the exact problem this feature removes. + +`hdb_oidc_token_use` (created lazily via `table()`, not the system schema) records spent `jti`s with `expiresAt` set past the token's own expiry. The get-then-put is not atomic and does not claim to be: a concurrent replay is not a privilege escalation, since whoever holds the token could obtain one operation token anyway. + ## Table drops, the `dropping` tombstone, and ghost tables A table is a set of RocksDB column families (`T/` plus `T/`) and a set of catalog rows diff --git a/bin/ciIdentityToken.ts b/bin/ciIdentityToken.ts new file mode 100644 index 000000000..e1c803fc7 --- /dev/null +++ b/bin/ciIdentityToken.ts @@ -0,0 +1,104 @@ +/** + * CI-side half of OIDC trusted publishing (#2171). + * + * On a runner that offers an OIDC identity token, the CLI can authenticate with no stored Harper + * credential at all: it asks the CI provider for a token addressed to this instance, and trades it + * for a short-lived operation token via `exchange_oidc_token`. + * + * GitHub Actions only for now. Other providers expose the same idea through different plumbing, so + * detection stays explicit rather than guessed — a runner we do not recognize simply falls through + * to the CLI's other credential sources. + */ + +import { httpRequest } from '../utility/common_utils.ts'; + +/** GitHub sets both of these on a job that declares `permissions: id-token: write`. */ +const GITHUB_TOKEN_REQUEST_URL = 'ACTIONS_ID_TOKEN_REQUEST_URL'; +const GITHUB_TOKEN_REQUEST_TOKEN = 'ACTIONS_ID_TOKEN_REQUEST_TOKEN'; + +const IDENTITY_REQUEST_TIMEOUT_MS = 10_000; + +/** + * True when this process is running somewhere that can mint an identity token. Both variables are + * required: GitHub sets them together, and their absence on an Actions runner means the workflow + * did not grant `id-token: write` — which is a configuration answer, not a failure to report here. + */ +export function ciIdentityAvailable(): boolean { + return Boolean(process.env[GITHUB_TOKEN_REQUEST_URL] && process.env[GITHUB_TOKEN_REQUEST_TOKEN]); +} + +/** + * Asks GitHub for an identity token addressed to `audience`. + * + * The audience is what binds the token to this Harper instance. GitHub's default audience is the + * repository owner's URL, shared by every repository under that owner, so passing the resolved + * target explicitly is not a nicety — it is what makes the token unusable anywhere else. + */ +async function requestGithubIdentityToken(audience: string): Promise { + const requestUrl = new URL(process.env[GITHUB_TOKEN_REQUEST_URL] as string); + requestUrl.searchParams.set('audience', audience); + + const response = await fetch(requestUrl, { + headers: { + authorization: `Bearer ${process.env[GITHUB_TOKEN_REQUEST_TOKEN]}`, + accept: 'application/json', + }, + signal: AbortSignal.timeout(IDENTITY_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`GitHub returned ${response.status} for an identity token`); + } + const body: any = await response.json(); + if (typeof body?.value !== 'string' || body.value === '') { + throw new Error('GitHub returned no identity token value'); + } + return body.value; +} + +/** + * Trades a CI identity token for a Harper operation token, or returns undefined when this runner + * has no identity to offer. + * + * Failures are reported and swallowed rather than thrown. This runs as the last credential source + * before the request would go out unauthenticated, and the resulting 401 says nothing useful — so + * the reason the exchange did not work is worth printing even though it is not, by itself, fatal. + */ +export async function exchangeCiIdentityForToken(options: any, audience: string): Promise { + if (!ciIdentityAvailable()) return undefined; + + console.error(`Requesting a CI identity token for ${audience}...`); + let identityToken: string; + try { + identityToken = await requestGithubIdentityToken(audience); + } catch (error) { + console.error(`Could not obtain a CI identity token: ${(error as Error).message}`); + return undefined; + } + + try { + const response = await httpRequest(options, { operation: 'exchange_oidc_token', token: identityToken }); + if (response.statusCode === 200) { + const data = JSON.parse(response.body); + if (data.operation_token) { + console.error(`Authenticated as '${data.username}' via OIDC trust policy '${data.policy}'.`); + return data.operation_token; + } + console.error('The OIDC exchange returned no operation token.'); + return undefined; + } + if (response.statusCode === 401) { + // The server deliberately does not say which check failed, so point at the two things the + // operator can actually inspect rather than inventing a cause. + console.error( + 'Harper rejected the CI identity token. Check that a trust policy matches this workflow ' + + '(list_oidc_trust) and that its audience is this instance; the server log records the reason.' + ); + return undefined; + } + console.error(`OIDC exchange failed: ${response.statusCode}`); + return undefined; + } catch (error) { + console.error(`Error exchanging the CI identity token: ${(error as Error).message}`); + return undefined; + } +} diff --git a/bin/cliOperations.ts b/bin/cliOperations.ts index 74b33ceeb..cef8dc6a0 100644 --- a/bin/cliOperations.ts +++ b/bin/cliOperations.ts @@ -6,6 +6,8 @@ import * as envMgr from '../utility/environment/environmentManager.ts'; envMgr.initSync(); import * as terms from '../utility/hdbTerms.ts'; import { httpRequest } from '../utility/common_utils.ts'; +import { ciIdentityAvailable, exchangeCiIdentityForToken } from './ciIdentityToken.ts'; +import * as path from 'path'; import * as fs from 'fs-extra'; import * as YAML from 'yaml'; import { Readable } from 'node:stream'; @@ -764,7 +766,8 @@ export async function resolveRequestOptions(req: any): Promise<{ options: any; t options.timeout = SSE_OPERATIONS.has(req.operation) ? SSE_OPERATION_TIMEOUT_MS : CLI_OPERATION_TIMEOUT_MS; // Authentication precedence: explicitly configured credentials (dedicated args, URL // userinfo, env vars) beat everything, then env-var tokens, then the saved `harper login` - // token, and only then the legacy `username=`/`password=` payload fallback below. The + // token, then a CI identity token exchanged via OIDC (#2171 — ambient, so it ranks below + // everything configured), and only then the legacy `username=`/`password=` fallback. The // tokens must outrank that fallback: for add_user/alter_user those args are the credentials // of the user being created/altered, so treating them as auth would authenticate as a user // who doesn't exist yet (or as the wrong identity) instead of using the admin's session. @@ -819,6 +822,13 @@ export async function resolveRequestOptions(req: any): Promise<{ options: any; t if (tokens.operation_token) { options.headers.Authorization = `Bearer ${tokens.operation_token}`; } + } else if (ciIdentityAvailable()) { + // Last credential source: no configured token, but this runner can prove its identity to + // the cluster directly (#2171). Deliberately below the env-var and saved tokens — an + // explicitly configured credential should keep working exactly as it did when someone adds + // `id-token: write` to a workflow, rather than silently switching which identity deploys. + const operationToken = await exchangeCiIdentityForToken(options, target.resolvedTarget); + if (operationToken) options.headers.Authorization = `Bearer ${operationToken}`; } } // Legacy fallback for operations where `username=`/`password=` genuinely ARE the caller's diff --git a/unitTests/bin/ciIdentityToken.test.js b/unitTests/bin/ciIdentityToken.test.js new file mode 100644 index 000000000..5a6423ceb --- /dev/null +++ b/unitTests/bin/ciIdentityToken.test.js @@ -0,0 +1,171 @@ +'use strict'; + +const assert = require('node:assert'); +const { ciIdentityAvailable, exchangeCiIdentityForToken } = require('#src/bin/ciIdentityToken'); +const commonUtilsModule = require('#src/utility/common_utils'); + +const REQUEST_URL = 'https://pipelines.actions.githubusercontent.com/abc/idtoken?api-version=2.0'; +const REQUEST_TOKEN = 'runner-request-token'; +const AUDIENCE = 'https://my-instance.harperdb.io:9925/'; + +describe('ciIdentityToken', () => { + let originalFetch; + let originalHttpRequest; + let originalEnv; + let originalConsoleError; + let fetchCalls; + let operationCalls; + let stderr; + + before(() => { + originalFetch = globalThis.fetch; + originalHttpRequest = commonUtilsModule.httpRequest; + originalConsoleError = console.error; + }); + + after(() => { + globalThis.fetch = originalFetch; + commonUtilsModule.httpRequest = originalHttpRequest; + console.error = originalConsoleError; + }); + + beforeEach(() => { + originalEnv = { + url: process.env.ACTIONS_ID_TOKEN_REQUEST_URL, + token: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, + }; + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = REQUEST_URL; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = REQUEST_TOKEN; + + fetchCalls = []; + operationCalls = []; + stderr = []; + console.error = (message) => stderr.push(String(message)); + + globalThis.fetch = async (url, init) => { + fetchCalls.push({ url: new URL(String(url)), init }); + return new Response(JSON.stringify({ value: 'identity.token.value' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + commonUtilsModule.httpRequest = async (options, body) => { + operationCalls.push({ options, body }); + return { + statusCode: 200, + body: JSON.stringify({ + operation_token: 'minted-operation-token', + username: 'ci-deploy', + policy: 'my-app-prod', + expires_in: 3600, + }), + }; + }; + }); + + afterEach(() => { + if (originalEnv.url === undefined) delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + else process.env.ACTIONS_ID_TOKEN_REQUEST_URL = originalEnv.url; + if (originalEnv.token === undefined) delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + else process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = originalEnv.token; + }); + + describe('ciIdentityAvailable', () => { + it('is true when the runner offers an identity token', () => { + assert.strictEqual(ciIdentityAvailable(), true); + }); + + // GitHub sets both together; either one missing means the workflow did not grant + // `id-token: write`, which is a configuration answer rather than something to report. + it('is false unless both variables are present', () => { + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + assert.strictEqual(ciIdentityAvailable(), false); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = REQUEST_TOKEN; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + assert.strictEqual(ciIdentityAvailable(), false); + }); + }); + + describe('exchangeCiIdentityForToken', () => { + it('returns an operation token', async () => { + const token = await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE); + assert.strictEqual(token, 'minted-operation-token'); + }); + + // The audience is what binds the token to this instance; GitHub's default is shared by every + // repository under an owner, so it must be set explicitly on the request. + it('requests the token for this instance as the audience', async () => { + await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE); + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(fetchCalls[0].url.searchParams.get('audience'), AUDIENCE); + // The api-version already on the URL must survive. + assert.strictEqual(fetchCalls[0].url.searchParams.get('api-version'), '2.0'); + assert.strictEqual(fetchCalls[0].init.headers.authorization, `Bearer ${REQUEST_TOKEN}`); + }); + + it('sends the identity token to exchange_oidc_token', async () => { + await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE); + assert.strictEqual(operationCalls.length, 1); + assert.deepStrictEqual(operationCalls[0].body, { + operation: 'exchange_oidc_token', + token: 'identity.token.value', + }); + }); + + it('names the policy and user it authenticated as', async () => { + await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE); + assert.ok( + stderr.some((line) => line.includes('ci-deploy') && line.includes('my-app-prod')), + `expected the identity to be reported; got ${JSON.stringify(stderr)}` + ); + }); + + it('does nothing on a runner with no identity to offer', async () => { + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + assert.strictEqual(await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE), undefined); + assert.strictEqual(fetchCalls.length, 0); + assert.strictEqual(operationCalls.length, 0); + }); + + it('gives up without exchanging when the provider refuses a token', async () => { + globalThis.fetch = async () => new Response('forbidden', { status: 403 }); + assert.strictEqual(await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE), undefined); + assert.strictEqual(operationCalls.length, 0, 'nothing to exchange'); + assert.ok(stderr.some((line) => line.includes('403'))); + }); + + it('gives up when the provider returns no token value', async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({}), { status: 200, headers: { 'content-type': 'application/json' } }); + assert.strictEqual(await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE), undefined); + assert.strictEqual(operationCalls.length, 0); + }); + + // The server deliberately does not say which check failed, so the CLI has to point at what the + // operator can inspect — otherwise this surfaces as a bare 401 later. + it('explains a rejected exchange', async () => { + commonUtilsModule.httpRequest = async () => ({ + statusCode: 401, + body: '{"error":"Identity token was rejected"}', + }); + assert.strictEqual(await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE), undefined); + assert.ok( + stderr.some((line) => line.includes('list_oidc_trust')), + `expected actionable guidance; got ${JSON.stringify(stderr)}` + ); + }); + + it('returns undefined when the exchange yields no token', async () => { + commonUtilsModule.httpRequest = async () => ({ statusCode: 200, body: '{}' }); + assert.strictEqual(await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE), undefined); + }); + + it('survives a transport failure', async () => { + commonUtilsModule.httpRequest = async () => { + throw new Error('socket hang up'); + }; + assert.strictEqual(await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE), undefined); + assert.ok(stderr.some((line) => line.includes('socket hang up'))); + }); + }); +}); diff --git a/unitTests/bin/cliOperations.test.js b/unitTests/bin/cliOperations.test.js index 22d126a3f..589b152c9 100644 --- a/unitTests/bin/cliOperations.test.js +++ b/unitTests/bin/cliOperations.test.js @@ -299,6 +299,159 @@ describe('cliOperations', () => { }); }); + // OIDC trusted publishing (#2171): the runner proves its identity to the cluster instead of + // carrying a Harper credential. Ambient, so it ranks below everything explicitly configured. + describe('CI identity auth (OIDC)', () => { + const target = 'https://example.com:9925/'; + const envVars = [ + 'HARPER_CLI_OPERATION_TOKEN', + 'HARPER_CLI_REFRESH_TOKEN', + 'CLI_TARGET_OPERATION_TOKEN', + 'CLI_TARGET_REFRESH_TOKEN', + 'ACTIONS_ID_TOKEN_REQUEST_URL', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', + ]; + const saved = {}; + let originalFetch; + let identityRequests; + + beforeEach(() => { + for (const v of envVars) { + saved[v] = process.env[v]; + delete process.env[v]; + } + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://pipelines.example/idtoken?api-version=2.0'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'runner-request-token'; + + identityRequests = []; + originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + identityRequests.push(new URL(String(url))); + return new Response(JSON.stringify({ value: 'identity.token.value' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + for (const v of envVars) { + if (saved[v] === undefined) delete process.env[v]; + else process.env[v] = saved[v]; + } + }); + + it('exchanges a CI identity for an operation token when nothing else is configured', async () => { + const requested = []; + commonUtilsModule.httpRequest = async (options, req) => { + requested.push({ auth: options.headers.Authorization, operation: req.operation }); + if (req.operation === 'exchange_oidc_token') { + return { + statusCode: 200, + body: JSON.stringify({ operation_token: 'oidc-op-token', username: 'ci-deploy', policy: 'p' }), + }; + } + return { statusCode: 200, body: JSON.stringify({ success: true }) }; + }; + + const result = await cliOperationsModule.cliOperations({ operation: 'test', target: 'example.com' }, true); + + assert.strictEqual(result.success, true); + assert.deepStrictEqual( + requested.map((r) => r.operation), + ['exchange_oidc_token', 'test'] + ); + assert.strictEqual(requested[1].auth, 'Bearer oidc-op-token'); + // The audience must be this instance, not the provider's shared default. + assert.strictEqual(identityRequests[0].searchParams.get('audience'), target); + }); + + // Adding `id-token: write` to a workflow that still sets HARPER_CLI_REFRESH_TOKEN must not + // silently change which identity deploys. + it('leaves a configured env token in charge', async () => { + process.env.HARPER_CLI_OPERATION_TOKEN = 'env-op-token'; + tokenAuthModule.isJWTExpired = () => false; + + const requested = []; + commonUtilsModule.httpRequest = async (options, req) => { + requested.push({ auth: options.headers.Authorization, operation: req.operation }); + return { statusCode: 200, body: JSON.stringify({ success: true }) }; + }; + + await cliOperationsModule.cliOperations({ operation: 'test', target: 'example.com' }, true); + + assert.deepStrictEqual( + requested.map((r) => r.operation), + ['test'] + ); + assert.strictEqual(requested[0].auth, 'Bearer env-op-token'); + assert.strictEqual(identityRequests.length, 0, 'must not ask the provider for an identity token'); + }); + + it('leaves saved login credentials in charge', async () => { + saveCredentials(target, { operation_token: 'file-token', refresh_token: 'file-refresh' }); + tokenAuthModule.isJWTExpired = () => false; + + let seenAuth; + commonUtilsModule.httpRequest = async (options) => { + seenAuth = options.headers.Authorization; + return { statusCode: 200, body: JSON.stringify({ success: true }) }; + }; + + await cliOperationsModule.cliOperations({ operation: 'test', target: 'example.com' }, true); + assert.strictEqual(seenAuth, 'Bearer file-token'); + assert.strictEqual(identityRequests.length, 0); + }); + + // Same rationale as the env-var tokens: a local operation is trusted via bypassLocalAuth, + // which only applies when no Authorization header is present. + it('does not exchange for a local (no-target) operation', async () => { + const originalGetHdbPid = processManagementModule.getHdbPid; + const originalInitConfig = configUtilsModule.initConfig; + const originalGetConfigPath = configUtilsModule.getConfigPath; + const socketPath = path.join(testDir, 'oidc-local-check.sock'); + fs.ensureFileSync(socketPath); + configUtilsModule.initConfig = () => {}; + processManagementModule.getHdbPid = () => 12345; + configUtilsModule.getConfigPath = () => socketPath; + + const requested = []; + commonUtilsModule.httpRequest = async (options, req) => { + requested.push({ auth: options.headers.Authorization, operation: req.operation }); + return { statusCode: 200, body: JSON.stringify({ success: true }) }; + }; + + try { + await cliOperationsModule.cliOperations({ operation: 'test' }, true); + } finally { + processManagementModule.getHdbPid = originalGetHdbPid; + configUtilsModule.initConfig = originalInitConfig; + configUtilsModule.getConfigPath = originalGetConfigPath; + } + + assert.strictEqual(identityRequests.length, 0); + assert.strictEqual(requested.length, 1); + assert.strictEqual(requested[0].auth, undefined); + }); + + // A rejected exchange must not leave a half-authenticated request; it goes out with no + // Authorization header and fails the way an unauthenticated request normally does. + it('proceeds unauthenticated when the exchange is rejected', async () => { + const requested = []; + commonUtilsModule.httpRequest = async (options, req) => { + requested.push({ auth: options.headers.Authorization, operation: req.operation }); + if (req.operation === 'exchange_oidc_token') { + return { statusCode: 401, body: JSON.stringify({ error: 'Identity token was rejected' }) }; + } + return { statusCode: 200, body: JSON.stringify({ success: true }) }; + }; + + await cliOperationsModule.cliOperations({ operation: 'test', target: 'example.com' }, true); + assert.strictEqual(requested[1].auth, undefined); + }); + }); + // The resolved target is an identity, not a credential: it keys ~/.harperdb/credentials.json, is // echoed by "Connecting to ...", written to .env, and emitted by `harper login --for-ci`. // Userinfo is stripped once, in normalizeTarget, so none of those sites can leak a password. From 08c7cebd52930f45c43d97c76b11c60181f4489c Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 14 Aug 2026 13:49:20 -0400 Subject: [PATCH 05/37] refactor(security): cut comments the code can carry itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-audit pass. No behavior change — 196 tests unchanged and green. Structural, not just prose: - claims.ts folded three copy-pasted structural checks into one STRUCTURAL_REQUIREMENTS table. The table shows the symmetry that a paragraph previously had to assert, and the three exported constant arrays it replaces were exported but imported nowhere, not even by tests. - Deleted describeUnpinned(), a wrapper around Array#join whose name described something it did not do. - listOidcTrust and loadEnabledPolicies were the same scan-toRecord-sort with one filter differing; both now call readPolicies(includeDisabled). - rejectToken moved to identityToken.ts and is shared with the exchange, replacing a second near-identical rejectExchange — and with it the fourth copy of the "reason goes to the log, not the caller" rationale. - findMatchingPolicy lost a try/catch and a `void error` to a .catch(). - security/oidcTrust/index.ts is now identityToken.ts. It was never a barrel, so `from './index.ts'` misdescribed what siblings were importing; the name now matches its test file. Three rationales were each told in three or four files (audience must be instance-specific, rejection reasons stay in the log, least privilege is the named user's role). Each now has one canonical site with the code that enforces it, and pointers elsewhere. Across security/oidcTrust/ plus bin/ciIdentityToken.ts: 1040 -> 939 lines, of which comment lines 310 -> 225 (29% -> 23%). What stayed is the non-obvious: why splitting on `@refs/` rather than `@`, why the rate-limit clock sits outside the cache entry, why createTokens cannot be used here, and why ref_type is not a ref gate. Co-Authored-By: Claude Opus 5 --- DESIGN.md | 2 +- bin/ciIdentityToken.ts | 33 ++--- security/oidcTrust/claims.ts | 122 +++++++---------- .../oidcTrust/{index.ts => identityToken.ts} | 40 ++---- security/oidcTrust/jwks.ts | 57 +++----- security/oidcTrust/tokenExchange.ts | 129 +++++++----------- security/oidcTrust/trustPolicyOperations.ts | 31 ++--- security/oidcTrust/types.ts | 27 ++-- .../oidcTrust/verifyIdentityToken.test.js | 2 +- upgrade/directives/5-3-0.ts | 14 +- 10 files changed, 174 insertions(+), 283 deletions(-) rename security/oidcTrust/{index.ts => identityToken.ts} (65%) diff --git a/DESIGN.md b/DESIGN.md index 4663c3a2b..45ff3599e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -198,7 +198,7 @@ The layering is deliberate and worth preserving: - `claims.ts` is pure — normalization, matching, and write-time policy validation. It never touches the network or storage, so the rules that decide whether a workflow may act as a user are directly testable. - `jwks.ts` owns issuer keys. The rate-limit clock for unknown-`kid` refetches lives _outside_ the cache entry: a successful fetch replaces the entry, and a rate limit that resets whenever it fires is not a rate limit. Keeping it separate also means a genuine key rotation is picked up on first use rather than after the window. -- `index.ts` verifies signature/issuer/audience and additionally requires `exp`, a bounded lifetime, and `jti`. +- `identityToken.ts` verifies signature/issuer/audience and additionally requires `exp`, a bounded lifetime, and `jti`. It also owns `rejectToken`, shared with the exchange so both halves refuse identically. - `tokenExchange.ts` selects a policy and mints. Verification is memoized per audience, so N policies sharing one audience cost one signature check. Three constraints that look like choices but are not: diff --git a/bin/ciIdentityToken.ts b/bin/ciIdentityToken.ts index e1c803fc7..c6fdbe2d2 100644 --- a/bin/ciIdentityToken.ts +++ b/bin/ciIdentityToken.ts @@ -1,13 +1,10 @@ /** - * CI-side half of OIDC trusted publishing (#2171). - * - * On a runner that offers an OIDC identity token, the CLI can authenticate with no stored Harper - * credential at all: it asks the CI provider for a token addressed to this instance, and trades it - * for a short-lived operation token via `exchange_oidc_token`. + * CI-side half of OIDC trusted publishing (#2171): ask the provider for an identity token addressed + * to this instance, trade it for a short-lived operation token via `exchange_oidc_token`. * * GitHub Actions only for now. Other providers expose the same idea through different plumbing, so - * detection stays explicit rather than guessed — a runner we do not recognize simply falls through - * to the CLI's other credential sources. + * detection stays explicit rather than guessed — an unrecognized runner falls through to the CLI's + * other credential sources. */ import { httpRequest } from '../utility/common_utils.ts'; @@ -19,20 +16,16 @@ const GITHUB_TOKEN_REQUEST_TOKEN = 'ACTIONS_ID_TOKEN_REQUEST_TOKEN'; const IDENTITY_REQUEST_TIMEOUT_MS = 10_000; /** - * True when this process is running somewhere that can mint an identity token. Both variables are - * required: GitHub sets them together, and their absence on an Actions runner means the workflow - * did not grant `id-token: write` — which is a configuration answer, not a failure to report here. + * Both variables are required: GitHub sets them together, so their absence means the workflow did + * not grant `id-token: write` — a configuration answer, not a failure to report here. */ export function ciIdentityAvailable(): boolean { return Boolean(process.env[GITHUB_TOKEN_REQUEST_URL] && process.env[GITHUB_TOKEN_REQUEST_TOKEN]); } /** - * Asks GitHub for an identity token addressed to `audience`. - * - * The audience is what binds the token to this Harper instance. GitHub's default audience is the - * repository owner's URL, shared by every repository under that owner, so passing the resolved - * target explicitly is not a nicety — it is what makes the token unusable anywhere else. + * Passing `audience` explicitly is what makes the token unusable anywhere else — GitHub's default is + * shared org-wide (see SHARED_DEFAULT_AUDIENCE in security/oidcTrust/trustPolicyOperations.ts). */ async function requestGithubIdentityToken(audience: string): Promise { const requestUrl = new URL(process.env[GITHUB_TOKEN_REQUEST_URL] as string); @@ -56,12 +49,10 @@ async function requestGithubIdentityToken(audience: string): Promise { } /** - * Trades a CI identity token for a Harper operation token, or returns undefined when this runner - * has no identity to offer. - * - * Failures are reported and swallowed rather than thrown. This runs as the last credential source - * before the request would go out unauthenticated, and the resulting 401 says nothing useful — so - * the reason the exchange did not work is worth printing even though it is not, by itself, fatal. + * Trades a CI identity token for a Harper operation token, or undefined when there is no identity to + * offer. Failures are reported and swallowed: this is the last credential source before the request + * goes out unauthenticated, and the resulting 401 says nothing useful, so the reason is worth + * printing even though it is not by itself fatal. */ export async function exchangeCiIdentityForToken(options: any, audience: string): Promise { if (!ciIdentityAvailable()) return undefined; diff --git a/security/oidcTrust/claims.ts b/security/oidcTrust/claims.ts index 1cadf8f0b..23462f0c2 100644 --- a/security/oidcTrust/claims.ts +++ b/security/oidcTrust/claims.ts @@ -9,38 +9,41 @@ import { ClientError } from '../../utility/errors/hdbError.ts'; import type { ClaimConstraint, TokenClaims } from './types.ts'; /** - * A ref-qualified workflow reference is `//@`, and the ref is always a full - * `refs/...` name. Splitting on `@refs/` rather than the first or last `@` is exact: a path segment - * cannot contain `/`, so `@refs/` cannot occur inside the path portion. + * Workflow references are `//@` and the ref is always a full `refs/...` name. + * Splitting on `@refs/` rather than the first or last `@` is exact, because a path segment cannot + * contain `/` — so this cannot occur inside the path, and a branch named `release@2` cannot fool it. */ const REF_QUALIFIER = '@refs/'; /** - * Claims that pin *which repository* minted the token. `repository_id` is an immutable numeric id: - * it survives renames and cannot be re-acquired by a new owner of a recycled org name, so it is the - * pin to prefer. `repository_owner` is deliberately absent — it identifies an org, not a repository, - * and would let any repo in the org match. - */ -export const REPOSITORY_PIN_CLAIMS = ['repository_id', 'repository']; - -/** Claims that pin *which workflow file* ran. */ -export const WORKFLOW_PIN_CLAIMS = ['workflow_ref', 'workflow_path', 'job_workflow_ref', 'job_workflow_path']; - -/** - * Claims that pin the run to a specific ref, or to an environment whose protection rules gate it. - * Without one of these, any branch that can be pushed to the repository can run the trusted workflow - * and mint a token — the weakness in trusting repository + workflow filename alone. + * Structural requirements on a policy's claim set, each guarding a distinct way a policy can be + * accidentally broad. Satisfying none of a row's claims admits, in order: any repository, any + * workflow in that repository, any branch that can be pushed to it. * - * `ref_type` is not here on purpose: `ref_type: tag` still admits any tag, which anyone with push - * access can create. A tag-triggered release should pin `environment` and lean on GitHub's - * environment protection (required reviewers, tag deployment rules) for the gate. + * `repository_owner` is absent from the first row on purpose — it identifies an org, not a + * repository, so it would admit every repo in the org. `ref_type` is absent from the third for the + * same shape of reason: `ref_type: tag` still admits any tag, and anyone with push access can create + * one. A tag-triggered release pins `environment` and leans on the provider's environment protection. */ -export const REF_GATE_CLAIMS = ['workflow_ref', 'job_workflow_ref', 'ref', 'environment']; +const STRUCTURAL_REQUIREMENTS = [ + { + requirement: 'pin the repository', + claims: ['repository_id', 'repository'], + because: ' (repository_id is immutable and survives renames)', + }, + { + requirement: 'pin the workflow', + claims: ['workflow_ref', 'workflow_path', 'job_workflow_ref', 'job_workflow_path'], + because: '', + }, + { + requirement: 'gate the ref', + claims: ['workflow_ref', 'job_workflow_ref', 'ref', 'environment'], + because: ' — otherwise any branch that can be pushed to the repository can run the workflow and mint a token', + }, +]; -/** - * Splits the ref off a workflow reference, yielding the workflow path alone. Returns undefined when - * the value isn't a ref-qualified reference, so a caller never matches against a guess. - */ +/** Returns undefined rather than guessing when the value is not a ref-qualified reference. */ export function splitWorkflowPath(workflowRef: unknown): string | undefined { if (typeof workflowRef !== 'string') return undefined; const qualifierIndex = workflowRef.indexOf(REF_QUALIFIER); @@ -48,30 +51,25 @@ export function splitWorkflowPath(workflowRef: unknown): string | undefined { } /** - * Adds derived claims to a verified token's payload. - * - * `workflow_path` / `job_workflow_path` are the workflow reference with the ref removed. They exist - * so a policy can pin the workflow *file* while gating the ref some other way — a tag-triggered - * release cannot pin `workflow_ref`, because the tag is not known when the policy is written. + * Adds `workflow_path` / `job_workflow_path` — the workflow reference with the ref removed — so a + * policy can pin the workflow *file* while gating the ref some other way. A tag-triggered release + * cannot pin `workflow_ref`, because the tag is unknown when the policy is written. * - * Derived entries are computed last so a claim actually present in the token cannot be displaced by - * one we synthesized. + * A claim the token actually carries is never displaced by one we synthesized. */ export function normalizeTokenClaims(payload: TokenClaims): TokenClaims { const claims: TokenClaims = { ...payload }; - const workflowPath = splitWorkflowPath(payload.workflow_ref); - const jobWorkflowPath = splitWorkflowPath(payload.job_workflow_ref); - if (workflowPath !== undefined && claims.workflow_path === undefined) claims.workflow_path = workflowPath; - if (jobWorkflowPath !== undefined && claims.job_workflow_path === undefined) { - claims.job_workflow_path = jobWorkflowPath; + for (const [source, derived] of [ + ['workflow_ref', 'workflow_path'], + ['job_workflow_ref', 'job_workflow_path'], + ]) { + const path = splitWorkflowPath(payload[source]); + if (path !== undefined && claims[derived] === undefined) claims[derived] = path; } return claims; } -/** - * Claim values arrive as strings, but an issuer is free to encode a numeric id as a JSON number. - * Anything else (boolean, object, array, null) is not a value we will compare. - */ +/** Issuers may encode a numeric id as a JSON number; anything non-scalar is not comparable. */ function claimToString(value: unknown): string | undefined { if (typeof value === 'string') return value; if (typeof value === 'number' && Number.isFinite(value)) return String(value); @@ -79,21 +77,18 @@ function claimToString(value: unknown): string | undefined { } /** - * Matches normalized token claims against a policy's constraints. Returns undefined on a match, or a - * short reason for the first failure — for the log, not for the caller: a client that learns *which* - * constraint failed can enumerate a policy one claim at a time. + * Returns undefined on a match, or the first failure's reason — for the log, never the caller. * - * Every constraint must be satisfied, and a constrained claim that is absent from the token is a - * failure rather than a pass, so a policy cannot be weakened by an issuer dropping a claim. + * A constrained claim absent from the token fails rather than passes, so a policy cannot be weakened + * by an issuer that stops emitting a claim. */ export function matchTrustPolicyClaims( claims: TokenClaims, policyClaims: Record ): string | undefined { const constraints = Object.entries(policyClaims); - // A policy with no constraints would match every token from the issuer. validateTrustPolicyClaims - // rejects that at write time; this is the matching-side backstop for a policy stored before (or - // around) that validation. + // validateTrustPolicyClaims rejects this at write time; this backstops a row that reached the + // table another way, such as replication from a peer. if (constraints.length === 0) return 'policy constrains no claims'; for (const [claimName, constraint] of constraints) { @@ -105,18 +100,7 @@ export function matchTrustPolicyClaims( return undefined; } -function describeUnpinned(claimNames: string[]): string { - return claimNames.join(', '); -} - -/** - * Validates a policy's claim constraints at write time. Throws ClientError describing the first - * problem; the caller is an administrator, so these messages are meant to be read. - * - * The three structural requirements exist because each guards a distinct way a policy can be - * accidentally broad: no repository pin admits any repository, no workflow pin admits any workflow - * in the repository, and no ref gate admits any branch that can be pushed. - */ +/** Throws ClientError naming the first problem; the reader is an administrator writing a policy. */ export function validateTrustPolicyClaims( policyClaims: unknown ): asserts policyClaims is Record { @@ -138,17 +122,9 @@ export function validateTrustPolicyClaims( } const constrained = new Set(entries.map(([claimName]) => claimName)); - if (!REPOSITORY_PIN_CLAIMS.some((claimName) => constrained.has(claimName))) { - throw new ClientError( - `claims must pin the repository with one of: ${describeUnpinned(REPOSITORY_PIN_CLAIMS)} (repository_id is immutable and survives renames)` - ); - } - if (!WORKFLOW_PIN_CLAIMS.some((claimName) => constrained.has(claimName))) { - throw new ClientError(`claims must pin the workflow with one of: ${describeUnpinned(WORKFLOW_PIN_CLAIMS)}`); - } - if (!REF_GATE_CLAIMS.some((claimName) => constrained.has(claimName))) { - throw new ClientError( - `claims must gate the ref with one of: ${describeUnpinned(REF_GATE_CLAIMS)} — otherwise any branch that can be pushed to the repository can run the workflow and mint a token` - ); + for (const { requirement, claims, because } of STRUCTURAL_REQUIREMENTS) { + if (!claims.some((claimName) => constrained.has(claimName))) { + throw new ClientError(`claims must ${requirement} with one of: ${claims.join(', ')}${because}`); + } } } diff --git a/security/oidcTrust/index.ts b/security/oidcTrust/identityToken.ts similarity index 65% rename from security/oidcTrust/index.ts rename to security/oidcTrust/identityToken.ts index e0b83e6b0..8597936ed 100644 --- a/security/oidcTrust/index.ts +++ b/security/oidcTrust/identityToken.ts @@ -2,8 +2,8 @@ * Identity-token verification for OIDC trusted publishing (#2171). * * Verifies that a token was signed by the configured issuer and is addressed to this instance. - * Matching the token against a trust policy's claim constraints is separate (see claims.ts), because - * one verification serves every policy sharing an issuer and audience. + * Matching it against a policy's claim constraints is separate (claims.ts), because one verification + * serves every policy sharing an issuer and audience. */ import jwt, { type Algorithm, type JwtPayload } from 'jsonwebtoken'; @@ -18,8 +18,8 @@ const logger = loggerWithTag('oidc-trust'); /** * Asymmetric signatures only. Passing this to jwt.verify is what prevents algorithm confusion: an - * `alg: none` token, or one signed with HMAC using a public key as the secret, is rejected before - * the signature is considered. + * `alg: none` token, or one HMAC-signed with a public key as the secret, is rejected before the + * signature is considered. */ const ALLOWED_ALGORITHMS: Algorithm[] = [ 'RS256', @@ -33,14 +33,9 @@ const ALLOWED_ALGORITHMS: Algorithm[] = [ 'PS512', ]; -/** Leeway for clock skew between the runner, the issuer, and this instance. */ const CLOCK_TOLERANCE_SECONDS = 60; -/** - * Ceiling on a token's own declared lifetime. CI identity tokens are minted per job and live minutes - * — a correctly-signed token claiming a far-future expiry is not something we should honor for that - * long, whatever the issuer intended. - */ +/** CI identity tokens are minted per job and live minutes; a far-future expiry is not one to honor. */ const MAX_TOKEN_LIFETIME_SECONDS = 3_600; export interface VerifyTokenTarget { @@ -56,21 +51,16 @@ export interface VerifyTokenOptions { } /** - * Fails verification. The reason is logged but never returned: the exchange endpoint is + * Refuses a token. Shared with the exchange so both halves fail identically: the endpoint is * unauthenticated, and a caller told exactly which check failed can probe a policy one claim at a - * time. + * time. The reason goes to the log instead. */ -function rejectToken(detail: string): never { +export function rejectToken(detail: string): never { logger.warn?.(`Rejecting identity token: ${detail}`); throw new ClientError('Identity token was rejected', 401); } -/** - * Verifies a CI identity token against one issuer/audience pair and returns its normalized claims. - * - * The audience check is not incidental: an issuer's default audience is shared by every repository - * under an owner, so a token minted for some other service would otherwise be replayable here. - */ +/** Verifies a CI identity token against one issuer/audience pair and returns its normalized claims. */ export async function verifyIdentityToken( token: unknown, target: VerifyTokenTarget, @@ -85,9 +75,10 @@ export async function verifyIdentityToken( const decoded = jwt.decode(token, { complete: true }); if (!decoded) rejectToken('token is not a well-formed JWT'); - // Check the algorithm before resolving a key so a garbage header costs no outbound request. - const algorithm = decoded.header.alg as Algorithm; - if (!ALLOWED_ALGORITHMS.includes(algorithm)) rejectToken(`unsupported algorithm ${decoded.header.alg}`); + // Checked before resolving a key so a garbage header costs no outbound request. + if (!ALLOWED_ALGORITHMS.includes(decoded.header.alg as Algorithm)) { + rejectToken(`unsupported algorithm ${decoded.header.alg}`); + } const getSigningKey = options.getSigningKey ?? defaultGetSigningKey; const key = await getSigningKey(issuer, decoded.header.kid); @@ -105,13 +96,12 @@ export async function verifyIdentityToken( rejectToken((error as Error).message); } - // jsonwebtoken only enforces `exp` when it is present, so a token without one never expires. + // jsonwebtoken only enforces `exp` when present, so a token without one never expires. if (typeof payload.exp !== 'number') rejectToken('token has no exp claim'); if (typeof payload.iat === 'number' && payload.exp - payload.iat > MAX_TOKEN_LIFETIME_SECONDS) { rejectToken(`token lifetime exceeds ${MAX_TOKEN_LIFETIME_SECONDS}s`); } - // The exchange records `jti` to block replay within the token's window; a token we cannot identify - // is one we cannot replay-protect, so it is not one we will accept. + // A token we cannot identify is one the exchange cannot replay-protect. if (typeof payload.jti !== 'string' || payload.jti === '') rejectToken('token has no jti claim'); return normalizeTokenClaims(payload as TokenClaims); diff --git a/security/oidcTrust/jwks.ts b/security/oidcTrust/jwks.ts index 99346a774..e94cb3a40 100644 --- a/security/oidcTrust/jwks.ts +++ b/security/oidcTrust/jwks.ts @@ -1,11 +1,10 @@ /** * OIDC discovery and JWKS retrieval for trusted publishing (#2171). * - * The issuer's signing keys are the root of trust for an exchanged token, so this module is - * deliberately conservative: HTTPS only, bounded response size, bounded fetch time, and a rate limit - * on the refetch that an unrecognized `kid` triggers. That last one matters because the exchange - * endpoint is unauthenticated — without it, a stream of forged `kid`s becomes one outbound fetch per - * request, against the issuer and on Harper's own event loop. + * The issuer's signing keys are the root of trust for an exchanged token, and the endpoint that + * reaches them is unauthenticated — hence HTTPS only, bounded body, bounded time, and a rate limit + * on the refetch an unrecognized `kid` triggers, without which forged `kid`s become one outbound + * fetch per request. */ import { createPublicKey, type KeyObject } from 'node:crypto'; @@ -20,17 +19,13 @@ const JWKS_CACHE_TTL_MS = 3_600_000; /** Floor between refetches triggered by an unrecognized `kid`. */ const MIN_REFETCH_INTERVAL_MS = 60_000; /** - * How long a cached key set may still be used after a refetch fails. Issuers rotate signing keys - * rarely, so a network blip should not break deploys — but an unbounded fallback would keep honoring - * a key set long after a key was pulled. + * How long a cached key set survives a failed refetch. Bounded, so a network blip does not break + * deploys but a pulled key does not stay honored forever. */ const STALE_KEY_GRACE_MS = 86_400_000; const FETCH_TIMEOUT_MS = 5_000; const MAX_RESPONSE_BYTES = 1_048_576; -/** - * Asymmetric key types only. An `oct` (symmetric) key in a JWKS is the setup for the classic - * algorithm-confusion attack, where a public value is replayed as an HMAC secret. - */ +/** Asymmetric only: an `oct` key in a JWKS is the setup for algorithm confusion. */ const SUPPORTED_KEY_TYPES = ['RSA', 'EC']; interface IssuerKeys { @@ -41,9 +36,9 @@ interface IssuerKeys { const issuerKeyCache = new Map(); const inFlightLoads = new Map>(); /** - * When an unrecognized `kid` last drove a refetch, per issuer. Kept outside the cache entry on - * purpose: the entry is replaced by every successful fetch, and a rate limit that resets whenever it - * fires is not a rate limit. + * When an unrecognized `kid` last drove a refetch, per issuer. Outside the cache entry on purpose: + * every successful fetch replaces that entry, and a rate limit that resets whenever it fires is not + * a rate limit. Separating them also lets a genuine key rotation be picked up on first use. */ const unknownKidRefetchAt = new Map(); @@ -55,9 +50,8 @@ export function clearJwksCache(): void { } /** - * Validates and canonicalizes an issuer URL. The result is both the cache key and the discovery - * base, so it has to be stable: a policy stored with a trailing slash and one without must not end - * up as two entries pointing at the same issuer. + * Canonicalizes an issuer URL. The result is both the cache key and the discovery base, so a policy + * stored with a trailing slash and one without must not become two entries for the same issuer. */ export function normalizeIssuer(issuer: unknown): string { if (typeof issuer !== 'string' || issuer === '') throw new ClientError('issuer is required'); @@ -73,9 +67,8 @@ export function normalizeIssuer(issuer: unknown): string { } /** - * Reads a JSON response with a hard byte ceiling. `content-length` is checked first as a cheap - * rejection, then the body is counted as it streams, because the header is advisory and a hostile - * endpoint can simply omit it. + * `content-length` is checked first as a cheap rejection, then the body is counted as it streams — + * the header is advisory, and a hostile endpoint can simply omit it. */ async function readBoundedJson(response: Response, url: string): Promise { const declaredLength = Number(response.headers.get('content-length')); @@ -116,14 +109,13 @@ async function fetchJson(url: string): Promise { } /** - * Resolves the issuer's `jwks_uri` via OIDC discovery. The discovery document's own `issuer` must - * equal the one we asked about — the spec requires it, and it is what stops a misdirected discovery - * document from quietly re-pointing an issuer we trust. + * The discovery document's own `issuer` must equal the one we asked about — the spec requires it, + * and it stops a misdirected document from quietly re-pointing an issuer we trust. */ async function discoverJwksUri(issuer: string): Promise { const document = await fetchJson(issuer + DISCOVERY_PATH); - // normalizeIssuer raises a ClientError, which is the wrong shape for a malformed *server* - // response — an unparseable `issuer` here is the issuer misbehaving, not the caller. + // Swallowed rather than propagated: normalizeIssuer raises ClientError, the wrong shape for a + // malformed *server* response. let declaredIssuer: string | undefined; try { declaredIssuer = normalizeIssuer(document?.issuer); @@ -141,9 +133,8 @@ async function discoverJwksUri(issuer: string): Promise { } /** - * Converts a JWK to a usable public key, or returns undefined for one we will not honor. A single - * unusable entry must not poison the whole set: issuers publish keys for other purposes, and future - * key types should degrade to "not usable here" rather than to a failed fetch. + * Undefined for a key we will not honor. One unusable entry must not poison the set: issuers publish + * keys for other purposes, and an unknown future type should degrade rather than fail the fetch. */ function toSigningKey(jwk: any): KeyObject | undefined { if (!jwk || typeof jwk !== 'object') return undefined; @@ -186,13 +177,7 @@ function loadIssuerKeys(issuer: string): Promise { return load; } -/** - * Resolves the public key an issuer used to sign a token, by `kid`. - * - * An unrecognized `kid` against a fresh cache means either a genuine key rotation or a forged - * header. Both look identical from here, so the refetch that distinguishes them is rate-limited - * rather than unconditional. - */ +/** Resolves the public key an issuer used to sign a token, by `kid`. */ export async function getSigningKey(issuer: string, kid: unknown): Promise { if (typeof kid !== 'string' || kid === '') throw new ClientError('Token has no key id', 401); const normalizedIssuer = normalizeIssuer(issuer); diff --git a/security/oidcTrust/tokenExchange.ts b/security/oidcTrust/tokenExchange.ts index 3fe5b66a8..97b7c9ff5 100644 --- a/security/oidcTrust/tokenExchange.ts +++ b/security/oidcTrust/tokenExchange.ts @@ -2,11 +2,10 @@ // exchange_oidc_token — the unauthenticated half of OIDC trusted publishing (#2171). // -// A CI runner presents an identity token minted by its provider. If the token verifies against a -// stored trust policy, Harper mints a short-lived operation token for the user that policy names. -// This is the only unauthenticated operation that yields a credential, so it fails closed and tells -// the caller as little as possible: every rejection is the same message, and the reason goes to the -// log. A caller who learns *which* check failed can enumerate a policy one claim at a time. +// A CI runner presents an identity token minted by its provider. If it verifies against a stored +// trust policy, Harper mints a short-lived operation token for the user that policy names. This is +// the only unauthenticated operation that yields a credential, so it fails closed throughout and +// refuses through rejectToken, which tells the caller nothing beyond "no". import jwt from 'jsonwebtoken'; import Joi from 'joi'; @@ -16,7 +15,7 @@ import { validateBySchema } from '../../validation/validationWrapper.ts'; import { loggerWithTag } from '../../utility/logging/logger.ts'; import { getUsersWithRolesCache } from '../user.ts'; import { createOperationToken } from '../tokenAuthentication.ts'; -import { verifyIdentityToken } from './index.ts'; +import { rejectToken, verifyIdentityToken } from './identityToken.ts'; import { matchTrustPolicyClaims } from './claims.ts'; import { normalizeIssuer } from './jwks.ts'; import { loadEnabledPolicies } from './trustPolicyOperations.ts'; @@ -24,34 +23,30 @@ import type { OidcTrustPolicy, TokenClaims } from './types.ts'; const logger = loggerWithTag('oidc-trust'); -/** - * Lifetime of the minted operation token. Long enough to cover a slow deploy without the client - * re-authenticating mid-run, short enough that the credential is worthless by the time it could - * surface in a log. Compare the 30-day refresh token this replaces. - */ +/** Long enough to cover a slow deploy, short enough to be worthless by the time it reaches a log. */ const EXCHANGED_TOKEN_LIFETIME_SECONDS = 3600; -/** Padding on the replay record so it outlives the token by more than the verifier's clock leeway. */ +/** Keeps the replay record alive past the token's expiry by more than the verifier's clock leeway. */ const REPLAY_RECORD_PADDING_MS = 120_000; -/** Bounds the token we are willing to even parse; real identity tokens are ~1-2 KB. */ +/** Real identity tokens are ~1-2 KB; this bounds what we are willing to even parse. */ const MAX_TOKEN_LENGTH = 8192; const TOKEN_USE_TABLE = 'hdb_oidc_token_use'; /** - * Records which identity tokens have been spent, keyed by issuer and `jti`. Rows expire with the - * token itself (`expiresAt`), so the table stays proportional to in-flight tokens rather than to - * deploy history. Replicated like other system tables, which extends the check across the cluster — - * though replication is asynchronous, so two truly simultaneous replays against different nodes can - * still both land. That race is not a privilege escalation: whoever holds the token could obtain one - * operation token regardless. What this stops is the realistic case — a token that leaks after a - * legitimate run and is reused while still inside its window. + * Spent identity tokens, keyed by issuer and `jti`, expiring with the token so the table stays + * proportional to in-flight tokens rather than to deploy history. + * + * Replicated like other system tables, which extends the check across the cluster — but replication + * is asynchronous, so two simultaneous replays against different nodes can both land. That race is + * not a privilege escalation: whoever holds the token could obtain one operation token regardless. + * What it stops is the realistic case, a token that leaks after a legitimate run and is reused + * inside its window. + * + * table() also registers into `databases.system`, so the lookup finds it after the first call. */ function getTokenUseTable(): any { - // table() both creates and registers into `databases.system`, so the lookup finds it on every - // call after the first. Untyped at the call sites, matching components/secretOperations.ts: the - // typed `put` overload takes an explicit target, and these callers use the record form. return ( (databases as any).system?.[TOKEN_USE_TABLE] ?? table
({ @@ -67,74 +62,54 @@ function getTokenUseTable(): any { ); } -/** Every rejection looks identical to the caller; the reason is for the operator reading the log. */ -function rejectExchange(detail: string): never { - logger.warn?.(`Rejecting OIDC token exchange: ${detail}`); - throw new ClientError('Identity token was rejected', 401); -} - function describeRun(claims: TokenClaims): string { - const parts = [ + return [ claims.repository, claims.workflow_ref ?? claims.workflow_path, claims.environment && `environment=${claims.environment}`, claims.run_id && `run=${claims.run_id}`, claims.actor && `actor=${claims.actor}`, - ]; - return parts.filter(Boolean).join(' '); + ] + .filter(Boolean) + .join(' '); } -/** - * Verifies each candidate policy's audience at most once. Policies for one instance normally share - * an audience, so this is usually a single verification; grouping keeps it that way rather than - * re-verifying the signature per policy. - */ +/** Verifies each distinct audience at most once, so N policies sharing one cost one verification. */ async function findMatchingPolicy( token: string, issuer: string, policies: OidcTrustPolicy[] ): Promise<{ policy: OidcTrustPolicy; claims: TokenClaims } | undefined> { - const verifiedByAudience = new Map(); + const claimsByAudience = new Map(); for (const policy of policies) { - if (!verifiedByAudience.has(policy.audience)) { - try { - verifiedByAudience.set( - policy.audience, - await verifyIdentityToken(token, { issuer, audience: policy.audience }) - ); - } catch (error) { - // verifyIdentityToken already logged the reason. - verifiedByAudience.set(policy.audience, undefined); - void error; - } + if (!claimsByAudience.has(policy.audience)) { + // verifyIdentityToken logs its own reason for refusing. + const verified = await verifyIdentityToken(token, { issuer, audience: policy.audience }).catch(() => undefined); + claimsByAudience.set(policy.audience, verified); } - const claims = verifiedByAudience.get(policy.audience); + const claims = claimsByAudience.get(policy.audience); if (!claims) continue; const mismatch = matchTrustPolicyClaims(claims, policy.claims); - if (mismatch) { - logger.debug?.(`Trust policy '${policy.id}' did not match: ${mismatch}`); - continue; - } - return { policy, claims }; + if (!mismatch) return { policy, claims }; + logger.debug?.(`Trust policy '${policy.id}' did not match: ${mismatch}`); } return undefined; } /** - * Marks an identity token as spent, rejecting one already recorded. + * Marks an identity token as spent, refusing one already recorded. * - * Recorded before the token is minted, not after: if minting fails the credential is burned, which - * costs a CI re-run. The reverse ordering would let a failure leave a spendable token behind. - * - * The get-then-put is not atomic. Harper's optimistic concurrency may serialize it in practice, but - * this deliberately does not depend on that — see getTokenUseTable for why the race is tolerable. + * Recorded before the token is minted: if minting then fails the credential is burned, costing a CI + * re-run, where the reverse ordering would leave a spendable token behind. The get-then-put is not + * atomic and deliberately does not depend on Harper's optimistic concurrency to make it so — see + * getTokenUseTable for why the race is tolerable. */ async function recordTokenUse(issuer: string, claims: TokenClaims, policyId: string): Promise { const useTable = getTokenUseTable(); const id = `${issuer}|${claims.jti}`; - if (await useTable.get(id)) rejectExchange(`token ${claims.jti} has already been exchanged`); + if (await useTable.get(id)) rejectToken(`token ${claims.jti} has already been exchanged`); await useTable.put({ id, @@ -145,10 +120,8 @@ async function recordTokenUse(issuer: string, claims: TokenClaims, policyId: str } /** - * Exchanges a CI identity token for a short-lived Harper operation token. - * - * Unauthenticated by design — this operation *is* the authentication, the same way - * create_authentication_tokens is. + * Exchanges a CI identity token for a short-lived Harper operation token. Unauthenticated by design — + * this operation *is* the authentication, the way create_authentication_tokens is against a password. */ export async function exchangeOidcToken(req: any) { const validation = validateBySchema( @@ -157,30 +130,29 @@ export async function exchangeOidcToken(req: any) { ); if (validation) throw new ClientError(validation.message); - // Read `iss` without verifying, only to select candidate policies. Nothing is trusted from this - // decode: the issuer it names must match a stored policy, and the signature is then checked - // against that policy's issuer. + // Nothing is trusted from this decode; it only selects candidate policies, and the signature is + // then checked against the issuer those policies declare. const unverified = jwt.decode(req.token, { complete: true }); let issuer: string; try { issuer = normalizeIssuer((unverified?.payload as any)?.iss); } catch { - rejectExchange('token has no usable iss claim'); + rejectToken('token has no usable iss claim'); } const policies = (await loadEnabledPolicies()).filter((policy) => policy.issuer === issuer); - if (policies.length === 0) rejectExchange(`no enabled trust policy for issuer ${issuer}`); + if (policies.length === 0) rejectToken(`no enabled trust policy for issuer ${issuer}`); const matched = await findMatchingPolicy(req.token, issuer, policies); - if (!matched) rejectExchange(`no trust policy matched a token from ${issuer}`); + if (!matched) rejectToken(`no trust policy matched a token from ${issuer}`); const { policy, claims } = matched; - // Resolve the user before spending the token, so a policy pointing at a deleted or deactivated - // user fails without burning a token the runner cannot re-mint. + // Resolved before the token is spent, so a policy naming a deleted or deactivated user fails + // without burning a token the runner cannot re-mint. const users = await getUsersWithRolesCache(); const user = users?.get(policy.user); - if (!user) rejectExchange(`trust policy '${policy.id}' names user '${policy.user}', which does not exist`); - if (user.active === false) rejectExchange(`trust policy '${policy.id}' names inactive user '${policy.user}'`); + if (!user) rejectToken(`trust policy '${policy.id}' names user '${policy.user}', which does not exist`); + if (user.active === false) rejectToken(`trust policy '${policy.id}' names inactive user '${policy.user}'`); await recordTokenUse(issuer, claims, policy.id); @@ -189,9 +161,8 @@ export async function exchangeOidcToken(req: any) { EXCHANGED_TOKEN_LIFETIME_SECONDS ); - // The audit trail for a credential handed to an external system: which policy, which user, and - // which run presented the token. - // TODO(#2171): route this through AuthAuditLog once the operation handler has request context. + // The audit trail for a credential handed to an external system. + // TODO(#2171): route through AuthAuditLog once the operation handler has request context. logger.info?.( `OIDC exchange: policy '${policy.id}' authenticated '${user.username}' for ${describeRun(claims)} (jti ${claims.jti})` ); diff --git a/security/oidcTrust/trustPolicyOperations.ts b/security/oidcTrust/trustPolicyOperations.ts index 0077734f1..43a818bae 100644 --- a/security/oidcTrust/trustPolicyOperations.ts +++ b/security/oidcTrust/trustPolicyOperations.ts @@ -24,9 +24,9 @@ const { HTTP_STATUS_CODES } = hdbErrors; const OIDC_TRUST_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TRUST_TABLE_NAME; /** - * GitHub's default audience is the repository owner's URL, which every repository under that owner - * shares. Accepting it would make a token minted by any repo in the org valid here, which is the one - * mistake this field exists to prevent. + * The canonical statement of why `audience` matters: GitHub's default is the repository owner's URL, + * shared by every repository under that owner, so accepting it would make a token minted by any repo + * in the org valid here — the one mistake the field exists to prevent. */ const SHARED_DEFAULT_AUDIENCE = /^https:\/\/github\.com\/[^/]+\/?$/i; @@ -81,19 +81,23 @@ function toRecord(row: any): OidcTrustPolicy & Record { } /** - * Reads every enabled policy. The set is small and administrator-managed, so a scan is cheaper than - * maintaining an index — and it keeps the matching order deterministic (by id) rather than + * The policy set is small and administrator-managed, so a scan beats maintaining an index — and + * sorting by id keeps both the listing and the exchange's match order deterministic rather than * dependent on an index's iteration order. */ -export async function loadEnabledPolicies(): Promise { +async function readPolicies(includeDisabled: boolean): Promise { const table = trustTable(); const policies: OidcTrustPolicy[] = []; for await (const row of table.search([])) { - if (row.enabled === false) continue; + if (!includeDisabled && row.enabled === false) continue; policies.push(toRecord(row)); } - policies.sort((a, b) => String(a.id).localeCompare(String(b.id))); - return policies; + return policies.sort((a, b) => String(a.id).localeCompare(String(b.id))); +} + +/** The policies the exchange will consider. */ +export function loadEnabledPolicies(): Promise { + return readPolicies(false); } /** @@ -169,14 +173,7 @@ export async function addOidcTrust(req: any) { export async function listOidcTrust(req: any) { requireSuperUser(req); - - const table = trustTable(); - const policies: unknown[] = []; - for await (const row of table.search([])) { - policies.push(toRecord(row)); - } - policies.sort((a: any, b: any) => String(a.id).localeCompare(String(b.id))); - return { policies }; + return { policies: await readPolicies(true) }; } export async function dropOidcTrust(req: any) { diff --git a/security/oidcTrust/types.ts b/security/oidcTrust/types.ts index c54a76ebf..450d08b4f 100644 --- a/security/oidcTrust/types.ts +++ b/security/oidcTrust/types.ts @@ -2,38 +2,27 @@ * Types for OIDC trusted publishing (#2171). */ -/** A claim constraint: one accepted value, or a set of accepted values. */ +/** One accepted value, or a set of them. */ export type ClaimConstraint = string | string[]; /** - * A stored trust policy. Matching a policy lets an external CI run act as `user` without holding - * any Harper credential — so every field here is load-bearing, and `claims` is validated at write - * time (see validateTrustPolicyClaims) rather than trusted as written. + * A stored trust policy. Matching one lets an external CI run act as `user` without holding any + * Harper credential, so `claims` is validated at write time rather than trusted as written — see + * validateTrustPolicyClaims for the structural requirements, and addOidcTrust for the rest. */ export interface OidcTrustPolicy { - /** Caller-supplied identifier, and the handle used to revoke. */ id: string; - /** Expected `iss`. Also the base for OIDC discovery. */ + /** Expected `iss`, and the base for OIDC discovery. */ issuer: string; - /** - * Expected `aud`. Must identify *this* instance: the issuer's default audience is shared by - * every repository under an owner, so without an instance-specific audience a token minted for - * an unrelated service is replayable here. - */ + /** Expected `aud`. Must identify this instance — see SHARED_DEFAULT_AUDIENCE. */ audience: string; - /** Claim constraints, matched against the normalized token claims. */ claims: Record; - /** - * Harper user the exchanged token authenticates as. Least privilege is this user's role — the - * policy deliberately carries no operation allowlist of its own, because a second authorization - * mechanism running alongside roles is one more place for the two to disagree. - */ + /** The exchanged token authenticates as this user, whose role is the least-privilege boundary. */ user: string; /** Defaults to true; false keeps the policy for reference without honoring it. */ enabled?: boolean; - /** Free-text note for whoever reads `list_oidc_trust` a year from now. */ description?: string; } -/** Claims carried by a verified identity token, plus the derived entries normalizeTokenClaims adds. */ +/** A verified token's payload, plus the entries normalizeTokenClaims derives. */ export type TokenClaims = Record; diff --git a/unitTests/security/oidcTrust/verifyIdentityToken.test.js b/unitTests/security/oidcTrust/verifyIdentityToken.test.js index 077195c3f..ae8f9045f 100644 --- a/unitTests/security/oidcTrust/verifyIdentityToken.test.js +++ b/unitTests/security/oidcTrust/verifyIdentityToken.test.js @@ -3,7 +3,7 @@ const assert = require('node:assert'); const { generateKeyPairSync, createPublicKey } = require('node:crypto'); const jwt = require('jsonwebtoken'); -const { verifyIdentityToken } = require('#src/security/oidcTrust/index'); +const { verifyIdentityToken } = require('#src/security/oidcTrust/identityToken'); const ISSUER = 'https://token.actions.githubusercontent.com'; const AUDIENCE = 'https://my-instance.harperdb.io:9925/'; diff --git a/upgrade/directives/5-3-0.ts b/upgrade/directives/5-3-0.ts index cf605e894..e38646862 100644 --- a/upgrade/directives/5-3-0.ts +++ b/upgrade/directives/5-3-0.ts @@ -2,17 +2,9 @@ // 5.3.0 — introduces system.hdb_oidc_trust for OIDC trusted publishing (#2171). // -// Fresh installs get the table automatically via utility/mount_hdb.ts (which iterates -// json/systemSchema.json on first boot). This directive handles the upgrade path: existing -// installs that already have a system schema need the new table added explicitly. -// -// IMPORTANT: this directive must be versioned to the first release that ships the trust-policy -// operations depending on the table. Directives only run when -// current_version < directive_version <= upgrade_version (see -// directivesController.getVersionsForUpgrade), so tagging it for a later release than the -// dependent code means it never fires and the table is missing on upgraded installs — -// exchange_oidc_token would then fail on every node that upgraded rather than installed fresh -// (see the mis-tagging history documented in 5-1-0.ts). +// Fresh installs get the table from json/systemSchema.json; this covers existing installs. The +// version must match the release that ships the dependent operations — see 5-1-0.ts for what +// happens when it does not, and DESIGN.md "System table bootstrap" for the three touchpoints. import { databases } from '../../resources/databases.ts'; import systemSchema from '../../json/systemSchema.json'; From 568cfbfc678a644b0c3c5c13d5302c557257bff4 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 14 Aug 2026 14:01:26 -0400 Subject: [PATCH 06/37] fix(security): keep identity and refresh tokens out of the operations log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processLocalTransaction logs every operation body at INFO — a common default level — after stripping a fixed field list. `token` was not on it, so exchange_oidc_token wrote the raw CI identity JWT verbatim on every call. The log happens before the handler, so a *rejected* attempt logged an unspent, still-usable credential. Caught in review by claude[bot] on #2173. Two adjacent fields had the same gap and are fixed here too, since it is the same list and the same class of bug: - `token` also carries the login-purpose token (login, #1876). - `refresh_token` carries the 30-day credential (refresh_operation_token) — pre-existing, and the longest-lived of the three. The inline rest-destructure became `redactForOperationLog` + `UNLOGGABLE_OPERATION_FIELDS`. That is not tidying: `operationLog` is built from mainLogger at module load, so the logged body cannot be intercepted after the fact, and the existing redaction test guards on `if (info_log_stub.called)` — which is never true in the unit environment, so it has been passing vacuously. Exporting the list and the function makes the contract directly testable, and drops an eslint-disable for unused vars along the way. Five tests cover it, including one pinning each credential-bearing field in the list so a refactor cannot quietly drop one the way harper#1527 did for set_env_value. Co-Authored-By: Claude Opus 5 --- server/serverHelpers/serverUtilities.ts | 62 +++++++++++-------- .../serverHelpers/serverUtilities.test.js | 55 ++++++++++++++++ 2 files changed, 92 insertions(+), 25 deletions(-) diff --git a/server/serverHelpers/serverUtilities.ts b/server/serverHelpers/serverUtilities.ts index 2998ffcae..ffa81097b 100644 --- a/server/serverHelpers/serverUtilities.ts +++ b/server/serverHelpers/serverUtilities.ts @@ -81,6 +81,42 @@ export type OperationFunctionName = ValueOf; * handles the response to the sender. */ // TODO: Replace Function type with an actual function type (e.g. (): Thingy) +/** + * Fields stripped from an operation body before it reaches the operations log. + * + * `credentials` carries a transient token on deploy_component (`registryAuth` is its pre-rename + * name — still stripped, since this runs ahead of the validation that now rejects it). `value` / + * `values` carry .env secrets from set_env_value; `value` / `envelope` carry secrets from + * set_secret. `token` is the login-purpose token (login) and the CI identity token + * (exchange_oidc_token), and is also stripped defensively — no operation declares a top-level `token`, + * but validation allows unknown keys, so a mistyped `harper deploy setup token=…` must not log a live + * credential. `refresh_token` is the 30-day credential (refresh_operation_token). + * + * Redaction runs *before* the handler, so a rejected request logs a still-spendable credential — + * which is why a new secret-bearing field belongs here rather than left to the default (harper#1527 + * was this same miss for set_env_value). + */ +export const UNLOGGABLE_OPERATION_FIELDS = [ + 'hdb_user', + 'hdbAuthHeader', + 'password', + 'payload', + 'credentials', + 'registryAuth', + 'value', + 'values', + 'envelope', + 'token', + 'refresh_token', +]; + +/** Callers gate this on log level: it allocates, and the operations log is often off. */ +export function redactForOperationLog(body: Record): Record { + const clean = { ...body }; + for (const field of UNLOGGABLE_OPERATION_FIELDS) delete clean[field]; + return clean; +} + export async function processLocalTransaction(req: OperationRequest, operationFunction: Function) { try { if ( @@ -89,31 +125,7 @@ export async function processLocalTransaction(req: OperationRequest, operationFu harperLogger.logLevel === terms.LOG_LEVELS.DEBUG || harperLogger.logLevel === terms.LOG_LEVELS.TRACE) ) { - // Need to remove auth variables and secret-bearing fields, but we don't want to create - // an object unless the logging is actually going to happen. credentials carries a - // transient token on deploy_component (registryAuth is its pre-rename name — still - // stripped, since this runs ahead of the validation that now rejects it, and a stale - // caller's token must not reach the log); value/values carry .env secrets from - // set_env_value; value/envelope carry secrets from set_secret — none may reach the - // operations log. `token` is stripped defensively: no operation declares one at the top - // level today, but validation allows unknown keys, so a caller that sends one anyway (a - // mistyped `harper deploy setup token=…`) would otherwise log a live credential. - /* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ - const { - hdb_user, - hdbAuthHeader, - password, - payload, - credentials, - registryAuth, - value, - values, - envelope, - token, - ...cleanBody - } = req.body; - /* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */ - operationLog.info(cleanBody); + operationLog.info(redactForOperationLog(req.body)); } } catch (e) { operationLog.error(e); diff --git a/unitTests/server/serverHelpers/serverUtilities.test.js b/unitTests/server/serverHelpers/serverUtilities.test.js index be79349c1..70bf34a7d 100644 --- a/unitTests/server/serverHelpers/serverUtilities.test.js +++ b/unitTests/server/serverHelpers/serverUtilities.test.js @@ -875,3 +875,58 @@ describe('Test serverUtilities.js module ', () => { }); }); }); + +// processLocalTransaction builds `operationLog` from mainLogger at module load, so the logged body +// cannot be intercepted after the fact — which is why the older redaction test above guards on +// `if (info_log_stub.called)` and passes vacuously. Testing the redaction directly avoids that. +describe('redactForOperationLog', () => { + const { UNLOGGABLE_OPERATION_FIELDS, redactForOperationLog } = serverUtilities; + + // Redaction runs before the handler, so a rejected request logs a still-spendable credential. + const CREDENTIAL_FIELDS = { + hdb_user: { username: 'admin' }, + hdbAuthHeader: 'Basic abc', + password: 'pw', + payload: 'blob', + credentials: [{ secret: 'deploy.app.github.com' }], + registryAuth: 'auth', + value: 'env-secret', + values: ['env-secret'], + envelope: 'enc:v1:sealed', + // login (#1876) and exchange_oidc_token (#2171) both carry a live token here. + token: 'eyJhbGciOiJSUzI1NiJ9.identity.signature', + // refresh_operation_token carries the 30-day credential. + refresh_token: 'eyJhbGciOiJSUzI1NiJ9.refresh.signature', + }; + + it('strips every credential-bearing field', () => { + const clean = redactForOperationLog({ operation: 'exchange_oidc_token', ...CREDENTIAL_FIELDS }); + for (const field of Object.keys(CREDENTIAL_FIELDS)) { + assert.ok(!(field in clean), `${field} must not reach the operations log`); + } + }); + + it('leaves nothing JWT-shaped behind', () => { + const clean = redactForOperationLog({ operation: 'exchange_oidc_token', ...CREDENTIAL_FIELDS }); + assert.ok(!/eyJ[A-Za-z0-9_-]/.test(JSON.stringify(clean)), 'no JWT-shaped value should survive'); + }); + + it('preserves everything else', () => { + const clean = redactForOperationLog({ operation: 'create_schema', schema: 'test', database: 'data' }); + assert.deepStrictEqual(clean, { operation: 'create_schema', schema: 'test', database: 'data' }); + }); + + it('does not mutate the request body', () => { + const body = { operation: 'exchange_oidc_token', token: 'live-credential' }; + redactForOperationLog(body); + assert.equal(body.token, 'live-credential', 'the handler still needs the field it was sent'); + }); + + // The list is the contract; pin the credential-bearing entries so a refactor cannot quietly drop + // one the way harper#1527 did for set_env_value. + it('pins the fields the list must contain', () => { + for (const field of Object.keys(CREDENTIAL_FIELDS)) { + assert.ok(UNLOGGABLE_OPERATION_FIELDS.includes(field), `${field} must stay in the redaction list`); + } + }); +}); From b0bfa96bb779f57312138948ac84d1f469f1c6de Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 14 Aug 2026 14:15:14 -0400 Subject: [PATCH 07/37] refactor(security): make the OIDC core issuer-agnostic, GitHub a profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @heskew's structural review on #2173. No change to the hardening; this is about where the issuer-specific parts live so the layer can seed an authn core rather than a GitHub feature. - security/oidcTrust/ -> security/authn/oidc/, and the CLI's ciIdentityToken.ts -> workloadIdentity.ts, structured as a provider list (GitHub Actions is entry one; a Kubernetes entry is available() testing for a projected token path and requestToken() reading it). - providers/githubActions.ts now owns everything GitHub-shaped: the three pin requirements, workflow_path derivation, the shared-default audience regex, and principal description. Nothing else in the module says GitHub. The ref-gate rule — the part flagged as most likely to be wrong — is right for GitHub and now cannot constrain any other issuer. - providers/generic.ts is the fallback for unregistered issuers, and is strict rather than permissive: the policy must pin `sub`. That makes Kubernetes service accounts, GCP service accounts, and SPIFFE SVIDs work with zero provider code, all of which have stable canonical subjects. GitHub needs a profile precisely because its `sub` is the one claim not to pin. - The GitHub profile default-denies `pull_request_target` unless a policy constrains event_name, closing the fork callout in #2171. A plain pull_request run from a fork already cannot mint (no id-token: write); pull_request_target can. - Replay is keyed on SHA-256 of the token rather than issuer|jti, and verifyIdentityToken no longer requires jti. Azure emits `uti` and others omit it; a replayed token is byte-identical by definition, so this is strictly more general. Hashed, so the table never holds a credential. - Exchanges now emit AuthAuditLog on success and failure, through the same stream and the same logging.auditAuthEvents switches as every other authentication event. serverHandlers already injects baseRequest for NO_AUTH_OPERATIONS, so ip/method/path are available — the TODO is gone rather than deferred. claims.ts keeps only issuer-agnostic matching and constraint-shape validation; validateTrustPolicyClaims split into that plus the profile's assertPolicyIsSpecific. Tests restructured to match: provider profiles get their own suites, claims.test.js uses a deliberately non-GitHub token, and the exchange suite gains a second issuer with no profile to prove the zero-provider- code path end to end. 438 green across the touched suites. Co-Authored-By: Claude Opus 5 --- DESIGN.md | 24 +- bin/ciIdentityToken.ts | 95 ------ bin/cliOperations.ts | 6 +- bin/workloadIdentity.ts | 119 ++++++++ security/authn/oidc/claims.ts | 68 +++++ .../oidc}/identityToken.ts | 12 +- security/{oidcTrust => authn/oidc}/jwks.ts | 4 +- security/authn/oidc/providers/generic.ts | 40 +++ .../authn/oidc/providers/githubActions.ts | 130 ++++++++ security/authn/oidc/providers/index.ts | 52 ++++ security/authn/oidc/tokenExchange.ts | 223 ++++++++++++++ .../oidc}/trustPolicyOperations.ts | 35 +-- security/{oidcTrust => authn/oidc}/types.ts | 0 security/oidcTrust/claims.ts | 130 -------- security/oidcTrust/tokenExchange.ts | 176 ----------- server/serverHelpers/serverUtilities.ts | 4 +- ...Token.test.js => workloadIdentity.test.js} | 34 +-- unitTests/security/authn/oidc/claims.test.js | 93 ++++++ .../{oidcTrust => authn/oidc}/jwks.test.js | 4 +- .../authn/oidc/providers/generic.test.js | 73 +++++ .../oidc/providers/githubActions.test.js | 207 +++++++++++++ .../oidc}/tokenExchange.test.js | 149 ++++++++- .../oidc}/trustPolicyOperations.test.js | 6 +- .../oidc}/verifyIdentityToken.test.js | 11 +- unitTests/security/oidcTrust/claims.test.js | 286 ------------------ utility/operation_authorization.ts | 4 +- 26 files changed, 1215 insertions(+), 770 deletions(-) delete mode 100644 bin/ciIdentityToken.ts create mode 100644 bin/workloadIdentity.ts create mode 100644 security/authn/oidc/claims.ts rename security/{oidcTrust => authn/oidc}/identityToken.ts (89%) rename security/{oidcTrust => authn/oidc}/jwks.ts (98%) create mode 100644 security/authn/oidc/providers/generic.ts create mode 100644 security/authn/oidc/providers/githubActions.ts create mode 100644 security/authn/oidc/providers/index.ts create mode 100644 security/authn/oidc/tokenExchange.ts rename security/{oidcTrust => authn/oidc}/trustPolicyOperations.ts (83%) rename security/{oidcTrust => authn/oidc}/types.ts (100%) delete mode 100644 security/oidcTrust/claims.ts delete mode 100644 security/oidcTrust/tokenExchange.ts rename unitTests/bin/{ciIdentityToken.test.js => workloadIdentity.test.js} (80%) create mode 100644 unitTests/security/authn/oidc/claims.test.js rename unitTests/security/{oidcTrust => authn/oidc}/jwks.test.js (99%) create mode 100644 unitTests/security/authn/oidc/providers/generic.test.js create mode 100644 unitTests/security/authn/oidc/providers/githubActions.test.js rename unitTests/security/{oidcTrust => authn/oidc}/tokenExchange.test.js (68%) rename unitTests/security/{oidcTrust => authn/oidc}/trustPolicyOperations.test.js (98%) rename unitTests/security/{oidcTrust => authn/oidc}/verifyIdentityToken.test.js (92%) delete mode 100644 unitTests/security/oidcTrust/claims.test.js diff --git a/DESIGN.md b/DESIGN.md index 45ff3599e..2c67f0348 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -190,24 +190,28 @@ System tables replicate by default. To opt out, add the name to `NON_REPLICATING If the table needs `audit: true`, set it both in the schema (for fresh installs) **and** on the `CreateTableObject` instance in the directive (for upgrades) — otherwise the two paths diverge. -## OIDC trusted publishing (`security/oidcTrust/`) +## OIDC trusted publishing (`security/authn/oidc/`) -`exchange_oidc_token` lets a CI runner authenticate with no stored Harper credential (#2171): it presents an identity token minted by its provider, and gets back a one-hour operation token for the user a stored trust policy names. It is in `NO_AUTH_OPERATIONS` because it _is_ the authentication, the same way `create_authentication_tokens` is against a password — the same three wiring points apply (`serverHandlers.js` `NO_AUTH_OPERATIONS`, the `verifyPerms` bypass in `serverUtilities.ts`, and a `permission(false, [])` registration). +`exchange_oidc_token` lets a workload authenticate with no stored Harper credential (#2171): it presents an identity token minted by its runtime, and gets back a one-hour operation token for the user a stored trust policy names. It is in `NO_AUTH_OPERATIONS` because it _is_ the authentication, the same way `create_authentication_tokens` is against a password — the same three wiring points apply (`serverHandlers.js` `NO_AUTH_OPERATIONS`, the `verifyPerms` bypass in `serverUtilities.ts`, and a `permission(false, [])` registration). -The layering is deliberate and worth preserving: +**The core is issuer-agnostic; everything issuer-specific lives in `providers/`.** That split is the point of the layout, not an accident of it — a new workload-identity issuer should be a profile, not a change to verification, matching, or storage. -- `claims.ts` is pure — normalization, matching, and write-time policy validation. It never touches the network or storage, so the rules that decide whether a workflow may act as a user are directly testable. -- `jwks.ts` owns issuer keys. The rate-limit clock for unknown-`kid` refetches lives _outside_ the cache entry: a successful fetch replaces the entry, and a rate limit that resets whenever it fires is not a rate limit. Keeping it separate also means a genuine key rotation is picked up on first use rather than after the window. -- `identityToken.ts` verifies signature/issuer/audience and additionally requires `exp`, a bounded lifetime, and `jti`. It also owns `rejectToken`, shared with the exchange so both halves refuse identically. -- `tokenExchange.ts` selects a policy and mints. Verification is memoized per audience, so N policies sharing one audience cost one signature check. +- `claims.ts` — matching and constraint _shape_ validation. Knows nothing about any issuer. +- `jwks.ts` — issuer keys. The rate-limit clock for unknown-`kid` refetches lives _outside_ the cache entry: a successful fetch replaces the entry, and a rate limit that resets whenever it fires is not a rate limit. Keeping it separate also means a genuine key rotation is picked up on first use rather than after the window. +- `identityToken.ts` — signature, issuer, audience, `exp`, and a bounded lifetime. Owns `rejectToken`, shared with the exchange so both halves refuse identically. +- `tokenExchange.ts` — policy selection, replay, minting, audit. Verification is memoized per audience, so N policies sharing one cost one signature check. +- `providers/` — `assertPolicyIsSpecific` / `assertAudienceIsSpecific` / `normalizeClaims` / `describePrincipal` / optional `vetoClaims`, resolved by normalized issuer. -Three constraints that look like choices but are not: +**An unregistered issuer gets `providers/generic.ts`, which is strict rather than permissive:** the policy must pin `sub`. That is what makes Kubernetes service accounts, GCP service accounts, and SPIFFE SVIDs work with zero provider code — each has a stable canonical subject. GitHub needs its own profile precisely because its `sub` is the one claim you should _not_ pin: it varies by trigger, and its format changed for repositories created after 2026-07-15. + +Four constraints that look like choices but are not: 1. **Every rejection returns the same message.** The endpoint is unauthenticated; a caller told which check failed can enumerate a policy one claim at a time. Reasons go to the `oidc-trust` logger. -2. **A policy must gate the ref.** `validateTrustPolicyClaims` rejects a policy pinning only repository + workflow, because anyone who can push a branch could then add that workflow to it and mint a token. This is stricter than npm's trusted-publishing model, which mitigates the same hole with environment protection instead. +2. **A GitHub policy must gate the ref.** `githubActionsProfile.assertPolicyIsSpecific` rejects a policy pinning only repository + workflow, because anyone who can push a branch could then add that workflow to it and mint a token. Stricter than npm's trusted-publishing model, which mitigates the same hole with environment protection instead — and profile-scoped, so it never constrains another issuer. 3. **`createOperationToken`, not `createTokens`.** `createTokens` overwrites `hdb_user.refresh_token` as a side effect, so minting for CI would silently revoke whatever credential that user already held (#2018) — the exact problem this feature removes. +4. **No per-policy operation allowlist.** Least privilege is the role of the user the policy names. A second authorization mechanism beside roles is one more place for the two to disagree, and Harper's existing `permission.operations` is not purely narrowing — gate 2 in `operation_authorization.ts` treats an explicit listing of an SU-only operation as a deliberate grant, so a naive reuse could _widen_ rather than narrow. -`hdb_oidc_token_use` (created lazily via `table()`, not the system schema) records spent `jti`s with `expiresAt` set past the token's own expiry. The get-then-put is not atomic and does not claim to be: a concurrent replay is not a privilege escalation, since whoever holds the token could obtain one operation token anyway. +`hdb_oidc_token_use` (created lazily via `table()`, not the system schema) records spent tokens keyed on a SHA-256 of the token itself, with `expiresAt` past the token's own expiry. Hashed rather than stored, so the table never holds a credential; keyed on the token rather than `jti` because not every issuer emits one (Azure uses `uti`) and a replayed token is byte-identical by definition. The get-then-put is not atomic and does not claim to be: a concurrent replay is not a privilege escalation, since whoever holds the token could obtain one operation token anyway. ## Table drops, the `dropping` tombstone, and ghost tables diff --git a/bin/ciIdentityToken.ts b/bin/ciIdentityToken.ts deleted file mode 100644 index c6fdbe2d2..000000000 --- a/bin/ciIdentityToken.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * CI-side half of OIDC trusted publishing (#2171): ask the provider for an identity token addressed - * to this instance, trade it for a short-lived operation token via `exchange_oidc_token`. - * - * GitHub Actions only for now. Other providers expose the same idea through different plumbing, so - * detection stays explicit rather than guessed — an unrecognized runner falls through to the CLI's - * other credential sources. - */ - -import { httpRequest } from '../utility/common_utils.ts'; - -/** GitHub sets both of these on a job that declares `permissions: id-token: write`. */ -const GITHUB_TOKEN_REQUEST_URL = 'ACTIONS_ID_TOKEN_REQUEST_URL'; -const GITHUB_TOKEN_REQUEST_TOKEN = 'ACTIONS_ID_TOKEN_REQUEST_TOKEN'; - -const IDENTITY_REQUEST_TIMEOUT_MS = 10_000; - -/** - * Both variables are required: GitHub sets them together, so their absence means the workflow did - * not grant `id-token: write` — a configuration answer, not a failure to report here. - */ -export function ciIdentityAvailable(): boolean { - return Boolean(process.env[GITHUB_TOKEN_REQUEST_URL] && process.env[GITHUB_TOKEN_REQUEST_TOKEN]); -} - -/** - * Passing `audience` explicitly is what makes the token unusable anywhere else — GitHub's default is - * shared org-wide (see SHARED_DEFAULT_AUDIENCE in security/oidcTrust/trustPolicyOperations.ts). - */ -async function requestGithubIdentityToken(audience: string): Promise { - const requestUrl = new URL(process.env[GITHUB_TOKEN_REQUEST_URL] as string); - requestUrl.searchParams.set('audience', audience); - - const response = await fetch(requestUrl, { - headers: { - authorization: `Bearer ${process.env[GITHUB_TOKEN_REQUEST_TOKEN]}`, - accept: 'application/json', - }, - signal: AbortSignal.timeout(IDENTITY_REQUEST_TIMEOUT_MS), - }); - if (!response.ok) { - throw new Error(`GitHub returned ${response.status} for an identity token`); - } - const body: any = await response.json(); - if (typeof body?.value !== 'string' || body.value === '') { - throw new Error('GitHub returned no identity token value'); - } - return body.value; -} - -/** - * Trades a CI identity token for a Harper operation token, or undefined when there is no identity to - * offer. Failures are reported and swallowed: this is the last credential source before the request - * goes out unauthenticated, and the resulting 401 says nothing useful, so the reason is worth - * printing even though it is not by itself fatal. - */ -export async function exchangeCiIdentityForToken(options: any, audience: string): Promise { - if (!ciIdentityAvailable()) return undefined; - - console.error(`Requesting a CI identity token for ${audience}...`); - let identityToken: string; - try { - identityToken = await requestGithubIdentityToken(audience); - } catch (error) { - console.error(`Could not obtain a CI identity token: ${(error as Error).message}`); - return undefined; - } - - try { - const response = await httpRequest(options, { operation: 'exchange_oidc_token', token: identityToken }); - if (response.statusCode === 200) { - const data = JSON.parse(response.body); - if (data.operation_token) { - console.error(`Authenticated as '${data.username}' via OIDC trust policy '${data.policy}'.`); - return data.operation_token; - } - console.error('The OIDC exchange returned no operation token.'); - return undefined; - } - if (response.statusCode === 401) { - // The server deliberately does not say which check failed, so point at the two things the - // operator can actually inspect rather than inventing a cause. - console.error( - 'Harper rejected the CI identity token. Check that a trust policy matches this workflow ' + - '(list_oidc_trust) and that its audience is this instance; the server log records the reason.' - ); - return undefined; - } - console.error(`OIDC exchange failed: ${response.statusCode}`); - return undefined; - } catch (error) { - console.error(`Error exchanging the CI identity token: ${(error as Error).message}`); - return undefined; - } -} diff --git a/bin/cliOperations.ts b/bin/cliOperations.ts index cef8dc6a0..e767a1c13 100644 --- a/bin/cliOperations.ts +++ b/bin/cliOperations.ts @@ -6,7 +6,7 @@ import * as envMgr from '../utility/environment/environmentManager.ts'; envMgr.initSync(); import * as terms from '../utility/hdbTerms.ts'; import { httpRequest } from '../utility/common_utils.ts'; -import { ciIdentityAvailable, exchangeCiIdentityForToken } from './ciIdentityToken.ts'; +import { workloadIdentityAvailable, exchangeWorkloadIdentityForToken } from './workloadIdentity.ts'; import * as path from 'path'; import * as fs from 'fs-extra'; import * as YAML from 'yaml'; @@ -822,12 +822,12 @@ export async function resolveRequestOptions(req: any): Promise<{ options: any; t if (tokens.operation_token) { options.headers.Authorization = `Bearer ${tokens.operation_token}`; } - } else if (ciIdentityAvailable()) { + } else if (workloadIdentityAvailable()) { // Last credential source: no configured token, but this runner can prove its identity to // the cluster directly (#2171). Deliberately below the env-var and saved tokens — an // explicitly configured credential should keep working exactly as it did when someone adds // `id-token: write` to a workflow, rather than silently switching which identity deploys. - const operationToken = await exchangeCiIdentityForToken(options, target.resolvedTarget); + const operationToken = await exchangeWorkloadIdentityForToken(options, target.resolvedTarget); if (operationToken) options.headers.Authorization = `Bearer ${operationToken}`; } } diff --git a/bin/workloadIdentity.ts b/bin/workloadIdentity.ts new file mode 100644 index 000000000..53675889b --- /dev/null +++ b/bin/workloadIdentity.ts @@ -0,0 +1,119 @@ +/** + * Client half of OIDC trusted publishing (#2171): ask the runtime for a workload identity token + * addressed to this instance, trade it for a short-lived operation token via `exchange_oidc_token`. + * + * Structured as a provider list because the runtimes differ only in how the token is obtained. + * GitHub Actions is entry one; a Kubernetes entry is `available()` testing for a projected + * service-account token path and `requestToken()` reading that file. A runtime none of them + * recognizes falls through to the CLI's other credential sources. + */ + +import { httpRequest } from '../utility/common_utils.ts'; + +interface WorkloadIdentityProvider { + name: string; + /** True when this process can obtain a token from this runtime. */ + available(): boolean; + /** + * Obtains an identity token bound to `audience`. Binding it is what makes the token unusable + * anywhere else — see SHARED_DEFAULT_AUDIENCE in security/authn/oidc/providers/githubActions.ts + * for what an unbound one costs. + */ + requestToken(audience: string): Promise; +} + +/** GitHub sets both of these on a job that declares `permissions: id-token: write`. */ +const GITHUB_TOKEN_REQUEST_URL = 'ACTIONS_ID_TOKEN_REQUEST_URL'; +const GITHUB_TOKEN_REQUEST_TOKEN = 'ACTIONS_ID_TOKEN_REQUEST_TOKEN'; + +const IDENTITY_REQUEST_TIMEOUT_MS = 10_000; + +const githubActions: WorkloadIdentityProvider = { + name: 'GitHub Actions', + + /** + * Both variables are required: GitHub sets them together, so their absence means the workflow did + * not grant `id-token: write` — a configuration answer, not a failure to report here. + */ + available(): boolean { + return Boolean(process.env[GITHUB_TOKEN_REQUEST_URL] && process.env[GITHUB_TOKEN_REQUEST_TOKEN]); + }, + + async requestToken(audience: string): Promise { + const requestUrl = new URL(process.env[GITHUB_TOKEN_REQUEST_URL] as string); + requestUrl.searchParams.set('audience', audience); + + const response = await fetch(requestUrl, { + headers: { + authorization: `Bearer ${process.env[GITHUB_TOKEN_REQUEST_TOKEN]}`, + accept: 'application/json', + }, + signal: AbortSignal.timeout(IDENTITY_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`GitHub returned ${response.status} for an identity token`); + + const body: any = await response.json(); + if (typeof body?.value !== 'string' || body.value === '') { + throw new Error('GitHub returned no identity token value'); + } + return body.value; + }, +}; + +const PROVIDERS: WorkloadIdentityProvider[] = [githubActions]; + +function activeProvider(): WorkloadIdentityProvider | undefined { + return PROVIDERS.find((provider) => provider.available()); +} + +/** True when this runtime can prove its own identity to the cluster. */ +export function workloadIdentityAvailable(): boolean { + return activeProvider() !== undefined; +} + +/** + * Trades a workload identity token for a Harper operation token, or undefined when this runtime has + * no identity to offer. Failures are reported and swallowed: this is the last credential source + * before the request goes out unauthenticated, and the resulting 401 says nothing useful, so the + * reason is worth printing even though it is not by itself fatal. + */ +export async function exchangeWorkloadIdentityForToken(options: any, audience: string): Promise { + const provider = activeProvider(); + if (!provider) return undefined; + + console.error(`Requesting a ${provider.name} identity token for ${audience}...`); + let identityToken: string; + try { + identityToken = await provider.requestToken(audience); + } catch (error) { + console.error(`Could not obtain a ${provider.name} identity token: ${(error as Error).message}`); + return undefined; + } + + try { + const response = await httpRequest(options, { operation: 'exchange_oidc_token', token: identityToken }); + if (response.statusCode === 200) { + const data = JSON.parse(response.body); + if (data.operation_token) { + console.error(`Authenticated as '${data.username}' via OIDC trust policy '${data.policy}'.`); + return data.operation_token; + } + console.error('The OIDC exchange returned no operation token.'); + return undefined; + } + if (response.statusCode === 401) { + // The server deliberately does not say which check failed, so point at the two things the + // operator can actually inspect rather than inventing a cause. + console.error( + 'Harper rejected the identity token. Check that a trust policy matches this workload ' + + '(list_oidc_trust) and that its audience is this instance; the server log records the reason.' + ); + return undefined; + } + console.error(`OIDC exchange failed: ${response.statusCode}`); + return undefined; + } catch (error) { + console.error(`Error exchanging the identity token: ${(error as Error).message}`); + return undefined; + } +} diff --git a/security/authn/oidc/claims.ts b/security/authn/oidc/claims.ts new file mode 100644 index 000000000..3df50d513 --- /dev/null +++ b/security/authn/oidc/claims.ts @@ -0,0 +1,68 @@ +/** + * Issuer-agnostic claim matching and constraint validation (#2171). + * + * Pure — no network, no storage, and nothing that knows which issuer a token came from. Rules that + * depend on the issuer live in providers/. + */ + +import { ClientError } from '../../../utility/errors/hdbError.ts'; +import type { ClaimConstraint, TokenClaims } from './types.ts'; + +/** Issuers may encode a numeric id as a JSON number; anything non-scalar is not comparable. */ +function claimToString(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return undefined; +} + +/** + * Returns undefined on a match, or the first failure's reason — for the log, never the caller. + * + * A constrained claim absent from the token fails rather than passes, so a policy cannot be weakened + * by an issuer that stops emitting a claim. + */ +export function matchTrustPolicyClaims( + claims: TokenClaims, + policyClaims: Record +): string | undefined { + const constraints = Object.entries(policyClaims); + // validateClaimConstraintShape rejects this at write time; this backstops a row that reached the + // table another way, such as replication from a peer. + if (constraints.length === 0) return 'policy constrains no claims'; + + for (const [claimName, constraint] of constraints) { + const actual = claimToString(claims[claimName]); + if (actual === undefined || actual === '') return `token has no usable ${claimName} claim`; + const accepted = Array.isArray(constraint) ? constraint : [constraint]; + if (!accepted.includes(actual)) return `${claimName} does not match the policy`; + } + return undefined; +} + +/** + * Validates the *shape* of a policy's constraints — that each is a usable set of comparable values. + * Whether the set is specific enough to be safe depends on the issuer; that is the provider profile's + * assertPolicyIsSpecific. + * + * Throws ClientError naming the first problem; the reader is an administrator writing a policy. + */ +export function validateClaimConstraintShape( + policyClaims: unknown +): asserts policyClaims is Record { + if (!policyClaims || typeof policyClaims !== 'object' || Array.isArray(policyClaims)) { + throw new ClientError('claims must be an object of claim constraints'); + } + + const entries = Object.entries(policyClaims as Record); + if (entries.length === 0) throw new ClientError('claims must constrain at least one claim'); + + for (const [claimName, constraint] of entries) { + const values = Array.isArray(constraint) ? constraint : [constraint]; + if (values.length === 0) throw new ClientError(`claims.${claimName} must accept at least one value`); + for (const value of values) { + if (typeof value !== 'string' || value === '') { + throw new ClientError(`claims.${claimName} must be a non-empty string or an array of non-empty strings`); + } + } + } +} diff --git a/security/oidcTrust/identityToken.ts b/security/authn/oidc/identityToken.ts similarity index 89% rename from security/oidcTrust/identityToken.ts rename to security/authn/oidc/identityToken.ts index 8597936ed..1c7ae8a65 100644 --- a/security/oidcTrust/identityToken.ts +++ b/security/authn/oidc/identityToken.ts @@ -8,10 +8,10 @@ import jwt, { type Algorithm, type JwtPayload } from 'jsonwebtoken'; import type { KeyObject } from 'node:crypto'; -import { ClientError } from '../../utility/errors/hdbError.ts'; -import { loggerWithTag } from '../../utility/logging/logger.ts'; +import { ClientError } from '../../../utility/errors/hdbError.ts'; +import { loggerWithTag } from '../../../utility/logging/logger.ts'; import { getSigningKey as defaultGetSigningKey, normalizeIssuer } from './jwks.ts'; -import { normalizeTokenClaims } from './claims.ts'; +import { profileForIssuer } from './providers/index.ts'; import type { TokenClaims } from './types.ts'; const logger = loggerWithTag('oidc-trust'); @@ -101,8 +101,8 @@ export async function verifyIdentityToken( if (typeof payload.iat === 'number' && payload.exp - payload.iat > MAX_TOKEN_LIFETIME_SECONDS) { rejectToken(`token lifetime exceeds ${MAX_TOKEN_LIFETIME_SECONDS}s`); } - // A token we cannot identify is one the exchange cannot replay-protect. - if (typeof payload.jti !== 'string' || payload.jti === '') rejectToken('token has no jti claim'); + // No `jti` requirement: not every issuer emits one (Azure uses `uti`, others omit it), and the + // exchange keys replay on a hash of the token itself, which is universal. - return normalizeTokenClaims(payload as TokenClaims); + return profileForIssuer(issuer).normalizeClaims(payload as TokenClaims); } diff --git a/security/oidcTrust/jwks.ts b/security/authn/oidc/jwks.ts similarity index 98% rename from security/oidcTrust/jwks.ts rename to security/authn/oidc/jwks.ts index e94cb3a40..0fb607822 100644 --- a/security/oidcTrust/jwks.ts +++ b/security/authn/oidc/jwks.ts @@ -8,8 +8,8 @@ */ import { createPublicKey, type KeyObject } from 'node:crypto'; -import { loggerWithTag } from '../../utility/logging/logger.ts'; -import { ClientError, ServerError } from '../../utility/errors/hdbError.ts'; +import { loggerWithTag } from '../../../utility/logging/logger.ts'; +import { ClientError, ServerError } from '../../../utility/errors/hdbError.ts'; const logger = loggerWithTag('oidc-trust'); diff --git a/security/authn/oidc/providers/generic.ts b/security/authn/oidc/providers/generic.ts new file mode 100644 index 000000000..3c5fa303b --- /dev/null +++ b/security/authn/oidc/providers/generic.ts @@ -0,0 +1,40 @@ +/** + * Fallback profile for issuers with no registered provider profile (#2171). + * + * Strict rather than permissive: the policy must pin `sub`. That is what makes workload identity work + * with zero provider code — a Kubernetes service-account token + * (`system:serviceaccount::`), a GCP service account, and a SPIFFE SVID all carry a + * stable canonical subject, so pinning it identifies exactly one principal. + * + * GitHub Actions needs its own profile precisely because its `sub` is the one claim you should not + * pin: it varies by trigger, and its format changed for repositories created after 2026-07-15. + */ + +import { ClientError } from '../../../../utility/errors/hdbError.ts'; +import type { ClaimConstraint, TokenClaims } from '../types.ts'; +import type { IdentityProviderProfile } from './index.ts'; + +export const genericProfile: IdentityProviderProfile = { + name: 'generic OIDC', + + assertPolicyIsSpecific(policyClaims: Record): void { + if (policyClaims.sub === undefined) { + throw new ClientError( + 'claims must pin `sub` for an issuer with no registered provider profile — it is the only ' + + 'claim every OIDC issuer defines as identifying a single principal. Register a provider ' + + 'profile if this issuer needs richer rules.' + ); + } + }, + + // No known shared default audience; the required `sub` pin already binds the policy to one principal. + assertAudienceIsSpecific(): void {}, + + normalizeClaims(payload: TokenClaims): TokenClaims { + return { ...payload }; + }, + + describePrincipal(claims: TokenClaims): string { + return typeof claims.sub === 'string' ? claims.sub : 'unknown principal'; + }, +}; diff --git a/security/authn/oidc/providers/githubActions.ts b/security/authn/oidc/providers/githubActions.ts new file mode 100644 index 000000000..ca026102c --- /dev/null +++ b/security/authn/oidc/providers/githubActions.ts @@ -0,0 +1,130 @@ +/** + * GitHub Actions provider profile (#2171). + * + * The only module that should know anything about GitHub. Its rules are deliberately stricter than + * npm's trusted-publishing model, and scoping them here means that strictness never constrains + * another issuer. + */ + +import { ClientError } from '../../../../utility/errors/hdbError.ts'; +import type { ClaimConstraint, TokenClaims } from '../types.ts'; +import type { IdentityProviderProfile } from './index.ts'; + +/** + * Workflow references are `//@` and the ref is always a full `refs/...` name. + * Splitting on `@refs/` rather than the first or last `@` is exact, because a path segment cannot + * contain `/` — so this cannot occur inside the path, and a branch named `release@2` cannot fool it. + */ +const REF_QUALIFIER = '@refs/'; + +/** + * GitHub's default audience is the repository owner's URL, shared by every repository under that + * owner, so accepting it would make a token minted by any repo in the org valid here — the one + * mistake the audience field exists to prevent. + */ +const SHARED_DEFAULT_AUDIENCE = /^https:\/\/github\.com\/[^/]+\/?$/i; + +/** + * Structural requirements on a policy's claim set, each guarding a distinct way a policy can be + * accidentally broad. Satisfying none of a row's claims admits, in order: any repository, any + * workflow in that repository, any branch that can be pushed to it. + * + * `repository_owner` is absent from the first row on purpose — it identifies an org, not a + * repository, so it would admit every repo in the org. `ref_type` is absent from the third for the + * same shape of reason: `ref_type: tag` still admits any tag, and anyone with push access can create + * one. A tag-triggered release pins `environment` and leans on GitHub's environment protection. + * + * `sub` is deliberately not accepted as a pin: it varies by trigger, and its format changed for + * repositories created after 2026-07-15 (immutable subjects embed owner and repo ids). + */ +const STRUCTURAL_REQUIREMENTS = [ + { + requirement: 'pin the repository', + claims: ['repository_id', 'repository'], + because: ' (repository_id is immutable and survives renames)', + }, + { + requirement: 'pin the workflow', + claims: ['workflow_ref', 'workflow_path', 'job_workflow_ref', 'job_workflow_path'], + because: '', + }, + { + requirement: 'gate the ref', + claims: ['workflow_ref', 'job_workflow_ref', 'ref', 'environment'], + because: ' — otherwise any branch that can be pushed to the repository can run the workflow and mint a token', + }, +]; + +/** Returns undefined rather than guessing when the value is not a ref-qualified reference. */ +export function splitWorkflowPath(workflowRef: unknown): string | undefined { + if (typeof workflowRef !== 'string') return undefined; + const qualifierIndex = workflowRef.indexOf(REF_QUALIFIER); + return qualifierIndex === -1 ? undefined : workflowRef.slice(0, qualifierIndex); +} + +export const githubActionsProfile: IdentityProviderProfile = { + name: 'GitHub Actions', + + assertPolicyIsSpecific(policyClaims: Record): void { + const constrained = new Set(Object.keys(policyClaims)); + for (const { requirement, claims, because } of STRUCTURAL_REQUIREMENTS) { + if (!claims.some((claimName) => constrained.has(claimName))) { + throw new ClientError(`claims must ${requirement} with one of: ${claims.join(', ')}${because}`); + } + } + }, + + assertAudienceIsSpecific(audience: string): void { + if (SHARED_DEFAULT_AUDIENCE.test(audience)) { + throw new ClientError( + `'audience' must identify this instance, not '${audience}' — GitHub's default audience is shared ` + + `by every repository under an owner, so a token minted for any of them would be accepted here. ` + + `Use the instance URL the CI client targets.` + ); + } + }, + + /** + * Adds `workflow_path` / `job_workflow_path` — the workflow reference with the ref removed — so a + * policy can pin the workflow *file* while gating the ref some other way. A tag-triggered release + * cannot pin `workflow_ref`, because the tag is unknown when the policy is written. + * + * A claim the token actually carries is never displaced by one we synthesized. + */ + normalizeClaims(payload: TokenClaims): TokenClaims { + const claims: TokenClaims = { ...payload }; + for (const [source, derived] of [ + ['workflow_ref', 'workflow_path'], + ['job_workflow_ref', 'job_workflow_path'], + ]) { + const path = splitWorkflowPath(payload[source]); + if (path !== undefined && claims[derived] === undefined) claims[derived] = path; + } + return claims; + }, + + /** + * A `pull_request_target` run executes the base repository's workflow with its secrets while a + * fork controls the checked-out code. A plain `pull_request` run from a fork gets no + * `id-token: write` and so cannot mint at all, but `pull_request_target` can — so it is denied + * unless a policy constrains `event_name`, which is the explicit opt-in. + */ + vetoClaims(claims: TokenClaims, policyClaims: Record): string | undefined { + if (claims.event_name === 'pull_request_target' && policyClaims.event_name === undefined) { + return 'pull_request_target is denied unless the policy constrains event_name'; + } + return undefined; + }, + + describePrincipal(claims: TokenClaims): string { + return [ + claims.repository, + claims.workflow_ref ?? claims.workflow_path, + claims.environment && `environment=${claims.environment}`, + claims.run_id && `run=${claims.run_id}`, + claims.actor && `actor=${claims.actor}`, + ] + .filter(Boolean) + .join(' '); + }, +}; diff --git a/security/authn/oidc/providers/index.ts b/security/authn/oidc/providers/index.ts new file mode 100644 index 000000000..552a28d2f --- /dev/null +++ b/security/authn/oidc/providers/index.ts @@ -0,0 +1,52 @@ +/** + * Provider profiles for OIDC trusted publishing (#2171). + * + * Everything issuer-specific lives behind this interface, resolved by normalized issuer, so the core + * stays generic: a new workload-identity issuer is a profile, not a change to verification, matching, + * or storage. Issuers with no registered profile fall back to `generic`, which is strict rather than + * permissive — see its `assertPolicyIsSpecific`. + */ + +import type { ClaimConstraint, TokenClaims } from '../types.ts'; +import { genericProfile } from './generic.ts'; +import { githubActionsProfile } from './githubActions.ts'; + +export interface IdentityProviderProfile { + /** Shown in policy-validation errors and the audit trail. */ + name: string; + + /** + * Rejects a claim set too broad to be a safe policy for this issuer. Called at write time, when + * the reader is an administrator who can act on the message. + */ + assertPolicyIsSpecific(policyClaims: Record): void; + + /** + * Rejects an audience the issuer shares across principals — the mistake that makes an audience + * check meaningless. A no-op for issuers with no such default. + */ + assertAudienceIsSpecific(audience: string): void; + + /** Adds issuer-specific derived claims. Must never displace a claim the token actually carries. */ + normalizeClaims(payload: TokenClaims): TokenClaims; + + /** One-line principal description for the audit trail. */ + describePrincipal(claims: TokenClaims): string; + + /** + * A match-time veto applied after the policy's own constraints pass, for runs this issuer should + * refuse unless a policy opts in explicitly. Returns a reason to deny, or undefined to allow. + */ + vetoClaims?(claims: TokenClaims, policyClaims: Record): string | undefined; +} + +const PROFILES_BY_ISSUER = new Map([ + ['https://token.actions.githubusercontent.com', githubActionsProfile], +]); + +/** Never returns undefined: an unregistered issuer gets the strict generic profile. */ +export function profileForIssuer(issuer: string): IdentityProviderProfile { + return PROFILES_BY_ISSUER.get(issuer) ?? genericProfile; +} + +export { genericProfile, githubActionsProfile }; diff --git a/security/authn/oidc/tokenExchange.ts b/security/authn/oidc/tokenExchange.ts new file mode 100644 index 000000000..3aa7fcd9f --- /dev/null +++ b/security/authn/oidc/tokenExchange.ts @@ -0,0 +1,223 @@ +'use strict'; + +// exchange_oidc_token — the unauthenticated half of OIDC trusted publishing (#2171). +// +// A CI runner presents an identity token minted by its provider. If it verifies against a stored +// trust policy, Harper mints a short-lived operation token for the user that policy names. This is +// the only unauthenticated operation that yields a credential, so it fails closed throughout and +// refuses through rejectToken, which tells the caller nothing beyond "no". + +import jwt from 'jsonwebtoken'; +import Joi from 'joi'; +import { createHash } from 'node:crypto'; +import { databases, table, type Table } from '../../../resources/databases.ts'; +import { ClientError } from '../../../utility/errors/hdbError.ts'; +import { validateBySchema } from '../../../validation/validationWrapper.ts'; +import { loggerWithTag } from '../../../utility/logging/logger.ts'; +import harperLogger from '../../../utility/logging/harper_logger.ts'; +import * as env from '../../../utility/environment/environmentManager.ts'; +import { AUTH_AUDIT_STATUS, AUTH_AUDIT_TYPES, CONFIG_PARAMS } from '../../../utility/hdbTerms.ts'; +import { getUsersWithRolesCache } from '../../user.ts'; +import { createOperationToken } from '../../tokenAuthentication.ts'; +import { rejectToken, verifyIdentityToken } from './identityToken.ts'; +import { matchTrustPolicyClaims } from './claims.ts'; +import { normalizeIssuer } from './jwks.ts'; +import { profileForIssuer, type IdentityProviderProfile } from './providers/index.ts'; +import { loadEnabledPolicies } from './trustPolicyOperations.ts'; +import type { OidcTrustPolicy, TokenClaims } from './types.ts'; + +const logger = loggerWithTag('oidc-trust'); +const { AuthAuditLog } = harperLogger; +// Same stream and same switches as every other authentication event (security/auth.ts), so an +// operator who turns on auth auditing sees OIDC exchanges alongside Basic, Bearer, and mTLS. +const authEventLog = harperLogger.forComponent('authentication').withTag('auth-event'); + +/** Long enough to cover a slow deploy, short enough to be worthless by the time it reaches a log. */ +const EXCHANGED_TOKEN_LIFETIME_SECONDS = 3600; + +/** Keeps the replay record alive past the token's expiry by more than the verifier's clock leeway. */ +const REPLAY_RECORD_PADDING_MS = 120_000; + +/** Real identity tokens are ~1-2 KB; this bounds what we are willing to even parse. */ +const MAX_TOKEN_LENGTH = 8192; + +const TOKEN_USE_TABLE = 'hdb_oidc_token_use'; + +/** + * Spent identity tokens, keyed by the fingerprint below and expiring with the token, so the table + * stays proportional to in-flight tokens rather than to deploy history and never holds a credential. + * + * Replicated like other system tables, which extends the check across the cluster — but replication + * is asynchronous, so two simultaneous replays against different nodes can both land. That race is + * not a privilege escalation: whoever holds the token could obtain one operation token regardless. + * What it stops is the realistic case, a token that leaks after a legitimate run and is reused + * inside its window. + * + * table() also registers into `databases.system`, so the lookup finds it after the first call. + */ +function getTokenUseTable(): any { + return ( + (databases as any).system?.[TOKEN_USE_TABLE] ?? + table
({ + table: TOKEN_USE_TABLE, + database: 'system', + attributes: [ + { name: 'id', isPrimaryKey: true }, + { name: 'policy_id' }, + { name: 'used_at' }, + { name: 'expiresAt', expiresAt: true, indexed: true }, + ], + }) + ); +} + +/** + * Records the exchange in the authentication audit stream. Emitted for failures as well as successes: + * a run repeatedly failing to authenticate is exactly what an audit trail is for. + */ +function auditExchange(req: any, username: string | undefined, status: string, detail: Record) { + const logSuccessful = env.get(CONFIG_PARAMS.LOGGING_AUDITAUTHEVENTS_LOGSUCCESSFUL) ?? false; + const logFailed = env.get(CONFIG_PARAMS.LOGGING_AUDITAUTHEVENTS_LOGFAILED) ?? false; + if (status === AUTH_AUDIT_STATUS.SUCCESS ? !logSuccessful : !logFailed) return; + + // serverHandlers injects baseRequest for NO_AUTH_OPERATIONS, so the transport details are here; + // they stay optional because an in-process caller (server.operation) has none. + const baseRequest = req?.baseRequest; + const log: any = new (AuthAuditLog as any)( + username, + status, + AUTH_AUDIT_TYPES.AUTHENTICATION, + baseRequest?.ip, + baseRequest?.method, + baseRequest?.pathname + ); + log.auth_strategy = 'oidc'; + Object.assign(log, detail); + if (status === AUTH_AUDIT_STATUS.SUCCESS) authEventLog.info?.(log); + else authEventLog.error?.(log); +} + +/** Verifies each distinct audience at most once, so N policies sharing one cost one verification. */ +async function findMatchingPolicy( + token: string, + issuer: string, + policies: OidcTrustPolicy[], + profile: IdentityProviderProfile +): Promise<{ policy: OidcTrustPolicy; claims: TokenClaims } | undefined> { + const claimsByAudience = new Map(); + + for (const policy of policies) { + if (!claimsByAudience.has(policy.audience)) { + // verifyIdentityToken logs its own reason for refusing. + const verified = await verifyIdentityToken(token, { issuer, audience: policy.audience }).catch(() => undefined); + claimsByAudience.set(policy.audience, verified); + } + const claims = claimsByAudience.get(policy.audience); + if (!claims) continue; + + const mismatch = matchTrustPolicyClaims(claims, policy.claims) ?? profile.vetoClaims?.(claims, policy.claims); + if (!mismatch) return { policy, claims }; + logger.debug?.(`Trust policy '${policy.id}' did not match: ${mismatch}`); + } + return undefined; +} + +/** + * Identifies a token for replay purposes. A hash of the token itself rather than `issuer|jti`: not + * every issuer emits `jti` (Azure uses `uti`, others omit it), and a replayed token is byte-identical + * by definition, so this is strictly more general with the same semantics. Hashed rather than stored + * so the table never holds a credential. + */ +function tokenFingerprint(token: string): string { + return createHash('sha256').update(token).digest('base64url'); +} + +/** + * Marks an identity token as spent, refusing one already recorded. + * + * Recorded before the token is minted: if minting then fails the credential is burned, costing a CI + * re-run, where the reverse ordering would leave a spendable token behind. The get-then-put is not + * atomic and deliberately does not depend on Harper's optimistic concurrency to make it so — see + * getTokenUseTable for why the race is tolerable. + */ +async function recordTokenUse(fingerprint: string, claims: TokenClaims, policyId: string): Promise { + const useTable = getTokenUseTable(); + if (await useTable.get(fingerprint)) rejectToken(`token ${fingerprint.slice(0, 12)} has already been exchanged`); + + await useTable.put({ + id: fingerprint, + policy_id: policyId, + used_at: Date.now(), + expiresAt: (claims.exp as number) * 1000 + REPLAY_RECORD_PADDING_MS, + }); +} + +/** + * Exchanges a CI identity token for a short-lived Harper operation token. Unauthenticated by design — + * this operation *is* the authentication, the way create_authentication_tokens is against a password. + */ +export async function exchangeOidcToken(req: any) { + const validation = validateBySchema( + req, + Joi.object({ token: Joi.string().min(1).max(MAX_TOKEN_LENGTH).required() }).unknown(true) + ); + if (validation) throw new ClientError(validation.message); + + // Populated as identification progresses so a failure audits with whatever was established. + const audit: Record = {}; + let username: string | undefined; + try { + // Nothing is trusted from this decode; it only selects candidate policies, and the signature + // is then checked against the issuer those policies declare. + const unverified = jwt.decode(req.token, { complete: true }); + let issuer: string; + try { + issuer = normalizeIssuer((unverified?.payload as any)?.iss); + } catch { + rejectToken('token has no usable iss claim'); + } + audit.issuer = issuer; + + const profile = profileForIssuer(issuer); + audit.provider = profile.name; + + const policies = (await loadEnabledPolicies()).filter((policy) => policy.issuer === issuer); + if (policies.length === 0) rejectToken(`no enabled trust policy for issuer ${issuer}`); + + const matched = await findMatchingPolicy(req.token, issuer, policies, profile); + if (!matched) rejectToken(`no trust policy matched a token from ${issuer}`); + const { policy, claims } = matched; + audit.oidc_policy = policy.id; + audit.principal = profile.describePrincipal(claims); + + // Resolved before the token is spent, so a policy naming a deleted or deactivated user fails + // without burning a token the runner cannot re-mint. + const users = await getUsersWithRolesCache(); + const user = users?.get(policy.user); + if (!user) rejectToken(`trust policy '${policy.id}' names user '${policy.user}', which does not exist`); + if (user.active === false) rejectToken(`trust policy '${policy.id}' names inactive user '${policy.user}'`); + username = user.username; + + const fingerprint = tokenFingerprint(req.token); + audit.token_fingerprint = fingerprint.slice(0, 12); + await recordTokenUse(fingerprint, claims, policy.id); + + const operationToken = await createOperationToken( + { username: user.username, super_user: user.role?.permission?.super_user === true }, + EXCHANGED_TOKEN_LIFETIME_SECONDS + ); + + logger.info?.(`OIDC exchange: policy '${policy.id}' authenticated '${user.username}' for ${audit.principal}`); + auditExchange(req, username, AUTH_AUDIT_STATUS.SUCCESS, audit); + + return { + operation_token: operationToken, + expires_in: EXCHANGED_TOKEN_LIFETIME_SECONDS, + username: user.username, + policy: policy.id, + }; + } catch (error) { + auditExchange(req, username, AUTH_AUDIT_STATUS.FAILURE, audit); + throw error; + } +} diff --git a/security/oidcTrust/trustPolicyOperations.ts b/security/authn/oidc/trustPolicyOperations.ts similarity index 83% rename from security/oidcTrust/trustPolicyOperations.ts rename to security/authn/oidc/trustPolicyOperations.ts index 43a818bae..ff090a5fe 100644 --- a/security/oidcTrust/trustPolicyOperations.ts +++ b/security/authn/oidc/trustPolicyOperations.ts @@ -11,25 +11,19 @@ // call replicateOperation, which would double-apply on top of it. import Joi from 'joi'; -import { databases } from '../../resources/databases.ts'; -import * as terms from '../../utility/hdbTerms.ts'; -import { ClientError, hdbErrors } from '../../utility/errors/hdbError.ts'; -import { validateBySchema } from '../../validation/validationWrapper.ts'; -import { getUsersWithRolesCache } from '../user.ts'; -import { validateTrustPolicyClaims } from './claims.ts'; +import { databases } from '../../../resources/databases.ts'; +import * as terms from '../../../utility/hdbTerms.ts'; +import { ClientError, hdbErrors } from '../../../utility/errors/hdbError.ts'; +import { validateBySchema } from '../../../validation/validationWrapper.ts'; +import { getUsersWithRolesCache } from '../../user.ts'; +import { validateClaimConstraintShape } from './claims.ts'; import { normalizeIssuer } from './jwks.ts'; +import { profileForIssuer } from './providers/index.ts'; import type { OidcTrustPolicy } from './types.ts'; const { HTTP_STATUS_CODES } = hdbErrors; const OIDC_TRUST_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TRUST_TABLE_NAME; -/** - * The canonical statement of why `audience` matters: GitHub's default is the repository owner's URL, - * shared by every repository under that owner, so accepting it would make a token minted by any repo - * in the org valid here — the one mistake the field exists to prevent. - */ -const SHARED_DEFAULT_AUDIENCE = /^https:\/\/github\.com\/[^/]+\/?$/i; - const POLICY_ID = Joi.string() .min(1) .max(128) @@ -125,15 +119,12 @@ export async function addOidcTrust(req: any) { ); const issuer = normalizeIssuer(req.issuer); - if (SHARED_DEFAULT_AUDIENCE.test(req.audience)) { - throw new ClientError( - `'audience' must identify this instance, not '${req.audience}' — an issuer's default audience is ` + - `shared by every repository under an owner, so a token minted for any of them would be accepted here. ` + - `Use the instance URL the CI client targets.` - ); - } - // Throws ClientError naming the first structural problem. - validateTrustPolicyClaims(req.claims); + // Issuer-specific rules live in the provider profile; an unregistered issuer gets the strict + // generic profile rather than a permissive default. Each throws ClientError naming the problem. + const profile = profileForIssuer(issuer); + profile.assertAudienceIsSpecific(req.audience); + validateClaimConstraintShape(req.claims); + profile.assertPolicyIsSpecific(req.claims); // Resolve the target user now: a policy pointing at a user that does not exist would fail only at // exchange time, in CI, with nothing to point at. Read the users cache directly rather than diff --git a/security/oidcTrust/types.ts b/security/authn/oidc/types.ts similarity index 100% rename from security/oidcTrust/types.ts rename to security/authn/oidc/types.ts diff --git a/security/oidcTrust/claims.ts b/security/oidcTrust/claims.ts deleted file mode 100644 index 23462f0c2..000000000 --- a/security/oidcTrust/claims.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Claim normalization, matching, and policy validation for OIDC trusted publishing (#2171). - * - * Pure — no network, no storage — so the rules that decide whether an external CI run may act as a - * Harper user can be exercised directly. - */ - -import { ClientError } from '../../utility/errors/hdbError.ts'; -import type { ClaimConstraint, TokenClaims } from './types.ts'; - -/** - * Workflow references are `//@` and the ref is always a full `refs/...` name. - * Splitting on `@refs/` rather than the first or last `@` is exact, because a path segment cannot - * contain `/` — so this cannot occur inside the path, and a branch named `release@2` cannot fool it. - */ -const REF_QUALIFIER = '@refs/'; - -/** - * Structural requirements on a policy's claim set, each guarding a distinct way a policy can be - * accidentally broad. Satisfying none of a row's claims admits, in order: any repository, any - * workflow in that repository, any branch that can be pushed to it. - * - * `repository_owner` is absent from the first row on purpose — it identifies an org, not a - * repository, so it would admit every repo in the org. `ref_type` is absent from the third for the - * same shape of reason: `ref_type: tag` still admits any tag, and anyone with push access can create - * one. A tag-triggered release pins `environment` and leans on the provider's environment protection. - */ -const STRUCTURAL_REQUIREMENTS = [ - { - requirement: 'pin the repository', - claims: ['repository_id', 'repository'], - because: ' (repository_id is immutable and survives renames)', - }, - { - requirement: 'pin the workflow', - claims: ['workflow_ref', 'workflow_path', 'job_workflow_ref', 'job_workflow_path'], - because: '', - }, - { - requirement: 'gate the ref', - claims: ['workflow_ref', 'job_workflow_ref', 'ref', 'environment'], - because: ' — otherwise any branch that can be pushed to the repository can run the workflow and mint a token', - }, -]; - -/** Returns undefined rather than guessing when the value is not a ref-qualified reference. */ -export function splitWorkflowPath(workflowRef: unknown): string | undefined { - if (typeof workflowRef !== 'string') return undefined; - const qualifierIndex = workflowRef.indexOf(REF_QUALIFIER); - return qualifierIndex === -1 ? undefined : workflowRef.slice(0, qualifierIndex); -} - -/** - * Adds `workflow_path` / `job_workflow_path` — the workflow reference with the ref removed — so a - * policy can pin the workflow *file* while gating the ref some other way. A tag-triggered release - * cannot pin `workflow_ref`, because the tag is unknown when the policy is written. - * - * A claim the token actually carries is never displaced by one we synthesized. - */ -export function normalizeTokenClaims(payload: TokenClaims): TokenClaims { - const claims: TokenClaims = { ...payload }; - for (const [source, derived] of [ - ['workflow_ref', 'workflow_path'], - ['job_workflow_ref', 'job_workflow_path'], - ]) { - const path = splitWorkflowPath(payload[source]); - if (path !== undefined && claims[derived] === undefined) claims[derived] = path; - } - return claims; -} - -/** Issuers may encode a numeric id as a JSON number; anything non-scalar is not comparable. */ -function claimToString(value: unknown): string | undefined { - if (typeof value === 'string') return value; - if (typeof value === 'number' && Number.isFinite(value)) return String(value); - return undefined; -} - -/** - * Returns undefined on a match, or the first failure's reason — for the log, never the caller. - * - * A constrained claim absent from the token fails rather than passes, so a policy cannot be weakened - * by an issuer that stops emitting a claim. - */ -export function matchTrustPolicyClaims( - claims: TokenClaims, - policyClaims: Record -): string | undefined { - const constraints = Object.entries(policyClaims); - // validateTrustPolicyClaims rejects this at write time; this backstops a row that reached the - // table another way, such as replication from a peer. - if (constraints.length === 0) return 'policy constrains no claims'; - - for (const [claimName, constraint] of constraints) { - const actual = claimToString(claims[claimName]); - if (actual === undefined || actual === '') return `token has no usable ${claimName} claim`; - const accepted = Array.isArray(constraint) ? constraint : [constraint]; - if (!accepted.includes(actual)) return `${claimName} does not match the policy`; - } - return undefined; -} - -/** Throws ClientError naming the first problem; the reader is an administrator writing a policy. */ -export function validateTrustPolicyClaims( - policyClaims: unknown -): asserts policyClaims is Record { - if (!policyClaims || typeof policyClaims !== 'object' || Array.isArray(policyClaims)) { - throw new ClientError('claims must be an object of claim constraints'); - } - - const entries = Object.entries(policyClaims as Record); - if (entries.length === 0) throw new ClientError('claims must constrain at least one claim'); - - for (const [claimName, constraint] of entries) { - const values = Array.isArray(constraint) ? constraint : [constraint]; - if (values.length === 0) throw new ClientError(`claims.${claimName} must accept at least one value`); - for (const value of values) { - if (typeof value !== 'string' || value === '') { - throw new ClientError(`claims.${claimName} must be a non-empty string or an array of non-empty strings`); - } - } - } - - const constrained = new Set(entries.map(([claimName]) => claimName)); - for (const { requirement, claims, because } of STRUCTURAL_REQUIREMENTS) { - if (!claims.some((claimName) => constrained.has(claimName))) { - throw new ClientError(`claims must ${requirement} with one of: ${claims.join(', ')}${because}`); - } - } -} diff --git a/security/oidcTrust/tokenExchange.ts b/security/oidcTrust/tokenExchange.ts deleted file mode 100644 index 97b7c9ff5..000000000 --- a/security/oidcTrust/tokenExchange.ts +++ /dev/null @@ -1,176 +0,0 @@ -'use strict'; - -// exchange_oidc_token — the unauthenticated half of OIDC trusted publishing (#2171). -// -// A CI runner presents an identity token minted by its provider. If it verifies against a stored -// trust policy, Harper mints a short-lived operation token for the user that policy names. This is -// the only unauthenticated operation that yields a credential, so it fails closed throughout and -// refuses through rejectToken, which tells the caller nothing beyond "no". - -import jwt from 'jsonwebtoken'; -import Joi from 'joi'; -import { databases, table, type Table } from '../../resources/databases.ts'; -import { ClientError } from '../../utility/errors/hdbError.ts'; -import { validateBySchema } from '../../validation/validationWrapper.ts'; -import { loggerWithTag } from '../../utility/logging/logger.ts'; -import { getUsersWithRolesCache } from '../user.ts'; -import { createOperationToken } from '../tokenAuthentication.ts'; -import { rejectToken, verifyIdentityToken } from './identityToken.ts'; -import { matchTrustPolicyClaims } from './claims.ts'; -import { normalizeIssuer } from './jwks.ts'; -import { loadEnabledPolicies } from './trustPolicyOperations.ts'; -import type { OidcTrustPolicy, TokenClaims } from './types.ts'; - -const logger = loggerWithTag('oidc-trust'); - -/** Long enough to cover a slow deploy, short enough to be worthless by the time it reaches a log. */ -const EXCHANGED_TOKEN_LIFETIME_SECONDS = 3600; - -/** Keeps the replay record alive past the token's expiry by more than the verifier's clock leeway. */ -const REPLAY_RECORD_PADDING_MS = 120_000; - -/** Real identity tokens are ~1-2 KB; this bounds what we are willing to even parse. */ -const MAX_TOKEN_LENGTH = 8192; - -const TOKEN_USE_TABLE = 'hdb_oidc_token_use'; - -/** - * Spent identity tokens, keyed by issuer and `jti`, expiring with the token so the table stays - * proportional to in-flight tokens rather than to deploy history. - * - * Replicated like other system tables, which extends the check across the cluster — but replication - * is asynchronous, so two simultaneous replays against different nodes can both land. That race is - * not a privilege escalation: whoever holds the token could obtain one operation token regardless. - * What it stops is the realistic case, a token that leaks after a legitimate run and is reused - * inside its window. - * - * table() also registers into `databases.system`, so the lookup finds it after the first call. - */ -function getTokenUseTable(): any { - return ( - (databases as any).system?.[TOKEN_USE_TABLE] ?? - table
({ - table: TOKEN_USE_TABLE, - database: 'system', - attributes: [ - { name: 'id', isPrimaryKey: true }, - { name: 'policy_id' }, - { name: 'used_at' }, - { name: 'expiresAt', expiresAt: true, indexed: true }, - ], - }) - ); -} - -function describeRun(claims: TokenClaims): string { - return [ - claims.repository, - claims.workflow_ref ?? claims.workflow_path, - claims.environment && `environment=${claims.environment}`, - claims.run_id && `run=${claims.run_id}`, - claims.actor && `actor=${claims.actor}`, - ] - .filter(Boolean) - .join(' '); -} - -/** Verifies each distinct audience at most once, so N policies sharing one cost one verification. */ -async function findMatchingPolicy( - token: string, - issuer: string, - policies: OidcTrustPolicy[] -): Promise<{ policy: OidcTrustPolicy; claims: TokenClaims } | undefined> { - const claimsByAudience = new Map(); - - for (const policy of policies) { - if (!claimsByAudience.has(policy.audience)) { - // verifyIdentityToken logs its own reason for refusing. - const verified = await verifyIdentityToken(token, { issuer, audience: policy.audience }).catch(() => undefined); - claimsByAudience.set(policy.audience, verified); - } - const claims = claimsByAudience.get(policy.audience); - if (!claims) continue; - - const mismatch = matchTrustPolicyClaims(claims, policy.claims); - if (!mismatch) return { policy, claims }; - logger.debug?.(`Trust policy '${policy.id}' did not match: ${mismatch}`); - } - return undefined; -} - -/** - * Marks an identity token as spent, refusing one already recorded. - * - * Recorded before the token is minted: if minting then fails the credential is burned, costing a CI - * re-run, where the reverse ordering would leave a spendable token behind. The get-then-put is not - * atomic and deliberately does not depend on Harper's optimistic concurrency to make it so — see - * getTokenUseTable for why the race is tolerable. - */ -async function recordTokenUse(issuer: string, claims: TokenClaims, policyId: string): Promise { - const useTable = getTokenUseTable(); - const id = `${issuer}|${claims.jti}`; - if (await useTable.get(id)) rejectToken(`token ${claims.jti} has already been exchanged`); - - await useTable.put({ - id, - policy_id: policyId, - used_at: Date.now(), - expiresAt: (claims.exp as number) * 1000 + REPLAY_RECORD_PADDING_MS, - }); -} - -/** - * Exchanges a CI identity token for a short-lived Harper operation token. Unauthenticated by design — - * this operation *is* the authentication, the way create_authentication_tokens is against a password. - */ -export async function exchangeOidcToken(req: any) { - const validation = validateBySchema( - req, - Joi.object({ token: Joi.string().min(1).max(MAX_TOKEN_LENGTH).required() }).unknown(true) - ); - if (validation) throw new ClientError(validation.message); - - // Nothing is trusted from this decode; it only selects candidate policies, and the signature is - // then checked against the issuer those policies declare. - const unverified = jwt.decode(req.token, { complete: true }); - let issuer: string; - try { - issuer = normalizeIssuer((unverified?.payload as any)?.iss); - } catch { - rejectToken('token has no usable iss claim'); - } - - const policies = (await loadEnabledPolicies()).filter((policy) => policy.issuer === issuer); - if (policies.length === 0) rejectToken(`no enabled trust policy for issuer ${issuer}`); - - const matched = await findMatchingPolicy(req.token, issuer, policies); - if (!matched) rejectToken(`no trust policy matched a token from ${issuer}`); - const { policy, claims } = matched; - - // Resolved before the token is spent, so a policy naming a deleted or deactivated user fails - // without burning a token the runner cannot re-mint. - const users = await getUsersWithRolesCache(); - const user = users?.get(policy.user); - if (!user) rejectToken(`trust policy '${policy.id}' names user '${policy.user}', which does not exist`); - if (user.active === false) rejectToken(`trust policy '${policy.id}' names inactive user '${policy.user}'`); - - await recordTokenUse(issuer, claims, policy.id); - - const operationToken = await createOperationToken( - { username: user.username, super_user: user.role?.permission?.super_user === true }, - EXCHANGED_TOKEN_LIFETIME_SECONDS - ); - - // The audit trail for a credential handed to an external system. - // TODO(#2171): route through AuthAuditLog once the operation handler has request context. - logger.info?.( - `OIDC exchange: policy '${policy.id}' authenticated '${user.username}' for ${describeRun(claims)} (jti ${claims.jti})` - ); - - return { - operation_token: operationToken, - expires_in: EXCHANGED_TOKEN_LIFETIME_SECONDS, - username: user.username, - policy: policy.id, - }; -} diff --git a/server/serverHelpers/serverUtilities.ts b/server/serverHelpers/serverUtilities.ts index ffa81097b..ceb3fa5a1 100644 --- a/server/serverHelpers/serverUtilities.ts +++ b/server/serverHelpers/serverUtilities.ts @@ -41,8 +41,8 @@ import * as status from '../status/index.ts'; import * as regDeprecated from '../../resources/registrationDeprecated.ts'; import * as deploymentOperations from '../../components/deploymentOperations.ts'; import * as secretOperations from '../../components/secretOperations.ts'; -import * as trustPolicyOperations from '../../security/oidcTrust/trustPolicyOperations.ts'; -import * as tokenExchange from '../../security/oidcTrust/tokenExchange.ts'; +import * as trustPolicyOperations from '../../security/authn/oidc/trustPolicyOperations.ts'; +import * as tokenExchange from '../../security/authn/oidc/tokenExchange.ts'; import { contextStorage } from '../../resources/transaction.ts'; import { isMainThread } from 'node:worker_threads'; import { diff --git a/unitTests/bin/ciIdentityToken.test.js b/unitTests/bin/workloadIdentity.test.js similarity index 80% rename from unitTests/bin/ciIdentityToken.test.js rename to unitTests/bin/workloadIdentity.test.js index 5a6423ceb..604630e80 100644 --- a/unitTests/bin/ciIdentityToken.test.js +++ b/unitTests/bin/workloadIdentity.test.js @@ -1,14 +1,14 @@ 'use strict'; const assert = require('node:assert'); -const { ciIdentityAvailable, exchangeCiIdentityForToken } = require('#src/bin/ciIdentityToken'); +const { workloadIdentityAvailable, exchangeWorkloadIdentityForToken } = require('#src/bin/workloadIdentity'); const commonUtilsModule = require('#src/utility/common_utils'); const REQUEST_URL = 'https://pipelines.actions.githubusercontent.com/abc/idtoken?api-version=2.0'; const REQUEST_TOKEN = 'runner-request-token'; const AUDIENCE = 'https://my-instance.harperdb.io:9925/'; -describe('ciIdentityToken', () => { +describe('workloadIdentity', () => { let originalFetch; let originalHttpRequest; let originalEnv; @@ -70,32 +70,32 @@ describe('ciIdentityToken', () => { else process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = originalEnv.token; }); - describe('ciIdentityAvailable', () => { + describe('workloadIdentityAvailable', () => { it('is true when the runner offers an identity token', () => { - assert.strictEqual(ciIdentityAvailable(), true); + assert.strictEqual(workloadIdentityAvailable(), true); }); // GitHub sets both together; either one missing means the workflow did not grant // `id-token: write`, which is a configuration answer rather than something to report. it('is false unless both variables are present', () => { delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; - assert.strictEqual(ciIdentityAvailable(), false); + assert.strictEqual(workloadIdentityAvailable(), false); process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = REQUEST_TOKEN; delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; - assert.strictEqual(ciIdentityAvailable(), false); + assert.strictEqual(workloadIdentityAvailable(), false); }); }); - describe('exchangeCiIdentityForToken', () => { + describe('exchangeWorkloadIdentityForToken', () => { it('returns an operation token', async () => { - const token = await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE); + const token = await exchangeWorkloadIdentityForToken({ headers: {} }, AUDIENCE); assert.strictEqual(token, 'minted-operation-token'); }); // The audience is what binds the token to this instance; GitHub's default is shared by every // repository under an owner, so it must be set explicitly on the request. it('requests the token for this instance as the audience', async () => { - await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE); + await exchangeWorkloadIdentityForToken({ headers: {} }, AUDIENCE); assert.strictEqual(fetchCalls.length, 1); assert.strictEqual(fetchCalls[0].url.searchParams.get('audience'), AUDIENCE); // The api-version already on the URL must survive. @@ -104,7 +104,7 @@ describe('ciIdentityToken', () => { }); it('sends the identity token to exchange_oidc_token', async () => { - await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE); + await exchangeWorkloadIdentityForToken({ headers: {} }, AUDIENCE); assert.strictEqual(operationCalls.length, 1); assert.deepStrictEqual(operationCalls[0].body, { operation: 'exchange_oidc_token', @@ -113,7 +113,7 @@ describe('ciIdentityToken', () => { }); it('names the policy and user it authenticated as', async () => { - await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE); + await exchangeWorkloadIdentityForToken({ headers: {} }, AUDIENCE); assert.ok( stderr.some((line) => line.includes('ci-deploy') && line.includes('my-app-prod')), `expected the identity to be reported; got ${JSON.stringify(stderr)}` @@ -122,14 +122,14 @@ describe('ciIdentityToken', () => { it('does nothing on a runner with no identity to offer', async () => { delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; - assert.strictEqual(await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE), undefined); + assert.strictEqual(await exchangeWorkloadIdentityForToken({ headers: {} }, AUDIENCE), undefined); assert.strictEqual(fetchCalls.length, 0); assert.strictEqual(operationCalls.length, 0); }); it('gives up without exchanging when the provider refuses a token', async () => { globalThis.fetch = async () => new Response('forbidden', { status: 403 }); - assert.strictEqual(await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE), undefined); + assert.strictEqual(await exchangeWorkloadIdentityForToken({ headers: {} }, AUDIENCE), undefined); assert.strictEqual(operationCalls.length, 0, 'nothing to exchange'); assert.ok(stderr.some((line) => line.includes('403'))); }); @@ -137,7 +137,7 @@ describe('ciIdentityToken', () => { it('gives up when the provider returns no token value', async () => { globalThis.fetch = async () => new Response(JSON.stringify({}), { status: 200, headers: { 'content-type': 'application/json' } }); - assert.strictEqual(await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE), undefined); + assert.strictEqual(await exchangeWorkloadIdentityForToken({ headers: {} }, AUDIENCE), undefined); assert.strictEqual(operationCalls.length, 0); }); @@ -148,7 +148,7 @@ describe('ciIdentityToken', () => { statusCode: 401, body: '{"error":"Identity token was rejected"}', }); - assert.strictEqual(await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE), undefined); + assert.strictEqual(await exchangeWorkloadIdentityForToken({ headers: {} }, AUDIENCE), undefined); assert.ok( stderr.some((line) => line.includes('list_oidc_trust')), `expected actionable guidance; got ${JSON.stringify(stderr)}` @@ -157,14 +157,14 @@ describe('ciIdentityToken', () => { it('returns undefined when the exchange yields no token', async () => { commonUtilsModule.httpRequest = async () => ({ statusCode: 200, body: '{}' }); - assert.strictEqual(await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE), undefined); + assert.strictEqual(await exchangeWorkloadIdentityForToken({ headers: {} }, AUDIENCE), undefined); }); it('survives a transport failure', async () => { commonUtilsModule.httpRequest = async () => { throw new Error('socket hang up'); }; - assert.strictEqual(await exchangeCiIdentityForToken({ headers: {} }, AUDIENCE), undefined); + assert.strictEqual(await exchangeWorkloadIdentityForToken({ headers: {} }, AUDIENCE), undefined); assert.ok(stderr.some((line) => line.includes('socket hang up'))); }); }); diff --git a/unitTests/security/authn/oidc/claims.test.js b/unitTests/security/authn/oidc/claims.test.js new file mode 100644 index 000000000..e0779ef33 --- /dev/null +++ b/unitTests/security/authn/oidc/claims.test.js @@ -0,0 +1,93 @@ +'use strict'; + +const assert = require('node:assert'); +const { matchTrustPolicyClaims, validateClaimConstraintShape } = require('#src/security/authn/oidc/claims'); + +// Deliberately not a GitHub token: this layer must not know which issuer minted anything. +const CLAIMS = Object.freeze({ + iss: 'https://oidc.example.com', + aud: 'https://my-instance.harperdb.io:9925/', + sub: 'system:serviceaccount:prod:deployer', + namespace: 'prod', + service_account: 'deployer', + numeric_id: '67890', + exp: 1_800_000_300, +}); + +describe('oidc claims', () => { + describe('matchTrustPolicyClaims', () => { + it('matches when every constraint is satisfied', () => { + const reason = matchTrustPolicyClaims(CLAIMS, { + sub: 'system:serviceaccount:prod:deployer', + namespace: 'prod', + }); + assert.strictEqual(reason, undefined); + }); + + it('accepts any value from a set', () => { + assert.strictEqual(matchTrustPolicyClaims(CLAIMS, { namespace: ['prod', 'staging'] }), undefined); + }); + + it('rejects a value outside the set', () => { + const reason = matchTrustPolicyClaims(CLAIMS, { namespace: ['staging', 'dev'] }); + assert.ok(reason); + assert.match(reason, /namespace/); + }); + + // The central fail-closed property: a constrained claim the token does not carry must deny, + // so a policy cannot be silently weakened by an issuer that stops emitting a claim. + it('rejects when a constrained claim is absent from the token', () => { + const reason = matchTrustPolicyClaims(CLAIMS, { environment: 'production' }); + assert.ok(reason); + assert.match(reason, /environment/); + }); + + it('rejects an empty-string claim value', () => { + assert.ok(matchTrustPolicyClaims({ ...CLAIMS, namespace: '' }, { namespace: 'prod' })); + }); + + it('rejects a policy that constrains nothing', () => { + assert.ok(matchTrustPolicyClaims(CLAIMS, {})); + }); + + it('compares a numerically-encoded claim as a string', () => { + assert.strictEqual(matchTrustPolicyClaims({ ...CLAIMS, numeric_id: 67890 }, { numeric_id: '67890' }), undefined); + }); + + it('refuses to compare a non-scalar claim', () => { + for (const value of [true, { nested: 'object' }, ['array'], null]) { + assert.ok( + matchTrustPolicyClaims({ ...CLAIMS, namespace: value }, { namespace: 'prod' }), + `expected rejection for ${JSON.stringify(value)}` + ); + } + }); + }); + + // Shape only — whether a claim set is *specific enough* is the provider profile's call. + describe('validateClaimConstraintShape', () => { + it('accepts a well-formed constraint set', () => { + assert.doesNotThrow(() => validateClaimConstraintShape({ sub: 'x', namespace: ['a', 'b'] })); + }); + + it('rejects a non-object', () => { + for (const value of [undefined, null, 'claims', 42, ['sub']]) { + assert.throws(() => validateClaimConstraintShape(value), /claims must be an object/); + } + }); + + it('rejects an empty object', () => { + assert.throws(() => validateClaimConstraintShape({}), /at least one claim/); + }); + + it('rejects an empty accepted-value set', () => { + assert.throws(() => validateClaimConstraintShape({ sub: 'x', event_name: [] }), /at least one value/); + }); + + it('rejects non-string and empty-string values', () => { + assert.throws(() => validateClaimConstraintShape({ sub: '' }), /non-empty/); + assert.throws(() => validateClaimConstraintShape({ sub: 42 }), /non-empty/); + assert.throws(() => validateClaimConstraintShape({ sub: ['x', ''] }), /non-empty/); + }); + }); +}); diff --git a/unitTests/security/oidcTrust/jwks.test.js b/unitTests/security/authn/oidc/jwks.test.js similarity index 99% rename from unitTests/security/oidcTrust/jwks.test.js rename to unitTests/security/authn/oidc/jwks.test.js index 7f95d756f..1dbe76298 100644 --- a/unitTests/security/oidcTrust/jwks.test.js +++ b/unitTests/security/authn/oidc/jwks.test.js @@ -2,13 +2,13 @@ const assert = require('node:assert'); const { generateKeyPairSync, createPublicKey } = require('node:crypto'); -const { getSigningKey, normalizeIssuer, clearJwksCache } = require('#src/security/oidcTrust/jwks'); +const { getSigningKey, normalizeIssuer, clearJwksCache } = require('#src/security/authn/oidc/jwks'); const ISSUER = 'https://token.actions.githubusercontent.com'; const JWKS_URI = 'https://token.actions.githubusercontent.com/.well-known/jwks'; const DISCOVERY_URI = ISSUER + '/.well-known/openid-configuration'; -describe('oidcTrust jwks', () => { +describe('oidc jwks', () => { describe('normalizeIssuer', () => { it('drops a trailing slash so one issuer is one cache entry', () => { assert.strictEqual(normalizeIssuer(ISSUER + '/'), ISSUER); diff --git a/unitTests/security/authn/oidc/providers/generic.test.js b/unitTests/security/authn/oidc/providers/generic.test.js new file mode 100644 index 000000000..58bc1c8a4 --- /dev/null +++ b/unitTests/security/authn/oidc/providers/generic.test.js @@ -0,0 +1,73 @@ +'use strict'; + +const assert = require('node:assert'); +const { genericProfile } = require('#src/security/authn/oidc/providers/generic'); +const { profileForIssuer } = require('#src/security/authn/oidc/providers/index'); + +// Representative subjects from the workload-identity issuers this profile is meant to serve with no +// provider code at all. +const KUBERNETES_SUB = 'system:serviceaccount:prod:deployer'; +const SPIFFE_SUB = 'spiffe://example.org/ns/prod/sa/deployer'; + +describe('generic provider profile', () => { + it('is the fallback for an unregistered issuer', () => { + for (const issuer of ['https://kubernetes.default.svc', 'https://accounts.google.com', 'https://gitlab.com']) { + assert.strictEqual(profileForIssuer(issuer), genericProfile); + } + }); + + // sub is the one claim every OIDC issuer defines as identifying a single principal, so requiring + // it is what makes an unregistered issuer safe by default rather than permissive by default. + describe('assertPolicyIsSpecific', () => { + it('accepts a policy that pins sub', () => { + for (const sub of [KUBERNETES_SUB, SPIFFE_SUB, 'deployer@project.iam.gserviceaccount.com']) { + assert.doesNotThrow(() => genericProfile.assertPolicyIsSpecific({ sub })); + } + }); + + it('accepts sub alongside further constraints', () => { + assert.doesNotThrow(() => genericProfile.assertPolicyIsSpecific({ sub: KUBERNETES_SUB, namespace: 'prod' })); + }); + + it('rejects a policy that does not pin sub', () => { + assert.throws(() => genericProfile.assertPolicyIsSpecific({ namespace: 'prod' }), /must pin `sub`/); + }); + + it('rejects a policy pinning only claims that do not identify a principal', () => { + assert.throws( + () => genericProfile.assertPolicyIsSpecific({ aud: 'https://my-instance.harperdb.io:9925/' }), + /must pin `sub`/ + ); + }); + }); + + describe('normalizeClaims', () => { + it('derives nothing and does not mutate', () => { + const payload = { sub: KUBERNETES_SUB, namespace: 'prod' }; + const claims = genericProfile.normalizeClaims(payload); + assert.deepStrictEqual(claims, payload); + assert.notStrictEqual(claims, payload, 'should be a copy'); + }); + }); + + describe('assertAudienceIsSpecific', () => { + // No known shared default; the required sub pin already binds the policy to one principal. + it('accepts any audience', () => { + assert.doesNotThrow(() => genericProfile.assertAudienceIsSpecific('https://github.com/HarperFast')); + }); + }); + + describe('describePrincipal', () => { + it('is the subject', () => { + assert.strictEqual(genericProfile.describePrincipal({ sub: SPIFFE_SUB }), SPIFFE_SUB); + }); + + it('tolerates a missing subject', () => { + assert.strictEqual(typeof genericProfile.describePrincipal({}), 'string'); + }); + }); + + it('declares no match-time veto', () => { + assert.strictEqual(genericProfile.vetoClaims, undefined); + }); +}); diff --git a/unitTests/security/authn/oidc/providers/githubActions.test.js b/unitTests/security/authn/oidc/providers/githubActions.test.js new file mode 100644 index 000000000..fa7d33067 --- /dev/null +++ b/unitTests/security/authn/oidc/providers/githubActions.test.js @@ -0,0 +1,207 @@ +'use strict'; + +const assert = require('node:assert'); +const { githubActionsProfile, splitWorkflowPath } = require('#src/security/authn/oidc/providers/githubActions'); +const { profileForIssuer, genericProfile } = require('#src/security/authn/oidc/providers/index'); + +const WORKFLOW_REF = 'HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main'; + +const GITHUB_CLAIMS = Object.freeze({ + iss: 'https://token.actions.githubusercontent.com', + sub: 'repo:HarperFast/my-app:environment:production', + repository: 'HarperFast/my-app', + repository_id: '67890', + repository_owner: 'HarperFast', + repository_owner_id: '12345', + workflow_ref: WORKFLOW_REF, + job_workflow_ref: WORKFLOW_REF, + environment: 'production', + ref: 'refs/heads/main', + event_name: 'push', + run_id: '99', + actor: 'octocat', +}); + +const VALID_POLICY_CLAIMS = Object.freeze({ repository_id: '67890', workflow_ref: WORKFLOW_REF }); + +describe('githubActions provider profile', () => { + it('is the profile registered for the GitHub Actions issuer', () => { + assert.strictEqual(profileForIssuer('https://token.actions.githubusercontent.com'), githubActionsProfile); + }); + + it('does not claim other issuers', () => { + assert.strictEqual(profileForIssuer('https://gitlab.example.com'), genericProfile); + }); + + describe('splitWorkflowPath', () => { + it('strips the ref', () => { + assert.strictEqual(splitWorkflowPath(WORKFLOW_REF), 'HarperFast/my-app/.github/workflows/deploy.yml'); + }); + + it('splits on the ref qualifier, not on an @ inside the branch name', () => { + assert.strictEqual( + splitWorkflowPath('HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/release@2'), + 'HarperFast/my-app/.github/workflows/deploy.yml' + ); + }); + + it('handles a tag ref', () => { + assert.strictEqual( + splitWorkflowPath('HarperFast/my-app/.github/workflows/release.yml@refs/tags/v1.2.3'), + 'HarperFast/my-app/.github/workflows/release.yml' + ); + }); + + it('returns undefined rather than guessing', () => { + for (const value of ['HarperFast/my-app/.github/workflows/deploy.yml', 'no-at-sign', undefined, null, 42]) { + assert.strictEqual(splitWorkflowPath(value), undefined); + } + }); + }); + + describe('normalizeClaims', () => { + it('derives workflow_path and job_workflow_path', () => { + const claims = githubActionsProfile.normalizeClaims(GITHUB_CLAIMS); + assert.strictEqual(claims.workflow_path, 'HarperFast/my-app/.github/workflows/deploy.yml'); + assert.strictEqual(claims.job_workflow_path, 'HarperFast/my-app/.github/workflows/deploy.yml'); + }); + + it('preserves the original claims and does not mutate the input', () => { + const payload = { ...GITHUB_CLAIMS }; + const claims = githubActionsProfile.normalizeClaims(payload); + assert.strictEqual(claims.repository_id, '67890'); + assert.strictEqual(claims.workflow_ref, WORKFLOW_REF); + assert.strictEqual(payload.workflow_path, undefined); + }); + + it('omits a derived claim when the reference is not ref-qualified', () => { + const claims = githubActionsProfile.normalizeClaims({ workflow_ref: 'owner/repo/.github/workflows/x.yml' }); + assert.strictEqual(claims.workflow_path, undefined); + }); + + // An issuer that one day emits workflow_path itself must win over our derivation. + it('does not displace a claim the token already carries', () => { + const claims = githubActionsProfile.normalizeClaims({ + workflow_ref: WORKFLOW_REF, + workflow_path: 'issuer-supplied', + }); + assert.strictEqual(claims.workflow_path, 'issuer-supplied'); + }); + }); + + describe('assertPolicyIsSpecific', () => { + it('accepts a repository pin plus a ref-qualified workflow pin', () => { + assert.doesNotThrow(() => githubActionsProfile.assertPolicyIsSpecific({ ...VALID_POLICY_CLAIMS })); + }); + + it('accepts a workflow path gated by an environment', () => { + assert.doesNotThrow(() => + githubActionsProfile.assertPolicyIsSpecific({ + repository_id: '67890', + workflow_path: 'HarperFast/my-app/.github/workflows/release.yml', + environment: 'production', + }) + ); + }); + + // repository_owner identifies an org, not a repository — it would admit every repo in the org. + it('rejects a policy with no repository pin', () => { + assert.throws( + () => + githubActionsProfile.assertPolicyIsSpecific({ repository_owner: 'HarperFast', workflow_ref: WORKFLOW_REF }), + /pin the repository/ + ); + }); + + it('rejects a policy with no workflow pin', () => { + assert.throws( + () => githubActionsProfile.assertPolicyIsSpecific({ repository_id: '67890', environment: 'production' }), + /pin the workflow/ + ); + }); + + // The npm-style "repository + workflow filename" policy: any branch that can be pushed can run + // the workflow and mint a token. + it('rejects a workflow pin with no ref gate', () => { + assert.throws( + () => + githubActionsProfile.assertPolicyIsSpecific({ + repository_id: '67890', + workflow_path: 'HarperFast/my-app/.github/workflows/deploy.yml', + }), + /gate the ref/ + ); + }); + + it('does not accept ref_type alone as a ref gate', () => { + assert.throws( + () => + githubActionsProfile.assertPolicyIsSpecific({ + repository_id: '67890', + workflow_path: 'HarperFast/my-app/.github/workflows/release.yml', + ref_type: 'tag', + }), + /gate the ref/ + ); + }); + + // GitHub's sub varies by trigger and changed format for repos created after 2026-07-15, so it + // is not one of the accepted pins — unlike the generic profile, which requires it. + it('does not accept sub as a repository pin', () => { + assert.throws( + () => githubActionsProfile.assertPolicyIsSpecific({ sub: GITHUB_CLAIMS.sub, workflow_ref: WORKFLOW_REF }), + /pin the repository/ + ); + }); + }); + + describe('assertAudienceIsSpecific', () => { + it('rejects the shared org-wide default', () => { + for (const audience of ['https://github.com/HarperFast', 'https://github.com/HarperFast/']) { + assert.throws(() => githubActionsProfile.assertAudienceIsSpecific(audience), /must identify this instance/); + } + }); + + it('accepts an instance URL', () => { + assert.doesNotThrow(() => githubActionsProfile.assertAudienceIsSpecific('https://my-instance.harperdb.io:9925/')); + }); + }); + + // A pull_request_target run executes the base repo's workflow with its secrets while a fork + // controls the code, so it is denied unless a policy opts in by constraining event_name. + describe('vetoClaims', () => { + it('denies pull_request_target when the policy does not constrain event_name', () => { + const reason = githubActionsProfile.vetoClaims( + { ...GITHUB_CLAIMS, event_name: 'pull_request_target' }, + VALID_POLICY_CLAIMS + ); + assert.ok(reason); + assert.match(reason, /pull_request_target/); + }); + + it('allows pull_request_target when the policy opts in', () => { + const reason = githubActionsProfile.vetoClaims( + { ...GITHUB_CLAIMS, event_name: 'pull_request_target' }, + { ...VALID_POLICY_CLAIMS, event_name: 'pull_request_target' } + ); + assert.strictEqual(reason, undefined); + }); + + it('leaves ordinary events alone', () => { + assert.strictEqual(githubActionsProfile.vetoClaims(GITHUB_CLAIMS, VALID_POLICY_CLAIMS), undefined); + }); + }); + + describe('describePrincipal', () => { + it('names the repository, workflow, environment, run, and actor', () => { + const described = githubActionsProfile.describePrincipal(GITHUB_CLAIMS); + for (const fragment of ['HarperFast/my-app', WORKFLOW_REF, 'environment=production', 'run=99', 'actor=octocat']) { + assert.ok(described.includes(fragment), `expected "${fragment}" in "${described}"`); + } + }); + + it('tolerates a sparse token', () => { + assert.strictEqual(typeof githubActionsProfile.describePrincipal({}), 'string'); + }); + }); +}); diff --git a/unitTests/security/oidcTrust/tokenExchange.test.js b/unitTests/security/authn/oidc/tokenExchange.test.js similarity index 68% rename from unitTests/security/oidcTrust/tokenExchange.test.js rename to unitTests/security/authn/oidc/tokenExchange.test.js index 72815cc2f..2d0745b39 100644 --- a/unitTests/security/oidcTrust/tokenExchange.test.js +++ b/unitTests/security/authn/oidc/tokenExchange.test.js @@ -6,16 +6,16 @@ // (Map-backed mocks on databases.system). const assert = require('node:assert'); -const testUtils = require('../../testUtils.js'); +const testUtils = require('../../../testUtils.js'); testUtils.preTestPrep(); const fs = require('node:fs'); const path = require('node:path'); const jwt = require('jsonwebtoken'); const { generateKeyPairSync, createPublicKey } = require('node:crypto'); -const { exchangeOidcToken } = require('#src/security/oidcTrust/tokenExchange'); -const { addOidcTrust } = require('#src/security/oidcTrust/trustPolicyOperations'); -const { clearJwksCache } = require('#src/security/oidcTrust/jwks'); +const { exchangeOidcToken } = require('#src/security/authn/oidc/tokenExchange'); +const { addOidcTrust } = require('#src/security/authn/oidc/trustPolicyOperations'); +const { clearJwksCache } = require('#src/security/authn/oidc/jwks'); const { validateOperationToken, clearJWTRSAKeysCache, decodeJWT } = require('#src/security/tokenAuthentication'); const { databases } = require('#src/resources/databases'); const { setUsersWithRolesCache } = require('#src/security/user'); @@ -25,8 +25,9 @@ const terms = require('#src/utility/hdbTerms'); const TRUST_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TRUST_TABLE_NAME; const TOKEN_USE_TABLE = 'hdb_oidc_token_use'; const ISSUER = 'https://token.actions.githubusercontent.com'; -const JWKS_URI = ISSUER + '/.well-known/jwks'; -const DISCOVERY_URI = ISSUER + '/.well-known/openid-configuration'; +// An issuer with no registered provider profile — served by the same fake JWKS, so the generic +// profile is exercised end-to-end with zero provider code. +const GENERIC_ISSUER = 'https://kubernetes.default.svc'; const AUDIENCE = 'https://my-instance.harperdb.io:9925/'; const WORKFLOW_REF = 'HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main'; const OIDC_KID = 'gh-signing-key'; @@ -121,11 +122,21 @@ describe('exchangeOidcToken', () => { useTable = installMockTable(TOKEN_USE_TABLE, 'id'); await seedUsers(); realFetch = globalThis.fetch; - globalThis.fetch = async (url) => - new Response( - JSON.stringify(String(url) === DISCOVERY_URI ? { issuer: ISSUER, jwks_uri: JWKS_URI } : { keys: [signingJwk] }), - { status: 200, headers: { 'content-type': 'application/json' } } + // Serves discovery for any issuer asked about, and one shared key set, so a second issuer needs + // no extra plumbing. + globalThis.fetch = async (url) => { + const asString = String(url); + const discoveryFor = [ISSUER, GENERIC_ISSUER].find( + (issuer) => asString === issuer + '/.well-known/openid-configuration' ); + const body = discoveryFor + ? { issuer: discoveryFor, jwks_uri: discoveryFor + '/.well-known/jwks' } + : { keys: [signingJwk] }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; }); afterEach(() => { @@ -319,6 +330,124 @@ describe('exchangeOidcToken', () => { await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: forged })); }); + // Replay is keyed on a hash of the token, not on `jti`, so an issuer that omits it is still + // protected — which is the point of the change: Azure uses `uti`, others omit it entirely. + it('refuses to spend a token twice even when it carries no jti', async () => { + await addPolicy(); + const { jti: _jti, ...withoutJti } = JSON.parse( + Buffer.from(identityToken().split('.')[1], 'base64url').toString('utf8') + ); + const token = jwt.sign(withoutJti, issuerPrivateKey, { + algorithm: 'RS256', + header: { alg: 'RS256', kid: OIDC_KID }, + }); + + const result = await exchangeOidcToken({ operation: 'exchange_oidc_token', token }); + assert.strictEqual(result.username, 'ci-deploy'); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token })); + }); + + it('stores only a hash, never the token itself', async () => { + await addPolicy(); + const token = identityToken(); + await exchangeOidcToken({ operation: 'exchange_oidc_token', token }); + + const [record] = [...useTable.mock.rows.values()]; + assert.ok(!JSON.stringify(record).includes(token), 'the raw token must not be stored'); + assert.ok(!/eyJ/.test(JSON.stringify(record)), 'nothing JWT-shaped should be stored'); + }); + + // A pull_request_target run executes the base repo's workflow with its secrets while a fork + // controls the code. Denied unless the policy opts in by constraining event_name. + it('denies a pull_request_target run when the policy does not constrain event_name', async () => { + await addPolicy(); + await assertRejected( + exchangeOidcToken({ + operation: 'exchange_oidc_token', + token: identityToken({ event_name: 'pull_request_target' }), + }) + ); + }); + + it('allows a pull_request_target run when the policy opts in', async () => { + await addPolicy({ + claims: { repository_id: '67890', workflow_ref: WORKFLOW_REF, event_name: 'pull_request_target' }, + }); + const result = await exchangeOidcToken({ + operation: 'exchange_oidc_token', + token: identityToken({ event_name: 'pull_request_target' }), + }); + assert.strictEqual(result.username, 'ci-deploy'); + }); + + // The generic profile with no provider code: a Kubernetes-shaped token, a policy pinning `sub`. + describe('an issuer with no registered profile', () => { + function serviceAccountToken(overrides = {}) { + const now = Math.floor(Date.now() / 1000); + return jwt.sign( + { + iss: GENERIC_ISSUER, + aud: AUDIENCE, + sub: 'system:serviceaccount:prod:deployer', + iat: now, + exp: now + 300, + ...overrides, + }, + issuerPrivateKey, + { algorithm: 'RS256', header: { alg: 'RS256', kid: OIDC_KID } } + ); + } + + it('exchanges a token whose sub the policy pins', async () => { + await addOidcTrust( + asAdmin({ + id: 'k8s-prod', + issuer: GENERIC_ISSUER, + audience: AUDIENCE, + claims: { sub: 'system:serviceaccount:prod:deployer' }, + user: 'ci-deploy', + }) + ); + const result = await exchangeOidcToken({ operation: 'exchange_oidc_token', token: serviceAccountToken() }); + assert.strictEqual(result.username, 'ci-deploy'); + assert.strictEqual(result.policy, 'k8s-prod'); + }); + + it('rejects a different service account', async () => { + await addOidcTrust( + asAdmin({ + id: 'k8s-prod', + issuer: GENERIC_ISSUER, + audience: AUDIENCE, + claims: { sub: 'system:serviceaccount:prod:deployer' }, + user: 'ci-deploy', + }) + ); + await assertRejected( + exchangeOidcToken({ + operation: 'exchange_oidc_token', + token: serviceAccountToken({ sub: 'system:serviceaccount:prod:attacker' }), + }) + ); + }); + + it('refuses to store a policy that does not pin sub', async () => { + await assert.rejects( + () => + addOidcTrust( + asAdmin({ + id: 'k8s-loose', + issuer: GENERIC_ISSUER, + audience: AUDIENCE, + claims: { namespace: 'prod' }, + user: 'ci-deploy', + }) + ), + /must pin `sub`/ + ); + }); + }); + it('rejects malformed input', async () => { await addPolicy(); for (const token of ['not-a-jwt', 'a.b.c']) { diff --git a/unitTests/security/oidcTrust/trustPolicyOperations.test.js b/unitTests/security/authn/oidc/trustPolicyOperations.test.js similarity index 98% rename from unitTests/security/oidcTrust/trustPolicyOperations.test.js rename to unitTests/security/authn/oidc/trustPolicyOperations.test.js index 0b8dd4eea..b2e5be36a 100644 --- a/unitTests/security/oidcTrust/trustPolicyOperations.test.js +++ b/unitTests/security/authn/oidc/trustPolicyOperations.test.js @@ -5,7 +5,7 @@ // without stubs. const assert = require('node:assert'); -const testUtils = require('../../testUtils.js'); +const testUtils = require('../../../testUtils.js'); testUtils.preTestPrep(); const { @@ -13,7 +13,7 @@ const { listOidcTrust, dropOidcTrust, loadEnabledPolicies, -} = require('#src/security/oidcTrust/trustPolicyOperations'); +} = require('#src/security/authn/oidc/trustPolicyOperations'); const { databases } = require('#src/resources/databases'); const { setUsersWithRolesCache } = require('#src/security/user'); const terms = require('#src/utility/hdbTerms'); @@ -92,7 +92,7 @@ function validPolicy(overrides = {}) { }; } -describe('oidcTrust trustPolicyOperations', () => { +describe('oidc trustPolicyOperations', () => { let installed; beforeEach(async () => { diff --git a/unitTests/security/oidcTrust/verifyIdentityToken.test.js b/unitTests/security/authn/oidc/verifyIdentityToken.test.js similarity index 92% rename from unitTests/security/oidcTrust/verifyIdentityToken.test.js rename to unitTests/security/authn/oidc/verifyIdentityToken.test.js index ae8f9045f..3a2d2fcf8 100644 --- a/unitTests/security/oidcTrust/verifyIdentityToken.test.js +++ b/unitTests/security/authn/oidc/verifyIdentityToken.test.js @@ -3,7 +3,7 @@ const assert = require('node:assert'); const { generateKeyPairSync, createPublicKey } = require('node:crypto'); const jwt = require('jsonwebtoken'); -const { verifyIdentityToken } = require('#src/security/oidcTrust/identityToken'); +const { verifyIdentityToken } = require('#src/security/authn/oidc/identityToken'); const ISSUER = 'https://token.actions.githubusercontent.com'; const AUDIENCE = 'https://my-instance.harperdb.io:9925/'; @@ -139,10 +139,13 @@ describe('verifyIdentityToken', () => { await assertRejected(sign(claimsFor({ exp: NOW_SECONDS + 86_400 }))); }); - // Without a jti the exchange cannot record the token as spent, so it cannot be replay-protected. - it('rejects a token with no jti claim', async () => { + // Replay is keyed on a hash of the token itself, so `jti` is not required — which is what lets + // issuers that use `uti` (Azure) or omit it entirely work with no provider code. + it('accepts a token with no jti claim', async () => { const { jti: _jti, ...withoutJti } = claimsFor(); - await assertRejected(sign(withoutJti)); + const claims = await verify(sign(withoutJti)); + assert.strictEqual(claims.repository_id, '67890'); + assert.strictEqual(claims.jti, undefined); }); it('rejects a token whose kid is unknown to the key lookup', async () => { diff --git a/unitTests/security/oidcTrust/claims.test.js b/unitTests/security/oidcTrust/claims.test.js deleted file mode 100644 index 84f6f1b99..000000000 --- a/unitTests/security/oidcTrust/claims.test.js +++ /dev/null @@ -1,286 +0,0 @@ -'use strict'; - -const assert = require('node:assert'); -const { - splitWorkflowPath, - normalizeTokenClaims, - matchTrustPolicyClaims, - validateTrustPolicyClaims, -} = require('#src/security/oidcTrust/claims'); - -// A representative GitHub Actions identity token payload, as emitted for a push to main running a -// job that declares `environment: production`. -const GITHUB_CLAIMS = Object.freeze({ - iss: 'https://token.actions.githubusercontent.com', - aud: 'https://my-instance.harperdb.io:9925/', - sub: 'repo:HarperFast/my-app:environment:production', - repository: 'HarperFast/my-app', - repository_id: '67890', - repository_owner: 'HarperFast', - repository_owner_id: '12345', - workflow_ref: 'HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main', - job_workflow_ref: 'HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main', - environment: 'production', - ref: 'refs/heads/main', - ref_type: 'branch', - event_name: 'push', - runner_environment: 'github-hosted', - jti: 'e5f7a0c2-0000-4000-8000-000000000001', -}); - -// The smallest policy that passes validation, reused as a base so each validation test varies one thing. -const VALID_POLICY_CLAIMS = Object.freeze({ - repository_id: '67890', - workflow_ref: 'HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main', -}); - -describe('oidcTrust claims', () => { - describe('splitWorkflowPath', () => { - it('strips the ref from a workflow reference', () => { - assert.strictEqual( - splitWorkflowPath('HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main'), - 'HarperFast/my-app/.github/workflows/deploy.yml' - ); - }); - - it('splits on the ref qualifier, not on an @ inside the branch name', () => { - assert.strictEqual( - splitWorkflowPath('HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/release@2'), - 'HarperFast/my-app/.github/workflows/deploy.yml' - ); - }); - - it('handles a tag ref', () => { - assert.strictEqual( - splitWorkflowPath('HarperFast/my-app/.github/workflows/release.yml@refs/tags/v1.2.3'), - 'HarperFast/my-app/.github/workflows/release.yml' - ); - }); - - it('returns undefined rather than guessing when the value is not ref-qualified', () => { - assert.strictEqual(splitWorkflowPath('HarperFast/my-app/.github/workflows/deploy.yml'), undefined); - assert.strictEqual(splitWorkflowPath('no-at-sign'), undefined); - }); - - it('returns undefined for non-string input', () => { - assert.strictEqual(splitWorkflowPath(undefined), undefined); - assert.strictEqual(splitWorkflowPath(null), undefined); - assert.strictEqual(splitWorkflowPath(42), undefined); - }); - }); - - describe('normalizeTokenClaims', () => { - it('derives workflow_path and job_workflow_path', () => { - const claims = normalizeTokenClaims(GITHUB_CLAIMS); - assert.strictEqual(claims.workflow_path, 'HarperFast/my-app/.github/workflows/deploy.yml'); - assert.strictEqual(claims.job_workflow_path, 'HarperFast/my-app/.github/workflows/deploy.yml'); - }); - - it('preserves the original claims', () => { - const claims = normalizeTokenClaims(GITHUB_CLAIMS); - assert.strictEqual(claims.repository_id, '67890'); - assert.strictEqual(claims.environment, 'production'); - assert.strictEqual(claims.workflow_ref, GITHUB_CLAIMS.workflow_ref); - }); - - it('does not mutate the input', () => { - const payload = { ...GITHUB_CLAIMS }; - normalizeTokenClaims(payload); - assert.strictEqual(payload.workflow_path, undefined); - }); - - it('omits a derived claim when the reference is not ref-qualified', () => { - const claims = normalizeTokenClaims({ workflow_ref: 'owner/repo/.github/workflows/deploy.yml' }); - assert.strictEqual(claims.workflow_path, undefined); - }); - - // An issuer that one day emits workflow_path itself must win over our derivation, otherwise a - // policy written against the real claim would be matched against a value we invented. - it('does not displace a claim the token already carries', () => { - const claims = normalizeTokenClaims({ - workflow_ref: 'owner/repo/.github/workflows/deploy.yml@refs/heads/main', - workflow_path: 'issuer-supplied', - }); - assert.strictEqual(claims.workflow_path, 'issuer-supplied'); - }); - }); - - describe('matchTrustPolicyClaims', () => { - it('matches when every constraint is satisfied', () => { - const claims = normalizeTokenClaims(GITHUB_CLAIMS); - const reason = matchTrustPolicyClaims(claims, { - repository_id: '67890', - repository_owner_id: '12345', - workflow_ref: GITHUB_CLAIMS.workflow_ref, - environment: 'production', - runner_environment: 'github-hosted', - }); - assert.strictEqual(reason, undefined); - }); - - it('accepts any value from a set', () => { - const claims = normalizeTokenClaims(GITHUB_CLAIMS); - const reason = matchTrustPolicyClaims(claims, { - repository_id: '67890', - workflow_ref: GITHUB_CLAIMS.workflow_ref, - event_name: ['push', 'workflow_dispatch'], - }); - assert.strictEqual(reason, undefined); - }); - - it('rejects a value outside the set', () => { - const claims = normalizeTokenClaims(GITHUB_CLAIMS); - const reason = matchTrustPolicyClaims(claims, { - repository_id: '67890', - event_name: ['workflow_dispatch', 'schedule'], - }); - assert.ok(reason, 'expected a mismatch reason'); - assert.match(reason, /event_name/); - }); - - // The central fail-closed property: a constrained claim the token does not carry must deny, - // so a policy cannot be silently weakened by an issuer that stops emitting a claim. - it('rejects when a constrained claim is absent from the token', () => { - const { environment: _environment, ...withoutEnvironment } = GITHUB_CLAIMS; - const claims = normalizeTokenClaims(withoutEnvironment); - const reason = matchTrustPolicyClaims(claims, { - repository_id: '67890', - environment: 'production', - }); - assert.ok(reason, 'expected a mismatch reason'); - assert.match(reason, /environment/); - }); - - it('rejects an empty-string claim value', () => { - const claims = normalizeTokenClaims({ ...GITHUB_CLAIMS, environment: '' }); - const reason = matchTrustPolicyClaims(claims, { environment: 'production' }); - assert.ok(reason, 'expected a mismatch reason'); - }); - - it('rejects a policy that constrains nothing', () => { - const claims = normalizeTokenClaims(GITHUB_CLAIMS); - const reason = matchTrustPolicyClaims(claims, {}); - assert.ok(reason, 'expected a rejection for an unconstrained policy'); - }); - - it('compares a numerically-encoded claim as a string', () => { - const claims = normalizeTokenClaims({ ...GITHUB_CLAIMS, repository_id: 67890 }); - assert.strictEqual(matchTrustPolicyClaims(claims, { repository_id: '67890' }), undefined); - }); - - it('refuses to compare a non-scalar claim', () => { - for (const value of [true, { nested: 'object' }, ['array'], null]) { - const claims = normalizeTokenClaims({ ...GITHUB_CLAIMS, environment: value }); - assert.ok( - matchTrustPolicyClaims(claims, { environment: 'production' }), - `expected rejection for ${JSON.stringify(value)}` - ); - } - }); - - it('matches on the derived workflow_path so a tag release can pin the file', () => { - const tagRun = normalizeTokenClaims({ - ...GITHUB_CLAIMS, - workflow_ref: 'HarperFast/my-app/.github/workflows/release.yml@refs/tags/v1.2.3', - ref: 'refs/tags/v1.2.3', - ref_type: 'tag', - }); - const reason = matchTrustPolicyClaims(tagRun, { - repository_id: '67890', - workflow_path: 'HarperFast/my-app/.github/workflows/release.yml', - environment: 'production', - }); - assert.strictEqual(reason, undefined); - }); - }); - - describe('validateTrustPolicyClaims', () => { - it('accepts a repository pin plus a ref-qualified workflow pin', () => { - assert.doesNotThrow(() => validateTrustPolicyClaims({ ...VALID_POLICY_CLAIMS })); - }); - - it('accepts a workflow path pinned by an environment gate', () => { - assert.doesNotThrow(() => - validateTrustPolicyClaims({ - repository_id: '67890', - workflow_path: 'HarperFast/my-app/.github/workflows/release.yml', - environment: 'production', - }) - ); - }); - - it('rejects a non-object', () => { - for (const value of [undefined, null, 'claims', 42, ['repository_id']]) { - assert.throws(() => validateTrustPolicyClaims(value), /claims must be an object/); - } - }); - - it('rejects an empty object', () => { - assert.throws(() => validateTrustPolicyClaims({}), /at least one claim/); - }); - - it('rejects an empty accepted-value set', () => { - assert.throws(() => validateTrustPolicyClaims({ ...VALID_POLICY_CLAIMS, event_name: [] }), /at least one value/); - }); - - it('rejects non-string and empty-string values', () => { - assert.throws(() => validateTrustPolicyClaims({ ...VALID_POLICY_CLAIMS, environment: '' }), /non-empty/); - assert.throws(() => validateTrustPolicyClaims({ ...VALID_POLICY_CLAIMS, environment: 42 }), /non-empty/); - assert.throws(() => validateTrustPolicyClaims({ ...VALID_POLICY_CLAIMS, event_name: ['push', ''] }), /non-empty/); - }); - - // repository_owner identifies an org, not a repository — pinning it would admit every repo in - // the org, which is exactly the over-broad policy this validation exists to prevent. - it('rejects a policy with no repository pin', () => { - assert.throws( - () => - validateTrustPolicyClaims({ - repository_owner: 'HarperFast', - workflow_ref: VALID_POLICY_CLAIMS.workflow_ref, - }), - /pin the repository/ - ); - }); - - it('rejects a policy with no workflow pin', () => { - assert.throws( - () => validateTrustPolicyClaims({ repository_id: '67890', environment: 'production' }), - /pin the workflow/ - ); - }); - - // The npm-style "repository + workflow filename" policy: any branch that can be pushed can run - // the workflow and mint a token. Refuse it unless something gates the ref. - it('rejects a workflow pin with no ref gate', () => { - assert.throws( - () => - validateTrustPolicyClaims({ - repository_id: '67890', - workflow_path: 'HarperFast/my-app/.github/workflows/deploy.yml', - }), - /gate the ref/ - ); - }); - - it('does not accept ref_type alone as a ref gate', () => { - assert.throws( - () => - validateTrustPolicyClaims({ - repository_id: '67890', - workflow_path: 'HarperFast/my-app/.github/workflows/release.yml', - ref_type: 'tag', - }), - /gate the ref/ - ); - }); - - it('treats a ref-qualified workflow_ref as both the workflow pin and the ref gate', () => { - assert.doesNotThrow(() => - validateTrustPolicyClaims({ - repository: 'HarperFast/my-app', - job_workflow_ref: VALID_POLICY_CLAIMS.workflow_ref, - }) - ); - }); - }); -}); diff --git a/utility/operation_authorization.ts b/utility/operation_authorization.ts index a87efd0a9..8c3f09a8b 100644 --- a/utility/operation_authorization.ts +++ b/utility/operation_authorization.ts @@ -47,8 +47,8 @@ import { handleHDBError, hdbErrors } from '../utility/errors/hdbError.ts'; import * as regDeprecated from '../resources/registrationDeprecated.ts'; import * as deploymentOperations from '../components/deploymentOperations.ts'; import * as secretOperations from '../components/secretOperations.ts'; -import * as trustPolicyOperations from '../security/oidcTrust/trustPolicyOperations.ts'; -import * as tokenExchange from '../security/oidcTrust/tokenExchange.ts'; +import * as trustPolicyOperations from '../security/authn/oidc/trustPolicyOperations.ts'; +import * as tokenExchange from '../security/authn/oidc/tokenExchange.ts'; const requiredPermissions = new Map(); const DELETE_PERM = 'delete'; From 7ddbf0f9c6aa70234e39a31e7655025a7e2accc2 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 14 Aug 2026 14:48:32 -0400 Subject: [PATCH 08/37] test: remove the JWT keys the exchange suite writes, and share the helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tokenExchange.test.js wrote real signing keys into the test base path and never removed them. That path is shared: unitTests/utility/install/checkJWTTokensExist.test.js asserts those files are ABSENT (its happy path expects accessSync to throw ENOENT), and mocha runs every file in one process against one base path — so whichever ran first decided whether the other passed. It has been latent here. It surfaced on the stacked branch (#2174) when a second file started writing the same keys, failing that suite on all three Node versions; the same landmine was already sitting on this branch waiting for a file-order change. The fix is a testUtils.installTestJwtKeys() that returns a cleanup function, so the next test needing signing keys gets the removal for free rather than copying the setup and not the teardown. Verified by running the exchange suite and checkJWTTokensExist together, which failed before and passes now. Co-Authored-By: Claude Opus 5 --- .../security/authn/oidc/tokenExchange.test.js | 30 ++++++---------- unitTests/testUtils.js | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/unitTests/security/authn/oidc/tokenExchange.test.js b/unitTests/security/authn/oidc/tokenExchange.test.js index 2d0745b39..84d21e500 100644 --- a/unitTests/security/authn/oidc/tokenExchange.test.js +++ b/unitTests/security/authn/oidc/tokenExchange.test.js @@ -9,8 +9,6 @@ const assert = require('node:assert'); const testUtils = require('../../../testUtils.js'); testUtils.preTestPrep(); -const fs = require('node:fs'); -const path = require('node:path'); const jwt = require('jsonwebtoken'); const { generateKeyPairSync, createPublicKey } = require('node:crypto'); const { exchangeOidcToken } = require('#src/security/authn/oidc/tokenExchange'); @@ -19,7 +17,6 @@ const { clearJwksCache } = require('#src/security/authn/oidc/jwks'); const { validateOperationToken, clearJWTRSAKeysCache, decodeJWT } = require('#src/security/tokenAuthentication'); const { databases } = require('#src/resources/databases'); const { setUsersWithRolesCache } = require('#src/security/user'); -const env = require('#src/utility/environment/environmentManager'); const terms = require('#src/utility/hdbTerms'); const TRUST_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TRUST_TABLE_NAME; @@ -35,22 +32,6 @@ const OIDC_KID = 'gh-signing-key'; let issuerPrivateKey; let signingJwk; -/** Writes the RSA keys createOperationToken signs with into the isolated test base path. */ -function installJwtSigningKeys() { - const passphrase = 'test-passphrase'; - const { privateKey, publicKey } = generateKeyPairSync('rsa', { - modulusLength: 2048, - publicKeyEncoding: { type: 'spki', format: 'pem' }, - privateKeyEncoding: { type: 'pkcs8', format: 'pem', cipher: 'aes-256-cbc', passphrase }, - }); - const keysDir = path.join(env.getHdbBasePath(), terms.LICENSE_KEY_DIR_NAME); - fs.mkdirSync(keysDir, { recursive: true }); - fs.writeFileSync(path.join(keysDir, terms.JWT_ENUM.JWT_PRIVATE_KEY_NAME), privateKey); - fs.writeFileSync(path.join(keysDir, terms.JWT_ENUM.JWT_PUBLIC_KEY_NAME), publicKey); - fs.writeFileSync(path.join(keysDir, terms.JWT_ENUM.JWT_PASSPHRASE_NAME), passphrase); - clearJWTRSAKeysCache(); -} - function installMockTable(name, primaryKey) { const rows = new Map(); const mock = { @@ -95,13 +76,17 @@ const asAdmin = (body) => ({ }); describe('exchangeOidcToken', () => { + let removeJwtKeys; let trustTable; let useTable; let realFetch; let tokenCounter = 0; before(() => { - installJwtSigningKeys(); + // Shared helper so the keys are removed afterwards — they live in a directory another suite + // asserts is empty (see testUtils.installTestJwtKeys). + removeJwtKeys = testUtils.installTestJwtKeys(); + clearJWTRSAKeysCache(); const pair = generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, @@ -145,6 +130,11 @@ describe('exchangeOidcToken', () => { useTable.restore(); }); + after(() => { + removeJwtKeys(); + clearJWTRSAKeysCache(); + }); + function identityToken(overrides = {}) { const now = Math.floor(Date.now() / 1000); return jwt.sign( diff --git a/unitTests/testUtils.js b/unitTests/testUtils.js index 64d956515..126af469b 100644 --- a/unitTests/testUtils.js +++ b/unitTests/testUtils.js @@ -506,11 +506,45 @@ function orderedArray(iterator) { return array; } +/** + * Writes a throwaway RSA keypair where getJWTRSAKeys() looks for it, and returns a cleanup function + * that removes exactly the files it created. + * + * The keys directory is shared with unitTests/utility/install/checkJWTTokensExist.test.js, whose + * happy path asserts those files are ABSENT (it expects accessSync to throw ENOENT). Mocha runs + * every file in one process against one base path, so a test that writes keys and does not clean up + * silently breaks that suite depending on file order — which is exactly how it broke, and why this + * lives here instead of being copy-pasted into each caller. + */ +function installTestJwtKeys() { + const { generateKeyPairSync } = require('node:crypto'); + const passphrase = 'test-passphrase'; + const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem', cipher: 'aes-256-cbc', passphrase }, + }); + const keysDir = path.join(env.getHdbBasePath(), terms.LICENSE_KEY_DIR_NAME); + fs.mkdirpSync(keysDir); + + const written = [ + [path.join(keysDir, terms.JWT_ENUM.JWT_PRIVATE_KEY_NAME), privateKey], + [path.join(keysDir, terms.JWT_ENUM.JWT_PUBLIC_KEY_NAME), publicKey], + [path.join(keysDir, terms.JWT_ENUM.JWT_PASSPHRASE_NAME), passphrase], + ]; + for (const [file, contents] of written) fs.writeFileSync(file, contents); + + return function removeTestJwtKeys() { + for (const [file] of written) fs.removeSync(file); + }; +} + module.exports = { changeProcessToBinDir, deepClone, mochaAsyncWrapper, preTestPrep, + installTestJwtKeys, cleanUpDirectories, createMockDB, tearDownMockDB, From 2e2dd0ca8251174c0936708da008509bc778963f Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 14 Aug 2026 14:19:31 -0400 Subject: [PATCH 09/37] feat(security): narrow a minted token to a subset of its user's operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on feat/oidc-trusted-publishing. Explores the per-policy operation scoping Kris asked about — with the constraint that makes it safe, which is the reason it is a separate PR rather than part of #2173. An OIDC trust policy may carry `operations`. The exchanged token then carries that list as a claim, and verifyPerms intersects it with the user's role. One Harper user can back several workflows, each holding a credential narrower than the user itself. It can only ever subtract. Two things make that true, and both are the whole point: 1. The check is the FIRST authorization step in verifyPerms. Both the super_user bypass and the `operations` gate-2 grant return null early, so a narrowing check after either would be bypassable by exactly the identities it most needs to constrain. Tested directly: a super_user token scoped to get_status cannot insert. 2. The scope is never merged into role.permission.operations. That field is not purely narrowing — gate 2 treats an explicit listing of an SU-only operation as a deliberate grant — so merging into it could widen instead of narrow. It travels on the user as `tokenOperations` and is intersected separately. Absent claim means today's behavior exactly, so every existing token and every unscoped policy is unaffected. Operation names are validated at write time against OPERATIONS_ENUM (groups expanded first): a typo would otherwise fail closed at request time, in CI, with nothing to point at. Co-Authored-By: Claude Opus 5 --- json/systemSchema.json | 3 + security/authn/oidc/tokenExchange.ts | 7 +- security/authn/oidc/trustPolicyOperations.ts | 20 +++++ security/authn/oidc/types.ts | 5 ++ security/tokenAuthentication.ts | 18 +++- .../security/authn/oidc/tokenExchange.test.js | 18 ++++ .../authn/oidc/trustPolicyOperations.test.js | 24 ++++++ .../security/tokenOperationScope.test.js | 84 +++++++++++++++++++ utility/operation_authorization.ts | 23 +++++ 9 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 unitTests/security/tokenOperationScope.test.js diff --git a/json/systemSchema.json b/json/systemSchema.json index 812c6ea12..a3161f100 100644 --- a/json/systemSchema.json +++ b/json/systemSchema.json @@ -494,6 +494,9 @@ { "attribute": "user" }, + { + "attribute": "operations" + }, { "attribute": "enabled" }, diff --git a/security/authn/oidc/tokenExchange.ts b/security/authn/oidc/tokenExchange.ts index 3aa7fcd9f..99974608a 100644 --- a/security/authn/oidc/tokenExchange.ts +++ b/security/authn/oidc/tokenExchange.ts @@ -203,9 +203,14 @@ export async function exchangeOidcToken(req: any) { await recordTokenUse(fingerprint, claims, policy.id); const operationToken = await createOperationToken( - { username: user.username, super_user: user.role?.permission?.super_user === true }, + { + username: user.username, + super_user: user.role?.permission?.super_user === true, + operations: policy.operations, + }, EXCHANGED_TOKEN_LIFETIME_SECONDS ); + if (policy.operations?.length) audit.scoped_operations = policy.operations; logger.info?.(`OIDC exchange: policy '${policy.id}' authenticated '${user.username}' for ${audit.principal}`); auditExchange(req, username, AUTH_AUDIT_STATUS.SUCCESS, audit); diff --git a/security/authn/oidc/trustPolicyOperations.ts b/security/authn/oidc/trustPolicyOperations.ts index ff090a5fe..c33d144cd 100644 --- a/security/authn/oidc/trustPolicyOperations.ts +++ b/security/authn/oidc/trustPolicyOperations.ts @@ -19,6 +19,7 @@ import { getUsersWithRolesCache } from '../../user.ts'; import { validateClaimConstraintShape } from './claims.ts'; import { normalizeIssuer } from './jwks.ts'; import { profileForIssuer } from './providers/index.ts'; +import { expandOperationsPerms } from '../../../utility/operationPermissions.ts'; import type { OidcTrustPolicy } from './types.ts'; const { HTTP_STATUS_CODES } = hdbErrors; @@ -44,6 +45,21 @@ function validate(validation: any): void { if (validation) throw new ClientError(validation.message); } +/** + * A typo in an operation name would otherwise fail closed at request time, in CI, with nothing to + * point at — so it is caught here, where the reader is the administrator who wrote it. Group names + * are accepted: expandOperationsPerms resolves them, and a name that expands to only itself and is + * not a known operation is the typo we are looking for. + */ +function assertOperationsAreKnown(operations: string[]): void { + const known = new Set(Object.values(terms.OPERATIONS_ENUM)); + for (const name of expandOperationsPerms(operations)) { + if (!known.has(name)) { + throw new ClientError(`operations contains '${name}', which is not a Harper operation`); + } + } +} + function trustTable() { const table = (databases as any).system?.[OIDC_TRUST_TABLE]; if (!table) { @@ -66,6 +82,7 @@ function toRecord(row: any): OidcTrustPolicy & Record { audience: row.audience, claims: row.claims ?? {}, user: row.user, + operations: row.operations ?? null, enabled: row.enabled !== false, description: row.description ?? null, updated_by: row.updated_by ?? null, @@ -112,6 +129,7 @@ export async function addOidcTrust(req: any) { audience: Joi.string().min(1).max(512).required(), claims: Joi.object().min(1).required(), user: Joi.string().min(1).max(512).required(), + operations: Joi.array().items(Joi.string().min(1)).min(1).max(100).unique(), enabled: Joi.boolean(), description: Joi.string().allow('').max(1024), }).unknown(true) @@ -123,6 +141,7 @@ export async function addOidcTrust(req: any) { // generic profile rather than a permissive default. Each throws ClientError naming the problem. const profile = profileForIssuer(issuer); profile.assertAudienceIsSpecific(req.audience); + if (req.operations) assertOperationsAreKnown(req.operations); validateClaimConstraintShape(req.claims); profile.assertPolicyIsSpecific(req.claims); @@ -146,6 +165,7 @@ export async function addOidcTrust(req: any) { audience: req.audience, claims: req.claims, user: req.user, + operations: req.operations ?? null, enabled: req.enabled !== false, description: req.description ?? null, updated_by: req.hdb_user?.username ?? null, diff --git a/security/authn/oidc/types.ts b/security/authn/oidc/types.ts index 450d08b4f..26891667f 100644 --- a/security/authn/oidc/types.ts +++ b/security/authn/oidc/types.ts @@ -19,6 +19,11 @@ export interface OidcTrustPolicy { claims: Record; /** The exchanged token authenticates as this user, whose role is the least-privilege boundary. */ user: string; + /** + * Optional narrowing of that boundary: the minted token may perform only these operations, even + * where the role allows more. Never widens — an operation the role forbids stays forbidden. + */ + operations?: string[]; /** Defaults to true; false keeps the policy for reference without honoring it. */ enabled?: boolean; description?: string; diff --git a/security/tokenAuthentication.ts b/security/tokenAuthentication.ts index c724d97d9..404a586d3 100644 --- a/security/tokenAuthentication.ts +++ b/security/tokenAuthentication.ts @@ -257,12 +257,20 @@ export async function refreshOperationToken(tokenObj: TokenObject): Promise { const keys: JWTRSAKeys = await getJWTRSAKeys(); + const payload: { username: string; super_user: boolean; operations?: string[] } = { + username: user.username, + super_user: user.super_user, + }; + // A narrowing scope, never a grant: verifyPerms intersects it with the user's role. Absent means + // the role governs alone, which is every token minted before this existed. + if (user.operations?.length) payload.operations = user.operations; + return jwt.sign( - { username: user.username, super_user: user.super_user }, + payload, { key: keys.privateKey, passphrase: keys.passphrase } satisfies Secret, { expiresIn, @@ -308,6 +316,12 @@ async function validateToken(token: string, tokenType: string): Promise { throw new Error('Invalid token'); } + // Surfaced on the user rather than merged into role.permission.operations: that field is not + // purely narrowing (verifyPerms gate 2 treats an explicit SU-only listing as a grant), so + // merging a token scope into it could widen rather than narrow. verifyPerms intersects this + // separately, ahead of every bypass. + if (Array.isArray(tokenVerified.operations)) user.tokenOperations = tokenVerified.operations; + return user; } catch (err) { logger.warn(err); diff --git a/unitTests/security/authn/oidc/tokenExchange.test.js b/unitTests/security/authn/oidc/tokenExchange.test.js index 84d21e500..a32ba8196 100644 --- a/unitTests/security/authn/oidc/tokenExchange.test.js +++ b/unitTests/security/authn/oidc/tokenExchange.test.js @@ -438,6 +438,24 @@ describe('exchangeOidcToken', () => { }); }); + // End to end: the policy's scope reaches the minted token, so verifyPerms can narrow on it. + it('carries the policy operation scope into the minted token', async () => { + await addPolicy({ operations: ['deploy_component'] }); + const result = await exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() }); + + const payload = JSON.parse(Buffer.from(result.operation_token.split('.')[1], 'base64url').toString('utf8')); + assert.deepStrictEqual(payload.operations, ['deploy_component']); + assert.strictEqual(payload.username, 'ci-deploy'); + }); + + it('omits the claim entirely when the policy does not scope', async () => { + await addPolicy(); + const result = await exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() }); + + const payload = JSON.parse(Buffer.from(result.operation_token.split('.')[1], 'base64url').toString('utf8')); + assert.strictEqual(payload.operations, undefined, 'an unscoped token must look exactly as it did before'); + }); + it('rejects malformed input', async () => { await addPolicy(); for (const token of ['not-a-jwt', 'a.b.c']) { diff --git a/unitTests/security/authn/oidc/trustPolicyOperations.test.js b/unitTests/security/authn/oidc/trustPolicyOperations.test.js index b2e5be36a..bea08de14 100644 --- a/unitTests/security/authn/oidc/trustPolicyOperations.test.js +++ b/unitTests/security/authn/oidc/trustPolicyOperations.test.js @@ -188,6 +188,30 @@ describe('oidc trustPolicyOperations', () => { assert.strictEqual(installed.mock.rows.get('my-app-prod').user, 'admin'); }); + it('stores an operation scope', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy({ operations: ['deploy_component'] }))); + assert.deepStrictEqual(installed.mock.rows.get('my-app-prod').operations, ['deploy_component']); + }); + + it('accepts an operation group', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy({ operations: ['read_only'] }))); + assert.deepStrictEqual(installed.mock.rows.get('my-app-prod').operations, ['read_only']); + }); + + // A typo would otherwise fail closed at request time, in CI, with nothing to point at. + it('rejects an operation name that is not a Harper operation', async () => { + await assert.rejects( + () => addOidcTrust(su('add_oidc_trust', validPolicy({ operations: ['deploy_compnent'] }))), + /not a Harper operation/ + ); + assert.strictEqual(installed.mock.rows.size, 0, 'expected nothing stored'); + }); + + it('leaves operations null when the policy does not scope', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy())); + assert.strictEqual(installed.mock.rows.get('my-app-prod').operations, null); + }); + it('rejects a malformed id', async () => { for (const id of ['', 'has spaces', 'has/slash', 'x'.repeat(129)]) { await assert.rejects(() => addOidcTrust(su('add_oidc_trust', validPolicy({ id })))); diff --git a/unitTests/security/tokenOperationScope.test.js b/unitTests/security/tokenOperationScope.test.js new file mode 100644 index 000000000..5cf89c360 --- /dev/null +++ b/unitTests/security/tokenOperationScope.test.js @@ -0,0 +1,84 @@ +'use strict'; + +// The narrowing contract for a token-scoped operation allowlist: it may only ever subtract from what +// the user's role allows, and it must not be bypassable by any of verifyPerms' early-return paths. + +const assert = require('node:assert'); +const testUtils = require('../testUtils.js'); +testUtils.preTestPrep(); + +const opAuth = require('#src/utility/operation_authorization'); + +// `insertData` is the internal function name for the `insert` operation; verifyPerms resolves the +// api_name via the permission registry, which is what a scope is written against. +const INSERT_FN = 'insertData'; + +function requestAs(permission, tokenOperations) { + const hdb_user = { username: 'ci-deploy', role: { role: 'r', permission } }; + if (tokenOperations !== undefined) hdb_user.tokenOperations = tokenOperations; + return { operation: 'insert', schema: 'data', table: 'dog', hdb_user, records: [] }; +} + +/** verifyPerms returns null when allowed, or a response object describing the denial. */ +function isAllowed(result) { + return result === null || result === undefined; +} + +describe('token-scoped operation narrowing', () => { + it('allows an operation inside the scope', () => { + const result = opAuth.verifyPerms(requestAs({ super_user: true }, ['insert']), INSERT_FN); + assert.ok(isAllowed(result), 'expected the in-scope operation to be permitted'); + }); + + it('denies an operation outside the scope', () => { + const result = opAuth.verifyPerms(requestAs({ super_user: true }, ['get_status']), INSERT_FN); + assert.ok(!isAllowed(result), 'expected the out-of-scope operation to be denied'); + }); + + // The whole point: a super_user returns null early in verifyPerms, so a narrowing check placed + // after that bypass would do nothing for exactly the identity that most needs constraining. + it('constrains a super_user', () => { + assert.ok(!isAllowed(opAuth.verifyPerms(requestAs({ super_user: true }, ['get_status']), INSERT_FN))); + }); + + // Gate 2 treats an explicit SU-only listing in role.permission.operations as a deliberate grant + // and returns null. The scope must still win. + it('constrains an operation the role granted through its own operations allowlist', () => { + const permission = { super_user: false, operations: ['insert'] }; + assert.ok(!isAllowed(opAuth.verifyPerms(requestAs(permission, ['get_status']), INSERT_FN))); + }); + + // Narrowing only: naming an operation the role forbids must not grant it. + it('does not grant an operation the role forbids', () => { + const permission = { super_user: false, operations: ['get_status'] }; + const result = opAuth.verifyPerms(requestAs(permission, ['insert']), INSERT_FN); + assert.ok(!isAllowed(result), 'a scope must never widen the role'); + }); + + it('is inert when the token carries no scope', () => { + assert.ok(isAllowed(opAuth.verifyPerms(requestAs({ super_user: true }), INSERT_FN))); + }); + + // A group name must be expanded rather than compared literally, or naming one would deny + // everything. `read_only` deliberately excludes insert, so it also proves the expansion narrows. + it('expands operation groups', () => { + const withGroup = requestAs({ super_user: true }, ['read_only']); + const result = opAuth.verifyPerms(withGroup, INSERT_FN); + + assert.ok(withGroup.hdb_user._expandedTokenOperations.size > 1, 'expected the group to expand'); + assert.ok(withGroup.hdb_user._expandedTokenOperations.has('search_by_value'), 'expected group members'); + assert.ok(!isAllowed(result), 'read_only must not admit insert'); + }); + + it('memoizes the expansion on the user', () => { + const request = requestAs({ super_user: true }, ['insert']); + opAuth.verifyPerms(request, INSERT_FN); + const first = request.hdb_user._expandedTokenOperations; + opAuth.verifyPerms(request, INSERT_FN); + assert.strictEqual(request.hdb_user._expandedTokenOperations, first, 'expected one expansion'); + }); + + it('denies everything when the scope is empty', () => { + assert.ok(!isAllowed(opAuth.verifyPerms(requestAs({ super_user: true }, []), INSERT_FN))); + }); +}); diff --git a/utility/operation_authorization.ts b/utility/operation_authorization.ts index 8c3f09a8b..64dc44452 100644 --- a/utility/operation_authorization.ts +++ b/utility/operation_authorization.ts @@ -582,6 +582,29 @@ export function verifyPerms(requestJson: any, operation: any, _options?: any) { const permsResponse = new PermissionResponseObject(); + // Token-scoped narrowing: a minted operation token may carry a subset of what its user's role + // allows, so one credential can be handed out with less authority than the user has (an OIDC + // trust policy uses this to scope a single workflow). + // + // Deliberately the FIRST authorization check in this function. Both the super_user bypass and the + // `operations` gate-2 grant below `return null` early, so a narrowing check placed after either + // would be silently bypassable by exactly the identities it most needs to constrain. + // + // It can only subtract. The scope is never merged into `permission.operations`, because that field + // is not purely narrowing — gate 2 treats an explicit listing of an SU-only operation as a + // deliberate grant, so merging into it could widen instead. + const tokenOperations = requestJson.hdb_user?.tokenOperations; + if (tokenOperations !== undefined) { + const scopedOps = + requestJson.hdb_user._expandedTokenOperations ?? + (requestJson.hdb_user._expandedTokenOperations = expandOperationsPerms(tokenOperations)); + const opApiName = requiredPermissions.get(op)?.api_name ?? op; + if (!scopedOps.has(opApiName)) { + harperLogger.info(`Operation '${opApiName}' is outside the scope of the presented token`); + return permsResponse.handleUnauthorizedItem(HDB_ERROR_MSGS.OP_NOT_IN_OPERATIONS(opApiName)); + } + } + if ( commonUtils.isEmptyOrZeroLength(requestJson.hdb_user?.role) || commonUtils.isEmptyOrZeroLength(requestJson.hdb_user?.role?.permission) From ff04730777a2d0b504d3ae9ff870d77ec03ba280 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 14 Aug 2026 14:41:58 -0400 Subject: [PATCH 10/37] fix(security): carry an empty operation scope instead of dropping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from gemini-code-assist on #2174; all four points were correct. The one that mattered: `createOperationToken` gated the claim on `user.operations?.length`, so an EMPTY scope — meaning "no operations" — was omitted from the payload entirely. The minted token then looked unscoped, verifyPerms skipped narrowing, and the holder got everything its role allowed. A security control failing open, and in the one direction that matters. add_oidc_trust rejects an empty array (Joi .min(1)), so this is not reachable through the documented API. It is reachable by a row arriving through replication from a peer, which is the same path matchTrustPolicyClaims already backstops against — a control must fail closed regardless of how the input got there. Also: - verifyPerms used `!== undefined` where an unscoped policy stores `operations: null`; expanding null would throw rather than fall through to the role. Now `!= null`, which is also the repo's documented idiom (.gemini/styleguide.md). - Operation-name validation delegates to validateOperations instead of a local OPERATIONS_ENUM check. That helper also accepts operations registered at runtime via server.registerOperation, which the local check would have rejected — so a policy could not scope to a dynamically registered op. Three tests added, one per failure mode. The empty-scope test is a round trip through createOperationToken rather than a verifyPerms unit check: the existing suite passed `[]` straight to verifyPerms and denied correctly, which is exactly why it missed a mint path that never emitted the claim. Co-Authored-By: Claude Opus 5 --- security/authn/oidc/trustPolicyOperations.ts | 18 +++---- security/tokenAuthentication.ts | 7 ++- .../authn/oidc/trustPolicyOperations.test.js | 15 ++++++ .../security/tokenOperationScope.test.js | 5 ++ .../tokenOperationScopeMinting.test.js | 51 +++++++++++++++++++ utility/operation_authorization.ts | 4 +- 6 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 unitTests/security/tokenOperationScopeMinting.test.js diff --git a/security/authn/oidc/trustPolicyOperations.ts b/security/authn/oidc/trustPolicyOperations.ts index c33d144cd..83e6b593a 100644 --- a/security/authn/oidc/trustPolicyOperations.ts +++ b/security/authn/oidc/trustPolicyOperations.ts @@ -19,7 +19,7 @@ import { getUsersWithRolesCache } from '../../user.ts'; import { validateClaimConstraintShape } from './claims.ts'; import { normalizeIssuer } from './jwks.ts'; import { profileForIssuer } from './providers/index.ts'; -import { expandOperationsPerms } from '../../../utility/operationPermissions.ts'; +import { validateOperations } from '../../../utility/operationPermissions.ts'; import type { OidcTrustPolicy } from './types.ts'; const { HTTP_STATUS_CODES } = hdbErrors; @@ -46,17 +46,15 @@ function validate(validation: any): void { } /** - * A typo in an operation name would otherwise fail closed at request time, in CI, with nothing to - * point at — so it is caught here, where the reader is the administrator who wrote it. Group names - * are accepted: expandOperationsPerms resolves them, and a name that expands to only itself and is - * not a known operation is the typo we are looking for. + * A typo would otherwise fail closed at request time, in CI, with nothing to point at — so it is + * caught here, where the reader is the administrator who wrote it. Delegates to the same helper + * add_role/alter_role use, which accepts group names and operations registered at runtime via + * server.registerOperation; a local OPERATIONS_ENUM check would reject those. */ function assertOperationsAreKnown(operations: string[]): void { - const known = new Set(Object.values(terms.OPERATIONS_ENUM)); - for (const name of expandOperationsPerms(operations)) { - if (!known.has(name)) { - throw new ClientError(`operations contains '${name}', which is not a Harper operation`); - } + const invalidOperation = validateOperations(operations); + if (invalidOperation != null) { + throw new ClientError(`operations contains '${invalidOperation}', which is not a Harper operation`); } } diff --git a/security/tokenAuthentication.ts b/security/tokenAuthentication.ts index 404a586d3..eba13def6 100644 --- a/security/tokenAuthentication.ts +++ b/security/tokenAuthentication.ts @@ -267,7 +267,12 @@ export async function createOperationToken( }; // A narrowing scope, never a grant: verifyPerms intersects it with the user's role. Absent means // the role governs alone, which is every token minted before this existed. - if (user.operations?.length) payload.operations = user.operations; + // + // `!= null` rather than a truthiness check on purpose: an EMPTY scope means "no operations", and + // a length check would drop it from the payload, leaving the token unscoped — a security control + // failing open. add_oidc_trust rejects an empty array, but a row can reach the table by + // replication from a peer, so this must not depend on that. + if (user.operations != null) payload.operations = user.operations; return jwt.sign( payload, diff --git a/unitTests/security/authn/oidc/trustPolicyOperations.test.js b/unitTests/security/authn/oidc/trustPolicyOperations.test.js index bea08de14..5615a2f8c 100644 --- a/unitTests/security/authn/oidc/trustPolicyOperations.test.js +++ b/unitTests/security/authn/oidc/trustPolicyOperations.test.js @@ -17,6 +17,7 @@ const { const { databases } = require('#src/resources/databases'); const { setUsersWithRolesCache } = require('#src/security/user'); const terms = require('#src/utility/hdbTerms'); +const opAuth = require('#src/utility/operation_authorization'); const OIDC_TRUST_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TRUST_TABLE_NAME; const ISSUER = 'https://token.actions.githubusercontent.com'; @@ -207,6 +208,20 @@ describe('oidc trustPolicyOperations', () => { assert.strictEqual(installed.mock.rows.size, 0, 'expected nothing stored'); }); + // Operations registered at runtime via server.registerOperation are grantable in a role's + // allowlist, so a policy must be able to scope to them too — this is why validation delegates + // to validateOperations rather than checking OPERATIONS_ENUM locally. + it('accepts a dynamically registered operation', async () => { + const dynamicOp = 'test_dynamic_scope_op'; + opAuth.registerOperationPermission(dynamicOp, { requiresSu: true }); + try { + await addOidcTrust(su('add_oidc_trust', validPolicy({ operations: [dynamicOp] }))); + assert.deepStrictEqual(installed.mock.rows.get('my-app-prod').operations, [dynamicOp]); + } finally { + opAuth.unregisterOperationPermission(dynamicOp); + } + }); + it('leaves operations null when the policy does not scope', async () => { await addOidcTrust(su('add_oidc_trust', validPolicy())); assert.strictEqual(installed.mock.rows.get('my-app-prod').operations, null); diff --git a/unitTests/security/tokenOperationScope.test.js b/unitTests/security/tokenOperationScope.test.js index 5cf89c360..3ea315466 100644 --- a/unitTests/security/tokenOperationScope.test.js +++ b/unitTests/security/tokenOperationScope.test.js @@ -78,6 +78,11 @@ describe('token-scoped operation narrowing', () => { assert.strictEqual(request.hdb_user._expandedTokenOperations, first, 'expected one expansion'); }); + // A null scope is what an unscoped policy stores; it must fall through to the role, not throw. + it('falls through to the role when the scope is null', () => { + assert.ok(isAllowed(opAuth.verifyPerms(requestAs({ super_user: true }, null), INSERT_FN))); + }); + it('denies everything when the scope is empty', () => { assert.ok(!isAllowed(opAuth.verifyPerms(requestAs({ super_user: true }, []), INSERT_FN))); }); diff --git a/unitTests/security/tokenOperationScopeMinting.test.js b/unitTests/security/tokenOperationScopeMinting.test.js new file mode 100644 index 000000000..f61ab7087 --- /dev/null +++ b/unitTests/security/tokenOperationScopeMinting.test.js @@ -0,0 +1,51 @@ +'use strict'; + +// The mint side of the narrowing contract. Kept separate from tokenOperationScope.test.js because +// this needs real JWT signing keys, and the point of these cases is the *round trip*: a scope that +// survives verifyPerms in isolation is worthless if createOperationToken drops it on the way out. + +const assert = require('node:assert'); +const testUtils = require('../testUtils.js'); +testUtils.preTestPrep(); + +const { createOperationToken, clearJWTRSAKeysCache } = require('#src/security/tokenAuthentication'); + +function payloadOf(token) { + return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); +} + +describe('operation scope on a minted token', () => { + const user = { username: 'ci-deploy', super_user: false }; + let removeJwtKeys; + + before(() => { + // The keys land in a directory another suite asserts is empty, so they must come back out — + // see testUtils.installTestJwtKeys. + removeJwtKeys = testUtils.installTestJwtKeys(); + clearJWTRSAKeysCache(); + }); + + after(() => { + removeJwtKeys(); + clearJWTRSAKeysCache(); + }); + + it('carries a scope', async () => { + const token = await createOperationToken({ ...user, operations: ['deploy_component'] }, 3600); + assert.deepStrictEqual(payloadOf(token).operations, ['deploy_component']); + }); + + // The bug this exists to prevent: an empty scope means "no operations". A truthiness check on + // length drops the claim, which leaves the token UNSCOPED — a security control failing open. + it('carries an empty scope rather than dropping it', async () => { + const token = await createOperationToken({ ...user, operations: [] }, 3600); + assert.deepStrictEqual(payloadOf(token).operations, [], 'an empty scope must not become an absent scope'); + }); + + it('omits the claim when there is no scope', async () => { + for (const operations of [undefined, null]) { + const token = await createOperationToken({ ...user, operations }, 3600); + assert.strictEqual(payloadOf(token).operations, undefined); + } + }); +}); diff --git a/utility/operation_authorization.ts b/utility/operation_authorization.ts index 64dc44452..c6dba50ab 100644 --- a/utility/operation_authorization.ts +++ b/utility/operation_authorization.ts @@ -593,8 +593,10 @@ export function verifyPerms(requestJson: any, operation: any, _options?: any) { // It can only subtract. The scope is never merged into `permission.operations`, because that field // is not purely narrowing — gate 2 treats an explicit listing of an SU-only operation as a // deliberate grant, so merging into it could widen instead. + // `!= null`, not `!== undefined`: an unscoped policy stores `operations: null`, and expanding a + // null would throw rather than fall through to the role. const tokenOperations = requestJson.hdb_user?.tokenOperations; - if (tokenOperations !== undefined) { + if (tokenOperations != null) { const scopedOps = requestJson.hdb_user._expandedTokenOperations ?? (requestJson.hdb_user._expandedTokenOperations = expandOperationsPerms(tokenOperations)); From 4cf388169699e031d02961e62ae7a00596d90017 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 14 Aug 2026 15:00:46 -0400 Subject: [PATCH 11/37] fix(security): apply the token operation scope to the SQL path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught in review by claude[bot] on #2174, and it falsified the PR's central claim. chooseOperation dispatches `operation === 'sql'` to verifyPermsAST, in a branch mutually exclusive with the verifyPerms call — and the narrowing gate lived only in verifyPerms. So a token scoped to, say, `operations: ['get_status']` could send {"operation":"sql","sql":"DELETE FROM ..."} and run arbitrary SQL against whatever its role could reach. verifyPermsAST also returns null unconditionally for a super_user, so the identity most needing the constraint was the least constrained. The gate is now a shared tokenScopeDenial() called first by BOTH entry points, rather than a second copy in verifyPermsAST. The lesson of the bug is that a check living inside one of two mutually exclusive branches is one refactor away from being skipped, so the comment enumerates all three early-return paths that bypass it if it ever moves. On the AST path the scope is checked against `sql` — the operation the caller actually invoked — because verifyPermsAST's `operation` parameter is the statement variant (select/insert/...), not the API name. It runs ahead of AST parsing as well as the super_user bypass: an out-of-scope request should not get its SQL parsed at all. Five tests on the SQL path. Verified they fail without the fix (2 failing) and pass with it, so they pin the hole rather than describing it. Co-Authored-By: Claude Opus 5 --- .../security/tokenOperationScope.test.js | 43 ++++++++++++ utility/operation_authorization.ts | 67 ++++++++++++------- 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/unitTests/security/tokenOperationScope.test.js b/unitTests/security/tokenOperationScope.test.js index 3ea315466..7830e5d7e 100644 --- a/unitTests/security/tokenOperationScope.test.js +++ b/unitTests/security/tokenOperationScope.test.js @@ -8,6 +8,7 @@ const testUtils = require('../testUtils.js'); testUtils.preTestPrep(); const opAuth = require('#src/utility/operation_authorization'); +const sql = require('#src/sqlTranslator/index'); // `insertData` is the internal function name for the `insert` operation; verifyPerms resolves the // api_name via the permission registry, which is what a scope is written against. @@ -87,3 +88,45 @@ describe('token-scoped operation narrowing', () => { assert.ok(!isAllowed(opAuth.verifyPerms(requestAs({ super_user: true }, []), INSERT_FN))); }); }); + +// `sql` dispatches to verifyPermsAST, in a branch mutually exclusive with the verifyPerms call in +// chooseOperation. A gate in only one of them lets a token scoped to e.g. get_status run arbitrary +// SQL against whatever its role can reach — which would falsify the whole "can only subtract" claim. +describe('token-scoped narrowing on the SQL path', () => { + function userWithScope(permission, tokenOperations) { + const user = { username: 'ci-deploy', role: { role: 'r', permission } }; + if (tokenOperations !== undefined) user.tokenOperations = tokenOperations; + return user; + } + + function checkSql(statement, user) { + const parsed = sql.convertSQLToAST(statement); + return sql.checkASTPermissions({ operation: 'sql', sql: statement, hdb_user: user }, parsed); + } + + it('denies SQL when the scope does not include it', () => { + const denial = checkSql('SELECT * FROM data.dog', userWithScope({ super_user: true }, ['get_status'])); + assert.ok(denial, 'a token scoped away from sql must not be able to run SQL'); + }); + + // The dangerous case: verifyPermsAST returns null unconditionally for a super_user. + it('denies a super_user whose scope excludes SQL', () => { + const denial = checkSql('DELETE FROM data.dog', userWithScope({ super_user: true }, ['deploy_component'])); + assert.ok(denial, 'the super_user bypass must not outrank the token scope'); + }); + + it('allows SQL when the scope includes it', () => { + const denial = checkSql('SELECT * FROM data.dog', userWithScope({ super_user: true }, ['sql'])); + assert.strictEqual(denial, null, 'an in-scope sql statement should reach the normal perms checks'); + }); + + it('allows SQL through a group that contains it', () => { + const denial = checkSql('SELECT * FROM data.dog', userWithScope({ super_user: true }, ['read_only'])); + assert.strictEqual(denial, null); + }); + + it('is inert for a token with no scope', () => { + const denial = checkSql('SELECT * FROM data.dog', userWithScope({ super_user: true })); + assert.strictEqual(denial, null); + }); +}); diff --git a/utility/operation_authorization.ts b/utility/operation_authorization.ts index c6dba50ab..bb7d48598 100644 --- a/utility/operation_authorization.ts +++ b/utility/operation_authorization.ts @@ -442,6 +442,40 @@ module.exports = { * @param operation - The operation specified in the call. * @returns {null | PermissionResponseObject} - null if permissions match, errors returned in the PermissionResponseObject */ +/** + * Token-scoped narrowing: a minted operation token may carry a subset of what its user's role allows, + * so one credential can be handed out with less authority than the user has (an OIDC trust policy + * uses this to scope a single workflow). Returns a denial, or undefined when the scope permits — or + * when there is no scope, which is every token minted before this existed. + * + * Shared by verifyPerms AND verifyPermsAST, and called first in both. Three ways this gets bypassed + * if it moves: + * + * 1. `sql` dispatches to verifyPermsAST, in a branch mutually exclusive with the verifyPerms call + * (server/serverHelpers/serverUtilities.ts). A gate in only one of them means a token scoped to + * `get_status` can still run arbitrary SQL against whatever its role can reach. + * 2. Both functions `return null` early for a super_user — the identity that most needs constraining. + * 3. verifyPerms' `operations` gate 2 also returns null, treating an explicit listing of an SU-only + * operation as a deliberate grant. + * + * It can only subtract. The scope is deliberately NOT merged into `permission.operations`, because + * that field is not purely narrowing (see gate 2) — merging into it could widen instead. + */ +function tokenScopeDenial(userObject: any, opApiName: string) { + // `!= null`, not `!== undefined`: an unscoped policy stores `operations: null`, and expanding a + // null would throw rather than fall through to the role. + const tokenOperations = userObject?.tokenOperations; + if (tokenOperations == null) return undefined; + + const scopedOps = + userObject._expandedTokenOperations ?? + (userObject._expandedTokenOperations = expandOperationsPerms(tokenOperations)); + if (scopedOps.has(opApiName)) return undefined; + + harperLogger.info(`Operation '${opApiName}' is outside the scope of the presented token`); + return new PermissionResponseObject().handleUnauthorizedItem(HDB_ERROR_MSGS.OP_NOT_IN_OPERATIONS(opApiName)); +} + export function verifyPermsAST(ast, userObject, operation) { //TODO - update these validation checks to use validate.js if (commonUtils.isEmptyOrZeroLength(ast)) { @@ -456,6 +490,13 @@ export function verifyPermsAST(ast, userObject, operation) { harperLogger.info('verify_perms_ast has a null operation parameter'); throw handleHDBError(new Error()); } + + // `operation` here is the SQL statement variant (select/insert/...), not the API operation, so the + // scope is checked against `sql` — the operation the caller actually invoked. Ahead of the AST + // parsing below as well as the super_user bypass: a denied scope should not parse attacker SQL. + const scopeDenial = tokenScopeDenial(userObject, terms.OPERATIONS_ENUM.SQL); + if (scopeDenial) return scopeDenial; + try { const bucketModule = require('../sqlTranslator/sql_statement_bucket'); const bucket = bucketModule.default || bucketModule; @@ -582,30 +623,8 @@ export function verifyPerms(requestJson: any, operation: any, _options?: any) { const permsResponse = new PermissionResponseObject(); - // Token-scoped narrowing: a minted operation token may carry a subset of what its user's role - // allows, so one credential can be handed out with less authority than the user has (an OIDC - // trust policy uses this to scope a single workflow). - // - // Deliberately the FIRST authorization check in this function. Both the super_user bypass and the - // `operations` gate-2 grant below `return null` early, so a narrowing check placed after either - // would be silently bypassable by exactly the identities it most needs to constrain. - // - // It can only subtract. The scope is never merged into `permission.operations`, because that field - // is not purely narrowing — gate 2 treats an explicit listing of an SU-only operation as a - // deliberate grant, so merging into it could widen instead. - // `!= null`, not `!== undefined`: an unscoped policy stores `operations: null`, and expanding a - // null would throw rather than fall through to the role. - const tokenOperations = requestJson.hdb_user?.tokenOperations; - if (tokenOperations != null) { - const scopedOps = - requestJson.hdb_user._expandedTokenOperations ?? - (requestJson.hdb_user._expandedTokenOperations = expandOperationsPerms(tokenOperations)); - const opApiName = requiredPermissions.get(op)?.api_name ?? op; - if (!scopedOps.has(opApiName)) { - harperLogger.info(`Operation '${opApiName}' is outside the scope of the presented token`); - return permsResponse.handleUnauthorizedItem(HDB_ERROR_MSGS.OP_NOT_IN_OPERATIONS(opApiName)); - } - } + const scopeDenial = tokenScopeDenial(requestJson.hdb_user, requiredPermissions.get(op)?.api_name ?? op); + if (scopeDenial) return scopeDenial; if ( commonUtils.isEmptyOrZeroLength(requestJson.hdb_user?.role) || From b66736f162848d81010a40b432e2ae2668d15a30 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 17 Aug 2026 15:40:56 -0400 Subject: [PATCH 12/37] fix(security): carry the token operation scope across credential minting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cross-model review (codex + gemini) found a fourth bypass of the token operation scope, the same authz-escape class as the earlier SQL-path hole: the scope is only enforced inside verifyPerms/verifyPermsAST, but three operations PRODUCE a new credential or principal and dropped it. - create_authentication_tokens (the headline path): it is in NO_AUTH_OPERATIONS, so verifyPerms — and the scope gate inside it — never runs. A token scoped to e.g. deploy_component could call it with no username/password and receive fresh, UNSCOPED operation + refresh tokens for its own user: full-role escalation. - refresh_operation_token: dropped the operations claim when re-signing. - impersonation: enforceDowngrade bounds the impersonated role's perms but shed the token scope, so a scoped super_user token could drop the scope by impersonating. Fix: the scope carries forward on all three surfaces, so a scoped credential can only ever mint/become an equally-scoped one — the same "can only subtract" invariant, extended to the paths that leave verifyPerms. createTokens and refreshOperationToken copy the caller's scope into the minted payload; applyImpersonation copies it onto the new principal. Also moved the api_name resolution into tokenScopeDenial so the unscoped default path (every non-scoped request) does no registry lookup before its `== null` return, and updated the helper's comment to enumerate this fourth bypass class alongside the three in-function ones. Tests: createTokens carries the scope into both minted tokens and stays unscoped for an unscoped caller (reusing the existing mocked suite); refresh_operation_token preserves the scope through a real validate->decode->sign round trip; impersonation carries it onto the impersonated user. 253 passing across the affected suites. Adjudication of the rest of that review is in the PR description. Two flagged "blockers" were false positives (an undefined `op` — `op` is declared and the scope tests exercise that exact line; and a non-existent alter_oidc_trust operation). Co-Authored-By: Claude Opus 4.8 --- security/impersonation.ts | 6 ++ security/tokenAuthentication.ts | 18 +++- unitTests/security/impersonation.test.js | 19 ++++ .../security/tokenAuthentication.test.js | 29 ++++++ unitTests/security/tokenScopeRefresh.test.js | 88 +++++++++++++++++++ utility/operation_authorization.ts | 17 +++- 6 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 unitTests/security/tokenScopeRefresh.test.js diff --git a/security/impersonation.ts b/security/impersonation.ts index 4d2ea3e14..de55d3178 100644 --- a/security/impersonation.ts +++ b/security/impersonation.ts @@ -38,6 +38,12 @@ export async function applyImpersonation(authenticatedUser: User, payload: Imper // Enforce downgrade: never allow escalation enforceDowngrade(impersonatedUser); + // A token's operation scope (#2174) constrains the credential regardless of which principal it + // acts as, so it survives impersonation. enforceDowngrade only bounds the impersonated role's + // permissions; without carrying the scope, a scoped super_user token would shed it by impersonating. + const inheritedScope = (authenticatedUser as any).tokenOperations; + if (Array.isArray(inheritedScope)) (impersonatedUser as any).tokenOperations = inheritedScope; + // Tag for audit trail impersonatedUser._impersonated = true; impersonatedUser._impersonatedBy = authenticatedUser.username; diff --git a/security/tokenAuthentication.ts b/security/tokenAuthentication.ts index eba13def6..7556ca82d 100644 --- a/security/tokenAuthentication.ts +++ b/security/tokenAuthentication.ts @@ -162,9 +162,18 @@ export async function createTokens(authObj: AuthObject): Promise { username: string; super_user: boolean; role?: any; + operations?: string[]; } = { username: authObj.username, super_user: superUser }; if (authObj.role) payload.role = authObj.role; + // A scoped credential can only mint an equally-scoped one (#2174). create_authentication_tokens is + // NO_AUTH, so verifyPerms — and the token-scope gate inside it — never runs here; if the caller + // authenticated with a scoped operation token, the operation AND refresh tokens it mints inherit + // that scope. Without this, a token scoped to e.g. deploy_component escalates to unscoped, full-role + // credentials simply by calling create_authentication_tokens with no username/password. + const inheritedScope = (authObj.hdb_user as any)?.tokenOperations; + if (Array.isArray(inheritedScope)) payload.operations = inheritedScope; + const keys: JWTRSAKeys = await getJWTRSAKeys(); if (authObj.purpose === 'login') { @@ -233,8 +242,15 @@ export async function refreshOperationToken(tokenObj: TokenObject): Promise { assert.strictEqual(modeC.role.id, '_impersonated_ctx_user'); }); }); + + describe('token operation scope survives impersonation (#2174)', () => { + // A scoped operation token constrains the credential regardless of which principal it acts as, + // so impersonating must not shed the scope — otherwise a scoped super_user token escalates by + // impersonating a broader (still-downgraded) role. + const INLINE_ROLE = { role: { permission: { super_user: false, dev: { tables: {} } } } }; + + it('carries tokenOperations onto the impersonated user', async () => { + const su = makeSuperUser(); + su.tokenOperations = ['deploy_component']; + const impersonated = await applyImpersonation(su, INLINE_ROLE); + assert.deepStrictEqual(impersonated.tokenOperations, ['deploy_component']); + }); + + it('adds no scope when the authenticating token was unscoped', async () => { + const impersonated = await applyImpersonation(makeSuperUser(), INLINE_ROLE); + assert.strictEqual(impersonated.tokenOperations, undefined); + }); + }); }); diff --git a/unitTests/security/tokenAuthentication.test.js b/unitTests/security/tokenAuthentication.test.js index 9cafb18a9..5bce6755e 100644 --- a/unitTests/security/tokenAuthentication.test.js +++ b/unitTests/security/tokenAuthentication.test.js @@ -441,6 +441,35 @@ describe('test createTokens', () => { rw_get_tokens(); }); + // #2174: create_authentication_tokens is NO_AUTH, so verifyPerms — and the token-scope gate + // inside it — never runs here. A caller authenticated with a scoped operation token must not be + // able to mint UNSCOPED credentials; the minted operation and refresh tokens inherit the scope. + it('carries an inherited token scope into the minted operation and refresh tokens', async () => { + let rw = token_auth.__set__( + 'getJWTRSAKeys', + async () => new JWTRSAKeys(PUBLIC_KEY_VALUE, PRIVATE_KEY_VALUE, PASSPHRASE_VALUE) + ); + // No username/password: the caller is the already-authenticated bearer, whose hdb_user carries + // the scope — exactly the shape a scoped OIDC token presents to create_authentication_tokens. + let result = await token_auth.createTokens({ + hdb_user: { username: 'HDB_USER', tokenOperations: ['deploy_component'] }, + }); + assert.deepStrictEqual(jwt.decode(result.operation_token).operations, ['deploy_component']); + assert.deepStrictEqual(jwt.decode(result.refresh_token).operations, ['deploy_component']); + rw(); + }); + + it('mints unscoped credentials when the caller is unscoped', async () => { + let rw = token_auth.__set__( + 'getJWTRSAKeys', + async () => new JWTRSAKeys(PUBLIC_KEY_VALUE, PRIVATE_KEY_VALUE, PASSPHRASE_VALUE) + ); + let result = await token_auth.createTokens({ username: 'HDB_USER', password: 'pass' }); + assert.strictEqual(jwt.decode(result.operation_token).operations, undefined); + assert.strictEqual(jwt.decode(result.refresh_token).operations, undefined); + rw(); + }); + it('test update failed', async () => { update_stub.callsFake(async (_update_object) => { throw Error('update failed'); diff --git a/unitTests/security/tokenScopeRefresh.test.js b/unitTests/security/tokenScopeRefresh.test.js new file mode 100644 index 000000000..548e88b98 --- /dev/null +++ b/unitTests/security/tokenScopeRefresh.test.js @@ -0,0 +1,88 @@ +'use strict'; + +// A scoped credential must only ever produce an equally-scoped one (#2174). refresh_operation_token +// mints a fresh operation token from a refresh token; if it dropped the `operations` claim, a scoped +// refresh credential would refresh back to the full role. This drives the real validate→decode→sign +// path with the test signing keys rather than asserting the payload in isolation. + +const assert = require('node:assert'); +const testUtils = require('../testUtils.js'); +testUtils.preTestPrep(); + +const fs = require('node:fs'); +const path = require('node:path'); +const jwt = require('jsonwebtoken'); +const { refreshOperationToken, clearJWTRSAKeysCache } = require('#src/security/tokenAuthentication'); +const password = require('#src/utility/password'); +const { setUsersWithRolesCache } = require('#src/security/user'); +const env = require('#src/utility/environment/environmentManager'); +const terms = require('#src/utility/hdbTerms'); + +const USERNAME = 'ci-deploy'; + +function payloadOf(token) { + return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); +} + +describe('refresh_operation_token operation scope', () => { + let removeJwtKeys; + let privateKey; + let passphrase; + + before(async () => { + removeJwtKeys = testUtils.installTestJwtKeys(); + clearJWTRSAKeysCache(); + // Sign the refresh token with the exact keys refreshOperationToken will verify against. + const keysDir = path.join(env.getHdbBasePath(), terms.LICENSE_KEY_DIR_NAME); + privateKey = fs.readFileSync(path.join(keysDir, terms.JWT_ENUM.JWT_PRIVATE_KEY_NAME)); + passphrase = fs.readFileSync(path.join(keysDir, terms.JWT_ENUM.JWT_PASSPHRASE_NAME), 'utf8'); + }); + + after(() => { + removeJwtKeys(); + clearJWTRSAKeysCache(); + }); + + // Mints a `refresh`-subject token and seeds the users cache so validateRefreshToken accepts it + // (it matches the token against the SHA-256 hash stored on the user). + async function seedRefreshToken(claims) { + const refreshToken = jwt.sign( + claims, + { key: privateKey, passphrase }, + { + algorithm: 'RS256', + subject: 'refresh', + expiresIn: '30d', + } + ); + const users = new Map([ + [ + USERNAME, + { + username: USERNAME, + active: true, + refresh_token: password.hash(refreshToken, password.HASH_FUNCTION.SHA256), + role: { role: 'deployer', permission: { super_user: false } }, + }, + ], + ]); + await setUsersWithRolesCache(users); + return refreshToken; + } + + it('carries the scope from the refresh token into the minted operation token', async () => { + const refreshToken = await seedRefreshToken({ + username: USERNAME, + super_user: false, + operations: ['deploy_component'], + }); + const { operation_token } = await refreshOperationToken({ refresh_token: refreshToken }); + assert.deepStrictEqual(payloadOf(operation_token).operations, ['deploy_component']); + }); + + it('mints an unscoped operation token from an unscoped refresh token', async () => { + const refreshToken = await seedRefreshToken({ username: USERNAME, super_user: false }); + const { operation_token } = await refreshOperationToken({ refresh_token: refreshToken }); + assert.strictEqual(payloadOf(operation_token).operations, undefined); + }); +}); diff --git a/utility/operation_authorization.ts b/utility/operation_authorization.ts index bb7d48598..cd217309e 100644 --- a/utility/operation_authorization.ts +++ b/utility/operation_authorization.ts @@ -448,8 +448,8 @@ module.exports = { * uses this to scope a single workflow). Returns a denial, or undefined when the scope permits — or * when there is no scope, which is every token minted before this existed. * - * Shared by verifyPerms AND verifyPermsAST, and called first in both. Three ways this gets bypassed - * if it moves: + * Shared by verifyPerms AND verifyPermsAST, and called first in both. Four ways this gets bypassed + * if it moves or is incomplete: * * 1. `sql` dispatches to verifyPermsAST, in a branch mutually exclusive with the verifyPerms call * (server/serverHelpers/serverUtilities.ts). A gate in only one of them means a token scoped to @@ -457,11 +457,19 @@ module.exports = { * 2. Both functions `return null` early for a super_user — the identity that most needs constraining. * 3. verifyPerms' `operations` gate 2 also returns null, treating an explicit listing of an SU-only * operation as a deliberate grant. + * 4. Operations that never reach verifyPerms at all: create_authentication_tokens (NO_AUTH), + * refresh_operation_token, and impersonation each PRODUCE a new credential or principal, so the + * scope has to be carried forward there too or a scoped token mints an unscoped one. That carry- + * forward lives in tokenAuthentication.ts and impersonation.ts, not here. * * It can only subtract. The scope is deliberately NOT merged into `permission.operations`, because * that field is not purely narrowing (see gate 2) — merging into it could widen instead. + * + * `op` is the raw operation (a handler function name, or the literal `sql`); its snake_case api_name + * is resolved here rather than by the caller, so the unscoped default path — every request that is + * not a scoped token — does no registry lookup and no allocation before the `== null` return. */ -function tokenScopeDenial(userObject: any, opApiName: string) { +function tokenScopeDenial(userObject: any, op: string) { // `!= null`, not `!== undefined`: an unscoped policy stores `operations: null`, and expanding a // null would throw rather than fall through to the role. const tokenOperations = userObject?.tokenOperations; @@ -470,6 +478,7 @@ function tokenScopeDenial(userObject: any, opApiName: string) { const scopedOps = userObject._expandedTokenOperations ?? (userObject._expandedTokenOperations = expandOperationsPerms(tokenOperations)); + const opApiName = requiredPermissions.get(op)?.api_name ?? op; if (scopedOps.has(opApiName)) return undefined; harperLogger.info(`Operation '${opApiName}' is outside the scope of the presented token`); @@ -623,7 +632,7 @@ export function verifyPerms(requestJson: any, operation: any, _options?: any) { const permsResponse = new PermissionResponseObject(); - const scopeDenial = tokenScopeDenial(requestJson.hdb_user, requiredPermissions.get(op)?.api_name ?? op); + const scopeDenial = tokenScopeDenial(requestJson.hdb_user, op); if (scopeDenial) return scopeDenial; if ( From 1e84eaf4dc59c9dd438327bd1a283bf273953238 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 17 Aug 2026 16:01:15 -0400 Subject: [PATCH 13/37] fix(security): deny a scoped token from minting a login token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth bypass of the token operation scope, found by claude[bot] on the (now-folded-in) #2174 review — same class as the create_authentication_tokens escalation: a path that produces a new credential and drops the scope. create_authentication_tokens with purpose:'login' is NO_AUTH, so the scope gate in verifyPerms never runs. Its login branch signs a username-only token, which the `login` operation trades for a cookie session — and a session is username-only by construction: session-restore reloads the FULL user via getUser (tokenOperations is only ever set from a JWT operations claim, never on a session-restored user). So a credential scoped to e.g. deploy_component could self-escalate to a fully unscoped session with two NO_AUTH calls, no password required. A session cannot carry an operation scope, so carrying it forward is not possible without reworking the session model; a scoped CI/OIDC credential has no use for a browser session anyway. Fix: deny purpose:'login' when the authenticating caller is scoped (reuses the inheritedScope already computed for the operation/refresh path just above). Tests: a scoped caller is refused (403); an unscoped caller still mints a login token (and no refresh token, as before). Co-Authored-By: Claude Opus 4.8 --- security/tokenAuthentication.ts | 8 +++++ .../security/tokenAuthentication.test.js | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/security/tokenAuthentication.ts b/security/tokenAuthentication.ts index 7556ca82d..eabe3d8ae 100644 --- a/security/tokenAuthentication.ts +++ b/security/tokenAuthentication.ts @@ -177,6 +177,14 @@ export async function createTokens(authObj: AuthObject): Promise { const keys: JWTRSAKeys = await getJWTRSAKeys(); if (authObj.purpose === 'login') { + // A cookie session is username-only: session-restore reloads the FULL user via getUser, so a + // session cannot carry an operation scope (#2174). A scoped credential must therefore not be + // able to trade a login token for a session — that would silently drop the scope and escalate + // to the full role. create_authentication_tokens is NO_AUTH, so the scope gate in verifyPerms + // never runs here; and a CI/OIDC scoped credential has no use for a browser session anyway. + if (Array.isArray(inheritedScope)) { + throw new ClientError('a scoped token cannot mint a login token', HTTP_STATUS_CODES.FORBIDDEN); + } // Login-scoped exchange token: no refresh token, no user record update — it's a one-shot // ticket for the `login` operation to trade for a session cookie, not a standing credential. const loginToken = jwt.sign( diff --git a/unitTests/security/tokenAuthentication.test.js b/unitTests/security/tokenAuthentication.test.js index 5bce6755e..ec3cc0149 100644 --- a/unitTests/security/tokenAuthentication.test.js +++ b/unitTests/security/tokenAuthentication.test.js @@ -459,6 +459,38 @@ describe('test createTokens', () => { rw(); }); + it('refuses to mint a login token for a scoped caller (#2174)', async () => { + let rw = token_auth.__set__( + 'getJWTRSAKeys', + async () => new JWTRSAKeys(PUBLIC_KEY_VALUE, PRIVATE_KEY_VALUE, PASSPHRASE_VALUE) + ); + // A cookie session is username-only, so a scoped credential trading a login token for a session + // would drop the scope — deny it. Otherwise: scoped token -> login -> session -> full role. + await assert.rejects( + () => + token_auth.createTokens({ + purpose: 'login', + hdb_user: { username: 'HDB_USER', tokenOperations: ['deploy_component'] }, + }), + (e) => { + assert.strictEqual(e.statusCode, 403); + return true; + } + ); + rw(); + }); + + it('still mints a login token for an unscoped caller', async () => { + let rw = token_auth.__set__( + 'getJWTRSAKeys', + async () => new JWTRSAKeys(PUBLIC_KEY_VALUE, PRIVATE_KEY_VALUE, PASSPHRASE_VALUE) + ); + let result = await token_auth.createTokens({ purpose: 'login', username: 'HDB_USER', password: 'pass' }); + assert.notDeepStrictEqual(result.operation_token, undefined); + assert.strictEqual(result.refresh_token, undefined); // login purpose mints no refresh token + rw(); + }); + it('mints unscoped credentials when the caller is unscoped', async () => { let rw = token_auth.__set__( 'getJWTRSAKeys', From 9daa298f14607db84af252e7ed9cfc5d757536c5 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 17 Aug 2026 16:08:40 -0400 Subject: [PATCH 14/37] refactor(security): extract the token-scope carry-forward into one helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operation scope had to be threaded through every path that produces a credential or principal — six sites, each re-inlining `Array.isArray(...)`, and one using `!= null` instead. That scatter is exactly why the five bypasses this feature closed turned up one at a time. `security/operationScope.ts` is now the single home for the guard: `hasOperationScope` (the predicate), `attachScopeToToken` (the `operations` claim on a payload), and `attachScopeToUser` (`tokenOperations` on a user principal). The six call sites — createTokens, its login-deny, refresh, createOperationToken, validateToken, and impersonation — each collapse to one self-documenting call, and the `!= null` outlier is normalized to the same array guard (behavior-identical: an empty deny-all scope is still carried, anything non-array still skipped). No behavior change — the module carries the same array-including-empty guard every site already used, verified by the full scope-path suite (createTokens scoped/unscoped/login-deny, refresh, impersonation, createOperationToken empty/absent, validateToken) plus 8 unit tests for the helper itself. The naming asymmetry (`operations` on tokens vs `tokenOperations` on users) is now documented once, in the module. Beyond making the diff tighter, this is the thing that makes the invariant maintainable: a future credential-producing path is one `attachScope*` call, and greppable rather than a pattern to remember. Co-Authored-By: Claude Opus 4.8 --- security/impersonation.ts | 4 +- security/operationScope.ts | 33 ++++++++++++ security/tokenAuthentication.ts | 50 ++++++++---------- unitTests/security/operationScope.test.js | 63 +++++++++++++++++++++++ 4 files changed, 120 insertions(+), 30 deletions(-) create mode 100644 security/operationScope.ts create mode 100644 unitTests/security/operationScope.test.js diff --git a/security/impersonation.ts b/security/impersonation.ts index de55d3178..3fd96f28a 100644 --- a/security/impersonation.ts +++ b/security/impersonation.ts @@ -5,6 +5,7 @@ import { validateOperations } from '../utility/operationPermissions.ts'; import { ClientError } from '../utility/errors/hdbError.ts'; import harperLogger from '../utility/logging/harper_logger.ts'; import { getRoleByName } from './role.ts'; +import { attachScopeToUser } from './operationScope.ts'; /** * Applies impersonation to a request. The authenticated user must be a super_user. @@ -41,8 +42,7 @@ export async function applyImpersonation(authenticatedUser: User, payload: Imper // A token's operation scope (#2174) constrains the credential regardless of which principal it // acts as, so it survives impersonation. enforceDowngrade only bounds the impersonated role's // permissions; without carrying the scope, a scoped super_user token would shed it by impersonating. - const inheritedScope = (authenticatedUser as any).tokenOperations; - if (Array.isArray(inheritedScope)) (impersonatedUser as any).tokenOperations = inheritedScope; + attachScopeToUser(impersonatedUser, (authenticatedUser as any).tokenOperations); // Tag for audit trail impersonatedUser._impersonated = true; diff --git a/security/operationScope.ts b/security/operationScope.ts new file mode 100644 index 000000000..863786237 --- /dev/null +++ b/security/operationScope.ts @@ -0,0 +1,33 @@ +/** + * A token operation scope (#2174) narrows a credential to a subset of its user's operations. + * + * It lives under two property names depending on the carrier: the claim `operations` on a JWT + * payload, and `tokenOperations` on an in-memory user principal (validateToken lifts the claim onto + * the user; verifyPerms reads it there). Every path that PRODUCES a credential or principal must + * carry the scope forward or the result is unscoped — the five bypasses this feature had to close + * were each a produce-a-credential path that forgot to. So the guard lives here, called at each such + * site, rather than re-inlined as `Array.isArray(...)` in six places that could drift apart. + * + * A present scope is an array — INCLUDING an empty (deny-all) array, which must be preserved rather + * than treated as "no scope". Anything else (absent/null) is skipped, so unscoped credentials behave + * exactly as they did before this existed. + */ + +export type OperationScope = string[]; + +/** True for a present scope: an array, including the empty deny-all array. */ +export function hasOperationScope(scope: unknown): scope is OperationScope { + return Array.isArray(scope); +} + +/** Copies a present scope onto a JWT payload (claim name `operations`). Returns the payload. */ +export function attachScopeToToken(payload: T, scope: unknown): T { + if (hasOperationScope(scope)) (payload as any).operations = scope; + return payload; +} + +/** Copies a present scope onto a user principal (property `tokenOperations`). Returns the user. */ +export function attachScopeToUser(user: T, scope: unknown): T { + if (hasOperationScope(scope)) (user as any).tokenOperations = scope; + return user; +} diff --git a/security/tokenAuthentication.ts b/security/tokenAuthentication.ts index eabe3d8ae..671b3302d 100644 --- a/security/tokenAuthentication.ts +++ b/security/tokenAuthentication.ts @@ -15,6 +15,7 @@ const { HTTP_STATUS_CODES, AUTHENTICATION_ERROR_MSGS } = hdbErrors; import logger from '../utility/logging/harper_logger.ts'; import * as password from '../utility/password.ts'; import { findAndValidateUser, type User } from './user.ts'; +import { attachScopeToToken, attachScopeToUser, hasOperationScope } from './operationScope.ts'; import { update } from '../dataLayer/insert.ts'; import UpdateObject from '../dataLayer/UpdateObject.ts'; import * as signalling from '../utility/signalling.ts'; @@ -166,23 +167,21 @@ export async function createTokens(authObj: AuthObject): Promise { } = { username: authObj.username, super_user: superUser }; if (authObj.role) payload.role = authObj.role; - // A scoped credential can only mint an equally-scoped one (#2174). create_authentication_tokens is - // NO_AUTH, so verifyPerms — and the token-scope gate inside it — never runs here; if the caller - // authenticated with a scoped operation token, the operation AND refresh tokens it mints inherit - // that scope. Without this, a token scoped to e.g. deploy_component escalates to unscoped, full-role - // credentials simply by calling create_authentication_tokens with no username/password. + // create_authentication_tokens is NO_AUTH, so verifyPerms — and the scope gate inside it — never + // runs here. If the caller authenticated with a scoped token, the operation and refresh tokens it + // mints inherit that scope; otherwise it escalates to unscoped, full-role credentials with no + // password. (See operationScope.ts for the carry-forward invariant.) const inheritedScope = (authObj.hdb_user as any)?.tokenOperations; - if (Array.isArray(inheritedScope)) payload.operations = inheritedScope; + attachScopeToToken(payload, inheritedScope); const keys: JWTRSAKeys = await getJWTRSAKeys(); if (authObj.purpose === 'login') { - // A cookie session is username-only: session-restore reloads the FULL user via getUser, so a - // session cannot carry an operation scope (#2174). A scoped credential must therefore not be - // able to trade a login token for a session — that would silently drop the scope and escalate - // to the full role. create_authentication_tokens is NO_AUTH, so the scope gate in verifyPerms - // never runs here; and a CI/OIDC scoped credential has no use for a browser session anyway. - if (Array.isArray(inheritedScope)) { + // A cookie session is username-only (session-restore reloads the full user via getUser), so it + // cannot carry a scope. A scoped credential must therefore not trade a login token for a + // session — that would silently drop the scope and escalate to the full role — and a CI/OIDC + // scoped credential has no use for a browser session anyway. + if (hasOperationScope(inheritedScope)) { throw new ClientError('a scoped token cannot mint a login token', HTTP_STATUS_CODES.FORBIDDEN); } // Login-scoped exchange token: no refresh token, no user record update — it's a one-shot @@ -254,9 +253,8 @@ export async function refreshOperationToken(tokenObj: TokenObject): Promise { throw new Error('Invalid token'); } - // Surfaced on the user rather than merged into role.permission.operations: that field is not - // purely narrowing (verifyPerms gate 2 treats an explicit SU-only listing as a grant), so - // merging a token scope into it could widen rather than narrow. verifyPerms intersects this - // separately, ahead of every bypass. - if (Array.isArray(tokenVerified.operations)) user.tokenOperations = tokenVerified.operations; + // Surfaced as `tokenOperations` rather than merged into role.permission.operations: that field + // is not purely narrowing (verifyPerms gate 2 treats an explicit SU-only listing as a grant), + // so merging a token scope into it could widen. verifyPerms intersects this separately, ahead + // of every bypass. + attachScopeToUser(user, tokenVerified.operations); return user; } catch (err) { diff --git a/unitTests/security/operationScope.test.js b/unitTests/security/operationScope.test.js new file mode 100644 index 000000000..7a53f6daf --- /dev/null +++ b/unitTests/security/operationScope.test.js @@ -0,0 +1,63 @@ +'use strict'; + +const assert = require('node:assert'); +const { hasOperationScope, attachScopeToToken, attachScopeToUser } = require('#src/security/operationScope'); + +describe('operationScope', () => { + describe('hasOperationScope', () => { + it('is true for any array, including the empty deny-all scope', () => { + assert.strictEqual(hasOperationScope(['deploy_component']), true); + assert.strictEqual(hasOperationScope([]), true); + }); + + it('is false for an absent scope', () => { + for (const value of [undefined, null, 'deploy_component', 42, {}]) { + assert.strictEqual(hasOperationScope(value), false, `expected false for ${JSON.stringify(value)}`); + } + }); + }); + + describe('attachScopeToToken', () => { + it('copies a present scope onto the `operations` claim', () => { + const payload = { username: 'u', super_user: false }; + assert.strictEqual(attachScopeToToken(payload, ['deploy_component']), payload); + assert.deepStrictEqual(payload.operations, ['deploy_component']); + }); + + // The fail-closed case this whole feature hinges on: an empty scope means deny-all and must + // survive, not be treated as "no scope". + it('preserves an empty deny-all scope', () => { + const payload = { username: 'u', super_user: false }; + attachScopeToToken(payload, []); + assert.deepStrictEqual(payload.operations, []); + }); + + it('leaves the payload untouched for an absent scope', () => { + for (const scope of [undefined, null]) { + const payload = { username: 'u', super_user: false }; + attachScopeToToken(payload, scope); + assert.ok(!('operations' in payload), `operations must be absent for ${JSON.stringify(scope)}`); + } + }); + }); + + describe('attachScopeToUser', () => { + it('copies a present scope onto `tokenOperations`', () => { + const user = { username: 'u' }; + assert.strictEqual(attachScopeToUser(user, ['get_status']), user); + assert.deepStrictEqual(user.tokenOperations, ['get_status']); + }); + + it('preserves an empty deny-all scope', () => { + const user = { username: 'u' }; + attachScopeToUser(user, []); + assert.deepStrictEqual(user.tokenOperations, []); + }); + + it('leaves the user untouched for an absent scope', () => { + const user = { username: 'u' }; + attachScopeToUser(user, undefined); + assert.ok(!('tokenOperations' in user)); + }); + }); +}); From 86799c26c49c75ed9165ee6b3c2474fc596c72c1 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 17 Aug 2026 16:18:48 -0400 Subject: [PATCH 15/37] fix(security): gate the token scope on the API operation, not the handler name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the combined cross-model review, one root cause. The scope gate resolved the operation name from the handler function (`requiredPermissions.get(op)?.api_name ?? op`) instead of using the API operation the caller actually sent — the namespace the policy scope is written in. - deploy_component (#481, the headline use case): its handler is registered with no api_name, so the gate resolved `deployComponent` and a policy scoped exactly to `deploy_component` was DENIED. The feature did not work for the operation it exists to scope. Fail-closed, so not an escalation — but functionally dead. Shared handlers (search_by_id/search_by_hash) were also conflated. - nested-SQL export jobs (#506): verifyPermsAST hardcoded `sql` as the scoped operation, but export_local/export_to_s3 carry their query as SQL through the same branch. A token scoped only to `sql` could start an export it was never granted, because the gate never saw `export_local`. Fix: verifyPerms passes `requestJson.operation`; verifyPermsAST takes the top-level API operation (threaded from checkASTPermissions' `jsonMessage.operation`, defaulting to `sql`); tokenScopeDenial compares that directly against the scope and no longer reconstructs a name from the handler. Using the real operation also distinguishes shared-handler aliases for free. Tests: deploy_component is allowed when scoped and denied when not; search_by_id vs search_by_value are distinguished though they share a handler; a `sql`-only scope cannot start an export_local job while an export_local-scoped one can. Co-Authored-By: Claude Opus 4.8 --- sqlTranslator/index.ts | 5 +- .../security/tokenOperationScope.test.js | 46 ++++++++++++++++++- utility/operation_authorization.ts | 34 ++++++++------ 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/sqlTranslator/index.ts b/sqlTranslator/index.ts index d1799a9fc..fdf25440b 100644 --- a/sqlTranslator/index.ts +++ b/sqlTranslator/index.ts @@ -79,7 +79,10 @@ export function checkASTPermissions(jsonMessage: any, parsedSqlObject: any) { verifyResult = opAuth.verifyPermsAST( parsedSqlObject.ast.statements[0], jsonMessage.hdb_user, - parsedSqlObject.variant + parsedSqlObject.variant, + // The top-level API operation for the token-scope check: `sql` for a direct SQL call, but + // `export_local`/`export_to_s3` when the SQL rides inside a job's search_operation. + jsonMessage.operation ); parsedSqlObject.permissions_checked = true; } catch (e) { diff --git a/unitTests/security/tokenOperationScope.test.js b/unitTests/security/tokenOperationScope.test.js index 7830e5d7e..5b29a0433 100644 --- a/unitTests/security/tokenOperationScope.test.js +++ b/unitTests/security/tokenOperationScope.test.js @@ -92,6 +92,36 @@ describe('token-scoped operation narrowing', () => { // `sql` dispatches to verifyPermsAST, in a branch mutually exclusive with the verifyPerms call in // chooseOperation. A gate in only one of them lets a token scoped to e.g. get_status run arbitrary // SQL against whatever its role can reach — which would falsify the whole "can only subtract" claim. +// The scope is written in API-operation names (`deploy_component`), which is what the caller sends as +// `operation`. verifyPerms must gate on that, not on the handler function name — deployComponent has +// no api_name mapping, so gating on the handler denied the feature's own headline operation. +describe('token scope gates on the API operation, not the handler name', () => { + function requestFor(operation, tokenOperations) { + const hdb_user = { username: 'ci-deploy', role: { role: 'r', permission: { super_user: true } } }; + if (tokenOperations !== undefined) hdb_user.tokenOperations = tokenOperations; + return { operation, hdb_user }; + } + + it('allows deploy_component when the scope names it', () => { + // deployComponent (the handler) has no api_name; the scope names the API op `deploy_component`. + const result = opAuth.verifyPerms(requestFor('deploy_component', ['deploy_component']), 'deployComponent'); + assert.ok(isAllowed(result), 'a token scoped to deploy_component must be able to deploy_component'); + }); + + it('denies deploy_component when the scope does not name it', () => { + const result = opAuth.verifyPerms(requestFor('deploy_component', ['get_status']), 'deployComponent'); + assert.ok(!isAllowed(result)); + }); + + // Shared-handler aliases must be distinguished by the API op, not conflated by handler name. + it('distinguishes shared-handler aliases (search_by_id vs search_by_value)', () => { + const allowed = opAuth.verifyPerms(requestFor('search_by_id', ['search_by_id']), 'searchByHash'); + assert.ok(isAllowed(allowed), 'search_by_id is in scope'); + const denied = opAuth.verifyPerms(requestFor('search_by_value', ['search_by_id']), 'searchByHash'); + assert.ok(!isAllowed(denied), 'search_by_value is not in scope even though it shares a handler'); + }); +}); + describe('token-scoped narrowing on the SQL path', () => { function userWithScope(permission, tokenOperations) { const user = { username: 'ci-deploy', role: { role: 'r', permission } }; @@ -99,9 +129,9 @@ describe('token-scoped narrowing on the SQL path', () => { return user; } - function checkSql(statement, user) { + function checkSql(statement, user, operation = 'sql') { const parsed = sql.convertSQLToAST(statement); - return sql.checkASTPermissions({ operation: 'sql', sql: statement, hdb_user: user }, parsed); + return sql.checkASTPermissions({ operation, sql: statement, hdb_user: user }, parsed); } it('denies SQL when the scope does not include it', () => { @@ -129,4 +159,16 @@ describe('token-scoped narrowing on the SQL path', () => { const denial = checkSql('SELECT * FROM data.dog', userWithScope({ super_user: true })); assert.strictEqual(denial, null); }); + + it('gates a nested-SQL export job on the export operation, not on `sql`', () => { + // export_local carries its query as SQL, but the scope names the job, not `sql`. A token scoped + // only to `sql` must not be able to start an export it was never granted. + const deniedUser = userWithScope({ super_user: true }, ['sql']); + const denied = checkSql('SELECT * FROM data.dog', deniedUser, 'export_local'); + assert.ok(denied, 'export_local is outside a `sql`-only scope'); + + const allowedUser = userWithScope({ super_user: true }, ['export_local']); + const allowed = checkSql('SELECT * FROM data.dog', allowedUser, 'export_local'); + assert.strictEqual(allowed, null, 'an export_local-scoped token may run the export'); + }); }); diff --git a/utility/operation_authorization.ts b/utility/operation_authorization.ts index cd217309e..af532d72b 100644 --- a/utility/operation_authorization.ts +++ b/utility/operation_authorization.ts @@ -465,11 +465,14 @@ module.exports = { * It can only subtract. The scope is deliberately NOT merged into `permission.operations`, because * that field is not purely narrowing (see gate 2) — merging into it could widen instead. * - * `op` is the raw operation (a handler function name, or the literal `sql`); its snake_case api_name - * is resolved here rather than by the caller, so the unscoped default path — every request that is - * not a scoped token — does no registry lookup and no allocation before the `== null` return. + * `apiOperation` MUST be the snake_case API operation the caller actually invoked (`deploy_component`, + * `sql`, `export_local`, ...) — i.e. `requestJson.operation`, the same namespace the policy's scope is + * written in. It is emphatically NOT the handler function name: many handlers have no `api_name` + * mapping (deploy_component → `deployComponent`) and some are shared across operations + * (`search_by_id`/`search_by_hash`), so resolving the scope name from the handler both denies the + * feature's own headline op and conflates aliases. Callers pass the real operation. */ -function tokenScopeDenial(userObject: any, op: string) { +function tokenScopeDenial(userObject: any, apiOperation: string) { // `!= null`, not `!== undefined`: an unscoped policy stores `operations: null`, and expanding a // null would throw rather than fall through to the role. const tokenOperations = userObject?.tokenOperations; @@ -478,14 +481,13 @@ function tokenScopeDenial(userObject: any, op: string) { const scopedOps = userObject._expandedTokenOperations ?? (userObject._expandedTokenOperations = expandOperationsPerms(tokenOperations)); - const opApiName = requiredPermissions.get(op)?.api_name ?? op; - if (scopedOps.has(opApiName)) return undefined; + if (scopedOps.has(apiOperation)) return undefined; - harperLogger.info(`Operation '${opApiName}' is outside the scope of the presented token`); - return new PermissionResponseObject().handleUnauthorizedItem(HDB_ERROR_MSGS.OP_NOT_IN_OPERATIONS(opApiName)); + harperLogger.info(`Operation '${apiOperation}' is outside the scope of the presented token`); + return new PermissionResponseObject().handleUnauthorizedItem(HDB_ERROR_MSGS.OP_NOT_IN_OPERATIONS(apiOperation)); } -export function verifyPermsAST(ast, userObject, operation) { +export function verifyPermsAST(ast, userObject, operation, apiOperation = terms.OPERATIONS_ENUM.SQL) { //TODO - update these validation checks to use validate.js if (commonUtils.isEmptyOrZeroLength(ast)) { harperLogger.info('verify_perms_ast has an empty user parameter'); @@ -500,10 +502,12 @@ export function verifyPermsAST(ast, userObject, operation) { throw handleHDBError(new Error()); } - // `operation` here is the SQL statement variant (select/insert/...), not the API operation, so the - // scope is checked against `sql` — the operation the caller actually invoked. Ahead of the AST - // parsing below as well as the super_user bypass: a denied scope should not parse attacker SQL. - const scopeDenial = tokenScopeDenial(userObject, terms.OPERATIONS_ENUM.SQL); + // `operation` here is the SQL statement variant (select/insert/...), not the API operation. The + // scope is checked against `apiOperation` — the top-level operation the caller invoked, which is + // `sql` for a direct SQL call but `export_local`/`export_to_s3` for a job whose inner + // search_operation is SQL. Checking a hardcoded `sql` would let a token scoped only to `sql` start + // an export that its scope excludes. Ahead of the super_user bypass, as on the NoSQL path. + const scopeDenial = tokenScopeDenial(userObject, apiOperation); if (scopeDenial) return scopeDenial; try { @@ -632,7 +636,9 @@ export function verifyPerms(requestJson: any, operation: any, _options?: any) { const permsResponse = new PermissionResponseObject(); - const scopeDenial = tokenScopeDenial(requestJson.hdb_user, op); + // The actual API operation the caller invoked — the namespace the policy scope is written in — + // not `op` (the handler function name), which would deny deploy_component and conflate aliases. + const scopeDenial = tokenScopeDenial(requestJson.hdb_user, requestJson.operation); if (scopeDenial) return scopeDenial; if ( From c8d429bd3bd3e76889b71c28efdc7b123e142e82 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 17 Aug 2026 16:25:50 -0400 Subject: [PATCH 16/37] fix(security): gate the token scope on the job op for non-SQL export jobs too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the previous commit, found by claude[bot]: I fixed the export-job scope bypass on the SQL path (verifyPermsAST) but left the identical hole on the NoSQL path. The dispatcher hands verifyPerms the nested search_operation as requestJson for a job, so requestJson.operation is the inner op (search_by_conditions, search_by_value, ...), not export_local. The scope gate therefore checked the read op: a token scoped to ['search_by_conditions'] passed, then the super_user bypass returned allowed, and the export ran — writing exported data to local disk or S3 from a credential meant to be read-only. Same "can only subtract" violation as #506, via the NoSQL search operations. Fix mirrors the verifyPermsAST one: serverUtilities threads the top-level json.operation into verifyPerms via options.apiOperation, and the scope gate uses `options?.apiOperation ?? requestJson.operation`, so the job op is checked while direct callers keep using requestJson.operation. Test: a search-scoped token cannot run an export_local job whose nested op is search_by_conditions; an export_local-scoped one can. Co-Authored-By: Claude Opus 4.8 --- server/serverHelpers/serverUtilities.ts | 7 ++++++- .../security/tokenOperationScope.test.js | 19 +++++++++++++++++++ utility/operation_authorization.ts | 11 +++++++---- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/server/serverHelpers/serverUtilities.ts b/server/serverHelpers/serverUtilities.ts index ceb3fa5a1..b7f0e869e 100644 --- a/server/serverHelpers/serverUtilities.ts +++ b/server/serverHelpers/serverUtilities.ts @@ -280,7 +280,12 @@ export function chooseOperation(json: OperationRequestBody, bypassAuth = false) operation_json.hdb_user = json.hdb_user; } - const verifyPermsResult = opAuth.verifyPerms(operation_json, functionToCheck); + // Pass the top-level operation for the token-scope check: for an export job, operation_json + // is the nested search_operation, so json.operation (export_local/export_to_s3) is the op the + // scope must gate — not the inner search. + const verifyPermsResult = opAuth.verifyPerms(operation_json, functionToCheck, { + apiOperation: json.operation, + }); if (verifyPermsResult) { operationLog.error(`${HTTP_STATUS_CODES.FORBIDDEN} from operation ${json.operation}`); diff --git a/unitTests/security/tokenOperationScope.test.js b/unitTests/security/tokenOperationScope.test.js index 5b29a0433..59e0f76e9 100644 --- a/unitTests/security/tokenOperationScope.test.js +++ b/unitTests/security/tokenOperationScope.test.js @@ -120,6 +120,25 @@ describe('token scope gates on the API operation, not the handler name', () => { const denied = opAuth.verifyPerms(requestFor('search_by_value', ['search_by_id']), 'searchByHash'); assert.ok(!isAllowed(denied), 'search_by_value is not in scope even though it shares a handler'); }); + + // A job (export_local/export_to_s3) is dispatched with its nested search_operation as requestJson, + // so the scope must gate the top-level op passed via options.apiOperation, not the inner search — + // otherwise a read-scoped token could exfiltrate data through an export. + it('gates a non-SQL export job on the export operation, not the nested search', () => { + const jobRequest = (tokenOperations) => ({ + operation: 'search_by_conditions', // the nested op the dispatcher hands verifyPerms + hdb_user: { username: 'ci-deploy', role: { role: 'r', permission: { super_user: true } }, tokenOperations }, + }); + const denied = opAuth.verifyPerms(jobRequest(['search_by_conditions']), 'searchByConditions', { + apiOperation: 'export_local', + }); + assert.ok(!isAllowed(denied), 'a search-scoped token must not be able to run export_local'); + + const allowed = opAuth.verifyPerms(jobRequest(['export_local']), 'searchByConditions', { + apiOperation: 'export_local', + }); + assert.ok(isAllowed(allowed), 'an export_local-scoped token may run the export'); + }); }); describe('token-scoped narrowing on the SQL path', () => { diff --git a/utility/operation_authorization.ts b/utility/operation_authorization.ts index af532d72b..f06274cd0 100644 --- a/utility/operation_authorization.ts +++ b/utility/operation_authorization.ts @@ -605,7 +605,7 @@ export function verifyPermsAST(ast, userObject, operation, apiOperation = terms. * @param operation - The name of the operation specified in the request. * @returns { null | PermissionResponseObject } - null if permissions match, errors are consolidated into PermissionResponseObj. */ -export function verifyPerms(requestJson: any, operation: any, _options?: any) { +export function verifyPerms(requestJson: any, operation: any, options?: { apiOperation?: string }) { if ( requestJson === null || operation === null || @@ -636,9 +636,12 @@ export function verifyPerms(requestJson: any, operation: any, _options?: any) { const permsResponse = new PermissionResponseObject(); - // The actual API operation the caller invoked — the namespace the policy scope is written in — - // not `op` (the handler function name), which would deny deploy_component and conflate aliases. - const scopeDenial = tokenScopeDenial(requestJson.hdb_user, requestJson.operation); + // The top-level API operation the caller invoked — the namespace the policy scope is written in. + // For a job (export_local/export_to_s3), the dispatcher passes the nested search_operation as + // requestJson, so requestJson.operation is the *inner* op (e.g. search_by_conditions); the caller + // threads the real top-level op through `options.apiOperation` so a read-scoped token cannot ride + // an export. Never `op` (the handler name), which would deny deploy_component and conflate aliases. + const scopeDenial = tokenScopeDenial(requestJson.hdb_user, options?.apiOperation ?? requestJson.operation); if (scopeDenial) return scopeDenial; if ( From a0d4e6285780e0a3f5679d34f44a26dceee889c4 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 17 Aug 2026 18:01:17 -0400 Subject: [PATCH 17/37] fix(cli): drop an unused path import left by the rebase conflict resolution The rebase onto main's deploy-setup change collided in the cliOperations import block; resolving it kept `import * as path from 'path'`, but the merged file no longer uses path. Removes the unused import (oxlint no-unused-vars). Co-Authored-By: Claude Opus 4.8 --- bin/cliOperations.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/cliOperations.ts b/bin/cliOperations.ts index e767a1c13..45f26ab09 100644 --- a/bin/cliOperations.ts +++ b/bin/cliOperations.ts @@ -7,7 +7,6 @@ envMgr.initSync(); import * as terms from '../utility/hdbTerms.ts'; import { httpRequest } from '../utility/common_utils.ts'; import { workloadIdentityAvailable, exchangeWorkloadIdentityForToken } from './workloadIdentity.ts'; -import * as path from 'path'; import * as fs from 'fs-extra'; import * as YAML from 'yaml'; import { Readable } from 'node:stream'; From d8740b7f2f53e7cfa7a173db408e052234641ebd Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 11:38:11 -0400 Subject: [PATCH 18/37] fix(security): harden token scoping per deep-review (4 findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single-pass Harper-domain deep-review of the combined PR (the lens the cross-model run's domain leg failed to execute) surfaced four issues. 1. Scoped token could mint a long-lived credential (significant). create_authentication_tokens is NO_AUTH, so the scope gate never runs. The login path already denied scoped callers, but the standing operation+refresh path carried the scope forward yet honored expires_in verbatim and wrote a refresh_token — turning a minutes-long leak into a decade-long one (scoped, so not privilege escalation, but it defeats the exchange's ephemerality guarantee), reachable even by a deny-all scope. Now a scoped caller is denied outright (covers login + standing paths); a CI token holds the operation token the exchange already gave it. 2. Scope guarantee overstated in types.ts (doc). The scope is enforced on the operations-API and SQL paths only (verifyPerms/verifyPermsAST); the REST/GraphQL resource path authorizes via table-level checkPermission and doesn't consult it. Narrowed the doc and pointed resource-path enforcement at the CORE-3061 follow-up surface. NOT enforcing it there in this PR. 3. Replay table not audited (significant). hdb_oidc_token_use was created without an explicit audit flag, so with logging.auditLog:false its rows never replicate — silently dropping cross-node replay protection while the trust policies that gate it still propagate. Now audit:true, matching its sibling hdb_oidc_trust. (Kept lazy table() rather than the systemSchema bootstrap because the expiresAt TTL is not expressible via CreateTableObject; this matches hdb_certificate_cache.) 4. Exchange trusted stored rows to be write-validated (suggestion). add_oidc_trust enforces assertPolicyIsSpecific, but the exchange only backstopped the empty-claims and pull_request_target cases. A row that arrived via replication from an older node or a restored backup — e.g. repository pinned, no workflow/ref gate — was honored. findMatchingPolicy now re-runs assertPolicyIsSpecific/assertAudienceIsSpecific and skips (logs) any row that fails. Fail closed. Tests: scoped caller denied on standing/deny-all/login paths with no user-record write; an under-specified stored row is ignored at exchange. Co-Authored-By: Claude Opus 4.8 --- security/authn/oidc/tokenExchange.ts | 21 ++++++++ security/authn/oidc/types.ts | 9 +++- security/tokenAuthentication.ts | 25 +++++----- .../security/authn/oidc/tokenExchange.test.js | 15 ++++++ .../security/tokenAuthentication.test.js | 49 +++++++++++++------ 5 files changed, 90 insertions(+), 29 deletions(-) diff --git a/security/authn/oidc/tokenExchange.ts b/security/authn/oidc/tokenExchange.ts index 99974608a..bd3583bb5 100644 --- a/security/authn/oidc/tokenExchange.ts +++ b/security/authn/oidc/tokenExchange.ts @@ -61,6 +61,14 @@ function getTokenUseTable(): any { table
({ table: TOKEN_USE_TABLE, database: 'system', + // `audit: true` explicitly, NOT the default (which follows logging.auditLog). Auditing is the + // replication change feed (databases.ts: "auditing must be enabled for replication"), and + // replay records MUST replicate so a token spent on one node cannot be re-spent on another + // inside its window. Without this, an operator with logging.auditLog:false would silently lose + // cross-node replay protection. Its sibling hdb_oidc_trust is audited for the same reason. + // (Lazy table() rather than the systemSchema+directive bootstrap because the expiresAt TTL + // below is not expressible through CreateTableObject; this matches hdb_certificate_cache.) + audit: true, attributes: [ { name: 'id', isPrimaryKey: true }, { name: 'policy_id' }, @@ -107,6 +115,19 @@ async function findMatchingPolicy( const claimsByAudience = new Map(); for (const policy of policies) { + // Re-validate specificity at exchange time. add_oidc_trust enforces these, but a stored row can + // arrive another way — replication from an older node that predates a check, or a restored/older + // system-DB backup — and the exchange must not trust that every row was validated when written. + // Skip (don't honor) any row that would be rejected for writing, so an under-specified policy + // (e.g. a repository pinned but no workflow/ref gate) can't mint a token. Fail closed. + try { + profile.assertAudienceIsSpecific(policy.audience); + profile.assertPolicyIsSpecific(policy.claims); + } catch (error) { + logger.warn?.(`Ignoring trust policy '${policy.id}': ${(error as Error).message}`); + continue; + } + if (!claimsByAudience.has(policy.audience)) { // verifyIdentityToken logs its own reason for refusing. const verified = await verifyIdentityToken(token, { issuer, audience: policy.audience }).catch(() => undefined); diff --git a/security/authn/oidc/types.ts b/security/authn/oidc/types.ts index 26891667f..966acdb1f 100644 --- a/security/authn/oidc/types.ts +++ b/security/authn/oidc/types.ts @@ -20,8 +20,15 @@ export interface OidcTrustPolicy { /** The exchanged token authenticates as this user, whose role is the least-privilege boundary. */ user: string; /** - * Optional narrowing of that boundary: the minted token may perform only these operations, even + * Optional narrowing of that boundary, enforced on the **operations API and SQL** paths (the + * verifyPerms / verifyPermsAST gate): the minted token may invoke only these operations, even * where the role allows more. Never widens — an operation the role forbids stays forbidden. + * + * NOT enforced on the application REST/GraphQL resource path, which authorizes through the + * table-level checkPermission and does not consult this scope — so a scoped operation token still + * carries the role's full CRUD there. Extending the scope to the resource path is a follow-up on + * the same surface as the GraphQL-bypasses-ops-allowlist gap (CORE-3061); until then, scope a + * policy's `user` to a role that is itself least-privilege for the data the token can reach. */ operations?: string[]; /** Defaults to true; false keeps the policy for reference without honoring it. */ diff --git a/security/tokenAuthentication.ts b/security/tokenAuthentication.ts index 671b3302d..3a5c518fd 100644 --- a/security/tokenAuthentication.ts +++ b/security/tokenAuthentication.ts @@ -163,27 +163,24 @@ export async function createTokens(authObj: AuthObject): Promise { username: string; super_user: boolean; role?: any; - operations?: string[]; } = { username: authObj.username, super_user: superUser }; if (authObj.role) payload.role = authObj.role; - // create_authentication_tokens is NO_AUTH, so verifyPerms — and the scope gate inside it — never - // runs here. If the caller authenticated with a scoped token, the operation and refresh tokens it - // mints inherit that scope; otherwise it escalates to unscoped, full-role credentials with no - // password. (See operationScope.ts for the carry-forward invariant.) - const inheritedScope = (authObj.hdb_user as any)?.tokenOperations; - attachScopeToToken(payload, inheritedScope); + // create_authentication_tokens is NO_AUTH, so verifyPerms — and the token-scope gate inside it — + // never runs here. A scoped caller (an OIDC-exchanged operation token, #2174) must not mint any + // standing credential through it: the scope would carry forward, but honoring expires_in verbatim + // turns a minutes-long leak into an arbitrarily long-lived one, and the refresh_token write below + // hands out a 30-day credential — both defeating the exchange's ephemerality guarantee, and both + // reachable even by a deny-all `[]` scope. A CI token needs none of this; it holds the operation + // token the exchange already gave it. Deny outright — this covers the login and standing paths + // alike, so a session (username-only, and therefore unscopeable) can't be minted from a scope either. + if (hasOperationScope((authObj.hdb_user as any)?.tokenOperations)) { + throw new ClientError('a scoped token cannot mint authentication tokens', HTTP_STATUS_CODES.FORBIDDEN); + } const keys: JWTRSAKeys = await getJWTRSAKeys(); if (authObj.purpose === 'login') { - // A cookie session is username-only (session-restore reloads the full user via getUser), so it - // cannot carry a scope. A scoped credential must therefore not trade a login token for a - // session — that would silently drop the scope and escalate to the full role — and a CI/OIDC - // scoped credential has no use for a browser session anyway. - if (hasOperationScope(inheritedScope)) { - throw new ClientError('a scoped token cannot mint a login token', HTTP_STATUS_CODES.FORBIDDEN); - } // Login-scoped exchange token: no refresh token, no user record update — it's a one-shot // ticket for the `login` operation to trade for a session cookie, not a standing credential. const loginToken = jwt.sign( diff --git a/unitTests/security/authn/oidc/tokenExchange.test.js b/unitTests/security/authn/oidc/tokenExchange.test.js index a32ba8196..56368e998 100644 --- a/unitTests/security/authn/oidc/tokenExchange.test.js +++ b/unitTests/security/authn/oidc/tokenExchange.test.js @@ -196,6 +196,21 @@ describe('exchangeOidcToken', () => { assert.strictEqual(user.username, 'ci-deploy'); }); + // A stored row that add_oidc_trust would reject (repository pinned, but no workflow/ref gate) — as + // could arrive via replication from an older node or a restored backup — must be ignored at + // exchange, not honored. Seeded directly into the store to bypass add_oidc_trust's validation. + it('ignores an under-specified stored policy that bypassed write-time validation', async () => { + trustTable.mock.rows.set('smuggled', { + id: 'smuggled', + issuer: ISSUER, + audience: AUDIENCE, + claims: { repository_id: '67890' }, // no workflow pin, no ref gate — add_oidc_trust rejects this + user: 'ci-deploy', + enabled: true, + }); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() })); + }); + // The whole point of trusted publishing: CI ends up holding nothing durable, and the user's // existing refresh credential is not rotated out from under whoever holds it (#2018). it('mints no refresh token', async () => { diff --git a/unitTests/security/tokenAuthentication.test.js b/unitTests/security/tokenAuthentication.test.js index ec3cc0149..a17d4cbc1 100644 --- a/unitTests/security/tokenAuthentication.test.js +++ b/unitTests/security/tokenAuthentication.test.js @@ -441,31 +441,52 @@ describe('test createTokens', () => { rw_get_tokens(); }); - // #2174: create_authentication_tokens is NO_AUTH, so verifyPerms — and the token-scope gate - // inside it — never runs here. A caller authenticated with a scoped operation token must not be - // able to mint UNSCOPED credentials; the minted operation and refresh tokens inherit the scope. - it('carries an inherited token scope into the minted operation and refresh tokens', async () => { + // #2174: create_authentication_tokens is NO_AUTH, so verifyPerms — and the token-scope gate inside + // it — never runs here. A scoped caller must not mint a STANDING credential: it would carry the + // scope forward but honor expires_in verbatim (a minutes-long leak becomes decade-long) and write + // a refresh_token to the user record. So a scoped caller is denied outright. + it('denies a scoped caller from minting standing operation/refresh tokens, and writes nothing', async () => { let rw = token_auth.__set__( 'getJWTRSAKeys', async () => new JWTRSAKeys(PUBLIC_KEY_VALUE, PRIVATE_KEY_VALUE, PASSPHRASE_VALUE) ); - // No username/password: the caller is the already-authenticated bearer, whose hdb_user carries - // the scope — exactly the shape a scoped OIDC token presents to create_authentication_tokens. - let result = await token_auth.createTokens({ - hdb_user: { username: 'HDB_USER', tokenOperations: ['deploy_component'] }, - }); - assert.deepStrictEqual(jwt.decode(result.operation_token).operations, ['deploy_component']); - assert.deepStrictEqual(jwt.decode(result.refresh_token).operations, ['deploy_component']); + update_stub.resetHistory(); + await assert.rejects( + () => + token_auth.createTokens({ + expires_in: '3650d', // the lifetime-extension the denial exists to prevent + hdb_user: { username: 'HDB_USER', tokenOperations: ['deploy_component'] }, + }), + (e) => { + assert.strictEqual(e.statusCode, 403); + return true; + } + ); + assert.strictEqual(update_stub.callCount, 0, 'a denied scoped caller must not write a refresh_token to the user'); + rw(); + }); + + // A deny-all ([]) scope is still a scope — it must be refused too (and must not touch the user record). + it('denies even a deny-all ([]) scoped caller', async () => { + let rw = token_auth.__set__( + 'getJWTRSAKeys', + async () => new JWTRSAKeys(PUBLIC_KEY_VALUE, PRIVATE_KEY_VALUE, PASSPHRASE_VALUE) + ); + await assert.rejects( + () => token_auth.createTokens({ hdb_user: { username: 'HDB_USER', tokenOperations: [] } }), + (e) => { + assert.strictEqual(e.statusCode, 403); + return true; + } + ); rw(); }); - it('refuses to mint a login token for a scoped caller (#2174)', async () => { + it('denies a scoped caller on the login path too', async () => { let rw = token_auth.__set__( 'getJWTRSAKeys', async () => new JWTRSAKeys(PUBLIC_KEY_VALUE, PRIVATE_KEY_VALUE, PASSPHRASE_VALUE) ); - // A cookie session is username-only, so a scoped credential trading a login token for a session - // would drop the scope — deny it. Otherwise: scoped token -> login -> session -> full role. await assert.rejects( () => token_auth.createTokens({ From 41042ec1d718e6429612f8e2d8d1c3d4babe086e Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 12:03:20 -0400 Subject: [PATCH 19/37] fix(security): close credential-minting, scope, and lifetime gaps from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent model reviews (Codex and Claude) converged on four of these. 1. create_authentication_tokens could still mint from an exchanged token. The previous guard fired only on a SCOPED caller, but a trust policy carries `operations` only when the operator opts in — so the ordinary exchanged token is unscoped and sailed through, taking a caller-controlled expires_in and a 30-day refresh token with it. Mint provenance is now a signed claim on every token createOperationToken produces, lifted onto the principal at validateToken and refused ahead of the user lookup, so a refused request reads nothing and writes nothing. Impersonation carries it forward too: it returns a new principal, which would otherwise launder the marker. 2. A `read_only` scope could run write SQL. The group expands to include `sql`, and verifyPermsAST returns null for a super_user before any table check, so DELETE/UPDATE/INSERT passed. A write statement now additionally requires its matching data operation in scope — which is exactly what separates read_only from standard_user, with no need to track which group admitted `sql`. 3. job_workflow_ref no longer satisfies the caller-ref gate. It names the reusable workflow that ran, not the caller that invoked it, so its @ref is constant however it is called and admitted any branch of any caller repo referencing that workflow. It remains valid as a workflow pin. 4. Identity tokens now require `iat` and bound `exp` against the verification clock. The ceiling was skipped entirely when `iat` was absent, and a pair shifted equally far into the future kept a small delta while staying valid for as long as it liked. Also corrects DESIGN.md, which asserted the opposite of the implemented per-policy allowlist, and records the REST/GraphQL enforcement boundary there rather than only on the types.ts field. The new createTokens cases drop the rewire mutations AGENTS.md prohibits — reachable now that the guard runs before any I/O — and the two positive-path cases they came with were already covered. Documents, rather than works around, a pre-existing gap in the shared grantable- operation registry: it is process-local and the OPERATION_REGISTERED bridge carries only name/thread routing, so add_role, alter_role, and impersonation validation reject worker-registered component operations on main identically. It fails closed, and the fix belongs to that bridge. Co-Authored-By: Claude Opus 4.8 --- DESIGN.md | 6 +- security/authn/oidc/identityToken.ts | 17 ++- .../authn/oidc/providers/githubActions.ts | 9 +- security/authn/oidc/trustPolicyOperations.ts | 13 ++- security/credentialProvenance.ts | 47 ++++++++ security/impersonation.ts | 5 + security/tokenAuthentication.ts | 50 ++++++-- .../oidc/providers/githubActions.test.js | 26 +++++ .../authn/oidc/verifyIdentityToken.test.js | 22 ++++ .../security/credentialProvenance.test.js | 110 ++++++++++++++++++ unitTests/security/impersonation.test.js | 15 +++ .../security/tokenAuthentication.test.js | 82 ------------- .../security/tokenOperationScope.test.js | 47 ++++++++ utility/operation_authorization.ts | 22 +++- 14 files changed, 371 insertions(+), 100 deletions(-) create mode 100644 security/credentialProvenance.ts create mode 100644 unitTests/security/credentialProvenance.test.js diff --git a/DESIGN.md b/DESIGN.md index 2c67f0348..a934ebd5e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -209,7 +209,11 @@ Four constraints that look like choices but are not: 1. **Every rejection returns the same message.** The endpoint is unauthenticated; a caller told which check failed can enumerate a policy one claim at a time. Reasons go to the `oidc-trust` logger. 2. **A GitHub policy must gate the ref.** `githubActionsProfile.assertPolicyIsSpecific` rejects a policy pinning only repository + workflow, because anyone who can push a branch could then add that workflow to it and mint a token. Stricter than npm's trusted-publishing model, which mitigates the same hole with environment protection instead — and profile-scoped, so it never constrains another issuer. 3. **`createOperationToken`, not `createTokens`.** `createTokens` overwrites `hdb_user.refresh_token` as a side effect, so minting for CI would silently revoke whatever credential that user already held (#2018) — the exact problem this feature removes. -4. **No per-policy operation allowlist.** Least privilege is the role of the user the policy names. A second authorization mechanism beside roles is one more place for the two to disagree, and Harper's existing `permission.operations` is not purely narrowing — gate 2 in `operation_authorization.ts` treats an explicit listing of an SU-only operation as a deliberate grant, so a naive reuse could _widen_ rather than narrow. +4. **The role is the boundary; the per-policy `operations` allowlist only narrows it.** Least privilege is primarily the role of the user the policy names. A policy may _optionally_ carry an `operations` scope, which can only subtract from that role — never add to it. It is deliberately not merged into `permission.operations`: gate 2 in `operation_authorization.ts` treats an explicit listing of an SU-only operation as a deliberate grant, so reusing that field would _widen_ where this must only narrow. The scope is carried as a separate `tokenOperations` claim and intersected ahead of every early return, including the super_user bypass. + + Its enforcement surface is the operations API and SQL (`verifyPerms` / `verifyPermsAST`) — **not** the application REST/GraphQL resource path, which authorizes through table-level `checkPermission` and does not consult the scope. A scoped token therefore still carries its role's full CRUD there, which is why the role has to be least-privilege on its own; the scope is defense in depth, not a substitute. Closing that gap is a follow-up on the same surface as CORE-3061. Because a second authorization mechanism beside roles is one more place for the two to disagree, whether to keep this at all is an open design question on #2173 rather than a settled constraint. + + Naming `sql` in a scope grants the SQL interface, not unrestricted DML through it: a write statement additionally requires its matching data operation (`insert`/`update`/`delete`) in scope. That is what keeps `read_only` — which expands to include `sql` — from admitting a DELETE, given that `verifyPermsAST` returns early for a super_user before any table check runs. `hdb_oidc_token_use` (created lazily via `table()`, not the system schema) records spent tokens keyed on a SHA-256 of the token itself, with `expiresAt` past the token's own expiry. Hashed rather than stored, so the table never holds a credential; keyed on the token rather than `jti` because not every issuer emits one (Azure uses `uti`) and a replayed token is byte-identical by definition. The get-then-put is not atomic and does not claim to be: a concurrent replay is not a privilege escalation, since whoever holds the token could obtain one operation token anyway. diff --git a/security/authn/oidc/identityToken.ts b/security/authn/oidc/identityToken.ts index 1c7ae8a65..d40a86ed1 100644 --- a/security/authn/oidc/identityToken.ts +++ b/security/authn/oidc/identityToken.ts @@ -98,9 +98,24 @@ export async function verifyIdentityToken( // jsonwebtoken only enforces `exp` when present, so a token without one never expires. if (typeof payload.exp !== 'number') rejectToken('token has no exp claim'); - if (typeof payload.iat === 'number' && payload.exp - payload.iat > MAX_TOKEN_LIFETIME_SECONDS) { + // `iat` is REQUIRED by OIDC, and required here rather than treated as optional: skipping the + // ceiling when it is absent means a token carrying a distant `exp` and no `iat` is unbounded — + // the ceiling would silently not apply to exactly the token that most needs it. + if (typeof payload.iat !== 'number') rejectToken('token has no iat claim'); + + // Bounded against the verification clock, not only as `exp - iat`. jsonwebtoken validates `exp` + // and `nbf` but never rejects a future `iat`, so an issuer that shifts the whole pair forward + // keeps a small delta and still hands out a token valid far longer than the ceiling. Both the + // delta and the absolute distance to `exp` are checked; the tolerance mirrors the one given to + // jwt.verify so genuine clock skew is not treated as an attack. + const now = options.clockTimestamp ?? Math.floor(Date.now() / 1000); + if (payload.iat > now + CLOCK_TOLERANCE_SECONDS) rejectToken('token iat is in the future'); + if (payload.exp - payload.iat > MAX_TOKEN_LIFETIME_SECONDS) { rejectToken(`token lifetime exceeds ${MAX_TOKEN_LIFETIME_SECONDS}s`); } + if (payload.exp - now > MAX_TOKEN_LIFETIME_SECONDS + CLOCK_TOLERANCE_SECONDS) { + rejectToken(`token expires more than ${MAX_TOKEN_LIFETIME_SECONDS}s from now`); + } // No `jti` requirement: not every issuer emits one (Azure uses `uti`, others omit it), and the // exchange keys replay on a hash of the token itself, which is universal. diff --git a/security/authn/oidc/providers/githubActions.ts b/security/authn/oidc/providers/githubActions.ts index ca026102c..1f90115f8 100644 --- a/security/authn/oidc/providers/githubActions.ts +++ b/security/authn/oidc/providers/githubActions.ts @@ -34,6 +34,13 @@ const SHARED_DEFAULT_AUDIENCE = /^https:\/\/github\.com\/[^/]+\/?$/i; * same shape of reason: `ref_type: tag` still admits any tag, and anyone with push access can create * one. A tag-triggered release pins `environment` and leans on GitHub's environment protection. * + * `job_workflow_ref` pins the workflow (second row) but deliberately does NOT gate the ref (third). + * For a reusable workflow it names the workflow that RAN, not the caller that invoked it — the + * caller's ref lives in `workflow_ref`/`ref`. Its `@ref` suffix therefore describes the reusable + * workflow's own branch, which is constant however it is called, so accepting it as a ref gate would + * admit any branch of any caller repository that references that reusable workflow: precisely the + * hole the third row exists to close. + * * `sub` is deliberately not accepted as a pin: it varies by trigger, and its format changed for * repositories created after 2026-07-15 (immutable subjects embed owner and repo ids). */ @@ -50,7 +57,7 @@ const STRUCTURAL_REQUIREMENTS = [ }, { requirement: 'gate the ref', - claims: ['workflow_ref', 'job_workflow_ref', 'ref', 'environment'], + claims: ['workflow_ref', 'ref', 'environment'], because: ' — otherwise any branch that can be pushed to the repository can run the workflow and mint a token', }, ]; diff --git a/security/authn/oidc/trustPolicyOperations.ts b/security/authn/oidc/trustPolicyOperations.ts index 83e6b593a..2e43c38b5 100644 --- a/security/authn/oidc/trustPolicyOperations.ts +++ b/security/authn/oidc/trustPolicyOperations.ts @@ -48,8 +48,17 @@ function validate(validation: any): void { /** * A typo would otherwise fail closed at request time, in CI, with nothing to point at — so it is * caught here, where the reader is the administrator who wrote it. Delegates to the same helper - * add_role/alter_role use, which accepts group names and operations registered at runtime via - * server.registerOperation; a local OPERATIONS_ENUM check would reject those. + * add_role/alter_role use, so group names resolve identically rather than through a second + * definition that could drift. + * + * Known limitation, inherited rather than introduced: that helper's registry of runtime-registered + * operations is process-local, and the OPERATION_REGISTERED bridge propagates only name→thread + * routing, never grantability (server/serverHelpers/registeredOperations.ts). A component's + * `server.registerOperation` runs in a worker while this operation runs on the main thread, so an + * operation registered that way is NOT recognized here and a policy naming one is rejected. It + * fails closed — a rejected policy, never a widened one — and `add_role`, `alter_role`, and + * impersonation validation all share the gap, which is why the fix belongs to that bridge rather + * than to a local workaround here. */ function assertOperationsAreKnown(operations: string[]): void { const invalidOperation = validateOperations(operations); diff --git a/security/credentialProvenance.ts b/security/credentialProvenance.ts new file mode 100644 index 000000000..ae1225f90 --- /dev/null +++ b/security/credentialProvenance.ts @@ -0,0 +1,47 @@ +/** + * Provenance for a credential minted from a workload identity exchange (#2171) rather than from a + * password. + * + * The exchange's whole guarantee is that CI holds nothing durable: a one-hour operation token, no + * refresh token, nothing on disk to leak. That guarantee is only as strong as the paths that turn + * one credential into another. `create_authentication_tokens` is such a path and does not look like + * one: it is in `NO_AUTH_OPERATIONS`, but `serverHandlers.js` special-cases it so a call with no + * username/password authenticates by Bearer token instead — so an exchanged token is accepted there + * as if it were a password, honors a caller-supplied `expires_in` verbatim, and returns a 30-day + * refresh token. That converts a minutes-long leak into a month-long one, which is the exact + * exposure this feature exists to remove. + * + * A scope (operationScope.ts) cannot carry this weight. A trust policy names `operations` only when + * the operator opts in, so the ordinary exchanged token is UNSCOPED and a scope check finds nothing + * to deny — the common case would sail through a scope-only guard. Provenance is therefore recorded + * independently of scope, on every exchanged token. + * + * Like the scope, it lives under two names depending on the carrier: the claim `workload_identity` + * on a JWT payload, and `fromWorkloadIdentity` on an in-memory principal (validateToken lifts the + * claim across). And like the scope, every path that produces a credential or a principal has to + * carry it forward — impersonation included, or a workload token launders its provenance by + * impersonating and then mints freely. + */ + +/** The JWT claim name. Signed with the rest of the payload, so it cannot be stripped in transit. */ +export const WORKLOAD_IDENTITY_CLAIM = 'workload_identity'; + +/** Stamps a token payload as workload-identity provenance. Returns the payload. */ +export function markTokenAsWorkloadIdentity(payload: T): T { + (payload as any)[WORKLOAD_IDENTITY_CLAIM] = true; + return payload; +} + +/** Lifts the claim from a verified token onto a user principal. Returns the user. */ +export function attachWorkloadIdentityToUser(user: T, claim: unknown): T { + if (claim === true) (user as any).fromWorkloadIdentity = true; + return user; +} + +/** + * True when this principal authenticated with a workload-identity token. Checked strictly against + * `true` so a forged string or object on a user record cannot widen it. + */ +export function isWorkloadIdentityPrincipal(user: unknown): boolean { + return (user as any)?.fromWorkloadIdentity === true; +} diff --git a/security/impersonation.ts b/security/impersonation.ts index 3fd96f28a..940501620 100644 --- a/security/impersonation.ts +++ b/security/impersonation.ts @@ -6,6 +6,7 @@ import { ClientError } from '../utility/errors/hdbError.ts'; import harperLogger from '../utility/logging/harper_logger.ts'; import { getRoleByName } from './role.ts'; import { attachScopeToUser } from './operationScope.ts'; +import { attachWorkloadIdentityToUser } from './credentialProvenance.ts'; /** * Applies impersonation to a request. The authenticated user must be a super_user. @@ -43,6 +44,10 @@ export async function applyImpersonation(authenticatedUser: User, payload: Imper // acts as, so it survives impersonation. enforceDowngrade only bounds the impersonated role's // permissions; without carrying the scope, a scoped super_user token would shed it by impersonating. attachScopeToUser(impersonatedUser, (authenticatedUser as any).tokenOperations); + // Same reasoning for provenance (#2171), and the omission would be worse: impersonation returns a + // NEW principal, so dropping the marker here would let a workload token impersonate — even down to + // a lesser role — and then mint a 30-day credential that createTokens would no longer refuse. + attachWorkloadIdentityToUser(impersonatedUser, (authenticatedUser as any).fromWorkloadIdentity); // Tag for audit trail impersonatedUser._impersonated = true; diff --git a/security/tokenAuthentication.ts b/security/tokenAuthentication.ts index 3a5c518fd..09e7be858 100644 --- a/security/tokenAuthentication.ts +++ b/security/tokenAuthentication.ts @@ -16,6 +16,12 @@ import logger from '../utility/logging/harper_logger.ts'; import * as password from '../utility/password.ts'; import { findAndValidateUser, type User } from './user.ts'; import { attachScopeToToken, attachScopeToUser, hasOperationScope } from './operationScope.ts'; +import { + WORKLOAD_IDENTITY_CLAIM, + attachWorkloadIdentityToUser, + isWorkloadIdentityPrincipal, + markTokenAsWorkloadIdentity, +} from './credentialProvenance.ts'; import { update } from '../dataLayer/insert.ts'; import UpdateObject from '../dataLayer/UpdateObject.ts'; import * as signalling from '../utility/signalling.ts'; @@ -134,6 +140,31 @@ export async function createTokens(authObj: AuthObject): Promise { ); if (validation) throw new ClientError(validation.message); + // create_authentication_tokens is NO_AUTH, so verifyPerms — and the token-scope gate inside it — + // never runs here. A caller holding a workload-identity token (#2171) must not mint any standing + // credential through it: honoring expires_in verbatim turns a minutes-long leak into an + // arbitrarily long-lived one, and the refresh_token write below hands out a 30-day credential — + // both defeating the exchange's ephemerality guarantee. A CI token needs none of this; it already + // holds the operation token the exchange gave it, and gets a fresh one next run. + // + // Gated on provenance, NOT on the scope: a trust policy carries `operations` only when the + // operator opts in, so the ordinary exchanged token is unscoped and a scope-only check would let + // exactly the common case through. The scope check stays as well, covering a scoped credential + // from any other source. + // + // First, ahead of the user lookup and the `purpose` branch: this reads only the caller's own + // principal, so a refused request should cost no database read and write nothing. That also + // covers the login path, where a session is minted from a username alone and so cannot be + // narrowed after the fact. + // `authObj?.` — a bare createTokens() reaches here, and must still fail as invalid credentials + // below rather than as a TypeError out of this guard. + if (isWorkloadIdentityPrincipal(authObj?.hdb_user)) { + throw new ClientError('a workload identity token cannot mint authentication tokens', HTTP_STATUS_CODES.FORBIDDEN); + } + if (hasOperationScope((authObj?.hdb_user as any)?.tokenOperations)) { + throw new ClientError('a scoped token cannot mint authentication tokens', HTTP_STATUS_CODES.FORBIDDEN); + } + let user: any; try { // Trusted bypass is dispatch/async-context state (set by a component calling @@ -166,18 +197,6 @@ export async function createTokens(authObj: AuthObject): Promise { } = { username: authObj.username, super_user: superUser }; if (authObj.role) payload.role = authObj.role; - // create_authentication_tokens is NO_AUTH, so verifyPerms — and the token-scope gate inside it — - // never runs here. A scoped caller (an OIDC-exchanged operation token, #2174) must not mint any - // standing credential through it: the scope would carry forward, but honoring expires_in verbatim - // turns a minutes-long leak into an arbitrarily long-lived one, and the refresh_token write below - // hands out a 30-day credential — both defeating the exchange's ephemerality guarantee, and both - // reachable even by a deny-all `[]` scope. A CI token needs none of this; it holds the operation - // token the exchange already gave it. Deny outright — this covers the login and standing paths - // alike, so a session (username-only, and therefore unscopeable) can't be minted from a scope either. - if (hasOperationScope((authObj.hdb_user as any)?.tokenOperations)) { - throw new ClientError('a scoped token cannot mint authentication tokens', HTTP_STATUS_CODES.FORBIDDEN); - } - const keys: JWTRSAKeys = await getJWTRSAKeys(); if (authObj.purpose === 'login') { @@ -288,6 +307,10 @@ export async function createOperationToken( // (deny-all) is preserved rather than dropped — attachScopeToToken carries any array, which is // what keeps this from failing open. attachScopeToToken(payload, user.operations); + // Unconditional, because every token this function mints is minted without a password — the + // caller vouched for the user instead. That is precisely the credential that must not be able to + // trade itself for a longer-lived one, whether or not a scope narrows it. + markTokenAsWorkloadIdentity(payload); return jwt.sign( payload, @@ -341,6 +364,9 @@ async function validateToken(token: string, tokenType: string): Promise { // so merging a token scope into it could widen. verifyPerms intersects this separately, ahead // of every bypass. attachScopeToUser(user, tokenVerified.operations); + // Provenance rides the same lift: the claim is signed, so a caller cannot strip it to look + // like a password-minted principal at createTokens. + attachWorkloadIdentityToUser(user, tokenVerified[WORKLOAD_IDENTITY_CLAIM]); return user; } catch (err) { diff --git a/unitTests/security/authn/oidc/providers/githubActions.test.js b/unitTests/security/authn/oidc/providers/githubActions.test.js index fa7d33067..edeab2c9c 100644 --- a/unitTests/security/authn/oidc/providers/githubActions.test.js +++ b/unitTests/security/authn/oidc/providers/githubActions.test.js @@ -133,6 +133,32 @@ describe('githubActions provider profile', () => { ); }); + // job_workflow_ref names the reusable workflow that RAN, not the caller that invoked it. Its + // @ref suffix is the reusable workflow's own branch and is constant however it is called, so + // accepting it as a ref gate would admit any branch of any caller repository that references + // that reusable workflow — exactly the hole the ref gate exists to close. + it('does not accept job_workflow_ref as a ref gate', () => { + assert.throws( + () => + githubActionsProfile.assertPolicyIsSpecific({ + repository_id: '67890', + job_workflow_ref: 'HarperFast/shared/.github/workflows/deploy.yml@refs/heads/main', + }), + /gate the ref/ + ); + }); + + // It remains a valid workflow pin — only the ref row rejects it. + it('accepts job_workflow_ref as a workflow pin alongside a real ref gate', () => { + assert.doesNotThrow(() => + githubActionsProfile.assertPolicyIsSpecific({ + repository_id: '67890', + job_workflow_ref: 'HarperFast/shared/.github/workflows/deploy.yml@refs/heads/main', + environment: 'production', + }) + ); + }); + it('does not accept ref_type alone as a ref gate', () => { assert.throws( () => diff --git a/unitTests/security/authn/oidc/verifyIdentityToken.test.js b/unitTests/security/authn/oidc/verifyIdentityToken.test.js index 3a2d2fcf8..15d173896 100644 --- a/unitTests/security/authn/oidc/verifyIdentityToken.test.js +++ b/unitTests/security/authn/oidc/verifyIdentityToken.test.js @@ -139,6 +139,28 @@ describe('verifyIdentityToken', () => { await assertRejected(sign(claimsFor({ exp: NOW_SECONDS + 86_400 }))); }); + // The ceiling is measured from `iat`, so treating `iat` as optional means a token that omits it + // is not bounded at all — the check would skip exactly the token that most needs it. OIDC + // requires the claim, so demanding it costs nothing. + it('rejects a token with no iat claim', async () => { + const { iat: _iat, ...withoutIat } = claimsFor({ exp: NOW_SECONDS + 86_400 }); + await assertRejected(sign(withoutIat)); + }); + + // jsonwebtoken validates exp and nbf but never rejects a future iat. Shifting the whole pair + // forward keeps `exp - iat` under the ceiling while leaving the token usable for a day, so the + // distance from the verification clock to `exp` has to be bounded independently. + it('rejects a token whose iat and exp are both shifted into the future', async () => { + const shifted = claimsFor({ iat: NOW_SECONDS + 86_400, exp: NOW_SECONDS + 86_700 }); + await assertRejected(sign(shifted)); + }); + + // Genuine clock skew is not an attack: an iat inside the tolerance still verifies. + it('accepts an iat slightly ahead of the verification clock', async () => { + const claims = await verify(sign(claimsFor({ iat: NOW_SECONDS + 30, exp: NOW_SECONDS + 300 }))); + assert.strictEqual(claims.repository, 'HarperFast/my-app'); + }); + // Replay is keyed on a hash of the token itself, so `jti` is not required — which is what lets // issuers that use `uti` (Azure) or omit it entirely work with no provider code. it('accepts a token with no jti claim', async () => { diff --git a/unitTests/security/credentialProvenance.test.js b/unitTests/security/credentialProvenance.test.js new file mode 100644 index 000000000..6d8a3f762 --- /dev/null +++ b/unitTests/security/credentialProvenance.test.js @@ -0,0 +1,110 @@ +'use strict'; + +// A credential minted from a workload identity exchange (#2171) must not be tradeable for a +// longer-lived one. `create_authentication_tokens` is the path that makes that possible and does not +// look like it: it is NO_AUTH, but serverHandlers.js special-cases a call with no username/password +// to authenticate by Bearer token instead — so an exchanged token is accepted there in place of a +// password, honors a caller-supplied `expires_in`, and returns a 30-day refresh token. +// +// These cases need no stubbing because createTokens refuses ahead of the user lookup: the guard +// reads only the caller's principal, so a refused request costs no database read and writes nothing. + +const assert = require('node:assert'); +const testUtils = require('../testUtils.js'); +testUtils.preTestPrep(); + +const { createTokens, createOperationToken, clearJWTRSAKeysCache } = require('#src/security/tokenAuthentication'); +const { WORKLOAD_IDENTITY_CLAIM, attachWorkloadIdentityToUser } = require('#src/security/credentialProvenance'); + +function payloadOf(token) { + return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); +} + +/** createTokens must reject with 403 — and reach neither the key material nor the user record. */ +async function assertDenied(authObj, message) { + await assert.rejects( + () => createTokens(authObj), + (e) => { + assert.strictEqual(e.statusCode, 403, message); + return true; + }, + message + ); +} + +describe('workload identity provenance blocks credential minting', () => { + // The case a scope-only guard misses: a trust policy names `operations` only when the operator + // opts in, so the ORDINARY exchanged token is unscoped. This is the PR's own headline example. + it('denies an unscoped workload identity caller', async () => { + await assertDenied( + { + expires_in: '3650d', // the lifetime extension the denial exists to prevent + hdb_user: { username: 'HDB_USER', fromWorkloadIdentity: true }, + }, + 'an unscoped workload token must not mint standing credentials' + ); + }); + + it('denies a scoped workload identity caller', async () => { + await assertDenied({ + hdb_user: { username: 'HDB_USER', fromWorkloadIdentity: true, tokenOperations: ['deploy_component'] }, + }); + }); + + // A session is minted from a username alone, so it cannot be narrowed after the fact — the guard + // has to sit ahead of the `purpose` branch, not inside the standing-credential path. + it('denies the login path too', async () => { + await assertDenied({ purpose: 'login', hdb_user: { username: 'HDB_USER', fromWorkloadIdentity: true } }); + }); + + // Provenance is independent of scope, but a scoped credential from any other source is still + // refused — that guard predates this one and both are load-bearing. + it('denies a scoped caller regardless of provenance', async () => { + await assertDenied({ hdb_user: { username: 'HDB_USER', tokenOperations: ['deploy_component'] } }); + }); + + it('denies a deny-all ([]) scope, which is a scope and not the absence of one', async () => { + await assertDenied({ hdb_user: { username: 'HDB_USER', tokenOperations: [] } }); + }); + + // Strict `=== true`, so a truthy value on a user record cannot be coerced into provenance and a + // falsy one cannot silently drop it. + it('treats only a literal true as provenance', () => { + for (const claim of [false, undefined, null, 'true', 1, {}]) { + const user = attachWorkloadIdentityToUser({}, claim); + assert.strictEqual(user.fromWorkloadIdentity, undefined, `${JSON.stringify(claim)} must not mark a principal`); + } + assert.strictEqual(attachWorkloadIdentityToUser({}, true).fromWorkloadIdentity, true); + }); +}); + +describe('provenance on a minted token', () => { + const user = { username: 'ci-deploy', super_user: false }; + let removeJwtKeys; + + before(() => { + // The keys land in a directory another suite asserts is empty, so they must come back out — + // see testUtils.installTestJwtKeys. + removeJwtKeys = testUtils.installTestJwtKeys(); + clearJWTRSAKeysCache(); + }); + + after(() => { + removeJwtKeys(); + clearJWTRSAKeysCache(); + }); + + // Signed with the rest of the payload: a caller cannot strip the claim to look password-minted. + it('stamps every token createOperationToken mints', async () => { + const token = await createOperationToken({ ...user, operations: ['deploy_component'] }, 3600); + assert.strictEqual(payloadOf(token)[WORKLOAD_IDENTITY_CLAIM], true); + }); + + // Unconditional, because the function mints without a password in every case — an unscoped + // exchanged token needs the marker just as much as a scoped one. + it('stamps an unscoped token too', async () => { + const token = await createOperationToken(user, 3600); + assert.strictEqual(payloadOf(token)[WORKLOAD_IDENTITY_CLAIM], true); + assert.strictEqual(payloadOf(token).operations, undefined, 'this token carries no scope to rely on'); + }); +}); diff --git a/unitTests/security/impersonation.test.js b/unitTests/security/impersonation.test.js index c39372e80..444d7797a 100644 --- a/unitTests/security/impersonation.test.js +++ b/unitTests/security/impersonation.test.js @@ -617,6 +617,21 @@ describe('security/impersonation.ts', () => { assert.deepStrictEqual(impersonated.tokenOperations, ['deploy_component']); }); + // Impersonation returns a NEW principal, so dropping the provenance marker would let a + // workload token launder it — impersonate, then mint the 30-day credential that + // createTokens would otherwise refuse. Carried for the same reason the scope is (#2171). + it('carries workload identity provenance onto the impersonated user', async () => { + const su = makeSuperUser(); + su.fromWorkloadIdentity = true; + const impersonated = await applyImpersonation(su, INLINE_ROLE); + assert.strictEqual(impersonated.fromWorkloadIdentity, true); + }); + + it('marks no provenance when the authenticating token was password-minted', async () => { + const impersonated = await applyImpersonation(makeSuperUser(), INLINE_ROLE); + assert.strictEqual(impersonated.fromWorkloadIdentity, undefined); + }); + it('adds no scope when the authenticating token was unscoped', async () => { const impersonated = await applyImpersonation(makeSuperUser(), INLINE_ROLE); assert.strictEqual(impersonated.tokenOperations, undefined); diff --git a/unitTests/security/tokenAuthentication.test.js b/unitTests/security/tokenAuthentication.test.js index a17d4cbc1..9cafb18a9 100644 --- a/unitTests/security/tokenAuthentication.test.js +++ b/unitTests/security/tokenAuthentication.test.js @@ -441,88 +441,6 @@ describe('test createTokens', () => { rw_get_tokens(); }); - // #2174: create_authentication_tokens is NO_AUTH, so verifyPerms — and the token-scope gate inside - // it — never runs here. A scoped caller must not mint a STANDING credential: it would carry the - // scope forward but honor expires_in verbatim (a minutes-long leak becomes decade-long) and write - // a refresh_token to the user record. So a scoped caller is denied outright. - it('denies a scoped caller from minting standing operation/refresh tokens, and writes nothing', async () => { - let rw = token_auth.__set__( - 'getJWTRSAKeys', - async () => new JWTRSAKeys(PUBLIC_KEY_VALUE, PRIVATE_KEY_VALUE, PASSPHRASE_VALUE) - ); - update_stub.resetHistory(); - await assert.rejects( - () => - token_auth.createTokens({ - expires_in: '3650d', // the lifetime-extension the denial exists to prevent - hdb_user: { username: 'HDB_USER', tokenOperations: ['deploy_component'] }, - }), - (e) => { - assert.strictEqual(e.statusCode, 403); - return true; - } - ); - assert.strictEqual(update_stub.callCount, 0, 'a denied scoped caller must not write a refresh_token to the user'); - rw(); - }); - - // A deny-all ([]) scope is still a scope — it must be refused too (and must not touch the user record). - it('denies even a deny-all ([]) scoped caller', async () => { - let rw = token_auth.__set__( - 'getJWTRSAKeys', - async () => new JWTRSAKeys(PUBLIC_KEY_VALUE, PRIVATE_KEY_VALUE, PASSPHRASE_VALUE) - ); - await assert.rejects( - () => token_auth.createTokens({ hdb_user: { username: 'HDB_USER', tokenOperations: [] } }), - (e) => { - assert.strictEqual(e.statusCode, 403); - return true; - } - ); - rw(); - }); - - it('denies a scoped caller on the login path too', async () => { - let rw = token_auth.__set__( - 'getJWTRSAKeys', - async () => new JWTRSAKeys(PUBLIC_KEY_VALUE, PRIVATE_KEY_VALUE, PASSPHRASE_VALUE) - ); - await assert.rejects( - () => - token_auth.createTokens({ - purpose: 'login', - hdb_user: { username: 'HDB_USER', tokenOperations: ['deploy_component'] }, - }), - (e) => { - assert.strictEqual(e.statusCode, 403); - return true; - } - ); - rw(); - }); - - it('still mints a login token for an unscoped caller', async () => { - let rw = token_auth.__set__( - 'getJWTRSAKeys', - async () => new JWTRSAKeys(PUBLIC_KEY_VALUE, PRIVATE_KEY_VALUE, PASSPHRASE_VALUE) - ); - let result = await token_auth.createTokens({ purpose: 'login', username: 'HDB_USER', password: 'pass' }); - assert.notDeepStrictEqual(result.operation_token, undefined); - assert.strictEqual(result.refresh_token, undefined); // login purpose mints no refresh token - rw(); - }); - - it('mints unscoped credentials when the caller is unscoped', async () => { - let rw = token_auth.__set__( - 'getJWTRSAKeys', - async () => new JWTRSAKeys(PUBLIC_KEY_VALUE, PRIVATE_KEY_VALUE, PASSPHRASE_VALUE) - ); - let result = await token_auth.createTokens({ username: 'HDB_USER', password: 'pass' }); - assert.strictEqual(jwt.decode(result.operation_token).operations, undefined); - assert.strictEqual(jwt.decode(result.refresh_token).operations, undefined); - rw(); - }); - it('test update failed', async () => { update_stub.callsFake(async (_update_object) => { throw Error('update failed'); diff --git a/unitTests/security/tokenOperationScope.test.js b/unitTests/security/tokenOperationScope.test.js index 59e0f76e9..ee2c8275f 100644 --- a/unitTests/security/tokenOperationScope.test.js +++ b/unitTests/security/tokenOperationScope.test.js @@ -174,6 +174,53 @@ describe('token-scoped narrowing on the SQL path', () => { assert.strictEqual(denial, null); }); + // `read_only` expands to include `sql` — the group defers DML enforcement to table CRUD perms — + // but verifyPermsAST returns null outright for a super_user before any table check runs. Without + // a variant check, a token scoped to `read_only` could DELETE: the one thing that name promises + // it cannot do. A write statement must additionally name its matching data operation. + for (const statement of [ + 'DELETE FROM data.dog', + "UPDATE data.dog SET name = 'x'", + 'INSERT INTO data.dog (id) VALUES (1)', + ]) { + const variant = statement.split(' ')[0].toLowerCase(); + + it(`denies ${variant} SQL for a read_only scope, even for a super_user`, () => { + const denial = checkSql(statement, userWithScope({ super_user: true }, ['read_only'])); + assert.ok(denial, `read_only must not admit ${variant} through SQL`); + }); + + // The same statement is admitted once the scope names the data operation — this is what + // separates read_only from standard_user without tracking which group admitted `sql`. + it(`allows ${variant} SQL when the scope names the data operation`, () => { + const denial = checkSql(statement, userWithScope({ super_user: true }, ['sql', variant])); + assert.strictEqual(denial, null, `a scope naming ${variant} may run it`); + }); + + // A write-capable non-SU role is bound by the scope too: the role permitting the write is + // not the question, the credential's scope is. + it(`denies ${variant} SQL for a write-capable non-super-user outside the scope`, () => { + const permission = { super_user: false, operations: ['sql', variant] }; + assert.ok(checkSql(statement, userWithScope(permission, ['read_only']))); + }); + } + + it('allows write SQL through standard_user, which names the data operations', () => { + const denial = checkSql('DELETE FROM data.dog', userWithScope({ super_user: true }, ['standard_user'])); + assert.strictEqual(denial, null); + }); + + // A bare ['sql'] grants the SQL interface, not unrestricted DML through it. + it('admits SELECT but not DELETE for a bare sql scope', () => { + assert.strictEqual(checkSql('SELECT * FROM data.dog', userWithScope({ super_user: true }, ['sql'])), null); + assert.ok(checkSql('DELETE FROM data.dog', userWithScope({ super_user: true }, ['sql']))); + }); + + // An unscoped token is unaffected: the variant gate only ever narrows a scope that exists. + it('is inert for write SQL when the token carries no scope', () => { + assert.strictEqual(checkSql('DELETE FROM data.dog', userWithScope({ super_user: true })), null); + }); + it('is inert for a token with no scope', () => { const denial = checkSql('SELECT * FROM data.dog', userWithScope({ super_user: true })); assert.strictEqual(denial, null); diff --git a/utility/operation_authorization.ts b/utility/operation_authorization.ts index f06274cd0..7104e84fb 100644 --- a/utility/operation_authorization.ts +++ b/utility/operation_authorization.ts @@ -487,6 +487,26 @@ function tokenScopeDenial(userObject: any, apiOperation: string) { return new PermissionResponseObject().handleUnauthorizedItem(HDB_ERROR_MSGS.OP_NOT_IN_OPERATIONS(apiOperation)); } +/** + * A scope naming `sql` grants the SQL interface, not unrestricted DML through it. + * + * `read_only` expands to include `sql` — the group defers DML enforcement to table CRUD permissions + * (see its note in operationPermissions.ts) — but verifyPermsAST returns null outright for a + * super_user before any table check runs. Without this, a token scoped to `read_only` could DELETE, + * which is the one thing that name promises it cannot do. + * + * So a write statement additionally requires its matching data operation in scope. That is exactly + * what separates `read_only` (no insert/update/delete) from `standard_user` (all three), with no + * need to track which group admitted `sql`. A bare `['sql']` therefore admits SELECT only; name the + * write operations alongside it to allow more. Anything that is not a recognized SELECT falls + * through to the same check and is denied unless named, so an unfamiliar statement type fails closed. + */ +function sqlWriteScopeDenial(userObject: any, sqlVariant: string) { + if (userObject?.tokenOperations == null) return undefined; + if (sqlVariant === terms.VALID_SQL_OPS_ENUM.SELECT) return undefined; + return tokenScopeDenial(userObject, sqlVariant); +} + export function verifyPermsAST(ast, userObject, operation, apiOperation = terms.OPERATIONS_ENUM.SQL) { //TODO - update these validation checks to use validate.js if (commonUtils.isEmptyOrZeroLength(ast)) { @@ -507,7 +527,7 @@ export function verifyPermsAST(ast, userObject, operation, apiOperation = terms. // `sql` for a direct SQL call but `export_local`/`export_to_s3` for a job whose inner // search_operation is SQL. Checking a hardcoded `sql` would let a token scoped only to `sql` start // an export that its scope excludes. Ahead of the super_user bypass, as on the NoSQL path. - const scopeDenial = tokenScopeDenial(userObject, apiOperation); + const scopeDenial = tokenScopeDenial(userObject, apiOperation) ?? sqlWriteScopeDenial(userObject, operation); if (scopeDenial) return scopeDenial; try { From 2afa5a61032730d5c18714ad53b8773b95424577 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 12:27:55 -0400 Subject: [PATCH 20/37] fix(security): key replay on the signed input, and fix issuer/audience matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third cross-model review (Barber AI). The replay finding is a real bypass and the premise it rests on was mine. 1. HIGH — replay protection was bypassable. The fingerprint hashed the whole token on the stated grounds that "a replayed token is byte-identical by definition". That is false: the signature segment is covered by nothing, and base64url decoding ignores the surplus low bits of its final character, so an RS256 signature has 16 distinct spellings that decode to identical bytes. Verified against this branch's jsonwebtoken — all 16 verify, each hashing differently, so one leaked identity token bought 16 operation tokens. ES* malleability (s -> n-s) is a second such vector. Now keyed on the signed input (header.payload), which is exactly what the issuer asserted, so every re-spelling collapses to one fingerprint. The regression test was confirmed to fail against the old fingerprint before being kept. 2. An issuer whose `iss` ends in `/` could never authenticate: the expectation passed to jwt.verify is normalized, the comparison is byte-for-byte. Azure AD v1 emits exactly that, and the generic profile exists to serve such issuers with no provider code. Both spellings are accepted now, which cannot widen trust — normalizeIssuer already collapses them for the cache key, the discovery check, and the policy lookup. 3. `audience` was stored raw while `issuer` was normalized, but the CLI requests its token for normalizeTarget(target) — port and trailing slash included. So the natural `audience=https://host` stored a policy that could never match, failing opaquely in CI. Rejected at write time now, where the administrator can see it. Rejected rather than canonicalized: silently rewriting a value whose job is byte-for-byte comparison is worse, and canonicalizing ahead of assertAudienceIsSpecific would disarm the shared-audience guard, since https://github.com/ normalizes out of that regex. A test pins the check to normalizeTarget's real output so the two cannot drift. Also: audit records the x-forwarded-for client rather than the proxy, matching auth.ts; auditing can no longer change the outcome it records (a throwing success emit was caught and re-reported as a failure); JWKS/network errors are logged instead of vanishing into a misleading "no policy matched"; a missing trust table no longer answers an anonymous caller with a descriptive 400 that breaks the uniform-rejection property; the issuer filter moved into the scan so an unauthenticated request no longer allocates a record per stored policy; the exchange uses the standard CLI timeout rather than inheriting deploy_component's 10-minute SSE timeout; a blank-but-set token namespace no longer falls through to workload identity, which would deploy a failed CI secret as a different identity; clearJwksCache can no longer be undone by an in-flight fetch; and two JSDoc references to symbols that never existed now name the real ones. Co-Authored-By: Claude Opus 4.8 --- DESIGN.md | 2 +- bin/cliOperations.ts | 22 ++++-- security/authn/oidc/identityToken.ts | 8 ++- security/authn/oidc/jwks.ts | 15 +++- security/authn/oidc/tokenExchange.ts | 63 ++++++++++++++--- security/authn/oidc/trustPolicyOperations.ts | 68 +++++++++++++++++-- security/authn/oidc/types.ts | 5 +- .../security/authn/oidc/tokenExchange.test.js | 27 ++++++++ .../authn/oidc/trustPolicyOperations.test.js | 37 ++++++++++ .../authn/oidc/verifyIdentityToken.test.js | 14 ++++ 10 files changed, 237 insertions(+), 24 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index a934ebd5e..08294b211 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -215,7 +215,7 @@ Four constraints that look like choices but are not: Naming `sql` in a scope grants the SQL interface, not unrestricted DML through it: a write statement additionally requires its matching data operation (`insert`/`update`/`delete`) in scope. That is what keeps `read_only` — which expands to include `sql` — from admitting a DELETE, given that `verifyPermsAST` returns early for a super_user before any table check runs. -`hdb_oidc_token_use` (created lazily via `table()`, not the system schema) records spent tokens keyed on a SHA-256 of the token itself, with `expiresAt` past the token's own expiry. Hashed rather than stored, so the table never holds a credential; keyed on the token rather than `jti` because not every issuer emits one (Azure uses `uti`) and a replayed token is byte-identical by definition. The get-then-put is not atomic and does not claim to be: a concurrent replay is not a privilege escalation, since whoever holds the token could obtain one operation token anyway. +`hdb_oidc_token_use` (created lazily via `table()`, not the system schema) records spent tokens keyed on a SHA-256 of the token itself, with `expiresAt` past the token's own expiry. Hashed rather than stored, so the table never holds a credential; keyed on the token's **signed input** (`header.payload`) rather than `jti` because not every issuer emits one (Azure uses `uti`). Not on the whole token string: the signature segment is covered by nothing, and base64url decoding ignores the surplus low bits of its final character, so 16 distinct spellings of an RS256 signature decode to the same bytes, all verify, and all hash differently — one leaked token would buy 16 exchanges. ES\* malleability (`s → n−s`) is a second such vector. The signed input is exactly what the issuer asserted, so every variant collapses to one fingerprint. The get-then-put is not atomic and does not claim to be: a concurrent replay is not a privilege escalation, since whoever holds the token could obtain one operation token anyway. ## Table drops, the `dropping` tombstone, and ghost tables diff --git a/bin/cliOperations.ts b/bin/cliOperations.ts index 45f26ab09..9663706e7 100644 --- a/bin/cliOperations.ts +++ b/bin/cliOperations.ts @@ -801,9 +801,12 @@ export async function resolveRequestOptions(req: any): Promise<{ options: any; t const envRefreshToken = tokenPrefix ? process.env[`${tokenPrefix}_REFRESH_TOKEN`]?.trim() : undefined; // A namespace that is set but blank is a broken CI secret, not a request to fall back to // whatever the developer last logged in as — say so rather than switching identity silently. - if (tokenPrefix && !envOperationToken && !envRefreshToken) { + const tokenNamespaceBlank = !!tokenPrefix && !envOperationToken && !envRefreshToken; + if (tokenNamespaceBlank) { console.error( - `Ignoring empty ${tokenPrefix}_OPERATION_TOKEN/${tokenPrefix}_REFRESH_TOKEN; falling back to saved login credentials.` + `Ignoring empty ${tokenPrefix}_OPERATION_TOKEN/${tokenPrefix}_REFRESH_TOKEN; falling back to saved ` + + `login credentials. Workload identity is deliberately NOT used here: a blank token namespace is a ` + + `failed secret, and deploying as a different identity would hide that.` ); } @@ -821,12 +824,23 @@ export async function resolveRequestOptions(req: any): Promise<{ options: any; t if (tokens.operation_token) { options.headers.Authorization = `Bearer ${tokens.operation_token}`; } - } else if (workloadIdentityAvailable()) { + } else if (workloadIdentityAvailable() && !tokenNamespaceBlank) { // Last credential source: no configured token, but this runner can prove its identity to // the cluster directly (#2171). Deliberately below the env-var and saved tokens — an // explicitly configured credential should keep working exactly as it did when someone adds // `id-token: write` to a workflow, rather than silently switching which identity deploys. - const operationToken = await exchangeWorkloadIdentityForToken(options, target.resolvedTarget); + // + // `!tokenNamespaceBlank` extends that invariant to the half-configured case. A CI secret + // that failed to populate leaves the namespace set but empty; without this, such a run + // would quietly deploy as the OIDC policy's user instead of failing, which is the same + // silent identity switch in a shape that is harder to notice. + // Standard operation timeout, not the caller's — by this point `options.timeout` may carry + // the 10-minute SSE timeout for a streaming deploy_component, and the exchange is a small + // fast request. Without the override a stalled exchange hangs the deploy for ten minutes, + // on the very operation this feature exists to serve. Same reasoning, same fix as + // refreshExpiredOperationToken above. + const exchangeOptions = { ...options, timeout: CLI_OPERATION_TIMEOUT_MS }; + const operationToken = await exchangeWorkloadIdentityForToken(exchangeOptions, target.resolvedTarget); if (operationToken) options.headers.Authorization = `Bearer ${operationToken}`; } } diff --git a/security/authn/oidc/identityToken.ts b/security/authn/oidc/identityToken.ts index d40a86ed1..52e8030bf 100644 --- a/security/authn/oidc/identityToken.ts +++ b/security/authn/oidc/identityToken.ts @@ -87,7 +87,13 @@ export async function verifyIdentityToken( try { payload = jwt.verify(token, key, { algorithms: ALLOWED_ALGORITHMS, - issuer, + // Both spellings, because `issuer` here is normalized (trailing slash stripped) while + // jsonwebtoken compares it byte-for-byte against the raw `iss`. Azure AD v1 emits + // `https://sts.windows.net//`, which would otherwise never authenticate — and the + // generic profile exists precisely to serve issuers like it with no provider code. This + // cannot widen trust: normalizeIssuer already maps the two forms to one value, which is + // what keys the JWKS cache, the discovery check, and the policy lookup. + issuer: [issuer, `${issuer}/`], audience: target.audience, clockTolerance: CLOCK_TOLERANCE_SECONDS, ...(options.clockTimestamp === undefined ? {} : { clockTimestamp: options.clockTimestamp }), diff --git a/security/authn/oidc/jwks.ts b/security/authn/oidc/jwks.ts index 0fb607822..7e4945902 100644 --- a/security/authn/oidc/jwks.ts +++ b/security/authn/oidc/jwks.ts @@ -42,8 +42,17 @@ const inFlightLoads = new Map>(); */ const unknownKidRefetchAt = new Map(); +/** + * Bumped by every clear. A fetch that was already in flight when the cache was cleared must not + * write its now-stale result back: clearing drops `inFlightLoads`, but the orphaned fetch still + * holds a reference and would repopulate the entry an operator just discarded — which for a clear + * issued precisely because the cached key is wrong means the re-read silently does nothing. + */ +let cacheGeneration = 0; + /** Drops all cached key sets. Exported for tests and for an operator forcing a re-read. */ export function clearJwksCache(): void { + cacheGeneration++; issuerKeyCache.clear(); inFlightLoads.clear(); unknownKidRefetchAt.clear(); @@ -150,6 +159,8 @@ function toSigningKey(jwk: any): KeyObject | undefined { } async function fetchIssuerKeys(issuer: string): Promise { + // Sampled before the first await, so any clear during the fetch is detectable at the end. + const generation = cacheGeneration; const jwksUri = await discoverJwksUri(issuer); const jwks = await fetchJson(jwksUri); if (!Array.isArray(jwks?.keys)) throw new ServerError(`JWKS at ${jwksUri} has no keys array`); @@ -162,7 +173,9 @@ async function fetchIssuerKeys(issuer: string): Promise { if (keys.size === 0) throw new ServerError(`JWKS at ${jwksUri} contains no usable signing keys`); const entry: IssuerKeys = { keys, fetchedAt: Date.now() }; - issuerKeyCache.set(issuer, entry); + // Still returned to the caller that asked for it — it is freshly fetched and valid — but only + // cached if no clear happened while this was in flight. + if (generation === cacheGeneration) issuerKeyCache.set(issuer, entry); logger.debug?.(`Loaded ${keys.size} signing key(s) for ${issuer}`); return entry; } diff --git a/security/authn/oidc/tokenExchange.ts b/security/authn/oidc/tokenExchange.ts index bd3583bb5..036bb194d 100644 --- a/security/authn/oidc/tokenExchange.ts +++ b/security/authn/oidc/tokenExchange.ts @@ -95,7 +95,10 @@ function auditExchange(req: any, username: string | undefined, status: string, d username, status, AUTH_AUDIT_TYPES.AUTHENTICATION, - baseRequest?.ip, + // Same precedence as every other auth event (security/auth.ts): behind a load balancer — + // which is every Fabric and cloud deployment — `ip` is the proxy, so an audit trail for the + // one unauthenticated credential-minting operation would record the proxy, not the runner. + baseRequest?.headers?.['x-forwarded-for'] ?? baseRequest?.ip, baseRequest?.method, baseRequest?.pathname ); @@ -105,6 +108,20 @@ function auditExchange(req: any, username: string | undefined, status: string, d else authEventLog.error?.(log); } +/** + * Auditing must never change the outcome it is recording. The success emit sits inside the + * exchange's try, so a throw there would be caught and re-reported as a FAILURE for a request that + * actually succeeded; on the failure path a throw would replace the original error with the audit's. + * Swallowing here fixes both, and keeps the two call sites free of defensive wrapping. + */ +function auditExchangeSafely(req: any, username: string | undefined, status: string, detail: Record) { + try { + auditExchange(req, username, status, detail); + } catch (error) { + logger.warn?.(`Failed to emit OIDC exchange audit record: ${(error as Error).message}`); + } +} + /** Verifies each distinct audience at most once, so N policies sharing one cost one verification. */ async function findMatchingPolicy( token: string, @@ -129,8 +146,18 @@ async function findMatchingPolicy( } if (!claimsByAudience.has(policy.audience)) { - // verifyIdentityToken logs its own reason for refusing. - const verified = await verifyIdentityToken(token, { issuer, audience: policy.audience }).catch(() => undefined); + // verifyIdentityToken logs its own reason via rejectToken, but everything jwks.ts throws + // (unknown kid, unreachable host, non-2xx, oversized body, bad JSON, no usable keys) is + // thrown directly and would otherwise vanish here — the request then falls through to + // "no trust policy matched", which is actively misleading when a policy DID match and the + // failure was operational. Log every swallowed reason so an issuer outage is + // distinguishable from a misconfigured policy in the log. + const verified = await verifyIdentityToken(token, { issuer, audience: policy.audience }).catch((error) => { + logger.warn?.( + `Verification failed for policy '${policy.id}' (audience '${policy.audience}'): ${(error as Error).message}` + ); + return undefined; + }); claimsByAudience.set(policy.audience, verified); } const claims = claimsByAudience.get(policy.audience); @@ -144,13 +171,27 @@ async function findMatchingPolicy( } /** - * Identifies a token for replay purposes. A hash of the token itself rather than `issuer|jti`: not - * every issuer emits `jti` (Azure uses `uti`, others omit it), and a replayed token is byte-identical - * by definition, so this is strictly more general with the same semantics. Hashed rather than stored - * so the table never holds a credential. + * Identifies a token for replay purposes: a hash of its SIGNED INPUT (`header.payload`), not of the + * whole token string. Keyed this way rather than on `issuer|jti` because not every issuer emits + * `jti` (Azure uses `uti`, others omit it), so this is strictly more general. + * + * Hashing the raw token instead would be bypassable, because the signature segment is covered by + * nothing. Base64url decoding ignores the surplus low bits of the final character, so for an RS256 + * signature there are 16 distinct spellings of that segment that decode to identical bytes — every + * one of them passes `jwt.verify`, and every one hashes differently. One leaked identity token would + * buy 16 operation tokens. ES* issuers add a second, independent vector, since `s → n−s` is a + * different valid signature over the same input. + * + * The signed input has neither problem: it is exactly what the issuer asserted and what the + * signature covers, so every variant spelling and every malleable re-signing of one assertion + * collapses to one fingerprint. Hashed rather than stored so the table never holds a credential. */ function tokenFingerprint(token: string): string { - return createHash('sha256').update(token).digest('base64url'); + // Not `slice(0, 2).join('.')` on a split of the whole token: lastIndexOf keeps this O(1) in the + // signature length and cannot silently succeed on a malformed token with too few segments — + // verifyIdentityToken has already established this is a well-formed three-segment JWT. + const signedInput = token.slice(0, token.lastIndexOf('.')); + return createHash('sha256').update(signedInput).digest('base64url'); } /** @@ -202,7 +243,7 @@ export async function exchangeOidcToken(req: any) { const profile = profileForIssuer(issuer); audit.provider = profile.name; - const policies = (await loadEnabledPolicies()).filter((policy) => policy.issuer === issuer); + const policies = await loadEnabledPolicies(issuer); if (policies.length === 0) rejectToken(`no enabled trust policy for issuer ${issuer}`); const matched = await findMatchingPolicy(req.token, issuer, policies, profile); @@ -234,7 +275,7 @@ export async function exchangeOidcToken(req: any) { if (policy.operations?.length) audit.scoped_operations = policy.operations; logger.info?.(`OIDC exchange: policy '${policy.id}' authenticated '${user.username}' for ${audit.principal}`); - auditExchange(req, username, AUTH_AUDIT_STATUS.SUCCESS, audit); + auditExchangeSafely(req, username, AUTH_AUDIT_STATUS.SUCCESS, audit); return { operation_token: operationToken, @@ -243,7 +284,7 @@ export async function exchangeOidcToken(req: any) { policy: policy.id, }; } catch (error) { - auditExchange(req, username, AUTH_AUDIT_STATUS.FAILURE, audit); + auditExchangeSafely(req, username, AUTH_AUDIT_STATUS.FAILURE, audit); throw error; } } diff --git a/security/authn/oidc/trustPolicyOperations.ts b/security/authn/oidc/trustPolicyOperations.ts index 2e43c38b5..cdab0fa23 100644 --- a/security/authn/oidc/trustPolicyOperations.ts +++ b/security/authn/oidc/trustPolicyOperations.ts @@ -67,6 +67,46 @@ function assertOperationsAreKnown(operations: string[]): void { } } +/** + * The audience is matched by exact string equality at verification time (`jwt.verify`), and the CLI + * asks its provider for a token whose audience is `normalizeTarget(target)` — which supplies `:9925` + * when no port was written, and a trailing slash. So `audience=https://my-instance.example.com`, the + * natural reading of "the instance URL the CI client targets", stores a policy that can never match + * anything; the exchange then refuses with the same opaque message it gives every other failure, + * leaving the operator nothing to look at, in CI. Caught here instead, where the reader is the + * administrator who wrote it — the same bargain assertOperationsAreKnown makes. + * + * Rejected rather than rewritten: silently canonicalizing a value whose whole job is to be compared + * byte-for-byte is worse than refusing it, and it would also have to run after the profile's + * shared-audience guard to avoid disarming it. Stating the requirement keeps this independent of + * normalizeTarget's exact spelling rules, which live in the CLI; a unit test pins the two together. + * + * Only http(s) URLs are shaped this way. An issuer-specific audience (an `api://` identifier, a bare + * GUID) is not ours to constrain, so anything that is not an http(s) URL passes untouched. + */ +function assertAudienceIsCanonical(audience: string): void { + let url: URL; + try { + url = new URL(audience); + } catch { + return; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return; + + // `url.port` is empty for a default port, so consult the raw authority — with any userinfo + // removed first, since that colon is not a port. + const authority = audience.slice(audience.indexOf('://') + 3).split(/[/?#]/)[0]; + const hasExplicitPort = !!url.port || authority.slice(authority.lastIndexOf('@') + 1).includes(':'); + if (hasExplicitPort && audience.endsWith('/')) return; + + throw new ClientError( + `'audience' must be the exact string the CI client requests its token for, which includes an ` + + `explicit port and a trailing slash (for example 'https://my-instance.example.com:9925/'). ` + + `'${audience}' is missing ${!hasExplicitPort ? 'a port' : 'a trailing slash'}, and the match is ` + + `byte-for-byte, so the policy could never authenticate.` + ); +} + function trustTable() { const table = (databases as any).system?.[OIDC_TRUST_TABLE]; if (!table) { @@ -103,19 +143,35 @@ function toRecord(row: any): OidcTrustPolicy & Record { * sorting by id keeps both the listing and the exchange's match order deterministic rather than * dependent on an index's iteration order. */ -async function readPolicies(includeDisabled: boolean): Promise { +async function readPolicies(includeDisabled: boolean, issuer?: string): Promise { const table = trustTable(); const policies: OidcTrustPolicy[] = []; for await (const row of table.search([])) { if (!includeDisabled && row.enabled === false) continue; + if (issuer !== undefined && row.issuer !== issuer) continue; policies.push(toRecord(row)); } return policies.sort((a, b) => String(a.id).localeCompare(String(b.id))); } -/** The policies the exchange will consider. */ -export function loadEnabledPolicies(): Promise { - return readPolicies(false); +/** + * The policies the exchange will consider, narrowed to one issuer. + * + * Filtered inside the scan rather than by the caller: the exchange is unauthenticated and reaches + * here having done nothing but `jwt.decode`, so an anonymous caller presenting any syntactically + * valid JWT would otherwise drive a `toRecord()` allocation for every stored policy before anything + * has been verified. The scan itself stays — the table is administrator-sized, and an index here + * would buy little (see readPolicies) — but non-matching rows now cost nothing beyond the compare. + * + * A missing table yields no policies rather than throwing. On a node where the upgrade directive has + * not run, trustTable()'s descriptive ClientError would surface to an anonymous caller as a 400 with + * a body unlike the uniform 401 every other rejection returns, which both leaks node state and + * breaks the single-message property. The SU-only add/list/drop handlers still get that error, where + * it is the useful thing to say. + */ +export function loadEnabledPolicies(issuer: string): Promise { + if (!(databases as any).system?.[OIDC_TRUST_TABLE]) return Promise.resolve([]); + return readPolicies(false, issuer); } /** @@ -147,7 +203,11 @@ export async function addOidcTrust(req: any) { // Issuer-specific rules live in the provider profile; an unregistered issuer gets the strict // generic profile rather than a permissive default. Each throws ClientError naming the problem. const profile = profileForIssuer(issuer); + // On the RAW audience, before the canonical-form check below. GitHub's shared-audience guard + // matches `https://github.com/`, and a canonicalized form of that would no longer match + // the regex — checking the other way round would silently disarm it. profile.assertAudienceIsSpecific(req.audience); + assertAudienceIsCanonical(req.audience); if (req.operations) assertOperationsAreKnown(req.operations); validateClaimConstraintShape(req.claims); profile.assertPolicyIsSpecific(req.claims); diff --git a/security/authn/oidc/types.ts b/security/authn/oidc/types.ts index 966acdb1f..9fbaed89e 100644 --- a/security/authn/oidc/types.ts +++ b/security/authn/oidc/types.ts @@ -8,7 +8,8 @@ export type ClaimConstraint = string | string[]; /** * A stored trust policy. Matching one lets an external CI run act as `user` without holding any * Harper credential, so `claims` is validated at write time rather than trusted as written — see - * validateTrustPolicyClaims for the structural requirements, and addOidcTrust for the rest. + * validateClaimConstraintShape (claims.ts) and the profile's assertPolicyIsSpecific for the + * structural requirements, and addOidcTrust for the rest. */ export interface OidcTrustPolicy { id: string; @@ -36,5 +37,5 @@ export interface OidcTrustPolicy { description?: string; } -/** A verified token's payload, plus the entries normalizeTokenClaims derives. */ +/** A verified token's payload, plus the entries the profile's normalizeClaims derives. */ export type TokenClaims = Record; diff --git a/unitTests/security/authn/oidc/tokenExchange.test.js b/unitTests/security/authn/oidc/tokenExchange.test.js index 56368e998..a7226d97d 100644 --- a/unitTests/security/authn/oidc/tokenExchange.test.js +++ b/unitTests/security/authn/oidc/tokenExchange.test.js @@ -227,6 +227,33 @@ describe('exchangeOidcToken', () => { await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token })); }); + // The replay key must be the SIGNED INPUT, not the token string. Base64url decoding ignores the + // surplus low bits of the final character, so an RS256 signature has 16 distinct spellings that + // decode to identical bytes — every one passes jwt.verify. Keying on the whole token gave each a + // different fingerprint, so one leaked identity token bought 16 operation tokens. + it('refuses a re-spelled signature that decodes to the same bytes', async () => { + await addPolicy(); + const token = identityToken(); + await exchangeOidcToken({ operation: 'exchange_oidc_token', token }); + + const [header, payload, signature] = token.split('.'); + const signatureBytes = Buffer.from(signature, 'base64url'); + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const twins = []; + for (const character of alphabet) { + const candidate = signature.slice(0, -1) + character; + if (candidate === signature) continue; + if (Buffer.from(candidate, 'base64url').equals(signatureBytes)) twins.push(candidate); + } + assert.ok(twins.length > 0, 'expected at least one surplus-bit spelling of the signature'); + + for (const twin of twins) { + await assertRejected( + exchangeOidcToken({ operation: 'exchange_oidc_token', token: `${header}.${payload}.${twin}` }) + ); + } + }); + it('records the spent token against the policy, expiring with the token', async () => { await addPolicy(); const token = identityToken(); diff --git a/unitTests/security/authn/oidc/trustPolicyOperations.test.js b/unitTests/security/authn/oidc/trustPolicyOperations.test.js index 5615a2f8c..354d1828b 100644 --- a/unitTests/security/authn/oidc/trustPolicyOperations.test.js +++ b/unitTests/security/authn/oidc/trustPolicyOperations.test.js @@ -154,6 +154,43 @@ describe('oidc trustPolicyOperations', () => { } }); + // The audience is compared byte-for-byte at verification time against what the CLI requested, + // and the CLI requests normalizeTarget(target) — port and trailing slash included. A policy + // written the natural way would therefore never match, and the exchange says only "rejected", + // so the operator learns nothing. It has to fail here, at write time, instead. + it('rejects an audience missing the port or the trailing slash', async () => { + for (const audience of [ + 'https://my-instance.harperdb.io', + 'https://my-instance.harperdb.io/', + 'https://my-instance.harperdb.io:9925', + ]) { + await assert.rejects( + () => addOidcTrust(su('add_oidc_trust', validPolicy({ audience }))), + /byte-for-byte/, + `expected '${audience}' to be refused as non-canonical` + ); + } + }); + + // Pins this check to the CLI's actual normalization rather than to my reading of it: whatever + // normalizeTarget produces must be accepted here, or the two drift and the feature breaks in + // the one place nobody is watching. + it('accepts every shape normalizeTarget produces', async () => { + const { normalizeTarget } = require('#src/bin/cliCredentials'); + for (const raw of [ + 'my-instance.harperdb.io', + 'https://my-instance.harperdb.io', + 'https://my-instance.harperdb.io:443', + 'http://localhost:9925', + ]) { + const audience = normalizeTarget(raw); + await assert.doesNotReject( + () => addOidcTrust(su('add_oidc_trust', validPolicy({ audience }))), + `normalizeTarget('${raw}') => '${audience}' must be a writable audience` + ); + } + }); + it('rejects a non-https issuer', async () => { await assert.rejects( () => addOidcTrust(su('add_oidc_trust', validPolicy({ issuer: 'http://token.actions.githubusercontent.com' }))), diff --git a/unitTests/security/authn/oidc/verifyIdentityToken.test.js b/unitTests/security/authn/oidc/verifyIdentityToken.test.js index 15d173896..c6be8a973 100644 --- a/unitTests/security/authn/oidc/verifyIdentityToken.test.js +++ b/unitTests/security/authn/oidc/verifyIdentityToken.test.js @@ -142,6 +142,20 @@ describe('verifyIdentityToken', () => { // The ceiling is measured from `iat`, so treating `iat` as optional means a token that omits it // is not bounded at all — the check would skip exactly the token that most needs it. OIDC // requires the claim, so demanding it costs nothing. + // The expectation handed to jwt.verify is the NORMALIZED issuer, but the comparison against `iss` + // is byte-for-byte — so an issuer that publishes a trailing slash (Azure AD v1: + // `https://sts.windows.net//`) could never authenticate. Both spellings already collapse + // to one value everywhere else (cache key, discovery check, policy lookup), so accepting both + // here is consistency, not widened trust. + it('accepts a token whose iss carries a trailing slash', async () => { + const claims = await verify(sign(claimsFor({ iss: `${ISSUER}/` }))); + assert.strictEqual(claims.repository, 'HarperFast/my-app'); + }); + + it('still rejects a token from a different issuer', async () => { + await assertRejected(sign(claimsFor({ iss: 'https://evil.example.com' }))); + }); + it('rejects a token with no iat claim', async () => { const { iat: _iat, ...withoutIat } = claimsFor({ exp: NOW_SECONDS + 86_400 }); await assertRejected(sign(withoutIat)); From 562fa7592f838bf4161fa407d775651460574c8f Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 12:45:49 -0400 Subject: [PATCH 21/37] fix(security): bootstrap the replay table and carry the job's real operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two remaining Barber AI findings. The replay table is now a properly bootstrapped system table: a systemSchema.json stub, a SYSTEM_TABLE_NAMES entry, and a 5.3.0 directive branch, matching the three touchpoints DESIGN.md requires. The previous comment cited hdb_certificate_cache as precedent for going lazy-only, which was wrong — that table is systemSchema-declared AND lazily extended, and the lazy half exists only because an expiresAt TTL is not expressible through CreateTableObject (confirmed: no systemSchema entry declares one and CreateTableObject has no support for it). This matters beyond tidiness: a table auto-provisioned by replication is created without the `audit` flag its schema declares, and auditing IS the replication feed, so a node that first learned of this table from a peer could end up with a non-replicating copy — losing exactly the cross-node replay protection the table exists to provide. That also forced the `??` short-circuit out of getTokenUseTable. With a bootstrap stub always present, short-circuiting on existence would have meant the TTL was never applied on any node — records accumulating in a system table forever. table() now runs once per process regardless, layering the TTL on top, as hdb_certificate_cache does. The exchange tests move their seam to the table factory accordingly, since seeding databases.system no longer intercepts it. Separately, an export job re-entered the SQL permission check with its own `operation: 'sql'`, because the checked parse is stashed on the top-level request while export dispatches the nested search_operation. A token scoped to `export_local` was therefore denied by its own job — fail-closed, so a broken feature rather than a hole, but the natural scope for an export-only CI identity did not work. serverUtilities now stamps the real operation onto the nested request and checkASTPermissions prefers it. This is also the only authorization that runs in the job worker, which never invokes the outer gate. Also guards the expanded-scope memo with an instanceof Set check: it rides on hdb_user, a job persists that user into hdb_job.request, and msgpackr returns a Set as a plain Array — .has() would then throw out of the auth gate as a 500 rather than a clean denial. Co-Authored-By: Claude Opus 4.8 --- json/systemSchema.json | 11 ++++ security/authn/oidc/tokenExchange.ts | 54 +++++++++++-------- server/serverHelpers/serverUtilities.ts | 9 ++++ sqlTranslator/index.ts | 6 ++- .../security/authn/oidc/tokenExchange.test.js | 19 ++++++- upgrade/directives/5-3-0.ts | 49 +++++++++++++++-- utility/hdbTerms.ts | 1 + utility/operation_authorization.ts | 11 ++-- 8 files changed, 129 insertions(+), 31 deletions(-) diff --git a/json/systemSchema.json b/json/systemSchema.json index a3161f100..bd29bb501 100644 --- a/json/systemSchema.json +++ b/json/systemSchema.json @@ -513,5 +513,16 @@ "attribute": "__updatedtime__" } ] + }, + "hdb_oidc_token_use": { + "hash_attribute": "id", + "name": "hdb_oidc_token_use", + "schema": "system", + "audit": true, + "attributes": [ + { + "attribute": "id" + } + ] } } diff --git a/security/authn/oidc/tokenExchange.ts b/security/authn/oidc/tokenExchange.ts index 036bb194d..fcd18266a 100644 --- a/security/authn/oidc/tokenExchange.ts +++ b/security/authn/oidc/tokenExchange.ts @@ -10,7 +10,7 @@ import jwt from 'jsonwebtoken'; import Joi from 'joi'; import { createHash } from 'node:crypto'; -import { databases, table, type Table } from '../../../resources/databases.ts'; +import { table, type Table } from '../../../resources/databases.ts'; import { ClientError } from '../../../utility/errors/hdbError.ts'; import { validateBySchema } from '../../../validation/validationWrapper.ts'; import { loggerWithTag } from '../../../utility/logging/logger.ts'; @@ -55,28 +55,38 @@ const TOKEN_USE_TABLE = 'hdb_oidc_token_use'; * * table() also registers into `databases.system`, so the lookup finds it after the first call. */ +let tokenUseTable: any; + +/** Drops the memo below so a test can install a different table. Not used in production. */ +export function clearTokenUseTableCache(): void { + tokenUseTable = undefined; +} + function getTokenUseTable(): any { - return ( - (databases as any).system?.[TOKEN_USE_TABLE] ?? - table
({ - table: TOKEN_USE_TABLE, - database: 'system', - // `audit: true` explicitly, NOT the default (which follows logging.auditLog). Auditing is the - // replication change feed (databases.ts: "auditing must be enabled for replication"), and - // replay records MUST replicate so a token spent on one node cannot be re-spent on another - // inside its window. Without this, an operator with logging.auditLog:false would silently lose - // cross-node replay protection. Its sibling hdb_oidc_trust is audited for the same reason. - // (Lazy table() rather than the systemSchema+directive bootstrap because the expiresAt TTL - // below is not expressible through CreateTableObject; this matches hdb_certificate_cache.) - audit: true, - attributes: [ - { name: 'id', isPrimaryKey: true }, - { name: 'policy_id' }, - { name: 'used_at' }, - { name: 'expiresAt', expiresAt: true, indexed: true }, - ], - }) - ); + // Memoized per process, but `table()` is NOT skipped merely because the table already exists. + // The systemSchema stub and the 5.3.0 directive declare only the primary key — the `expiresAt` + // TTL is not expressible through CreateTableObject — so this call is what actually installs the + // TTL, layered on top of the bootstrap. Short-circuiting on existence would mean a table that + // arrived any other way (bootstrap, replication, restore, a manual create_table) never gets it, + // and replay records would then accumulate in a system table forever. hdb_certificate_cache + // does the same two-step for the same reason. + tokenUseTable ??= table
({ + table: TOKEN_USE_TABLE, + database: 'system', + // `audit: true` explicitly, NOT the default (which follows logging.auditLog). Auditing is the + // replication change feed (databases.ts: "auditing must be enabled for replication"), and + // replay records MUST replicate so a token spent on one node cannot be re-spent on another + // inside its window. Without this, an operator with logging.auditLog:false would silently lose + // cross-node replay protection. Its sibling hdb_oidc_trust is audited for the same reason. + audit: true, + attributes: [ + { name: 'id', isPrimaryKey: true }, + { name: 'policy_id' }, + { name: 'used_at' }, + { name: 'expiresAt', expiresAt: true, indexed: true }, + ], + }); + return tokenUseTable; } /** diff --git a/server/serverHelpers/serverUtilities.ts b/server/serverHelpers/serverUtilities.ts index b7f0e869e..5fd43c635 100644 --- a/server/serverHelpers/serverUtilities.ts +++ b/server/serverHelpers/serverUtilities.ts @@ -249,6 +249,15 @@ export function chooseOperation(json: OperationRequestBody, bypassAuth = false) const sqlStatement = json.operation === 'sql' ? json.sql : json.search_operation.sql; const parsedSqlObject = sql.convertSQLToAST(sqlStatement); json.parsed_sql_object = parsedSqlObject; + // Carry the real top-level operation onto the nested search for the token-scope check. + // The checked parse above is stashed on the TOP-LEVEL json, but export.ts dispatches the + // job with `search_operation` alone — so evaluateSQL finds no parsed_sql_object, re-parses + // with permissions_checked false, and processAST re-runs the check against a request whose + // `operation` is now 'sql'. A token scoped to `export_local` would be denied by its own + // job. (The job worker deserializes this body from hdb_job and never runs the outer gate + // at all, so this string is the only thing that survives to tell the inner check what the + // caller actually invoked.) Fails closed either way — a broken feature, not a hole. + if (json.search_operation) json.search_operation.api_operation = json.operation; if (!bypassAuth) { const astPermCheck = sql.checkASTPermissions(json, parsedSqlObject); if (astPermCheck) { diff --git a/sqlTranslator/index.ts b/sqlTranslator/index.ts index fdf25440b..554f676b9 100644 --- a/sqlTranslator/index.ts +++ b/sqlTranslator/index.ts @@ -81,8 +81,10 @@ export function checkASTPermissions(jsonMessage: any, parsedSqlObject: any) { jsonMessage.hdb_user, parsedSqlObject.variant, // The top-level API operation for the token-scope check: `sql` for a direct SQL call, but - // `export_local`/`export_to_s3` when the SQL rides inside a job's search_operation. - jsonMessage.operation + // `export_local`/`export_to_s3` when the SQL rides inside a job's search_operation. On that + // job path the request reaching here IS the search_operation, whose own `operation` is + // 'sql', so serverUtilities stamps the real one as `api_operation` — prefer it when present. + jsonMessage.api_operation ?? jsonMessage.operation ); parsedSqlObject.permissions_checked = true; } catch (e) { diff --git a/unitTests/security/authn/oidc/tokenExchange.test.js b/unitTests/security/authn/oidc/tokenExchange.test.js index a7226d97d..b7e2137a2 100644 --- a/unitTests/security/authn/oidc/tokenExchange.test.js +++ b/unitTests/security/authn/oidc/tokenExchange.test.js @@ -11,7 +11,7 @@ testUtils.preTestPrep(); const jwt = require('jsonwebtoken'); const { generateKeyPairSync, createPublicKey } = require('node:crypto'); -const { exchangeOidcToken } = require('#src/security/authn/oidc/tokenExchange'); +const { exchangeOidcToken, clearTokenUseTableCache } = require('#src/security/authn/oidc/tokenExchange'); const { addOidcTrust } = require('#src/security/authn/oidc/trustPolicyOperations'); const { clearJwksCache } = require('#src/security/authn/oidc/jwks'); const { validateOperationToken, clearJWTRSAKeysCache, decodeJWT } = require('#src/security/tokenAuthentication'); @@ -57,6 +57,19 @@ function installMockTable(name, primaryKey) { return { mock, restore: () => (databases.system[name] = prior) }; } +/** + * The replay table is reached through an unconditional `table()` call, not a lookup — it has to be, + * because the systemSchema bootstrap declares only the primary key and `table()` is what layers the + * expiresAt TTL on top. So intercept the factory rather than seeding `databases.system`, which that + * call would otherwise sail straight past. + */ +function installMockTableFactory(name, mock) { + const databasesModule = require('#src/resources/databases'); + const realTable = databasesModule.table; + databasesModule.table = (definition) => (definition?.table === name ? mock : realTable(definition)); + return () => (databasesModule.table = realTable); +} + function seedUsers() { const users = new Map(); users.set('ci-deploy', { @@ -79,6 +92,7 @@ describe('exchangeOidcToken', () => { let removeJwtKeys; let trustTable; let useTable; + let restoreTableFactory; let realFetch; let tokenCounter = 0; @@ -105,6 +119,8 @@ describe('exchangeOidcToken', () => { clearJwksCache(); trustTable = installMockTable(TRUST_TABLE, 'id'); useTable = installMockTable(TOKEN_USE_TABLE, 'id'); + restoreTableFactory = installMockTableFactory(TOKEN_USE_TABLE, useTable.mock); + clearTokenUseTableCache(); // or every test after the first reuses the first one's mock await seedUsers(); realFetch = globalThis.fetch; // Serves discovery for any issuer asked about, and one shared key set, so a second issuer needs @@ -128,6 +144,7 @@ describe('exchangeOidcToken', () => { globalThis.fetch = realFetch; trustTable.restore(); useTable.restore(); + restoreTableFactory(); }); after(() => { diff --git a/upgrade/directives/5-3-0.ts b/upgrade/directives/5-3-0.ts index e38646862..ea6fc73cb 100644 --- a/upgrade/directives/5-3-0.ts +++ b/upgrade/directives/5-3-0.ts @@ -1,6 +1,7 @@ 'use strict'; -// 5.3.0 — introduces system.hdb_oidc_trust for OIDC trusted publishing (#2171). +// 5.3.0 — introduces system.hdb_oidc_trust and system.hdb_oidc_token_use for OIDC trusted +// publishing (#2171). // // Fresh installs get the table from json/systemSchema.json; this covers existing installs. The // version must match the release that ships the dependent operations — see 5-1-0.ts for what @@ -14,6 +15,48 @@ import bridge from '../../dataLayer/harperBridge/harperBridge.ts'; import hdbLogger from '../../utility/logging/harper_logger.ts'; const OIDC_TRUST_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TRUST_TABLE_NAME; +const OIDC_TOKEN_USE_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TOKEN_USE_TABLE_NAME; + +/** + * The replay table gets the same bootstrap as the trust table, for a reason specific to it: replay + * records must replicate, and a node that has never completed an exchange would otherwise not have + * the table at all when a peer's record arrives. Provisioning it at install/upgrade time removes + * that question entirely rather than depending on how replication treats a row for an unknown table. + * + * Only the primary key is declared here. The `expiresAt` TTL attribute is not expressible through + * CreateTableObject, so tokenExchange.ts layers it on with an unconditional `table()` call — the + * same two-step hdb_certificate_cache uses. + */ +async function createHdbOidcTokenUseIfMissing() { + if (databases.system?.[OIDC_TOKEN_USE_TABLE]) { + hdbLogger.info(`system.${OIDC_TOKEN_USE_TABLE} already exists; skipping create.`); + return; + } + + hdbLogger.info(`Creating system.${OIDC_TOKEN_USE_TABLE} table for OIDC replay protection.`); + + const CreateTableObject = + require('../../dataLayer/CreateTableObject').default || require('../../dataLayer/CreateTableObject'); + const schema = (systemSchema as any)[OIDC_TOKEN_USE_TABLE]; + if (!schema) { + throw new Error(`systemSchema.${OIDC_TOKEN_USE_TABLE} is missing; cannot run 5.3.0 directive.`); + } + + initPaths.initSystemSchemaPaths(terms.SYSTEM_SCHEMA_NAME, OIDC_TOKEN_USE_TABLE); + const createTable = new (CreateTableObject as any)( + terms.SYSTEM_SCHEMA_NAME, + OIDC_TOKEN_USE_TABLE, + schema.hash_attribute + ); + createTable.attributes = schema.attributes; + const primaryKeyAttribute = createTable.attributes.find(({ attribute }) => attribute === schema.hash_attribute); + if (primaryKeyAttribute) primaryKeyAttribute.isPrimaryKey = true; + // Must match `"audit": true` in systemSchema.json — and here auditing is not cosmetic: it is the + // replication change feed, without which replay protection would be node-local. + createTable.audit = true; + + await bridge.createTable(OIDC_TOKEN_USE_TABLE, createTable); +} async function createHdbOidcTrustIfMissing() { if (databases.system?.[OIDC_TRUST_TABLE]) { @@ -68,9 +111,9 @@ async function patchHdbOidcTrustIsHashAttribute() { const directive530 = { version: '5.3.0', - description: 'create system.hdb_oidc_trust table for OIDC trusted publishing', + description: 'create system.hdb_oidc_trust and system.hdb_oidc_token_use tables for OIDC trusted publishing', sync_functions: [] as Array<() => unknown>, - async_functions: [createHdbOidcTrustIfMissing] as Array<() => Promise>, + async_functions: [createHdbOidcTrustIfMissing, createHdbOidcTokenUseIfMissing] as Array<() => Promise>, }; export default [directive530]; diff --git a/utility/hdbTerms.ts b/utility/hdbTerms.ts index 6914813a6..dc911eb9f 100644 --- a/utility/hdbTerms.ts +++ b/utility/hdbTerms.ts @@ -212,6 +212,7 @@ export const SYSTEM_TABLE_NAMES = { AGENT_SESSION_TABLE_NAME: 'hdb_agent_session', SECRET_TABLE_NAME: 'hdb_secret', OIDC_TRUST_TABLE_NAME: 'hdb_oidc_trust', + OIDC_TOKEN_USE_TABLE_NAME: 'hdb_oidc_token_use', } as const; /** Hash attribute for the system info table */ diff --git a/utility/operation_authorization.ts b/utility/operation_authorization.ts index 7104e84fb..1c09895e8 100644 --- a/utility/operation_authorization.ts +++ b/utility/operation_authorization.ts @@ -478,9 +478,14 @@ function tokenScopeDenial(userObject: any, apiOperation: string) { const tokenOperations = userObject?.tokenOperations; if (tokenOperations == null) return undefined; - const scopedOps = - userObject._expandedTokenOperations ?? - (userObject._expandedTokenOperations = expandOperationsPerms(tokenOperations)); + // `instanceof Set`, not just presence: this memo rides on hdb_user, and a job persists that user + // into hdb_job.request, where msgpackr round-trips a Set back as a plain Array. Trusting the + // memo's presence would then call .has() on an Array and throw a TypeError out of the auth gate + // — still fail-closed, but as a 500 rather than a denial, and only inside a job worker. + let scopedOps = userObject._expandedTokenOperations; + if (!(scopedOps instanceof Set)) { + scopedOps = userObject._expandedTokenOperations = expandOperationsPerms(tokenOperations); + } if (scopedOps.has(apiOperation)) return undefined; harperLogger.info(`Operation '${apiOperation}' is outside the scope of the presented token`); From 0286fb7fc65c3757665958e1f3bb39ee6c9eb7da Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 12:47:51 -0400 Subject: [PATCH 22/37] fix(security): reject a non-boolean `enabled`, and pin exact claim matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `add_oidc_trust` with `"enabled": "false"` stored an ENABLED policy. Joi's boolean converts by default, but validateBySchema keeps only `result.error` and discards the converted value, so the string survived to `req.enabled !== false` — true for `"false"` — and `readPolicies` filters on the same comparison, so nothing downstream caught it either. An operator disabling a policy this way got no error and a policy that kept minting tokens. `Joi.boolean().strict()` now rejects it outright: a revocation control has to fail closed. Claim matching was already exact, but nothing pinned it — replacing `accepted.includes(actual)` with a `startsWith` left every OIDC test green, which makes it the one escalation-critical invariant a future refactor could relax silently. Prefix matching on `repository`/`sub` is the classic trusted- publishing escalation, since `HarperFast/my-app-evil` is a name anyone can register. Added negative cases in both argument orders, and confirmed they fail against exactly that mutation before keeping them. Co-Authored-By: Claude Opus 4.8 --- security/authn/oidc/claims.ts | 4 +++ security/authn/oidc/trustPolicyOperations.ts | 7 ++++- unitTests/security/authn/oidc/claims.test.js | 31 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/security/authn/oidc/claims.ts b/security/authn/oidc/claims.ts index 3df50d513..dc7e8a53b 100644 --- a/security/authn/oidc/claims.ts +++ b/security/authn/oidc/claims.ts @@ -34,6 +34,10 @@ export function matchTrustPolicyClaims( const actual = claimToString(claims[claimName]); if (actual === undefined || actual === '') return `token has no usable ${claimName} claim`; const accepted = Array.isArray(constraint) ? constraint : [constraint]; + // Exact membership, deliberately — never a prefix, wildcard, or regex. Relaxing this to + // something like `startsWith` is the classic trusted-publishing escalation: a policy pinning + // `HarperFast/my-app` would then also admit `HarperFast/my-app-evil`, a repository anyone can + // create. unitTests/security/authn/oidc/claims.test.js pins both argument orders. if (!accepted.includes(actual)) return `${claimName} does not match the policy`; } return undefined; diff --git a/security/authn/oidc/trustPolicyOperations.ts b/security/authn/oidc/trustPolicyOperations.ts index cdab0fa23..ed689bc2b 100644 --- a/security/authn/oidc/trustPolicyOperations.ts +++ b/security/authn/oidc/trustPolicyOperations.ts @@ -193,7 +193,12 @@ export async function addOidcTrust(req: any) { claims: Joi.object().min(1).required(), user: Joi.string().min(1).max(512).required(), operations: Joi.array().items(Joi.string().min(1)).min(1).max(100).unique(), - enabled: Joi.boolean(), + // `.strict()` — Joi coerces by default, but validateBySchema keeps only `result.error` + // and discards the converted value, so `"false"` would validate cleanly and then be + // stored as the string it arrived as. `req.enabled !== false` is true for that string, + // which silently leaves a policy an operator meant to disable still minting tokens. + // A revocation control has to fail closed, so reject the string outright. + enabled: Joi.boolean().strict(), description: Joi.string().allow('').max(1024), }).unknown(true) ) diff --git a/unitTests/security/authn/oidc/claims.test.js b/unitTests/security/authn/oidc/claims.test.js index e0779ef33..5cb96647b 100644 --- a/unitTests/security/authn/oidc/claims.test.js +++ b/unitTests/security/authn/oidc/claims.test.js @@ -34,6 +34,37 @@ describe('oidc claims', () => { assert.match(reason, /namespace/); }); + // Matching is exact membership, never a prefix. This is the classic trusted-publishing + // escalation: `HarperFast/my-app-evil` is a repository anyone can create, so a policy pinning + // `HarperFast/my-app` must not admit it. Both argument orders are pinned, because a + // `startsWith` introduced on either side would be a hole and only one order catches each. + it('does not match a repository that merely extends the pinned one', () => { + const reason = matchTrustPolicyClaims( + { repository: 'HarperFast/my-app-evil' }, + { repository: 'HarperFast/my-app' } + ); + assert.ok(reason, 'a longer repository name must not satisfy a shorter pin'); + assert.match(reason, /repository/); + }); + + it('does not match a pinned repository that merely extends the token claim', () => { + const reason = matchTrustPolicyClaims( + { repository: 'HarperFast/my-app' }, + { repository: 'HarperFast/my-app-evil' } + ); + assert.ok(reason, 'a shorter repository name must not satisfy a longer pin'); + }); + + // Same property on `sub`, the claim the generic profile requires a policy to pin. + it('does not match a sub that merely extends the pinned one', () => { + assert.ok( + matchTrustPolicyClaims( + { sub: 'system:serviceaccount:prod:deployer-admin' }, + { sub: 'system:serviceaccount:prod:deployer' } + ) + ); + }); + // The central fail-closed property: a constrained claim the token does not carry must deny, // so a policy cannot be silently weakened by an issuer that stops emitting a claim. it('rejects when a constrained claim is absent from the token', () => { From db6a5a97a7765899b46f1cc09cda1babd86fb011 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 12:51:20 -0400 Subject: [PATCH 23/37] fix(security): act on the SQL permission denial processAST was discarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processAST computed a permission denial and then threw it away. The guard read `permissionsCheck && permissionsCheck.length > 0`, but checkASTPermissions returns null or a PermissionResponseObject, which has no `length` — so the test evaluated `undefined > 0` and was always false, and the statement executed anyway. This only bites where processAST is the FIRST checker rather than the second. A direct SQL call arrives with permissions_checked already true, set by chooseOperation, whose own guard is a correct bare truthiness test. An export job is the case that does not: it re-parses from its nested search_operation, and in the job worker no outer gate runs at all — so the denial dropped here was the only one standing. Now a bare truthiness test, matching serverUtilities. The existing scope tests all asserted that a denial is COMPUTED; none asserted anyone acts on it, which is why a dead consumer went unnoticed. Added a case that drives processAST itself, confirmed to fail against the old guard. Co-Authored-By: Claude Opus 4.8 --- sqlTranslator/index.ts | 9 +++++++- .../security/tokenOperationScope.test.js | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/sqlTranslator/index.ts b/sqlTranslator/index.ts index 554f676b9..adb10ca31 100644 --- a/sqlTranslator/index.ts +++ b/sqlTranslator/index.ts @@ -141,7 +141,14 @@ export function processAST(jsonMessage: any, parsedSqlObject: any, callback: any // server/serverHelpers/serverHandlers.js and components/mcp/tools/operations.ts). if (!isOperationAuthorizationBypassed() && !parsedSqlObject.permissions_checked) { let permissionsCheck = checkASTPermissions(jsonMessage, parsedSqlObject); - if (permissionsCheck && permissionsCheck.length > 0) { + // Bare truthiness, matching serverUtilities.ts. `checkASTPermissions` returns null or a + // PermissionResponseObject, which has no `length` — so the previous `length > 0` test + // evaluated `undefined > 0` and was ALWAYS false, discarding a denial it had correctly + // computed. That only bites where processAST is the first checker rather than the second: + // the direct-SQL path arrives with permissions_checked already true from chooseOperation. + // An export job is exactly that case, and in the job worker this is the only authorization + // that runs at all — so a denial dropped here is a denial dropped entirely. + if (permissionsCheck) { return callback(UNAUTHORIZED_RESPONSE, permissionsCheck); } } diff --git a/unitTests/security/tokenOperationScope.test.js b/unitTests/security/tokenOperationScope.test.js index ee2c8275f..cd446cba6 100644 --- a/unitTests/security/tokenOperationScope.test.js +++ b/unitTests/security/tokenOperationScope.test.js @@ -226,6 +226,27 @@ describe('token-scoped narrowing on the SQL path', () => { assert.strictEqual(denial, null); }); + // Everything above asserts that a denial is COMPUTED. This asserts processAST acts on it — the + // guard there tested `permissionsCheck.length > 0`, and a PermissionResponseObject has no + // `length`, so `undefined > 0` silently discarded every denial. It only matters where processAST + // is the first checker rather than the second, which is precisely the job path: an export runs + // in a worker that never invokes the outer gate, so this was the only check standing. + it('processAST refuses to execute a statement whose scope check denied it', async () => { + const user = userWithScope({ super_user: true }, ['get_status']); + const jsonMessage = { operation: 'sql', sql: 'SELECT * FROM data.dog', hdb_user: user }; + const parsed = sql.convertSQLToAST(jsonMessage.sql); + + // Asserted outside the callback: processAST wraps its body in try/catch, so a failing + // assertion thrown in here would be swallowed and re-reported as a second callback. + const outcome = await new Promise((resolve) => { + sql.processAST(jsonMessage, parsed, (error, results) => resolve({ error, results })); + }); + + assert.strictEqual(outcome.error, 403, 'expected the denial to reach the callback as unauthorized'); + // The second argument on this path is the denial itself, not query results. + assert.ok(outcome.results?.unauthorized_access, 'expected the permission response, not a result set'); + }); + it('gates a nested-SQL export job on the export operation, not on `sql`', () => { // export_local carries its query as SQL, but the scope names the job, not `sql`. A token scoped // only to `sql` must not be able to start an export it was never granted. From 64879505321e590cd9fc793f733ee4a3de84f909 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 12:57:03 -0400 Subject: [PATCH 24/37] refactor: split the processAST guard fix out to its own PR (#2202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer pointed out the dead `permissionsCheck.length > 0` guard is pre-existing and affects all SQL authorization, not just this feature, and asked for it as its own change with its own coverage rather than bolted onto an auth PR. Agreed — it is now #2202, with tests that drive processAST directly and cover the allowed and already-checked paths too, so it cannot start denying statements that were always permitted. This PR does not depend on it. The outer gate in serverUtilities refuses an out-of-scope job operation and sqlWriteScopeDenial refuses write SQL, both through correct truthiness tests, so export_local + DELETE is already refused at the front door. Left a note at the call site pointing at #2202 so the next reader does not re-derive it. Co-Authored-By: Claude Opus 4.8 --- sqlTranslator/index.ts | 14 ++++++------- .../security/tokenOperationScope.test.js | 21 ------------------- 2 files changed, 6 insertions(+), 29 deletions(-) diff --git a/sqlTranslator/index.ts b/sqlTranslator/index.ts index adb10ca31..69f7b0dc1 100644 --- a/sqlTranslator/index.ts +++ b/sqlTranslator/index.ts @@ -141,14 +141,12 @@ export function processAST(jsonMessage: any, parsedSqlObject: any, callback: any // server/serverHelpers/serverHandlers.js and components/mcp/tools/operations.ts). if (!isOperationAuthorizationBypassed() && !parsedSqlObject.permissions_checked) { let permissionsCheck = checkASTPermissions(jsonMessage, parsedSqlObject); - // Bare truthiness, matching serverUtilities.ts. `checkASTPermissions` returns null or a - // PermissionResponseObject, which has no `length` — so the previous `length > 0` test - // evaluated `undefined > 0` and was ALWAYS false, discarding a denial it had correctly - // computed. That only bites where processAST is the first checker rather than the second: - // the direct-SQL path arrives with permissions_checked already true from chooseOperation. - // An export job is exactly that case, and in the job worker this is the only authorization - // that runs at all — so a denial dropped here is a denial dropped entirely. - if (permissionsCheck) { + // NOTE: this guard is dead — PermissionResponseObject has no `length`, so `undefined > 0` + // discards a denial that was computed correctly. Pre-existing and not specific to this + // feature, so it is fixed separately in #2202 rather than bundled here. This PR does not + // depend on it: the outer gate in serverUtilities refuses an out-of-scope job operation, + // and sqlWriteScopeDenial refuses write SQL, both through correct truthiness tests. + if (permissionsCheck && permissionsCheck.length > 0) { return callback(UNAUTHORIZED_RESPONSE, permissionsCheck); } } diff --git a/unitTests/security/tokenOperationScope.test.js b/unitTests/security/tokenOperationScope.test.js index cd446cba6..ee2c8275f 100644 --- a/unitTests/security/tokenOperationScope.test.js +++ b/unitTests/security/tokenOperationScope.test.js @@ -226,27 +226,6 @@ describe('token-scoped narrowing on the SQL path', () => { assert.strictEqual(denial, null); }); - // Everything above asserts that a denial is COMPUTED. This asserts processAST acts on it — the - // guard there tested `permissionsCheck.length > 0`, and a PermissionResponseObject has no - // `length`, so `undefined > 0` silently discarded every denial. It only matters where processAST - // is the first checker rather than the second, which is precisely the job path: an export runs - // in a worker that never invokes the outer gate, so this was the only check standing. - it('processAST refuses to execute a statement whose scope check denied it', async () => { - const user = userWithScope({ super_user: true }, ['get_status']); - const jsonMessage = { operation: 'sql', sql: 'SELECT * FROM data.dog', hdb_user: user }; - const parsed = sql.convertSQLToAST(jsonMessage.sql); - - // Asserted outside the callback: processAST wraps its body in try/catch, so a failing - // assertion thrown in here would be swallowed and re-reported as a second callback. - const outcome = await new Promise((resolve) => { - sql.processAST(jsonMessage, parsed, (error, results) => resolve({ error, results })); - }); - - assert.strictEqual(outcome.error, 403, 'expected the denial to reach the callback as unauthorized'); - // The second argument on this path is the denial itself, not query results. - assert.ok(outcome.results?.unauthorized_access, 'expected the permission response, not a result set'); - }); - it('gates a nested-SQL export job on the export operation, not on `sql`', () => { // export_local carries its query as SQL, but the scope names the job, not `sql`. A token scoped // only to `sql` must not be able to start an export it was never granted. From 602cdc641b14d35277ed77e23dd1d85971b0819e Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 13:05:30 -0400 Subject: [PATCH 25/37] fix(security): never read the SQL scope's operation from the request body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checkASTPermissions` resolved the token-scope operation as `jsonMessage.api_operation ?? jsonMessage.operation`. On the direct-SQL path `jsonMessage` IS the client's request body, and that check is the ONLY gate there — the `sql` branch of chooseOperation is mutually exclusive with its verifyPerms call. So a caller could send `{operation: 'sql', sql: '...', api_operation: ''}` and run arbitrary SQL under it. Reproduced against this branch; the regression test was confirmed to fail before the fix. I introduced this in 11f2280c6, carrying a job's real operation to the nested check on a request property. That is reverted. The operation now comes from an explicit argument or the dispatched `json.operation`, never from a field on the message — chooseOperation passes the operation it already resolved. Stripping `api_operation` at the ingress points was the first fix I tried, and it is the wrong shape: it leaves the check trusting a body property and makes safety depend on every current and future entry point remembering to strip. The property is gone instead. The trade is that a job's SQL is checked as `sql` rather than as `export_local`. That changes no outcome today, because the branch in processAST that would act on the denial is dead — PermissionResponseObject has no `length`, so its guard never fires (#2202). When #2202 makes that branch live it needs a carrier for the job's operation that a client cannot forge; a request property is not one, however carefully it is stripped. Recorded at both sites so the next reader does not re-derive it. Co-Authored-By: Claude Opus 4.8 --- server/serverHelpers/serverUtilities.ts | 19 ++++++++--------- sqlTranslator/index.ts | 21 ++++++++++++++----- .../security/tokenOperationScope.test.js | 15 +++++++++++++ 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/server/serverHelpers/serverUtilities.ts b/server/serverHelpers/serverUtilities.ts index 5fd43c635..c98d82b74 100644 --- a/server/serverHelpers/serverUtilities.ts +++ b/server/serverHelpers/serverUtilities.ts @@ -249,17 +249,16 @@ export function chooseOperation(json: OperationRequestBody, bypassAuth = false) const sqlStatement = json.operation === 'sql' ? json.sql : json.search_operation.sql; const parsedSqlObject = sql.convertSQLToAST(sqlStatement); json.parsed_sql_object = parsedSqlObject; - // Carry the real top-level operation onto the nested search for the token-scope check. - // The checked parse above is stashed on the TOP-LEVEL json, but export.ts dispatches the - // job with `search_operation` alone — so evaluateSQL finds no parsed_sql_object, re-parses - // with permissions_checked false, and processAST re-runs the check against a request whose - // `operation` is now 'sql'. A token scoped to `export_local` would be denied by its own - // job. (The job worker deserializes this body from hdb_job and never runs the outer gate - // at all, so this string is the only thing that survives to tell the inner check what the - // caller actually invoked.) Fails closed either way — a broken feature, not a hole. - if (json.search_operation) json.search_operation.api_operation = json.operation; + // NOTE: a job's SQL is re-parsed from its nested search_operation when the job runs, so the + // check there sees `sql` rather than the job's own operation. Carrying the real one on the + // request was tried and reverted: on the direct-SQL path the request is the client's body, + // so any property consulted by that check is forgeable, and it is the only gate on that + // path. This changes no outcome today — the branch that would act on the denial is dead + // (#2202) — but #2202 needs an unforgeable carrier before making it live. if (!bypassAuth) { - const astPermCheck = sql.checkASTPermissions(json, parsedSqlObject); + // `json.operation` explicitly — the operation this dispatch already resolved, not a + // field read back off the request body. + const astPermCheck = sql.checkASTPermissions(json, parsedSqlObject, json.operation); if (astPermCheck) { operationLog.error(`${HTTP_STATUS_CODES.FORBIDDEN} from operation ${json.operation}`); operationLog.warn(`User '${json.hdb_user?.username}' is not permitted to ${json.operation}`); diff --git a/sqlTranslator/index.ts b/sqlTranslator/index.ts index 69f7b0dc1..31efd1257 100644 --- a/sqlTranslator/index.ts +++ b/sqlTranslator/index.ts @@ -73,7 +73,7 @@ export function evaluateSQL(jsonMessage: any, callback: any) { * @param parsedSqlObject - The Parsed SQL statement specified in the inbound json message, of type ParsedSQLObject. * @returns {Array} - False if permissions check denys the statement. */ -export function checkASTPermissions(jsonMessage: any, parsedSqlObject: any) { +export function checkASTPermissions(jsonMessage: any, parsedSqlObject: any, apiOperation?: string) { let verifyResult = undefined; try { verifyResult = opAuth.verifyPermsAST( @@ -81,10 +81,21 @@ export function checkASTPermissions(jsonMessage: any, parsedSqlObject: any) { jsonMessage.hdb_user, parsedSqlObject.variant, // The top-level API operation for the token-scope check: `sql` for a direct SQL call, but - // `export_local`/`export_to_s3` when the SQL rides inside a job's search_operation. On that - // job path the request reaching here IS the search_operation, whose own `operation` is - // 'sql', so serverUtilities stamps the real one as `api_operation` — prefer it when present. - jsonMessage.api_operation ?? jsonMessage.operation + // `export_local`/`export_to_s3` when the SQL rides inside a job's search_operation. + // + // From the explicit argument or the dispatched operation — deliberately NEVER a field read + // off `jsonMessage`. On the direct-SQL path that object IS the client's request body, and + // this check is the ONLY gate there, since the `sql` branch of chooseOperation is mutually + // exclusive with its verifyPerms call. Any body field consulted here is therefore a way for + // a caller to name whichever operation their token scope happens to allow and run arbitrary + // SQL under it. + // + // That rules out carrying the job's real operation on the request too. A job re-parses from + // its nested search_operation, so this sees `sql` rather than `export_local` there — which + // today changes nothing, because the branch in processAST that would act on the denial is + // dead (see #2202). When #2202 makes it live, the job's operation needs a carrier that a + // client cannot forge; a request property is not one, however carefully it is stripped. + apiOperation ?? jsonMessage.operation ); parsedSqlObject.permissions_checked = true; } catch (e) { diff --git a/unitTests/security/tokenOperationScope.test.js b/unitTests/security/tokenOperationScope.test.js index ee2c8275f..499fa25a3 100644 --- a/unitTests/security/tokenOperationScope.test.js +++ b/unitTests/security/tokenOperationScope.test.js @@ -158,6 +158,21 @@ describe('token-scoped narrowing on the SQL path', () => { assert.ok(denial, 'a token scoped away from sql must not be able to run SQL'); }); + // `api_operation` is how a job tells this check which top-level operation it was invoked as. For a + // direct `sql` call the request IS the client's body, so if that field were read from the body a + // caller could name any operation their scope happens to allow and run arbitrary SQL under it — + // and on this path checkASTPermissions is the ONLY gate, since the sql branch of chooseOperation + // is mutually exclusive with verifyPerms. It must never be honored from an inbound request. + it('ignores a caller-supplied api_operation on a direct SQL call', () => { + const user = userWithScope({ super_user: true }, ['deploy_component']); + const parsed = sql.convertSQLToAST('SELECT * FROM data.dog'); + const denial = sql.checkASTPermissions( + { operation: 'sql', sql: 'SELECT * FROM data.dog', api_operation: 'deploy_component', hdb_user: user }, + parsed + ); + assert.ok(denial, 'a body-supplied api_operation must not satisfy the scope gate'); + }); + // The dangerous case: verifyPermsAST returns null unconditionally for a super_user. it('denies a super_user whose scope excludes SQL', () => { const denial = checkSql('DELETE FROM data.dog', userWithScope({ super_user: true }, ['deploy_component'])); From 42315f8a48a217c0af52b69d637b0f361228c469 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 13:07:14 -0400 Subject: [PATCH 26/37] fix(upgrade): patch is_hash_attribute on the replay table; pin the enabled strictness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review follow-ups. hdb_oidc_token_use is created through the same CreateTableObject + bridge.createTable path as hdb_oidc_trust, hdb_deployment, and hdb_secret, but skipped the is_hash_attribute __dbis__ patch all three of those apply. If the reason they need it holds — harperdb@4.x derives the LMDB DBI open flags from that field, and its absence opens the DBI with DUPSORT and throws MDB_INCOMPATIBLE — then a 5.3.0 install that later downgrades hits it here too. The helper is now parameterized by table name and applied on both branches for both tables, so the asymmetry is gone rather than undocumented. The `.strict()` fix on `enabled` had no test: dropping it back to a plain Joi.boolean() left the whole OIDC suite green, which is a poor state for a revocation control whose failure direction is "stops revoking". Added cases for the coercible values and for a genuinely disabled policy, and confirmed they fail against the un-strict schema. Co-Authored-By: Claude Opus 4.8 --- .../authn/oidc/trustPolicyOperations.test.js | 19 +++++++++++++++++ upgrade/directives/5-3-0.ts | 21 +++++++++++-------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/unitTests/security/authn/oidc/trustPolicyOperations.test.js b/unitTests/security/authn/oidc/trustPolicyOperations.test.js index 354d1828b..82f53e486 100644 --- a/unitTests/security/authn/oidc/trustPolicyOperations.test.js +++ b/unitTests/security/authn/oidc/trustPolicyOperations.test.js @@ -158,6 +158,25 @@ describe('oidc trustPolicyOperations', () => { // and the CLI requests normalizeTarget(target) — port and trailing slash included. A policy // written the natural way would therefore never match, and the exchange says only "rejected", // so the operator learns nothing. It has to fail here, at write time, instead. + // `enabled` is a revocation control, so it has to fail closed. Joi coerces by default and + // validateBySchema keeps only `result.error`, discarding the converted value — so without + // `.strict()` the string "false" validated cleanly, survived as a string, and `!== false` read + // it as enabled. An operator disabling a policy this way would get no error and a policy that + // kept minting tokens. + it('rejects a non-boolean enabled rather than coercing it', async () => { + for (const enabled of ['false', 'true', 0, 1]) { + await assert.rejects( + () => addOidcTrust(su('add_oidc_trust', validPolicy({ id: 'coerce-me', enabled }))), + `expected ${JSON.stringify(enabled)} to be refused rather than coerced` + ); + } + }); + + it('stores a genuinely disabled policy as disabled', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy({ id: 'off', enabled: false }))); + assert.strictEqual(installed.mock.rows.get('off').enabled, false); + }); + it('rejects an audience missing the port or the trailing slash', async () => { for (const audience of [ 'https://my-instance.harperdb.io', diff --git a/upgrade/directives/5-3-0.ts b/upgrade/directives/5-3-0.ts index ea6fc73cb..9d92066fe 100644 --- a/upgrade/directives/5-3-0.ts +++ b/upgrade/directives/5-3-0.ts @@ -30,6 +30,7 @@ const OIDC_TOKEN_USE_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TOKEN_USE_TABLE_NAME; async function createHdbOidcTokenUseIfMissing() { if (databases.system?.[OIDC_TOKEN_USE_TABLE]) { hdbLogger.info(`system.${OIDC_TOKEN_USE_TABLE} already exists; skipping create.`); + await patchIsHashAttribute(OIDC_TOKEN_USE_TABLE); return; } @@ -56,12 +57,13 @@ async function createHdbOidcTokenUseIfMissing() { createTable.audit = true; await bridge.createTable(OIDC_TOKEN_USE_TABLE, createTable); + await patchIsHashAttribute(OIDC_TOKEN_USE_TABLE); } async function createHdbOidcTrustIfMissing() { if (databases.system?.[OIDC_TRUST_TABLE]) { hdbLogger.info(`system.${OIDC_TRUST_TABLE} already exists; skipping create.`); - await patchHdbOidcTrustIsHashAttribute(); + await patchIsHashAttribute(OIDC_TRUST_TABLE); return; } @@ -83,29 +85,30 @@ async function createHdbOidcTrustIfMissing() { createTable.audit = true; await bridge.createTable(OIDC_TRUST_TABLE, createTable); - await patchHdbOidcTrustIsHashAttribute(); + await patchIsHashAttribute(OIDC_TRUST_TABLE); } /** - * Ensure the hdb_oidc_trust __dbis__ primary-key entry carries is_hash_attribute: true. + * Ensure a table's __dbis__ primary-key entry carries is_hash_attribute: true. * * harperdb@4.x reads is_hash_attribute from __dbis__ to derive the LMDB DBI open flags; without it * the DBI is opened with the opposite flags (DUPSORT set) and LMDB throws MDB_INCOMPATIBLE, breaking - * downgrade — the same guard 5-1-0.ts and 5-2-0.ts apply to their tables. Idempotent: no-op when the - * field is already set. + * downgrade — the same guard 5-1-0.ts and 5-2-0.ts apply to their tables. Both tables created here + * go through the identical CreateTableObject + bridge.createTable path, so both need it; exempting + * one would be a silent asymmetry rather than a decision. Idempotent: no-op when already set. */ -async function patchHdbOidcTrustIsHashAttribute() { - const systemTable = (databases as any).system?.[OIDC_TRUST_TABLE]; +async function patchIsHashAttribute(tableName: string) { + const systemTable = (databases as any).system?.[tableName]; if (!systemTable?.dbisDB) return; - const dbiName = `${OIDC_TRUST_TABLE}/`; + const dbiName = `${tableName}/`; const primaryAttr = systemTable.dbisDB.getSync(dbiName); if (!primaryAttr || primaryAttr.is_hash_attribute) return; // already correct primaryAttr.is_hash_attribute = true; await systemTable.dbisDB.put(dbiName, primaryAttr); hdbLogger.info( - `Patched system.${OIDC_TRUST_TABLE} __dbis__ entry with is_hash_attribute=true for harperdb@4.x downgrade compatibility.` + `Patched system.${tableName} __dbis__ entry with is_hash_attribute=true for harperdb@4.x downgrade compatibility.` ); } From ce1f99962ed12dec6033816bf09c4257952a6cf1 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 13:08:51 -0400 Subject: [PATCH 27/37] docs(upgrade): record the TTL-on-first-use limitation for the replay table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expiresAt TTL is installed by the table() call on the exchange path, so a node that never performs an exchange has the table from this directive but never registers the TTL locally — replicated replay rows land there and are never evicted. Documented rather than fixed: hdb_certificate_cache has the identical shape, so the real fix is installing the TTL at system-table setup for every lazily-extended system table, not special-casing this one. Co-Authored-By: Claude Opus 4.8 --- upgrade/directives/5-3-0.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/upgrade/directives/5-3-0.ts b/upgrade/directives/5-3-0.ts index 9d92066fe..86033401b 100644 --- a/upgrade/directives/5-3-0.ts +++ b/upgrade/directives/5-3-0.ts @@ -26,6 +26,16 @@ const OIDC_TOKEN_USE_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TOKEN_USE_TABLE_NAME; * Only the primary key is declared here. The `expiresAt` TTL attribute is not expressible through * CreateTableObject, so tokenExchange.ts layers it on with an unconditional `table()` call — the * same two-step hdb_certificate_cache uses. + * + * KNOWN LIMITATION, shared with hdb_certificate_cache rather than specific to this table: that + * second step runs on the exchange path, so a node that never performs an exchange — the passive + * members of a cluster, which is most of them when CI always targets one endpoint — has the table + * from this directive but never registers `expiresAt` locally. Replicated replay rows still land + * there (that is what `audit: true` buys) but get no eviction, so the table grows on exactly the + * nodes doing no work. Rows are small and bounded by exchange volume, so this is slow rather than + * dangerous. Fixing it properly means installing the TTL at system-table setup instead of on first + * use, which is a change to how every lazily-extended system table is provisioned — worth doing + * once, for both tables, rather than special-casing this one. */ async function createHdbOidcTokenUseIfMissing() { if (databases.system?.[OIDC_TOKEN_USE_TABLE]) { From 14bcc6fc5bc8833ecef97efbb4339b400c1b23f3 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 13:11:55 -0400 Subject: [PATCH 28/37] test: assert the export-job scope gate enforces, not just computes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverting the processAST guard to #2202 removed the one test that asserted this invariant is ENFORCED rather than merely computed, and the safety argument now rests entirely on chooseOperation's front-door gate — which had no enforcement test of its own. The rest of the scope suite only checks that verifyPermsAST returns a denial object, which is exactly how a dead consumer goes unnoticed. Three cases on the real dispatch path: an export job carrying nested write SQL outside the scope throws 403, an export whose own operation is outside the scope throws 403, and an in-scope export still runs — the last so this cannot pass by refusing everything. Confirmed they fail when the front-door gate is given the same dead-guard shape (`astPermCheck && astPermCheck.length > 0`) that made the inner branch a no-op. Co-Authored-By: Claude Opus 4.8 --- .../serverHelpers/serverUtilities.test.js | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/unitTests/server/serverHelpers/serverUtilities.test.js b/unitTests/server/serverHelpers/serverUtilities.test.js index 70bf34a7d..5a6a4b400 100644 --- a/unitTests/server/serverHelpers/serverUtilities.test.js +++ b/unitTests/server/serverHelpers/serverUtilities.test.js @@ -73,6 +73,44 @@ describe('Test serverUtilities.js module ', () => { request.operation = 'add_user'; assert.doesNotThrow(() => serverUtilities.chooseOperation(request, true)); }); + + // The token scope's "can only ever subtract" invariant, asserted where it is ENFORCED rather + // than where it is computed (#2171/#2174). This is the front door for an export job carrying + // nested SQL: the job is gated here, before it is ever queued, and the check inside the job's + // own SQL execution is a dead branch (#2202). So this gate is the whole safety argument, and + // the rest of the scope suite only asserts that a denial object comes back — not that anyone + // throws on it. + function exportJobRequest(tokenOperations, sql) { + const request = testUtils.deepClone(TEST_JSON_SUPER_USER); + request.operation = 'export_local'; + request.search_operation = { operation: 'sql', sql }; + request.hdb_user.tokenOperations = tokenOperations; + return request; + } + + it('throws 403 for an export job whose nested write SQL is outside the token scope', function () { + // Scoped to the export itself but not to `delete`: a write statement additionally requires + // its matching data operation, which is what keeps `read_only` from admitting a DELETE. + assert.throws( + () => serverUtilities.chooseOperation(exportJobRequest(['export_local'], 'DELETE FROM data.dog')), + (error) => { + assert.strictEqual(error.statusCode ?? error.http_code, 403, 'expected a forbidden status'); + return true; + }, + 'an export job must not smuggle write SQL past the scope gate' + ); + }); + + it('throws 403 for an export job when the export operation itself is outside the scope', function () { + assert.throws(() => serverUtilities.chooseOperation(exportJobRequest(['get_status'], 'SELECT * FROM data.dog'))); + }); + + // The other direction, so this cannot pass by refusing everything: an in-scope export runs. + it('admits an export job whose nested SQL is inside the token scope', function () { + assert.doesNotThrow(() => + serverUtilities.chooseOperation(exportJobRequest(['export_local'], 'SELECT * FROM data.dog')) + ); + }); }); describe('registered operation authorization envelope', function () { From ced27a5e44b5a7d2ea5b342fbd8e067e5c757a1a Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 14:04:08 -0400 Subject: [PATCH 29/37] test: make the #2202 ordering constraint fail loudly instead of living in a comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interaction between this PR and #2202 was documented only in prose, and the two can merge in either order. Removing the forgeable operation carrier leaves checkASTPermissions falling back to jsonMessage.operation, which at the processAST call site is the nested search_operation's own `sql` — so once #2202 makes that branch live, an export_local-scoped token 403s on its own export job. Added a tripwire that drives evaluateSQL with the exact shape export.ts:363 dispatches and asserts an in-scope export is not refused by the permission gate. It passes today and was confirmed to fail with #2202's one-line change applied on top, so whichever PR lands second turns CI red rather than shipping a silently broken feature. The comment on it says what to do when it fires — supply the job's real operation through a carrier a client cannot set, rather than relaxing the scope check. Preferred this over making apiOperation a required parameter: that turns the missing carrier into a compile error the next author satisfies by passing jsonMessage.operation, which is the wrong value and compiles clean. Co-Authored-By: Claude Opus 4.8 --- .../security/tokenOperationScope.test.js | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/unitTests/security/tokenOperationScope.test.js b/unitTests/security/tokenOperationScope.test.js index 499fa25a3..4848f2e8f 100644 --- a/unitTests/security/tokenOperationScope.test.js +++ b/unitTests/security/tokenOperationScope.test.js @@ -241,6 +241,44 @@ describe('token-scoped narrowing on the SQL path', () => { assert.strictEqual(denial, null); }); + // TRIPWIRE for the #2202 split — this test exists to go red, not to describe desired behavior. + // + // An export job runs by re-parsing its nested search_operation, so the check inside job execution + // sees `operation: 'sql'` rather than `export_local`. Carrying the real operation on the request + // was reverted here because that object is client-supplied on the direct-SQL path, making any + // property it consults forgeable. That costs nothing TODAY only because the branch in processAST + // that would act on the denial is dead (its guard tests `.length` on an object that has none). + // + // #2202 makes that branch live. The moment it does, this admits-an-in-scope-export assertion + // fails, because the job's own SQL would be judged as `sql` against a scope naming only + // `export_local`. The two PRs can merge in either order, so whichever lands second turns CI red + // here instead of silently shipping an export-scoped token that 403s on its own export. + // + // If you are reading this because it just went red: the fix is not to relax the scope check. It is + // to give the job's real operation a carrier a client cannot set, then assert it here. + it('admits an in-scope export job through the path export.ts actually dispatches', async () => { + const outcome = await new Promise((resolve) => { + sql.evaluateSQL( + { + // Exactly what export.ts hands the SQL handler: the nested search_operation, with + // hdb_user attached and no parsed_sql_object, so it re-parses and re-checks. + operation: 'sql', + sql: 'SELECT * FROM data.dog', + hdb_user: userWithScope({ super_user: true }, ['export_local']), + }, + (error) => resolve({ error }) + ); + }); + + // Not "no error" — the table does not exist in this unit context, so it may fail downstream. + // The assertion is specifically that it was not refused by the permission gate. + assert.notStrictEqual( + outcome.error, + 403, + 'an export_local-scoped token must not be denied by its own export job (see #2202)' + ); + }); + it('gates a nested-SQL export job on the export operation, not on `sql`', () => { // export_local carries its query as SQL, but the scope names the job, not `sql`. A token scoped // only to `sql` must not be able to start an export it was never granted. From 10256f42d961f5f1a21c448b65d2bc50d2c1091f Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 14:16:00 -0400 Subject: [PATCH 30/37] test: decouple the #2202 tripwire from the status literal it watches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tripwire compared against 403, which is UNAUTHORIZED_RESPONSE in the very file it exists to watch — so changing that constant would leave it green while the refusal it guards against still happened. A tripwire must not depend on a constant its own target owns. Now asserted by shape: the permission path is the only one that calls back with a bare numeric status, while every other failure forwards an Error. evaluateSQL drops the second callback argument on error, so the denial object never reaches the test and the number is the whole signal — which also rules out asserting on the PermissionResponseObject shape directly. Verified across four states: passes today, fails with #2202's guard applied, still fails with #2202 applied AND the status changed to 401 (the case the old assertion missed), and passes again restored. Co-Authored-By: Claude Opus 4.8 --- .../security/tokenOperationScope.test.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/unitTests/security/tokenOperationScope.test.js b/unitTests/security/tokenOperationScope.test.js index 4848f2e8f..723ec5512 100644 --- a/unitTests/security/tokenOperationScope.test.js +++ b/unitTests/security/tokenOperationScope.test.js @@ -270,12 +270,19 @@ describe('token-scoped narrowing on the SQL path', () => { ); }); - // Not "no error" — the table does not exist in this unit context, so it may fail downstream. - // The assertion is specifically that it was not refused by the permission gate. - assert.notStrictEqual( - outcome.error, - 403, - 'an export_local-scoped token must not be denied by its own export job (see #2202)' + // Not "no error" — the table does not exist in this unit context, so it fails downstream. The + // assertion is specifically that it was not refused by the PERMISSION gate, identified by + // shape rather than by value: that path is the only one that calls back with a bare numeric + // status (`UNAUTHORIZED_RESPONSE`), while every other failure forwards an Error. evaluateSQL + // drops the second callback argument on error, so the denial object itself never arrives here + // — the number is the whole signal. + // + // Deliberately not `!== 403`: that literal lives in the file this test watches, so changing it + // would leave the tripwire green while the refusal it exists to catch still happened. A + // tripwire must not depend on a constant its own target owns. + assert.ok( + typeof outcome.error !== 'number', + `an export_local-scoped token must not be denied by its own export job (see #2202); got status ${outcome.error}` ); }); From 9c322ed75584d958952ba30c341302a004e6aaf2 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 17:17:54 -0400 Subject: [PATCH 31/37] fix(security): refuse malformed stored policies; let exchange_oidc_token keep its token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from kriszyp's review. The exchange-time recheck stopped at audience/claim specificity, so a row that reached the table another way — replication from an older node, a restored backup, a direct system-table write — could still fail OPEN in two shapes: operations: 'deploy_component' a scalar, not an array. hasOperationScope tests Array.isArray, so the scope was silently dropped and the token minted UNSCOPED, carrying the policy user's entire role. A malformed narrowing must never widen. enabled: 'false' a string. `row.enabled !== false` is true for it, so a policy an operator disabled kept minting tokens. Both are now refused rather than normalized, by running the SAME validators the add path uses (validateOperations, validateClaimConstraintShape) against the raw row before toRecord touches it — normalizing first is exactly what hid them. Two implementations of "is this row valid" is how a write path and a read path drift apart, so they share one. Regression cases write each shape straight to the store, with a control proving a well-formed direct write still authenticates. Separately, `token` is stripped from every CLI request body as transport-only, on the stated grounds that no operation takes a top-level `token`. This feature broke that premise: exchange_oidc_token's identity token IS its request, so the generic CLI path sent it without the field it requires and the issuer-agnostic operation was unusable there even though direct HTTP worked. The strip is now keyed on the operation rather than dropped — the mistyped-`setup` case it guards is real — with tests for both directions. Co-Authored-By: Claude Opus 4.8 --- bin/cliOperations.ts | 26 ++++++--- security/authn/oidc/trustPolicyOperations.ts | 54 ++++++++++++++++++- unitTests/bin/cliOperations.test.js | 36 +++++++++++++ .../security/authn/oidc/tokenExchange.test.js | 49 +++++++++++++++++ 4 files changed, 157 insertions(+), 8 deletions(-) diff --git a/bin/cliOperations.ts b/bin/cliOperations.ts index 9663706e7..438d00694 100644 --- a/bin/cliOperations.ts +++ b/bin/cliOperations.ts @@ -56,11 +56,11 @@ const TRANSPORT_ONLY_FIELDS = new Set([ 'by_ref', 'ref', 'credential', - // `deploy setup=true`'s token, read off the parsed request by deploySetup and sealed locally. No - // operation takes a *top-level* `token`, so keeping it out of every body costs nothing and means a - // mistyped `setup` — which parses as a bare word and falls through to a real deploy — can't carry a - // PAT to the server. Distinct from `credentials[].token`, which is nested inside a field that IS - // sent (ingestCredentials seals it server-side) and is only kept out of the operations log. + // `deploy setup=true`'s token, read off the parsed request by deploySetup and sealed locally. + // Stripping it means a mistyped `setup` — which parses as a bare word and falls through to a real + // deploy — can't carry a PAT to the server. Distinct from `credentials[].token`, which is nested + // inside a field that IS sent (ingestCredentials seals it server-side) and is only kept out of the + // operations log. See OPERATIONS_TAKING_A_TOKEN for the one operation this must not apply to. 'token', ]); @@ -180,10 +180,24 @@ async function* wrapPackagingStream(stream: Readable, projectPath: string): Asyn // Build the JSON operation-field set from `req`, dropping the CLI's internal (`_`-prefixed) // and transport-only fields so neither the CLI internals nor credentials leak into the // request body. Shared by the multipart and legacy-JSON deploy body builders. +/** + * Operations whose own request body has a top-level `token`, which must therefore survive the + * transport-only stripping above. + * + * `exchange_oidc_token` (#2171) is one: the identity token IS the request. The blanket strip was + * written when no operation took a top-level `token`, and left this one reaching the server without + * the field it requires — so the issuer-agnostic path was unusable through the generic CLI even + * though direct HTTP worked. Keyed on the operation rather than dropping the strip, because the + * mistyped-`setup` case it guards against is real. + */ +const OPERATIONS_TAKING_A_TOKEN = new Set(['exchange_oidc_token']); + function operationFields(req: any): any { + const keepsToken = OPERATIONS_TAKING_A_TOKEN.has(req?.operation); const fields: any = {}; for (const [key, value] of Object.entries(req)) { - if (key.startsWith('_') || TRANSPORT_ONLY_FIELDS.has(key)) continue; + if (key.startsWith('_')) continue; + if (TRANSPORT_ONLY_FIELDS.has(key) && !(key === 'token' && keepsToken)) continue; fields[key] = value; } return fields; diff --git a/security/authn/oidc/trustPolicyOperations.ts b/security/authn/oidc/trustPolicyOperations.ts index ed689bc2b..3d4fc7d9c 100644 --- a/security/authn/oidc/trustPolicyOperations.ts +++ b/security/authn/oidc/trustPolicyOperations.ts @@ -15,6 +15,7 @@ import { databases } from '../../../resources/databases.ts'; import * as terms from '../../../utility/hdbTerms.ts'; import { ClientError, hdbErrors } from '../../../utility/errors/hdbError.ts'; import { validateBySchema } from '../../../validation/validationWrapper.ts'; +import { loggerWithTag } from '../../../utility/logging/logger.ts'; import { getUsersWithRolesCache } from '../../user.ts'; import { validateClaimConstraintShape } from './claims.ts'; import { normalizeIssuer } from './jwks.ts'; @@ -24,6 +25,8 @@ import type { OidcTrustPolicy } from './types.ts'; const { HTTP_STATUS_CODES } = hdbErrors; const OIDC_TRUST_TABLE = terms.SYSTEM_TABLE_NAMES.OIDC_TRUST_TABLE_NAME; +// Same tag as tokenExchange: an ignored row and the exchange that ignored it belong in one stream. +const logger = loggerWithTag('oidc-trust'); const POLICY_ID = Joi.string() .min(1) @@ -143,17 +146,64 @@ function toRecord(row: any): OidcTrustPolicy & Record { * sorting by id keeps both the listing and the exchange's match order deterministic rather than * dependent on an index's iteration order. */ -async function readPolicies(includeDisabled: boolean, issuer?: string): Promise { +async function readPolicies( + includeDisabled: boolean, + issuer?: string, + forExchange = false +): Promise { const table = trustTable(); const policies: OidcTrustPolicy[] = []; for await (const row of table.search([])) { if (!includeDisabled && row.enabled === false) continue; if (issuer !== undefined && row.issuer !== issuer) continue; + if (forExchange) { + // Validate the RAW row, before toRecord normalizes it — normalizing is precisely what would + // hide the two shapes that fail open (see storedPolicyProblem). + const problem = storedPolicyProblem(row); + if (problem) { + logger.warn?.(`Ignoring trust policy '${row.id}': ${problem}`); + continue; + } + } policies.push(toRecord(row)); } return policies.sort((a, b) => String(a.id).localeCompare(String(b.id))); } +/** + * Why a stored row must not be honored, or undefined if it is usable. + * + * add_oidc_trust enforces all of this at write time, but a row can reach the table another way — + * replication from a node predating a check, a restored backup, a direct write to the system table — + * so the exchange re-runs the same validators rather than trusting that every row was validated when + * written. Two shapes in particular fail OPEN if merely normalized instead of rejected: + * + * operations: 'deploy_component' — a scalar rather than an array. hasOperationScope tests + * Array.isArray, so the scope is silently dropped and the token is minted UNSCOPED, carrying the + * policy user's entire role. A malformed narrowing must never widen. + * enabled: 'false' — a string. `row.enabled !== false` is true for it, so a policy + * an operator meant to disable keeps minting tokens. + * + * Shares validateOperations and validateClaimConstraintShape with the add path deliberately: two + * implementations of "is this row valid" is how the write path and the read path drift apart. + */ +export function storedPolicyProblem(row: any): string | undefined { + if (row.enabled !== undefined && typeof row.enabled !== 'boolean') { + return `'enabled' is a ${typeof row.enabled}, not a boolean`; + } + if (row.operations != null) { + if (!Array.isArray(row.operations)) return `'operations' is a ${typeof row.operations}, not an array`; + const invalidOperation = validateOperations(row.operations); + if (invalidOperation != null) return `operations contains '${invalidOperation}', which is not a Harper operation`; + } + try { + validateClaimConstraintShape(row.claims); + } catch (error) { + return (error as Error).message; + } + return undefined; +} + /** * The policies the exchange will consider, narrowed to one issuer. * @@ -171,7 +221,7 @@ async function readPolicies(includeDisabled: boolean, issuer?: string): Promise< */ export function loadEnabledPolicies(issuer: string): Promise { if (!(databases as any).system?.[OIDC_TRUST_TABLE]) return Promise.resolve([]); - return readPolicies(false, issuer); + return readPolicies(false, issuer, true); } /** diff --git a/unitTests/bin/cliOperations.test.js b/unitTests/bin/cliOperations.test.js index 589b152c9..599fef288 100644 --- a/unitTests/bin/cliOperations.test.js +++ b/unitTests/bin/cliOperations.test.js @@ -52,6 +52,42 @@ describe('cliOperations', () => { fs.ensureDirSync(testDir); }); + // `token` is stripped from every request body as transport-only, so that a mistyped `deploy + // setup=...` cannot carry a PAT to the server. exchange_oidc_token is the one operation whose body + // legitimately has a top-level `token` — the identity token IS the request — so the strip must not + // apply to it, or the issuer-agnostic path is unusable through the generic CLI (#2171). + it('sends the token for exchange_oidc_token instead of stripping it', async () => { + let sentBody; + commonUtilsModule.httpRequest = async (_options, body) => { + sentBody = body; + return { statusCode: 200, body: JSON.stringify({ operation_token: 'minted' }) }; + }; + + await cliOperationsModule.cliOperations( + { operation: 'exchange_oidc_token', token: 'the-identity-token', target: 'example.com' }, + true + ); + + assert.strictEqual(sentBody.operation, 'exchange_oidc_token'); + assert.strictEqual(sentBody.token, 'the-identity-token', 'the identity token must reach the server'); + }); + + // The other direction: the strip still protects every other operation. + it('still strips a top-level token from other operations', async () => { + let sentBody; + commonUtilsModule.httpRequest = async (_options, body) => { + sentBody = body; + return { statusCode: 200, body: JSON.stringify({}) }; + }; + + await cliOperationsModule.cliOperations( + { operation: 'test', token: 'a-pat-that-must-not-leave', target: 'example.com' }, + true + ); + + assert.strictEqual(sentBody.token, undefined, 'a PAT must not reach the server on other operations'); + }); + it('Leg 1: should use non-expired token directly', async () => { const target = 'https://example.com:9925/'; saveCredentials(target, { diff --git a/unitTests/security/authn/oidc/tokenExchange.test.js b/unitTests/security/authn/oidc/tokenExchange.test.js index b7e2137a2..4a951590c 100644 --- a/unitTests/security/authn/oidc/tokenExchange.test.js +++ b/unitTests/security/authn/oidc/tokenExchange.test.js @@ -216,6 +216,55 @@ describe('exchangeOidcToken', () => { // A stored row that add_oidc_trust would reject (repository pinned, but no workflow/ref gate) — as // could arrive via replication from an older node or a restored backup — must be ignored at // exchange, not honored. Seeded directly into the store to bypass add_oidc_trust's validation. + // A row can reach the table without passing add_oidc_trust — replication from a node predating a + // check, a restored backup, a direct system-table write. These two shapes fail OPEN if the exchange + // merely normalizes them instead of refusing the row, which is why validation is shared rather than + // duplicated. Written straight to the store, deliberately bypassing the add handler. + function storePolicyDirectly(overrides) { + trustTable.mock.rows.set('my-app-prod', { + id: 'my-app-prod', + issuer: ISSUER, + audience: AUDIENCE, + claims: { repository_id: '67890', workflow_ref: WORKFLOW_REF }, + user: 'ci-deploy', + enabled: true, + ...overrides, + }); + } + + // operations as a scalar: hasOperationScope tests Array.isArray, so the scope would be dropped and + // the token minted UNSCOPED with the policy user's whole role. A malformed narrowing must never + // widen — so the row is refused rather than honored without its scope. + it('refuses a stored policy whose operations is a scalar rather than an array', async () => { + storePolicyDirectly({ operations: 'deploy_component' }); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() })); + }); + + it('refuses a stored policy naming an operation that does not exist', async () => { + storePolicyDirectly({ operations: ['not_a_harper_operation'] }); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() })); + }); + + // enabled as the string 'false': `row.enabled !== false` is true for it, so a policy an operator + // meant to disable would keep minting tokens. + it('refuses a stored policy whose enabled is a string rather than a boolean', async () => { + storePolicyDirectly({ enabled: 'false' }); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() })); + }); + + it('refuses a stored policy whose claims are not a usable constraint shape', async () => { + storePolicyDirectly({ claims: { repository_id: { nested: 'object' } } }); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() })); + }); + + // The control: the same direct-store path with a well-formed row still authenticates, so the four + // refusals above are the validator working rather than the helper failing to store anything. + it('still exchanges a well-formed directly-stored policy', async () => { + storePolicyDirectly({ operations: ['deploy_component'] }); + const result = await exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() }); + assert.strictEqual(result.username, 'ci-deploy'); + }); + it('ignores an under-specified stored policy that bypassed write-time validation', async () => { trustTable.mock.rows.set('smuggled', { id: 'smuggled', From 2ebb8e0dbfe294129992ec194a46f50d681c465c Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 17:19:35 -0400 Subject: [PATCH 32/37] fix(security): back off after a failed JWKS refresh; stop overclaiming registry support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a cached key set passed its TTL, a failed refresh returned a stale key but advanced no clock — `fetchedAt` is only set on success — so every subsequent request wave started discovery again and rode the same timeout before falling back to the same stale key. The exchange is unauthenticated and picks its issuer from an unverified JWT, and key ids are public, so an anonymous caller could keep that cycle running for the length of an issuer outage: exactly when the stale-key grace is meant to absorb load rather than generate it. A failed refresh is now recorded on its own clock, and while a usable stale key is on hand the fetch is skipped entirely for the backoff interval — skipping the fetch is the point, since that is the expensive half. Recorded even when no stale key rescues the request, so the backoff also covers an issuer whose keys we have never held, and cleared on success. Also corrected the dynamic-operation test's claim. It said component-registered operations are supported; the registry is process-local, add_oidc_trust runs on main, and server.registerOperation runs in a worker whose announcement carries only name→thread routing. The test asserts the delegation to validateOperations, not the topology, and now says so — the production behavior is that such a policy is rejected, which fails closed and is shared with add_role/alter_role. Co-Authored-By: Claude Opus 4.8 --- security/authn/oidc/jwks.ts | 29 +++++++++++++++++-- .../authn/oidc/trustPolicyOperations.test.js | 14 ++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/security/authn/oidc/jwks.ts b/security/authn/oidc/jwks.ts index 7e4945902..e71f2a95e 100644 --- a/security/authn/oidc/jwks.ts +++ b/security/authn/oidc/jwks.ts @@ -42,6 +42,19 @@ const inFlightLoads = new Map>(); */ const unknownKidRefetchAt = new Map(); +/** + * When a refresh for an issuer last FAILED, per issuer. Tracked separately from `fetchedAt`, which a + * failed fetch does not advance — so without this, every request wave arriving after the cache went + * stale starts discovery again and rides the same timeout before falling back to the same stale key. + * The exchange is unauthenticated and picks its issuer from an unverified JWT, and issuer key ids are + * public, so an anonymous caller could keep that cycle running for the length of an issuer outage: + * exactly when the stale-key grace is supposed to be absorbing load, not generating it. + */ +const failedRefreshAt = new Map(); + +/** How long a failed refresh suppresses the next one, while a usable stale key is still on hand. */ +const FAILED_REFRESH_BACKOFF_MS = 30_000; + /** * Bumped by every clear. A fetch that was already in flight when the cache was cleared must not * write its now-stale result back: clearing drops `inFlightLoads`, but the orphaned fetch still @@ -56,6 +69,7 @@ export function clearJwksCache(): void { issuerKeyCache.clear(); inFlightLoads.clear(); unknownKidRefetchAt.clear(); + failedRefreshAt.clear(); } /** @@ -208,16 +222,27 @@ export async function getSigningKey(issuer: string, kid: unknown): Promise { assert.strictEqual(installed.mock.rows.size, 0, 'expected nothing stored'); }); - // Operations registered at runtime via server.registerOperation are grantable in a role's - // allowlist, so a policy must be able to scope to them too — this is why validation delegates - // to validateOperations rather than checking OPERATIONS_ENUM locally. - it('accepts a dynamically registered operation', async () => { + // Delegating to validateOperations rather than checking OPERATIONS_ENUM locally means an + // operation registered in THIS process is accepted. That is the seam, not a promise about + // component-registered operations: the registry is process-local, `add_oidc_trust` runs on the + // main thread, and `server.registerOperation` runs in a worker whose OPERATION_REGISTERED + // announcement carries only name→thread routing, never grantability. So a component's + // operation is NOT recognized here in production, and this same-thread test cannot show that + // — it is asserting the delegation, not the topology. A policy naming such an operation is + // rejected, which fails closed; add_role, alter_role, and impersonation validation share the + // gap, so the fix belongs to that bridge rather than to a workaround here. + it('accepts an operation registered in this process', async () => { const dynamicOp = 'test_dynamic_scope_op'; opAuth.registerOperationPermission(dynamicOp, { requiresSu: true }); try { From d8d826c897228560487e8c215fba3ce6a85a631b Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 17:26:16 -0400 Subject: [PATCH 33/37] fix(security): apply the JWKS backoff to an issuer with no cached keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skip-fetch gate required a stale key, so it never fired for an issuer whose keys had never been cached — every request still rode the full discovery and fetch timeout, and `failedRefreshAt` was written but never read on that path. The comment above it claimed the opposite. That is the worse half of the case: with no cached key there is nothing to fall back to, and the exchange is unauthenticated with the issuer chosen from an unverified JWT. The backoff now applies regardless: a stale key is served when there is one, and otherwise the request is refused for the interval instead of re-driving the fetch. Fails closed. The cost is that a legitimate first exchange waits out the interval after a blip, which is bounded and the right side to err on for an unauthenticated endpoint. This half is testable without a time seam, unlike the expired-cache half — so there are now two cases: repeated failures for a never-cached issuer stop producing fetches, and a recovered issuer is picked up again once cleared. The first was confirmed to fail against the stale-key-gated version. Co-Authored-By: Claude Opus 4.8 --- security/authn/oidc/jwks.ts | 12 ++++--- unitTests/security/authn/oidc/jwks.test.js | 40 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/security/authn/oidc/jwks.ts b/security/authn/oidc/jwks.ts index e71f2a95e..1106722f7 100644 --- a/security/authn/oidc/jwks.ts +++ b/security/authn/oidc/jwks.ts @@ -225,10 +225,14 @@ export async function getSigningKey(issuer: string, kid: unknown): Promise { await assert.rejects(() => getSigningKey(ISSUER, 'key-1'), /exceeds/); }); + // An issuer whose keys we have NEVER cached is the worse half of the outage case: there is no + // stale key to fall back on, so without a backoff every request rides the full discovery and + // fetch timeout — and the exchange is unauthenticated, with the issuer chosen from an + // unverified JWT. Reachable without any time travel, unlike the expired-cache half. + it('stops re-fetching for an issuer whose keys have never been cached', async () => { + respond = () => { + throw new Error('issuer is down'); + }; + + await assert.rejects(() => getSigningKey(ISSUER, 'key-1')); + const afterFirst = requestLog.length; + assert.ok(afterFirst > 0, 'expected the first attempt to actually try'); + + await assert.rejects(() => getSigningKey(ISSUER, 'key-1')); + await assert.rejects(() => getSigningKey(ISSUER, 'key-1')); + + assert.strictEqual( + requestLog.length, + afterFirst, + 'a failed refresh must suppress further fetches for the backoff interval' + ); + }); + + // The backoff must not outlive the outage: a recovered issuer is picked up once the interval + // passes, and clearing the cache is the operator's way to force that immediately. + it('fetches again for a recovered issuer once the failure is cleared', async () => { + respond = () => { + throw new Error('issuer is down'); + }; + await assert.rejects(() => getSigningKey(ISSUER, 'key-1')); + + clearJwksCache(); + requestLog.length = 0; + respond = (url) => json(url === DISCOVERY_URI ? discoveryDocument() : jwksDocument([signingJwk])); + + const key = await getSigningKey(ISSUER, 'key-1'); + assert.strictEqual(key.type, 'public'); + assert.ok(requestLog.length > 0, 'expected the recovered issuer to be fetched again'); + }); + it('requires a key id', async () => { for (const kid of ['', undefined, null, 42]) { await assert.rejects(() => getSigningKey(ISSUER, kid), /no key id/); From be34107f4c25e959a17a08e772021010d98c1035 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 17:44:15 -0400 Subject: [PATCH 34/37] fix(security): tell the truth in list_oidc_trust; pin the guards the tests missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review follow-ups on the stored-policy validation. A row the exchange refuses still listed as healthy. `storedPolicyProblem` ran only on the exchange path, and `toRecord` normalized exactly the shapes it exists to refuse — `enabled: 'false'` rendered as `enabled: true`. So a row arriving by the routes this validation defends against would fail every exchange with the deliberately opaque 401 while `list_oidc_trust`, the one command an operator runs to check, confirmed the trust was fine. Validation now runs on both paths; the exchange refuses, and a listing reports `invalid_reason` and the stored `enabled` as-is rather than coerced. A listing's job is to describe what is stored. The two tests for the fail-open shapes did not actually pin their guards, which a mutation check demonstrated: operations — the case seeded a STRING scalar, so validateOperations iterated it character by character and refused the row by reporting 'd' as an unknown operation: right outcome, wrong check, guard deletable with tests green. Now seeded with a number, where `for (const op of 42)` throws TypeError and turns one malformed row into a 500 for every exchange against that issuer. claims — the case used a wholly-bad shape that matchTrustPolicyClaims refuses downstream anyway. Now a constraint list that MATCHES on its string entry and carries a non-string alongside it, which only the shape validator refuses. Both confirmed to fail with their guard deleted, and the string-scalar case is kept as well since it is the shape most likely to arrive. Co-Authored-By: Claude Opus 4.8 --- security/authn/oidc/trustPolicyOperations.ts | 21 +++++++++---- .../security/authn/oidc/tokenExchange.test.js | 24 ++++++++++++++- .../authn/oidc/trustPolicyOperations.test.js | 30 +++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/security/authn/oidc/trustPolicyOperations.ts b/security/authn/oidc/trustPolicyOperations.ts index 3d4fc7d9c..0d45066e3 100644 --- a/security/authn/oidc/trustPolicyOperations.ts +++ b/security/authn/oidc/trustPolicyOperations.ts @@ -125,7 +125,7 @@ function trustTable() { * Rebuild a plain record from a stored row's known attributes. Never spread rows — RecordObject * prototype fields don't survive a spread reliably (see DESIGN.md). */ -function toRecord(row: any): OidcTrustPolicy & Record { +function toRecord(row: any, problem?: string): OidcTrustPolicy & Record { return { id: row.id, issuer: row.issuer, @@ -133,7 +133,13 @@ function toRecord(row: any): OidcTrustPolicy & Record { claims: row.claims ?? {}, user: row.user, operations: row.operations ?? null, - enabled: row.enabled !== false, + // Reported as stored rather than coerced. `row.enabled !== false` would render the string + // 'false' — one of the shapes the exchange refuses — as `enabled: true`, telling the operator + // a dead trust is live. Absent still means enabled, which is the documented default. + enabled: row.enabled === undefined ? true : row.enabled, + // Present only on a row the exchange will refuse, so a listing says why the trust is dead + // instead of leaving a per-attempt log line as the only signal. + ...(problem ? { invalid_reason: problem } : {}), description: row.description ?? null, updated_by: row.updated_by ?? null, __createdtime__: row.__createdtime__, @@ -156,16 +162,19 @@ async function readPolicies( for await (const row of table.search([])) { if (!includeDisabled && row.enabled === false) continue; if (issuer !== undefined && row.issuer !== issuer) continue; + // Validated on BOTH paths, always against the RAW row — normalizing first is precisely what + // hides the shapes that fail open (see storedPolicyProblem). The two paths then differ in what + // they do with the answer: the exchange refuses the row, while a listing reports it. A listing + // that silently skipped a refused policy, or that showed it as healthy, would leave the + // operator's belief that the trust works corroborated by the very command they ran to check. + const problem = storedPolicyProblem(row); if (forExchange) { - // Validate the RAW row, before toRecord normalizes it — normalizing is precisely what would - // hide the two shapes that fail open (see storedPolicyProblem). - const problem = storedPolicyProblem(row); if (problem) { logger.warn?.(`Ignoring trust policy '${row.id}': ${problem}`); continue; } } - policies.push(toRecord(row)); + policies.push(toRecord(row, problem)); } return policies.sort((a, b) => String(a.id).localeCompare(String(b.id))); } diff --git a/unitTests/security/authn/oidc/tokenExchange.test.js b/unitTests/security/authn/oidc/tokenExchange.test.js index 4a951590c..2f8b7d1b4 100644 --- a/unitTests/security/authn/oidc/tokenExchange.test.js +++ b/unitTests/security/authn/oidc/tokenExchange.test.js @@ -235,7 +235,19 @@ describe('exchangeOidcToken', () => { // operations as a scalar: hasOperationScope tests Array.isArray, so the scope would be dropped and // the token minted UNSCOPED with the policy user's whole role. A malformed narrowing must never // widen — so the row is refused rather than honored without its scope. - it('refuses a stored policy whose operations is a scalar rather than an array', async () => { + // + // A NUMBER, not a string. A string scalar is refused even without the Array.isArray guard, because + // validateOperations iterates it character by character and reports 'd' as an unknown operation — + // the right outcome via the wrong check, which would let the guard be deleted with tests green. A + // number isolates it: `for (const op of 42)` throws TypeError, which would escape readPolicies and + // turn one malformed row into a 500 on every exchange for that issuer instead of a uniform 401. + // assertRejected requires exactly that 401, so it fails if the guard goes. + it('refuses a stored policy whose operations is a non-iterable scalar', async () => { + storePolicyDirectly({ operations: 42 }); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() })); + }); + + it('refuses a stored policy whose operations is a string rather than an array', async () => { storePolicyDirectly({ operations: 'deploy_component' }); await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() })); }); @@ -252,6 +264,16 @@ describe('exchangeOidcToken', () => { await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() })); }); + // The constraint list MATCHES on its string entry, so matchTrustPolicyClaims would admit this row + // — only validateClaimConstraintShape refuses it. A wholly-bad shape (`{nested: 'object'}`) is + // refused downstream by the matcher too, so it cannot tell the new validator from the pre-existing + // one; this can. A non-string entry in an otherwise-matching list is how a constraint gets + // silently wider than it reads. + it('refuses a stored policy whose claim list has a non-string entry, even though it matches', async () => { + storePolicyDirectly({ claims: { repository_id: ['67890', 42], workflow_ref: WORKFLOW_REF } }); + await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() })); + }); + it('refuses a stored policy whose claims are not a usable constraint shape', async () => { storePolicyDirectly({ claims: { repository_id: { nested: 'object' } } }); await assertRejected(exchangeOidcToken({ operation: 'exchange_oidc_token', token: identityToken() })); diff --git a/unitTests/security/authn/oidc/trustPolicyOperations.test.js b/unitTests/security/authn/oidc/trustPolicyOperations.test.js index cfd8a3e03..464c33a55 100644 --- a/unitTests/security/authn/oidc/trustPolicyOperations.test.js +++ b/unitTests/security/authn/oidc/trustPolicyOperations.test.js @@ -296,6 +296,36 @@ describe('oidc trustPolicyOperations', () => { }); }); + // A row the exchange refuses must not list as healthy. The failure mode this guards: the row + // arrives by replication or a restore, every exchange returns the deliberately opaque 401, and + // list_oidc_trust — the one command an operator runs to check — tells them the trust is enabled. + describe('listing an unusable stored policy', () => { + it('reports why a refused policy is dead instead of showing it as healthy', async () => { + installed.mock.rows.set('broken', { + id: 'broken', + issuer: ISSUER, + audience: AUDIENCE, + claims: { ...VALID_CLAIMS }, + user: 'ci-deploy', + enabled: 'false', + }); + + const listed = (await listOidcTrust(su('list_oidc_trust'))).policies.find((policy) => policy.id === 'broken'); + assert.ok(listed, 'the row must still be listed — a listing tells the truth about what is stored'); + assert.notStrictEqual(listed.enabled, true, "'false' must not be reported as enabled: true"); + assert.match(listed.invalid_reason, /enabled/, 'expected the listing to say why it is refused'); + }); + + it('leaves a well-formed policy unannotated', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy())); + const listed = (await listOidcTrust(su('list_oidc_trust'))).policies.find( + (policy) => policy.id === 'my-app-prod' + ); + assert.strictEqual(listed.enabled, true); + assert.strictEqual(listed.invalid_reason, undefined); + }); + }); + describe('listOidcTrust', () => { it('returns policies sorted by id', async () => { await addOidcTrust(su('add_oidc_trust', validPolicy({ id: 'zulu' }))); From 9436f5098a46fbf3996fdfafadc5e648e8833420 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 18:13:44 -0400 Subject: [PATCH 35/37] fix(security): report a deleted or deactivated policy user in list_oidc_trust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `invalid_reason` covered row shape only, but the exchange also refuses a well-formed row whose user has since been deleted or deactivated — with the same opaque 401. That is the same availability trap the previous commit closed, reached by its most mundane cause: someone removes the CI user, every deploy starts failing, and the one command an operator would run to check reports the trust as enabled and healthy. Annotated in listOidcTrust rather than in readPolicies, deliberately. It is one users-cache read for the whole listing on an SU-only path; doing it per row in readPolicies would put a user lookup on the unauthenticated exchange path, which already resolves the user itself at the point it matters. A shape problem still wins, being the more fundamental complaint. Both cases confirmed to fail with the annotation removed. Co-Authored-By: Claude Opus 4.8 --- security/authn/oidc/trustPolicyOperations.ts | 24 +++++++++++++++++-- .../authn/oidc/trustPolicyOperations.test.js | 21 ++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/security/authn/oidc/trustPolicyOperations.ts b/security/authn/oidc/trustPolicyOperations.ts index 0d45066e3..b4d1a1f4e 100644 --- a/security/authn/oidc/trustPolicyOperations.ts +++ b/security/authn/oidc/trustPolicyOperations.ts @@ -138,7 +138,8 @@ function toRecord(row: any, problem?: string): OidcTrustPolicy & Record { assert.match(listed.invalid_reason, /enabled/, 'expected the listing to say why it is refused'); }); + // The exchange also refuses a well-formed row whose user has been deleted or deactivated, with + // the same opaque 401 — and that is the most mundane arrival of all: someone removes the CI + // user and every deploy starts failing while the listing still says the trust is fine. + it('reports a policy naming a user that no longer exists', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy({ id: 'gone' }))); + await setUsersWithRolesCache(new Map()); + + const listed = (await listOidcTrust(su('list_oidc_trust'))).policies.find((p) => p.id === 'gone'); + assert.match(listed.invalid_reason, /does not exist/); + }); + + it('reports a policy naming a deactivated user', async () => { + await addOidcTrust(su('add_oidc_trust', validPolicy({ id: 'inactive' }))); + const users = new Map(); + users.set('ci-deploy', { username: 'ci-deploy', active: false, role: { role: 'r', permission: {} } }); + await setUsersWithRolesCache(users); + + const listed = (await listOidcTrust(su('list_oidc_trust'))).policies.find((p) => p.id === 'inactive'); + assert.match(listed.invalid_reason, /inactive/); + }); + it('leaves a well-formed policy unannotated', async () => { await addOidcTrust(su('add_oidc_trust', validPolicy())); const listed = (await listOidcTrust(su('list_oidc_trust'))).policies.find( From d5a1821e6929417eea5ebf9ae7359b7dd861bb1e Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 18 Aug 2026 18:26:39 -0400 Subject: [PATCH 36/37] test: pin the invalid_reason precedence a row with both problems relies on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule that a shape problem outranks a missing user was stated in the comment and the commit message but pinned by nothing: every shape case named a valid user and both user cases were well-formed, so no test had a row with both. The `continue` implementing it could be mutated to a no-op with all tests green. Added a row that is both malformed and names a deleted user, asserting the shape problem is what surfaces — it is the more fundamental complaint, since the row stays refused even if the user is restored. Confirmed it kills exactly the mutation that survived before. Co-Authored-By: Claude Opus 4.8 --- .../authn/oidc/trustPolicyOperations.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/unitTests/security/authn/oidc/trustPolicyOperations.test.js b/unitTests/security/authn/oidc/trustPolicyOperations.test.js index e793a0f00..a1542365b 100644 --- a/unitTests/security/authn/oidc/trustPolicyOperations.test.js +++ b/unitTests/security/authn/oidc/trustPolicyOperations.test.js @@ -337,6 +337,25 @@ describe('oidc trustPolicyOperations', () => { assert.match(listed.invalid_reason, /inactive/); }); + // Precedence: when a row is both malformed AND names a missing user, the shape problem is the + // one reported. It is the more fundamental complaint — the row would be refused even if the + // user were restored — and without this case the `continue` implementing it can be mutated to + // a no-op with every test still green. + it('reports the shape problem, not the user, when a row has both', async () => { + installed.mock.rows.set('both', { + id: 'both', + issuer: ISSUER, + audience: AUDIENCE, + claims: { ...VALID_CLAIMS }, + user: 'ghost-user', + operations: 'deploy_component', + }); + + const listed = (await listOidcTrust(su('list_oidc_trust'))).policies.find((p) => p.id === 'both'); + assert.match(listed.invalid_reason, /operations/, 'the shape problem is the more fundamental one'); + assert.doesNotMatch(listed.invalid_reason, /ghost-user/); + }); + it('leaves a well-formed policy unannotated', async () => { await addOidcTrust(su('add_oidc_trust', validPolicy())); const listed = (await listOidcTrust(su('list_oidc_trust'))).policies.find( From 5da87ee13ca73bab5ab5a6c5f87f691af004a8c2 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 19 Aug 2026 11:31:11 -0400 Subject: [PATCH 37/37] test: pin the stale-key grace ceiling with an injected clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STALE_KEY_GRACE_MS was the one JWKS guard nothing pinned: replacing it with an unbounded stale fallback left the whole suite green. That bound is the security half of the blip-tolerance tradeoff — `fetchedAt` advances only on a SUCCESSFUL fetch, so without the ceiling a key the issuer has pulled stays honored for the entire length of an outage instead of 24 hours. getSigningKey now takes an optional `now`, mirroring the clockTimestamp seam verifyIdentityToken already exposes rather than inventing a second convention — production callers pass nothing. Reaching this branch otherwise needs a cache aged past an hour, and this repo bars new fake timers, which is why the gap was previously documented rather than closed. Both sides asserted: a cached key is still served at grace−1 (a blip must not break deploys) and refused at grace+1. Confirmed the reviewer's exact mutation now dies, and that it reached dist/ before running — a .ts-only edit would have been a silent no-op since .mocharc sets no --conditions. Co-Authored-By: Claude Opus 4.8 --- security/authn/oidc/jwks.ts | 12 ++++++-- unitTests/security/authn/oidc/jwks.test.js | 33 +++++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/security/authn/oidc/jwks.ts b/security/authn/oidc/jwks.ts index 1106722f7..3669c55a1 100644 --- a/security/authn/oidc/jwks.ts +++ b/security/authn/oidc/jwks.ts @@ -205,10 +205,16 @@ function loadIssuerKeys(issuer: string): Promise { } /** Resolves the public key an issuer used to sign a token, by `kid`. */ -export async function getSigningKey(issuer: string, kid: unknown): Promise { +/** + * `now` exists so the two time-based bounds below can be tested — the cache TTL and, more + * importantly, the STALE_KEY_GRACE_MS ceiling that stops a pulled key being honored forever through + * a prolonged outage. Reaching either otherwise needs a cache aged past an hour, and this repo bars + * new fake timers. Mirrors the `clockTimestamp` seam verifyIdentityToken already takes, rather than + * inventing a second convention. Callers in production pass nothing. + */ +export async function getSigningKey(issuer: string, kid: unknown, now: number = Date.now()): Promise { if (typeof kid !== 'string' || kid === '') throw new ClientError('Token has no key id', 401); const normalizedIssuer = normalizeIssuer(issuer); - const now = Date.now(); const cached = issuerKeyCache.get(normalizedIssuer); if (cached && now - cached.fetchedAt < JWKS_CACHE_TTL_MS) { @@ -241,7 +247,7 @@ export async function getSigningKey(issuer: string, kid: unknown): Promise { await assert.rejects(() => getSigningKey(ISSUER, 'key-1'), /exceeds/); }); + // The expired-cache half, reachable now that getSigningKey takes a clock. A cached set survives + // a failed refresh so a network blip does not break deploys — but only for STALE_KEY_GRACE_MS, + // because `fetchedAt` is advanced ONLY by a successful fetch. Without the ceiling, a key the + // issuer has pulled stays honored for the whole length of an outage rather than 24h. Both + // sides are asserted: dropping the bound leaves the grace+1 case passing on the served key, + // and dropping the grace entirely would take the grace−1 case with it. + describe('the stale-key grace ceiling', () => { + const GRACE_MS = 86_400_000; + + async function cacheThenFail() { + await getSigningKey(ISSUER, 'key-1'); // populate + respond = () => { + throw new Error('issuer is down'); + }; + } + + it('still serves a cached key just inside the grace window', async () => { + await cacheThenFail(); + const key = await getSigningKey(ISSUER, 'key-1', Date.now() + GRACE_MS - 1000); + assert.strictEqual(key.type, 'public', 'a blip must not break deploys inside the window'); + }); + + it('refuses a cached key once the grace window has passed', async () => { + await cacheThenFail(); + await assert.rejects( + () => getSigningKey(ISSUER, 'key-1', Date.now() + GRACE_MS + 1000), + 'a pulled key must not be honored indefinitely through an outage' + ); + }); + }); + // An issuer whose keys we have NEVER cached is the worse half of the outage case: there is no // stale key to fall back on, so without a backoff every request rides the full discovery and // fetch timeout — and the exchange is unauthenticated, with the issuer chosen from an - // unverified JWT. Reachable without any time travel, unlike the expired-cache half. + // unverified JWT. Reachable without time travel; the expired-cache half above needs the clock. it('stops re-fetching for an issuer whose keys have never been cached', async () => { respond = () => { throw new Error('issuer is down');