diff --git a/oidc-proxy/README.md b/oidc-proxy/README.md index 361eb99dfb..8f442125f0 100644 --- a/oidc-proxy/README.md +++ b/oidc-proxy/README.md @@ -28,7 +28,7 @@ Edit `wrangler.toml` for your upstream: |---|---| | `OIDC_ISSUER` | `https://token.actions.githubusercontent.com` | | `OIDC_AUDIENCE` | The audience your workflow requests (e.g. `goose-oidc-proxy`) | -| `MAX_TOKEN_AGE_SECONDS` | Max age of OIDC token in seconds (default: `1200` = 20 min) | +| `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 | @@ -108,4 +108,9 @@ Both limits are enforced atomically — the Durable Object processes one request ## Token age vs expiry -GitHub OIDC tokens expire after ~5 minutes. For longer-running jobs, set `MAX_TOKEN_AGE_SECONDS` to allow recently-expired tokens. When set, the proxy checks the token's `iat` (issued-at) claim instead of `exp`. +The proxy enforces **both** gates and a token must pass each: + +1. The IdP's `exp` claim (always enforced). +2. The operator's `MAX_TOKEN_AGE_SECONDS` cap on `iat`, when configured (default `1200`s = 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. diff --git a/oidc-proxy/src/index.js b/oidc-proxy/src/index.js index a942a95e53..8188a52852 100644 --- a/oidc-proxy/src/index.js +++ b/oidc-proxy/src/index.js @@ -190,17 +190,16 @@ async function verifyOidcToken(token, env) { const header = decodeJwtPart(headerB64); const payload = decodeJwtPart(payloadB64); + if (!payload.exp || payload.exp < Date.now() / 1000) { + return { valid: false, reason: "Token expired" }; + } if (env.MAX_TOKEN_AGE_SECONDS && payload.iat) { - const age = Date.now() / 1000 - payload.iat; + const age = Math.floor(Date.now() / 1000) - payload.iat; if (age > parseInt(env.MAX_TOKEN_AGE_SECONDS, 10)) { return { valid: false, reason: "Token too old" }; } } - if (!payload.exp || payload.exp < Date.now() / 1000) { - return { valid: false, reason: "Token expired" }; - } - const expectedIssuer = env.OIDC_ISSUER.replace(/\/$/, ""); const actualIssuer = (payload.iss || "").replace(/\/$/, ""); if (actualIssuer !== expectedIssuer) { diff --git a/oidc-proxy/test/index.test.js b/oidc-proxy/test/index.test.js index 1a1d0b8bbf..41de473856 100644 --- a/oidc-proxy/test/index.test.js +++ b/oidc-proxy/test/index.test.js @@ -206,6 +206,62 @@ describe("rejects invalid requests", () => { expect(response.status).toBe(401); expect((await response.json()).error).toBe("Token too old"); }); + + it("age cap fires independently of exp (iat past cap, exp still valid)", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await createSignedJwt( + validPayload({ iat: now - 1500, exp: now + 300 }), + ); + const request = new Request("https://proxy.example.com/v1/messages", { + headers: { "x-api-key": token }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, testEnv(), ctx); + await waitOnExecutionContext(ctx); + + expect(response.status).toBe(401); + expect((await response.json()).error).toBe("Token too old"); + }); + + it("rejects expired token even when MAX_TOKEN_AGE_SECONDS is set", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await createSignedJwt( + validPayload({ iat: now - 600, exp: now - 300 }), + ); + const request = new Request("https://proxy.example.com/v1/messages", { + method: "POST", + headers: { "x-api-key": token, "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, testEnv(), ctx); + await waitOnExecutionContext(ctx); + + expect(response.status).toBe(401); + expect((await response.json()).error).toBe("Token expired"); + }); + + it("rejects expired token when MAX_TOKEN_AGE_SECONDS is unset", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await createSignedJwt( + validPayload({ iat: now - 600, exp: now - 300 }), + ); + const request = new Request("https://proxy.example.com/v1/messages", { + method: "POST", + headers: { "x-api-key": token, "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const ctx = createExecutionContext(); + const response = await worker.fetch( + request, + testEnv({ MAX_TOKEN_AGE_SECONDS: undefined }), + ctx, + ); + await waitOnExecutionContext(ctx); + + expect(response.status).toBe(401); + expect((await response.json()).error).toBe("Token expired"); + }); }); describe("proxies valid requests", () => {