Make examples reproducible, verify access tokens, and refresh dependencies - #46
Conversation
CI builds images but never runs them, and every Python example used floor-only `>=` pins with no lockfile. Three examples were resolving to breaking majors and failing at import with nothing to catch it: mcp/remote-server-py mcp 2.0.0 removed mcp.server.fastmcp mcp/credential-broker same a2a/python-agent a2a-sdk 1.1.2 removed a2a.server.apps Pin those two SDKs below the breaking major (mcp 1.29.0, a2a-sdk 0.2.16) and lock every ecosystem at what it already resolves to today. No intentional version increases; the migrations to mcp 2.x and a2a-sdk 1.x land separately. Python moves to requirements.in (floors) compiled to a fully pinned requirements.txt via `uv pip compile --universal`. Universal resolution is required because development is arm64 macOS and CI builds linux/amd64. npm switches to `npm ci` and drops the `package-lock.json*` glob, whose trailing `*` silently tolerated the 8 missing lockfiles. Also adds --locked for cargo, frozen for bundler, --locked-mode for NuGet, composer.lock for PHP, and a real Gemfile.lock (the committed one was 0 bytes). Removes `go mod tidy` from the go-oidc build, which mutated go.mod/go.sum at image build time. Adds .dockerignore to all 27 build contexts. There were none, so `COPY . .` shipped the host's node_modules and target/ into images and overwrote what the Dockerfile had just installed -- which is why the missing lockfiles went unnoticed. Verified: all 27 images build, all 16 servers boot and serve, all 5 static SPAs still substitute their config placeholders at container start. Co-Authored-By: Claude <[email protected]>
CI only built images, so anything that failed at import or boot stayed invisible -- which is how three examples shipped broken SDK majors. Add scripts/smoke.sh, which starts an image and probes it with throwaway credentials, and wire it into the matrix after the build. Probes are per category: web apps must render /; static SPAs must render / and have their __VOUCH_* placeholders substituted into the built bundle; MCP and A2A servers must serve well-known metadata and 401 unauthenticated calls; native CLIs must get past module loading. The SPA placeholder assertion is the load-bearing one. entrypoint.sh substitutes config with `sed -i ... assets/*.js` and exits 0 whether or not the glob matched, so a bundler that renames its output directory would ship an image serving a literal __VOUCH_CLIENT_ID__ with every other check green. Verified against deliberately broken images: the SPA check catches a stale sed glob and the native check catches a missing module, both exit 1. Base images: Node 25 reached EOL on 2026-06-01, so all 10 Node examples move to Node 26 (current stable, Active LTS from 2026-10-28; Angular 21's CLI already allows >=24). Rust 1.94 -> 1.97, alpine 3.23 -> 3.24, and pin the two floating tags -- nginx:alpine -> nginx:1.31-alpine (same digest today) and composer:latest -> composer:2.9. Spring Boot's JDK images are left alone; they move with the Boot upgrade. Also adds the github-actions Dependabot ecosystem, without which the SHA-pinned actions that secure_workflows.yml enforces were never updated, and sets contents:read plus persist-credentials:false to clear zizmor. Corrects CLAUDE.md, which undercounted spa (5 vs 6), native (3 vs 6) and mcp (2 vs 3) examples, said 22 Dockerfiles instead of 27, and claimed no test suite exists. README no longer says SPA examples need no client secret, which was never true of spa/bff-express. Verified: all 27 build and pass smoke on Node 26 / Rust 1.97. Co-Authored-By: Claude <[email protected]>
The test harness looked for ~/.vouch/{cookie.txt,config.json}, which the
Vouch CLI no longer writes. Point it at $XDG_STATE_HOME/vouch/cookie.txt
and $XDG_CONFIG_HOME/vouch/config.json, honouring both env vars and
falling back to the spec defaults. The whole end-to-end suite was
unrunnable before this.
Adds tests/tests/claims.spec.js, which establishes what a Vouch access
token actually is. This blocks the JWKS refactor, because the repo
contradicted itself: oidc-flow.js said access tokens were "opaque" and
"HS256-signed and not verifiable via JWKS", while README.md said they were
RFC 9068 JWTs.
The README was right:
header {"alg":"ES256","typ":"at+jwt","kid":"vouch-oidc-kms-04a0ccc2e45995c1"}
claims acr amr aud auth_time client_id email email_verified exp
hardware_verified iat iss jti nbf scope sub
The signature verifies against /oauth/jwks with stdlib crypto, so every
example can verify instead of blind-decoding.
Three findings beyond that:
- acr and amr arrive on both tokens without being requested, carrying
acr=...aal3 and amr=["hwk","pin","user"]. These are the claims Vouch
actually advertises in claims_supported, and amr:hwk is RFC 8176 for a
hardware-secured key -- the standards-defined form of hardware_verified.
- hardware_aaguid is never issued, despite being documented in the README
and rendered by several examples.
- Introspection does not return hardware_verified, so it is not a fallback
route to that claim.
- An RFC 8707 resource parameter does narrow aud to the requested value,
which is what makes resource-server audience validation possible at all;
the default aud is the calling client's own client_id.
Committed as a spec rather than run as a throwaway probe so a future change
to the token format fails one test instead of silently degrading 19
examples.
Co-Authored-By: Claude <[email protected]>
The MCP and A2A servers accepted any Vouch-issued JWT. remote-server-ts checked only the issuer while publishing a `resource` identifier it never enforced; the three Python servers passed verify_aud=False. A token minted for an unrelated client was accepted everywhere. Each server now derives one RESOURCE constant, publishes it in its RFC 9728 metadata, and requires it as `aud`. Clients opt in by sending the RFC 8707 `resource` parameter, which makes Vouch narrow the audience -- without that the audience is the calling client's own client_id and there is nothing a resource server can check. They also require `typ: at+jwt` (RFC 9068), which structurally rejects ID tokens. That matters because the test harness was handing resource servers `tokens.id_token || tokens.access_token`, so every one of these servers was being exercised with an ID token. obtainAccessToken now returns the access token and fails loudly if there isn't one. Added a negative test: a valid, correctly-signed, unexpired Vouch token minted for a different client is now rejected with 401. It passes for both remote servers and is skipped for the credential broker. The broker keeps verify_aud=False deliberately, with a comment explaining why: it forwards the caller's token to Vouch's /v1/credentials/* endpoints, which reject audience-narrowed tokens, so it cannot both require a token minted for itself and spend that token at Vouch. Fixing it properly needs RFC 8693 token exchange. Drops hardware_aaguid from these servers -- claims.spec.js shows Vouch never issues it -- and surfaces acr/amr instead, which are standard, advertised in claims_supported, and present on both tokens. Corrects the README, which documented hardware_aaguid as a real claim, and the introspect-token tool, which told users Vouch access tokens were opaque. Verified: mcp 15 passed, a2a 3 passed, tsc clean. Co-Authored-By: Claude <[email protected]>
Every example read hardware_verified by splitting the access token on "." and base64-decoding the payload, with no signature check -- trusting whatever bytes it was handed. claims.spec.js established that Vouch access tokens are ES256-signed RFC 9068 JWTs, so they can simply be verified. Server-side confidential clients now verify against JWKS with issuer, audience and typ checks. The audience is the client's own client_id, which is what Vouch issues when the authorization request carries no RFC 8707 resource parameter. Done here for express-openid, flask-authlib, nextjs-nextauth and bff-express; the other seven languages follow. The five browser SPAs deliberately do NOT verify. decodeAccessToken is renamed decodeUnverifiedForDisplay and carries a comment saying so: a public client gains nothing by verifying a token it just received over TLS from the token endpoint, and shipping a JOSE library to the browser to do it would teach the wrong lesson. The security decision belongs to the resource server. Drops hardware_aaguid, which Vouch never issues, and shows acr/amr instead. Also adds `import json` back to flask-authlib, which I had removed while its /userinfo route still used it -- caught by ruff, which is now clean across all Python examples, and by the Playwright suite. Verified: web.spec.js 37 passed (1 infra flake), all changed examples build and pass smoke. Known issue, unrelated and pre-existing: all five spa.spec.js login tests fail. Vouch issues no client_secret for application_type spa/native but still sets token_endpoint_auth_method to client_secret_basic, so the token endpoint answers "invalid_client: client authentication required" and no public-client code flow can complete. Passing token_endpoint_auth_method "none" at creation is silently ignored. Documented in createApp; it needs a server-side fix. Co-Authored-By: Claude <[email protected]>
Completes the JWKS refactor across every server-side client and the three native CLIs. Each verifies issuer, audience and typ=at+jwt using its ecosystem's own library: PyJWT for fastapi, go-oidc's existing verifier for Go, NimbusJwtDecoder for Spring, json-jwt for Rails, firebase/php-jwt for Laravel, JsonWebTokenHandler for ASP.NET, and jsonwebtoken for both Rust examples. native/node verifies with node:crypto alone so it keeps its zero-dependency footprint. Three bugs the end-to-end suite caught that the smoke tests could not, because all three only fire after a real login: - Spring: NimbusJwtDecoder.withJwkSetUri defaults to RS256 only, so ES256 tokens failed as "no matching key(s) found". Separately, Spring Security 7's JwtValidators.createDefaultWithIssuer chain includes a JwtTypeValidator requiring typ=JWT, which rejects every RFC 9068 token; the validator chain is now built explicitly. - Rust: jsonwebtoken 11 panics on first use unless exactly one crypto provider feature is enabled. Now pins rust_crypto, matching rustls. - Flask/Go: removing the old decoder left unused imports and one genuinely undefined name. ruff and the Go compiler caught these. cargo --locked also did its job here: changing a feature flag invalidated both Cargo.lock files and the Docker build refused to proceed until they were regenerated. Also drops hardware_aaguid from the last four READMEs. It is now referenced only in claims.spec.js, which asserts Vouch does not issue it. Verified: 27/27 build and smoke; web, mcp, a2a, native and claims suites = 68 passed, 1 infra flake (port collision). ruff, gofmt, go vet, cargo fmt, clippy and tsc all clean. Co-Authored-By: Claude <[email protected]>
Routine updates, no source changes beyond what the bumps required. go-oidc v3.17.0 -> v3.20.0 (go-jose v4.1.3 -> v4.1.4) go directive 1.25.0 -> 1.26.0, matching the golang:1.26 builder Spring Boot 4.0.4 -> 4.1.0 ASP.NET OIDC 10.0.5 -> 10.0.10 @types/node ^25 -> ^26, matching the Node 26 runtime Rust crates cargo update on both examples Python floors raised to what actually resolves today npm lockfiles refreshed across all 11 directories Spring Boot's pom said java.version 21 while both images were Temurin 25; now 25 in all three places. Its Dockerfile pins maven:3 to maven:3.9 and uses -Dmaven.test.skip=true, which is what Boot 4.1's plugin honours when suppressing test AOT. Rails had no version constraint at all on puma or sqlite3, so a fresh resolve could pick anything; both are now pinned, and rails moves to ~> 8.1 to match what the lock already resolved. Deliberately held back: mcp <2 and a2a-sdk <0.3, migrated separately reqwest 0.12 in axum, pinned by openidconnect 4 -> oauth2 5 typescript ~5.9; TS 7 is the native port, a real migration next-auth 4.x is genuinely latest-stable, v5 is beta-tagged Angular 21; 22 needs TypeScript 6 and lands on its own Verified: 27/27 build and smoke; web, mcp, a2a, native and claims suites = 69 passed, 0 failures. Co-Authored-By: Claude <[email protected]>
Angular 22 peers on TypeScript >=6.0 <6.1, so this cannot ride along with the routine bumps: ~5.9 is too old and 7.0 (the native port) is out of range. Pins typescript ~6.0.3. Switches the builder from @angular-devkit/build-angular:application to @angular/build:application, the supported path since v20, and drops the now-unused devkit package. Removes downlevelIteration from tsconfig. TypeScript 6 errors on it as deprecated, and it was never needed with an ES2022 target. Note npm audit reports a moderate advisory reachable from @angular/cli via @modelcontextprotocol/sdk -> @hono/node-server. It is a Windows path traversal in a build-time dependency, the built bundle is served by nginx with no node_modules in the runtime image, and `npm audit fix` resolves it by downgrading the CLI to 21.0.4. Left alone deliberately. Verified: builds locally and in Docker, smoke passes, config placeholders still substituted in the emitted bundle. Co-Authored-By: Claude <[email protected]>
Laravel 13 requires PHP ^8.3, and kovah/laravel-socialite-oidc ^0.7 caps illuminate/* at ^12 -- so the pin had to move to ^0.8, which added ^13 support. That coupling is why this could not ride along with the routine bumps. Pulls Symfony 8 components in transitively. The image was already php:8.5-cli, so the declared ^8.2 floor was understating what this example actually runs on. Verified: builds, smoke passes, and the full login and logout flows pass end-to-end against Vouch. Co-Authored-By: Claude <[email protected]>
mcp 2.0 removed mcp.server.fastmcp, which is what was breaking these two examples before the pins went in. FastMCP -> MCPServer mcp.server.fastmcp -> mcp.server.mcpserver host/port/json_response moved from the constructor to run() Both servers also drop their contextvars side channel. 2.0's AccessToken carries a `claims` field, so the verified payload rides on the SDK's own per-request auth context and tools read it via get_access_token(). That removes the assumption the old code depended on -- that verify_token and the tool body share an asyncio task context -- which 2.0's dispatcher no longer guarantees. Verified: both build, smoke passes, and the full MCP suite passes 15/15, including whoami and sensitive-action, which are what actually exercise the new auth context, plus the wrong-audience rejection test. Co-Authored-By: Claude <[email protected]>
CI status27/27 smoke jobs pass — every example builds, starts, and answers. All 8 CodeQL The red CodeQL check is a line-shift artifact, not a regressionIt reports 2 high alerts, both
Both already exist on Nothing in this PR added a route handler or removed rate limiting. Verified with: The check also warns that 3 configurations on Worth fixing on its own, though: rate-limiting the OAuth callback and the MCP token-verification endpoints is reasonable even for demonstration code, and it would clear all 7. I left it out here because it is unrelated to this PR's scope and would mean adding |
GitHub reported 15 open Dependabot alerts against the lockfiles that landed in #46, six of them high. Dependabot opened eight PRs for them; this fixes the underlying alerts in one change instead. react-router 7 -> 8 in spa/react is a genuine major, and the only route to the fix: react-router-dom is capped at 7.18.2, so the patched version only exists in the `react-router` package. Four imports move (BrowserRouter, Routes, Route, useNavigate). This was the last outstanding major upgrade in the repo. Everything else was transitive. Most cleared with `npm audit fix`, which is non-breaking: path-to-regexp 8.3.0 -> 8.4.2 HIGH, ReDoS qs 6.15.0 -> 6.15.3 MEDIUM, DoS body-parser 2.2.2 -> 2.3.0 LOW Three could not, because npm's own fix proposes a multi-major downgrade of the direct dependency -- [email protected] (from 16), @sveltejs/[email protected] (from 2.70), @angular/cli@21 (from 22). Those use npm `overrides` to pin the patched transitive instead: postcss ^8.5.24 HIGH, XSS + path traversal sharp ^0.35.3 HIGH, libvips CVEs cookie ^0.7.2 LOW @hono/node-server ^2.0.12 MEDIUM That last one also resolves the advisory left open in #46, where the only offered fix was downgrading the Angular CLI. Supersedes Dependabot #41 and #48-55; every version those PRs asked for is now met or exceeded. The two rand PRs no longer apply at all -- jsonwebtoken's rust_crypto backend pulls 0.8.7 and 0.10.2, not the 0.9.x line they target. Verified: 27/27 build and smoke; web, mcp, a2a, native and claims suites = 69 passed, 0 failures. npm audit clean in all 11 directories, pip-audit clean in all 10. Co-authored-by: Claude <[email protected]>
Ten commits, each self-contained and independently verified. Best reviewed commit by commit.
Why
Three problems, found while auditing coverage and versions:
Three examples were broken and CI could not see it. CI built images but never ran them. Every Python example used floor-only
>=pins with no lockfile, sopip installresolved fresh on each build.mcp2.0.0 anda2a-sdk1.x had shipped underneath them:node:25reached end-of-life on 2026-06-01 and was used by 10 Dockerfiles.Every example read
hardware_verifiedfrom an unverified token — splitting the access token on.and base64-decoding the payload with no signature check.What changed
Reproducibility. Every example now installs from a committed lockfile:
requirements.incompiled withuv pip compile --universal,npm ci(the oldpackage-lock.json*glob silently tolerated 8 missing lockfiles),cargo --locked, frozen bundler,--locked-modeNuGet, a firstcomposer.lock, and a realGemfile.lock— the committed one was 0 bytes.There were also no
.dockerignorefiles at all, soCOPY . .shipped the host'snode_modulesandtarget/into images, overwriting what the Dockerfile had just installed. Added to all 27 contexts.CI now runs each example, not just builds it (
scripts/smoke.sh). Verified against deliberately broken images: it catches both a missing module and a stalesedglob in an SPAentrypoint.sh, exiting non-zero for each.Token verification.
tests/tests/claims.spec.jsestablishes what a Vouch access token actually is — an ES256typ: at+jwtRFC 9068 JWT that verifies against/oauth/jwks. All server-side clients and native CLIs now verify it (issuer + audience + typ). The MCP and A2A servers additionally enforceaudagainst their RFC 9728 resource identifier; a negative test proves a valid token minted for a different client is now rejected with 401.The five browser SPAs deliberately do not verify — renamed to
decodeUnverifiedForDisplaywith a comment explaining the decision belongs to the resource server.Upgrades. Node 26, Rust 1.97, Angular 22 + TypeScript 6, Laravel 13, Spring Boot 4.1, mcp 2.0, go-oidc v3.20, .NET 10.0.10, plus pinned
nginx/composertags and the missinggithub-actionsDependabot ecosystem — without which the SHA pins thatsecure_workflows.ymlenforces were never updated.Findings worth acting on separately
hardware_aaguiddoes not exist. It was documented in the README and rendered by ~10 examples, all showing "N/A". Now referenced only inclaims.spec.js, which asserts its absence.acr/amrare better thanhardware_verified— they arrive on both tokens unrequested (acr=...aal3,amr=["hwk","pin","user"]), are standard, and are what Vouch advertises inclaims_supported. Examples now surface them.application_type: spaornativeget noclient_secretbut come back withtoken_endpoint_auth_method: client_secret_basic, so the token endpoint answers401 invalid_client. Passing"none"at creation is silently ignored. Reproduces on unmodifiedmain; documented increateApp. Needs a fix in Vouch beforespa.spec.jscan pass.mcp/credential-brokerkeepsverify_aud: Falsedeliberately — it forwards the caller's token to/v1/credentials/*, which rejects audience-narrowed tokens. The real fix is RFC 8693 token exchange.Held back deliberately
reqwest0.12 in axum (pinned byopenidconnect4 →oauth25) · TypeScript~5.9inmcp/remote-server-ts(TS 7 is the native port) ·next-auth4.x (genuinely latest-stable; v5 is beta) ·a2a-sdkat 0.2.16, pinned below the breaking major and migrated separately.Verification
web,mcp,a2a,native,claimssuites: 69 passed, 0 failuresruff,gofmt,go vet,cargo fmt,clippy,tsc,shellcheck,actionlint,zizmorall cleanSupersedes #41, which predates the lockfile rewrite.
🤖 Generated with Claude Code