mirror of
https://github.com/aaif-goose/goose.git
synced 2026-06-01 22:09:18 +02:00
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>
This commit is contained in:
@@ -28,7 +28,7 @@ Edit `wrangler.toml` for your upstream:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `OIDC_ISSUER` | `https://token.actions.githubusercontent.com` |
|
| `OIDC_ISSUER` | `https://token.actions.githubusercontent.com` |
|
||||||
| `OIDC_AUDIENCE` | The audience your workflow requests (e.g. `goose-oidc-proxy`) |
|
| `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`) |
|
| `MAX_REQUESTS_PER_TOKEN` | Max requests per OIDC token (default: `200`) |
|
||||||
| `RATE_LIMIT_PER_SECOND` | Max requests per second per token (default: `2`) |
|
| `RATE_LIMIT_PER_SECOND` | Max requests per second per token (default: `2`) |
|
||||||
| `ALLOWED_REPOS` | *(optional)* Comma-separated `owner/repo` list |
|
| `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
|
## 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.
|
||||||
|
|||||||
@@ -190,17 +190,16 @@ async function verifyOidcToken(token, env) {
|
|||||||
const header = decodeJwtPart(headerB64);
|
const header = decodeJwtPart(headerB64);
|
||||||
const payload = decodeJwtPart(payloadB64);
|
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) {
|
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)) {
|
if (age > parseInt(env.MAX_TOKEN_AGE_SECONDS, 10)) {
|
||||||
return { valid: false, reason: "Token too old" };
|
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 expectedIssuer = env.OIDC_ISSUER.replace(/\/$/, "");
|
||||||
const actualIssuer = (payload.iss || "").replace(/\/$/, "");
|
const actualIssuer = (payload.iss || "").replace(/\/$/, "");
|
||||||
if (actualIssuer !== expectedIssuer) {
|
if (actualIssuer !== expectedIssuer) {
|
||||||
|
|||||||
@@ -206,6 +206,62 @@ describe("rejects invalid requests", () => {
|
|||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
expect((await response.json()).error).toBe("Token too old");
|
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", () => {
|
describe("proxies valid requests", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user