Skip to content

Make examples reproducible, verify access tokens, and refresh dependencies - #46

Merged
jplock merged 10 commits into
mainfrom
chore/reproducibility-and-token-verification
Jul 28, 2026
Merged

jplock merged 10 commits into
mainfrom
chore/reproducibility-and-token-verification

Conversation

@jplock

@jplock jplock commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Ten commits, each self-contained and independently verified. Best reviewed commit by commit.

Why

Three problems, found while auditing coverage and versions:

  1. 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, so pip install resolved fresh on each build. mcp 2.0.0 and a2a-sdk 1.x had shipped underneath them:

    mcp/remote-server-py    ModuleNotFoundError: No module named 'mcp.server.fastmcp'
    mcp/credential-broker   ModuleNotFoundError: No module named 'mcp.server.fastmcp'
    a2a/python-agent        ModuleNotFoundError: No module named 'a2a.server.apps'
    
  2. node:25 reached end-of-life on 2026-06-01 and was used by 10 Dockerfiles.

  3. Every example read hardware_verified from 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.in compiled with uv pip compile --universal, npm ci (the old package-lock.json* glob silently tolerated 8 missing lockfiles), cargo --locked, frozen bundler, --locked-mode NuGet, a first composer.lock, and a real Gemfile.lock — the committed one was 0 bytes.

There were also no .dockerignore files at all, so COPY . . shipped the host's node_modules and target/ 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 stale sed glob in an SPA entrypoint.sh, exiting non-zero for each.

Token verification. tests/tests/claims.spec.js establishes what a Vouch access token actually is — an ES256 typ: at+jwt RFC 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 enforce aud against 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 decodeUnverifiedForDisplay with 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/composer tags and the missing github-actions Dependabot ecosystem — without which the SHA pins that secure_workflows.yml enforces were never updated.

Findings worth acting on separately

  • hardware_aaguid does not exist. It was documented in the README and rendered by ~10 examples, all showing "N/A". Now referenced only in claims.spec.js, which asserts its absence.
  • acr/amr are better than hardware_verified — they arrive on both tokens unrequested (acr=...aal3, amr=["hwk","pin","user"]), are standard, and are what Vouch advertises in claims_supported. Examples now surface them.
  • The SPA E2E tests fail, and it is server-side. Apps created with application_type: spa or native get no client_secret but come back with token_endpoint_auth_method: client_secret_basic, so the token endpoint answers 401 invalid_client. Passing "none" at creation is silently ignored. Reproduces on unmodified main; documented in createApp. Needs a fix in Vouch before spa.spec.js can pass.
  • mcp/credential-broker keeps verify_aud: False deliberately — 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

reqwest 0.12 in axum (pinned by openidconnect 4 → oauth2 5) · TypeScript ~5.9 in mcp/remote-server-ts (TS 7 is the native port) · next-auth 4.x (genuinely latest-stable; v5 is beta) · a2a-sdk at 0.2.16, pinned below the breaking major and migrated separately.

Verification

  • 27/27 images build and pass smoke
  • web, mcp, a2a, native, claims suites: 69 passed, 0 failures
  • ruff, gofmt, go vet, cargo fmt, clippy, tsc, shellcheck, actionlint, zizmor all clean

Supersedes #41, which predates the lockfile rewrite.

🤖 Generated with Claude Code

jplock and others added 10 commits July 28, 2026 14:14
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]>
Comment thread web/express-openid/app.js Outdated
Comment on lines 90 to 123
@jplock

jplock commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

CI status

27/27 smoke jobs pass — every example builds, starts, and answers. All 8 CodeQL Analyze jobs pass.

The red CodeQL check is a line-shift artifact, not a regression

It reports 2 high alerts, both js/missing-rate-limiting:

File Line (this PR) Line (main)
web/express-openid/app.js 104 90
spa/bff-express/app.js 74 63

Both already exist on main, opened 2026-03-18. There are 7 alerts of this rule on main in total, including three in mcp/remote-server-ts. My edits moved the route handlers down a few lines, so CodeQL re-attributes them as "new in code changed by this pull request."

Nothing in this PR added a route handler or removed rate limiting. Verified with:

gh api "repos/vouch-sh/examples/code-scanning/alerts?ref=refs/heads/main&state=open" \
  --jq '.[] | select(.rule.id=="js/missing-rate-limiting")'

The check also warns that 3 configurations on main (csharp, java-kotlin, rust) were not found — those jobs did run and pass here, so that part looks like a default-setup mismatch rather than anything in the diff.

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 express-rate-limit to three more examples.

@jplock
jplock merged commit 7daeca3 into main Jul 28, 2026
37 of 38 checks passed
@jplock
jplock deleted the chore/reproducibility-and-token-verification branch July 28, 2026 23:38
jplock added a commit that referenced this pull request Jul 29, 2026
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants