Signed-off-by: jeffhuang <jeffwalt630@gmail.com> Signed-off-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Douwe Osinga <douwe@squareup.com> |
||
|---|---|---|
| .. | ||
| src | ||
| test | ||
| .gitignore | ||
| anthropic-oidc-proxy.json | ||
| package.json | ||
| README.md | ||
| vitest.config.js | ||
| wrangler.toml | ||
OIDC Proxy
A Cloudflare Worker that authenticates GitHub Actions OIDC tokens and proxies requests to an upstream API with an injected API key. This lets CI workflows call APIs without storing long-lived secrets in GitHub.
How it works
GitHub Actions (OIDC token) → Worker (validate JWT, inject API key) → Upstream API
- A GitHub Actions workflow mints an OIDC token with a configured audience
- The workflow sends requests to this proxy, passing the OIDC token as the API key
- The worker validates the JWT against GitHub's JWKS, checks issuer/audience/age/repo
- If valid, the request is forwarded to the upstream API with the real API key injected
Setup
cd oidc-proxy
npm install
Configuration
Edit wrangler.toml for your upstream:
| Variable | Description |
|---|---|
OIDC_ISSUER |
https://token.actions.githubusercontent.com |
OIDC_AUDIENCE |
The audience your workflow requests (e.g. goose-oidc-proxy) |
MAX_TOKEN_AGE_SECONDS |
Operator-configured upper bound on iat age in seconds (default: 1200 = 20 min). Applied in addition to the IdP's exp claim, never as a replacement. |
MAX_REQUESTS_PER_TOKEN |
Max requests per OIDC token (default: 200) |
RATE_LIMIT_PER_SECOND |
Max requests per second per token (default: 2) |
ALLOWED_REPOS |
(optional) Comma-separated owner/repo list |
ALLOWED_REFS |
(optional) Comma-separated allowed refs |
UPSTREAM_URL |
The upstream API base URL |
UPSTREAM_AUTH_HEADER |
Header name for the API key (e.g. x-api-key, Authorization) |
UPSTREAM_AUTH_PREFIX |
(optional) Prefix before the key (e.g. Bearer ) — omit for raw value |
CORS_ORIGIN |
(optional) Allowed CORS origin |
CORS_EXTRA_HEADERS |
(optional) Additional CORS allowed headers |
Set your upstream API key as a secret:
npx wrangler secret put UPSTREAM_API_KEY
Example: Anthropic
UPSTREAM_URL = "https://api.anthropic.com"
UPSTREAM_AUTH_HEADER = "x-api-key"
CORS_EXTRA_HEADERS = "anthropic-version"
Example: OpenAI-compatible
UPSTREAM_URL = "https://api.openai.com"
UPSTREAM_AUTH_HEADER = "Authorization"
UPSTREAM_AUTH_PREFIX = "Bearer "
Usage in GitHub Actions
permissions:
id-token: write
steps:
- name: Get OIDC token
id: oidc
uses: actions/github-script@v7
with:
script: |
const token = await core.getIDToken('goose-oidc-proxy');
core.setOutput('token', token);
core.setSecret(token);
- name: Call API through proxy
env:
ANTHROPIC_BASE_URL: https://oidc-proxy.your-subdomain.workers.dev
ANTHROPIC_API_KEY: ${{ steps.oidc.outputs.token }}
run: goose run --recipe my-recipe.yaml
Testing
npm test
Deploy
npx wrangler secret put UPSTREAM_API_KEY
npm run deploy
Token budget and rate limiting
Each OIDC token is tracked by its jti (JWT ID) claim using a Durable Object. This provides:
- Budget: Each token is limited to
MAX_REQUESTS_PER_TOKENtotal requests (default: 200). Once exhausted, the proxy returns429with{"error": "Token budget exhausted"}. - Rate limit: Each token is limited to
RATE_LIMIT_PER_SECONDrequests per second (default: 2). When exceeded, the proxy returns429with{"error": "Rate limit exceeded"}and aRetry-After: 1header.
Both limits are enforced atomically — the Durable Object processes one request at a time per token, so there are no race conditions.
Token age vs expiry
The proxy enforces both gates and a token must pass each:
- The IdP's
expclaim (always enforced). - The operator's
MAX_TOKEN_AGE_SECONDScap oniat, when configured (default1200s = 20 min).
MAX_TOKEN_AGE_SECONDS is a stricter upper bound on top of exp — it cannot extend a token past its exp. For workflows longer than the IdP's token lifetime (GitHub OIDC issues exp = iat + 300 ≈ 5 min), refresh the OIDC token rather than relying on MAX_TOKEN_AGE_SECONDS to accept expired tokens.