Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,12 @@ updates:
- "/web/aspnet-core"
patterns: ["*"]
multi-ecosystem-group: "all"

# Workflow actions are SHA-pinned and enforced by secure_workflows.yml, so without
# this they never move. Dependabot preserves the `owner/repo@<sha> # vX.Y.Z` form
# and updates both halves together.
- package-ecosystem: "github-actions"
directories:
- "/"
patterns: ["*"]
multi-ecosystem-group: "all"
24 changes: 21 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# CI: build each example via Docker to verify Dockerfiles and dependencies
# CI: build each example, then start it and check it actually serves.
#
# Building alone is not enough: an example whose dependencies resolved to a breaking
# major still builds fine and only fails when the process starts, so the smoke step
# is what gives this workflow real signal. It uses throwaway credentials and needs no
# Vouch account. The credentialed end-to-end suite in tests/ is run by hand.
name: CI

on:
Expand All @@ -9,9 +14,12 @@ on:
branches:
- main

permissions:
contents: read

jobs:
build:
name: Build example
name: Build and smoke-test example
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand Down Expand Up @@ -48,6 +56,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
Expand All @@ -57,6 +67,14 @@ jobs:
with:
context: ${{ matrix.directory }}
push: false
tags: vouch-example-${{ matrix.directory }}
# load makes the image available to the local daemon so the next step can run it.
load: true
tags: vouch-example:${{ strategy.job-index }}
cache-from: type=gha,scope=${{ matrix.directory }}
cache-to: type=gha,mode=max,scope=${{ matrix.directory }}

- name: Smoke-test example
env:
EXAMPLE_DIR: ${{ matrix.directory }}
IMAGE_TAG: vouch-example:${{ strategy.job-index }}
run: ./scripts/smoke.sh "$EXAMPLE_DIR" "$IMAGE_TAG"
2 changes: 2 additions & 0 deletions .github/workflows/secure_workflows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Ensure 3rd party workflows have SHA pinned
uses: zgosalvez/github-actions-ensure-sha-pinned-actions@6124774845927d14c601359ab8138699fa5b70c3 # v4.0.1
11 changes: 10 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ vendor/
target/
.gradle/
build/
__pycache__/
.venv/

# Environment
.env
Expand All @@ -22,5 +24,12 @@ Thumbs.db
# Build artifacts
dist/
.next/
.svelte-kit/
.angular/
obj/
bin/
tests/playwright-report/
tests/test-results/
tests/test-results/
# Go build output
web/go-oidc/go-oidc
web/go-oidc/server
51 changes: 42 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ A collection of self-contained example applications demonstrating OIDC integrati
Examples are organized by client type:

- **`web/`** — Server-side apps (confidential clients, Authorization Code flow with client secret). 11 examples across Node.js, Python, Ruby, PHP, Java, Go, Rust, C#.
- **`spa/`** — Browser-only apps (public clients, PKCE flow, no client secret). 5 examples: React, Vue, Angular, SvelteKit, Vanilla JS.
- **`native/`** — CLI/terminal apps (public clients, Device Authorization Grant / RFC 8628). 3 examples: Node.js, Python, Rust.
- **`mcp/`** — Model Context Protocol servers with bearer token auth + RFC 9728 Protected Resource Metadata. TypeScript and Python.
- **`spa/`** — Browser-based apps. 6 examples: React, Vue, Angular, SvelteKit, Vanilla JS (all public clients using PKCE), plus `bff-express` — a Backend-for-Frontend that is a **confidential** client and does require `VOUCH_CLIENT_SECRET`.
- **`native/`** — CLI/terminal apps (public clients, Device Authorization Grant / RFC 8628). 6 examples: Node.js, Python, Rust, plus three Python credential-brokering agents (`python-agent-aws`, `python-agent-github`, `python-agent-multi`).
- **`mcp/`** — Model Context Protocol servers with bearer token auth + RFC 9728 Protected Resource Metadata. 3 examples: `remote-server-ts`, `remote-server-py`, `credential-broker`.
- **`a2a/`** — Agent-to-Agent protocol with OIDC security scheme in the Agent Card. Python.
- **`tests/`** — Playwright end-to-end suite (not an example). Driven by the root `Makefile`.

27 examples total.

## Build and Run

Expand All @@ -35,16 +38,31 @@ SPA examples omit `VOUCH_CLIENT_SECRET`. Native examples omit both `VOUCH_CLIENT

## CI

GitHub Actions (`.github/workflows/ci.yml`) builds all 22 Dockerfiles on push/PR to `main` using a matrix strategy with `docker/build-push-action` and GitHub Actions cache. No test suites exist — CI validates that each Docker image builds successfully.
GitHub Actions (`.github/workflows/ci.yml`) builds all 27 Dockerfiles on push/PR to `main` using a matrix strategy with `docker/build-push-action` and GitHub Actions cache, then **starts each image and checks it serves** via `scripts/smoke.sh`.

The smoke step matters more than the build. A build succeeds even when dependencies resolved to a breaking major — the failure only appears when the process starts. Run it locally the same way CI does:

```bash
docker build -t my-example web/express-openid
scripts/smoke.sh web/express-openid my-example
```

It uses throwaway credentials and needs no Vouch account. Probes are chosen per category: web apps must render `/`; static SPAs must render `/` **and** have their `__VOUCH_*` placeholders substituted into the built bundle (`entrypoint.sh` exits 0 even when its `sed` glob matches nothing, so this is the only thing catching a bundler output-layout change); MCP and A2A servers must serve their well-known metadata and reject unauthenticated calls with 401; native CLIs must get past module loading.

A Playwright end-to-end suite lives in `tests/` (specs for web, spa, native, mcp, a2a) and is driven by the root `Makefile` (`make test`, `make test-mcp`, …). It is **not** wired into CI: it shells out to the macOS Keychain for a DPoP signing key, needs a live hardware-key-backed Vouch session (`~/.vouch/cookie.txt`), and creates/destroys real OAuth applications. Run it locally before merging anything non-trivial.

## Adding a New Example

1. Create a directory under the appropriate category (`web/`, `spa/`, `native/`, `mcp/`, `a2a/`).
2. Include a `Dockerfile` that exposes port 3000 and reads `VOUCH_ISSUER`, `VOUCH_CLIENT_ID`, and (if applicable) `VOUCH_CLIENT_SECRET` / `VOUCH_REDIRECT_URI` from environment variables.
3. Add a `README.md` following the style of existing examples.
4. Add the directory to the matrix in `.github/workflows/ci.yml`.
5. Add a Dependabot entry in `.github/dependabot.yml` for the relevant package ecosystem.
6. Update the table in the root `README.md`.
3. **Commit a lockfile** and install from it — see Dependency Management below.
4. **Add a `.dockerignore`** excluding at minimum `.git` and the ecosystem's build output (`node_modules`, `target`, `vendor`, `__pycache__`, `obj`). Without it, `COPY . .` ships your host's build artifacts into the image and overwrites what the Dockerfile installed.
5. Add a `README.md` following the style of existing examples.
6. Add the directory to the matrix in `.github/workflows/ci.yml`.
7. Add a Dependabot entry in `.github/dependabot.yml` — **two entries**: one for the language ecosystem and one under `docker`. Every example appears in the docker list.
8. Confirm `scripts/smoke.sh <dir>` passes. If the example does not match an existing category, add a case for it.
9. Update the table in the root `README.md`.
10. Register the example for end-to-end tests: add it to `tests/src/examples.js`, and if it needs a new spec, add one under `tests/tests/` plus a script in `tests/package.json` and a target in the root `Makefile`.

## Environment Variables

Expand All @@ -57,4 +75,19 @@ GitHub Actions (`.github/workflows/ci.yml`) builds all 22 Dockerfiles on push/PR

## Dependency Management

8 package ecosystems managed by Dependabot (`.github/dependabot.yml`): npm, pip, Docker, gomod, cargo, bundler, composer, maven, nuget. All updates are grouped into a single weekly PR.
9 package ecosystems managed by Dependabot (`.github/dependabot.yml`): npm, pip, Docker, gomod, cargo, bundler, composer, maven, nuget. All updates are grouped into a single weekly PR.

**Every example installs from a committed lockfile.** Builds must be reproducible, and an example that silently resolves to a new major is worse than no example — CI only builds images, so a breaking install is invisible until someone runs it.

| Ecosystem | Source of truth | Regenerate with | Dockerfile installs with |
|-----------|-----------------|-----------------|--------------------------|
| Python | `requirements.in` (floors) → `requirements.txt` (fully pinned) | `uv pip compile requirements.in --universal --python-version 3.14 -o requirements.txt` | `pip install -r requirements.txt` |
| npm | `package.json` → `package-lock.json` | `npm install --package-lock-only` | `npm ci` |
| Cargo | `Cargo.toml` → `Cargo.lock` | `cargo update` | `cargo build --release --locked` |
| Go | `go.mod` → `go.sum` | `go get <mod>@<ver> && go mod tidy` | `go build` (never `go mod tidy`) |
| Bundler | `Gemfile` → `Gemfile.lock` | `bundle lock --add-platform x86_64-linux --add-platform aarch64-linux` | `bundle config set --local frozen true && bundle install` |
| Composer | `composer.json` → `composer.lock` | `docker run --rm -v "$PWD":/app -w /app composer:2.9 composer update --no-install` | `composer install --no-dev` |
| NuGet | `*.csproj` → `packages.lock.json` | `dotnet restore --use-lock-file` | `dotnet restore --locked-mode` |
| Maven | `pom.xml` (BOM-pinned) | — | `mvn package` |

`--universal` on the Python compile is required, not optional: development is arm64 macOS and CI builds linux/amd64, so a platform-specific resolution would pin wheels that don't exist on the other architecture.
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: test test-web test-spa test-native test-mcp test-a2a install report
.PHONY: test test-web test-spa test-native test-mcp test-a2a test-claims install report

test:
cd tests && npm test
Expand All @@ -18,6 +18,9 @@ test-mcp:
test-a2a:
cd tests && npm run test:a2a

test-claims:
cd tests && npm run test:claims

install:
cd tests && npm install

Expand Down
69 changes: 61 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Server-side applications that securely store a client secret. Uses the Authoriza

### Single Page Applications (Public Clients)

Browser-only applications using PKCE (no client secret required).
Browser-based applications. The first five are public clients using PKCE with no client secret.

| Framework | Directory | Language |
|-----------|-----------|----------|
Expand All @@ -44,6 +44,11 @@ Browser-only applications using PKCE (no client secret required).
| Angular + angular-auth-oidc-client | [`spa/angular`](spa/angular) | TypeScript |
| BFF + Express (recommended) | [`spa/bff-express`](spa/bff-express) | Node.js |

> [!IMPORTANT]
> [`spa/bff-express`](spa/bff-express) is the exception: it is a **confidential** client and
> requires `VOUCH_CLIENT_SECRET`. Tokens stay on the server and the browser only ever receives an
> HttpOnly session cookie.

### Native & CLI Applications (Public Clients)

Terminal tools and headless servers using the Device Authorization Grant (RFC 8628).
Expand Down Expand Up @@ -88,7 +93,9 @@ docker run -p 3000:3000 \
```

> [!NOTE]
> SPA examples do not require `VOUCH_CLIENT_SECRET`. Native/CLI examples do not require `VOUCH_REDIRECT_URI` or `VOUCH_CLIENT_SECRET`.
> SPA examples do not require `VOUCH_CLIENT_SECRET`, except [`spa/bff-express`](spa/bff-express),
> which is a confidential client. Native/CLI examples do not require `VOUCH_REDIRECT_URI` or
> `VOUCH_CLIENT_SECRET`.

## Environment Variables

Expand Down Expand Up @@ -130,14 +137,60 @@ Several examples go beyond basic login to demonstrate real-world OIDC patterns:

## Custom Claims

Vouch access tokens ([RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) JWTs) include these additional claims:
Vouch access tokens are [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) JWTs
(`typ: at+jwt`), signed with ES256 and verifiable against `{VOUCH_ISSUER}/oauth/jwks`.
Alongside the standard claims they carry:

| Claim | Type | Where | Description |
|-------|------|-------|-------------|
| `acr` | string | access token + id_token | `urn:nist:authentication:assurance-level:aal3` |
| `amr` | string[] | access token + id_token | Authentication methods, e.g. `["hwk","pin","user"]`. `hwk` is [RFC 8176](https://www.rfc-editor.org/rfc/rfc8176) for a hardware-secured key |
| `hardware_verified` | boolean | access token only | Vouch-specific; `true` when a hardware key was used |

Prefer `acr` and `amr` where you can — they are standard, they appear in Vouch's
`claims_supported`, and they are present on both tokens. `hardware_verified` is
equivalent but Vouch-specific and absent from the id_token.

> [!IMPORTANT]
> **Verify the token before trusting any of these claims.** Decoding a JWT payload
> without checking its signature means trusting whatever the caller sent. Resource
> servers must additionally validate `aud` — see below.

## Audience and Resource Indicators

By default an access token's `aud` is the **requesting client's own `client_id`**, which a
resource server has no way to anticipate. To let a server validate the audience, the client
requests a specific resource with the [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)
`resource` parameter at the authorization endpoint, and Vouch narrows `aud` to that value.

The MCP and A2A examples publish their resource identifier in their metadata
([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) and validate `aud` against the same
value, so a token minted for a different client is rejected. Set `VOUCH_AUDIENCE` when the
server's public URL differs from its listening port.

They also require `typ: at+jwt`, which structurally rejects ID tokens. An ID token is not a
bearer credential and must never be accepted as one.

| Claim | Type | Description |
|-------|------|-------------|
| `hardware_verified` | boolean | Always `true` for Vouch sessions — confirms a hardware key was used |
| `hardware_aaguid` | string | Identifies the authenticator hardware model |
## Testing

These claims are **not** in the OIDC id_token or userinfo response. Examples decode the access token JWT payload to read them.
Every example is smoke-tested in CI: the image is built, started, and probed to confirm it
actually serves. This needs no Vouch account and can be run locally:

```bash
docker build -t my-example web/express-openid
scripts/smoke.sh web/express-openid my-example
```

A full end-to-end Playwright suite lives in [`tests/`](tests) and drives real browser login
flows against Vouch. It requires a live Vouch CLI session and creates temporary OAuth
applications, so it runs locally rather than in CI:

```bash
make install
make test # all examples
make test-web # or: test-spa, test-native, test-mcp, test-a2a, test-claims
make report # open the last HTML report
```

## Security Considerations

Expand Down
12 changes: 12 additions & 0 deletions a2a/python-agent/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.git
.gitignore
.dockerignore
README.md
.env
.env.local
__pycache__
*.py[cod]
.venv
venv
*.egg-info
db.sqlite3
1 change: 1 addition & 0 deletions a2a/python-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This example demonstrates:
| Variable | Required | Description |
|----------|----------|-------------|
| `VOUCH_ISSUER` | No | Vouch issuer URL (default: `https://us.vouch.sh`) |
| `VOUCH_AUDIENCE` | No | This server's RFC 9728 resource identifier. Published in its metadata and enforced as the token's `aud`. Defaults to `http://localhost:$PORT`; set it when the public URL differs. |

## Run

Expand Down
19 changes: 13 additions & 6 deletions a2a/python-agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
VOUCH_ISSUER = os.environ.get('VOUCH_ISSUER', 'https://us.vouch.sh')
PORT = int(os.environ.get('PORT', '3000'))

# This agent's resource identifier. Callers pass it as the RFC 8707 `resource`
# parameter when they authorize, so Vouch narrows the access token's `aud` to it
# and we can prove the token was minted for us specifically.
RESOURCE = os.environ.get('VOUCH_AUDIENCE', f'http://localhost:{PORT}')

jwks_client = PyJWKClient(f'{VOUCH_ISSUER}/oauth/jwks')


Expand All @@ -29,15 +34,20 @@ def verify_bearer_token(request: Request) -> dict | None:
return None
token = auth[7:]
try:
# RFC 9068 access tokens carry `typ: at+jwt`. Requiring it rejects ID tokens,
# which are not bearer credentials no matter whose they are.
if jwt.get_unverified_header(token).get('typ', '').lower() != 'at+jwt':
return None
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
return jwt.decode(
token,
signing_key.key,
algorithms=[signing_key.algorithm_name],
issuer=VOUCH_ISSUER,
options={'verify_aud': False},
# Without an audience check, any Vouch-issued token reaches this agent,
# including one minted for an unrelated client.
audience=RESOURCE,
)
return payload
except Exception:
return None

Expand Down Expand Up @@ -98,10 +108,7 @@ async def cancel(self, context, event_queue):
)

# Wrap with auth middleware
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Route


async def auth_middleware(request: Request, call_next):
Expand Down
5 changes: 5 additions & 0 deletions a2a/python-agent/requirements.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
a2a-sdk[http-server]>=0.2.0,<0.3
uvicorn>=0.51
PyJWT>=2.13
cryptography>=49.0
httpx>=0.28
Loading
Loading