Skip to content

Commit 46ea65d

Browse files
simple-agent-manager[bot]raphaeltmclaude
authored
fix: codex refresh proxy security hardening (#603)
* chore: move task to active Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address deferred security findings from codex refresh proxy - Add per-workspace rate limiting to /api/auth/codex-refresh endpoint (default: 30/hour, configurable via RATE_LIMIT_CODEX_REFRESH) - Add scope validation on upstream token responses with warning log (configurable via CODEX_EXPECTED_SCOPES, non-blocking) - Document token-in-URL accepted risk in secrets-taxonomy.md with mitigations (short-lived JWT, scope enforcement, rate limiting) - Document JWT token lifetimes and callback token design rationale Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use structured logger for scope validation warnings Replace console.warn with log.warn from lib/logger to comply with no-console lint rule for API code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update task checklist with completed items Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address validator findings — doc sync and edge case test - Add RATE_LIMIT_CODEX_REFRESH and CODEX_EXPECTED_SCOPES to .env.example - Add test for non-string scope edge case in upstream response Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: archive completed task Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add env var override for codex refresh rate limit window RATE_LIMIT_CODEX_REFRESH_WINDOW_SECONDS allows configuring the rate limit window per constitution Principle XI (no hardcoded values). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: address review findings — self-hosting vars, KV race note - Add RATE_LIMIT_CODEX_REFRESH, RATE_LIMIT_CODEX_REFRESH_WINDOW_SECONDS, and CODEX_EXPECTED_SCOPES to self-hosting.md configurable variables table - Document KV rate limiter's non-atomic read-modify-write as a known limitation (pre-existing pattern across all rate-limited endpoints) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Raphaël Titsworth-Morin <raphael@raphaeltm.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5d0a520 commit 46ea65d

10 files changed

Lines changed: 387 additions & 27 deletions

File tree

apps/api/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,6 @@ BASE_DOMAIN=simple-agent-manager.org
345345
# CODEX_REFRESH_UPSTREAM_URL=https://auth.openai.com/oauth/token # OpenAI token endpoint
346346
# CODEX_REFRESH_UPSTREAM_TIMEOUT_MS=10000 # Upstream request timeout (default: 10s)
347347
# CODEX_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann # OpenAI OAuth client_id
348+
# RATE_LIMIT_CODEX_REFRESH=30 # Max refresh requests per window per workspace (default: 30)
349+
# RATE_LIMIT_CODEX_REFRESH_WINDOW_SECONDS=3600 # Rate limit window in seconds (default: 3600 = 1 hour)
350+
# CODEX_EXPECTED_SCOPES=openid,offline_access # Comma-separated expected scopes for upstream validation (warning log only)

apps/api/src/durable-objects/codex-refresh-lock.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
*/
1414
import { DurableObject } from 'cloudflare:workers';
1515

16+
import { log } from '../lib/logger';
1617
import { getCredentialEncryptionKey } from '../lib/secrets';
1718
import { decrypt, encrypt } from '../services/encryption';
1819

@@ -31,6 +32,7 @@ interface CodexRefreshEnv {
3132
CODEX_REFRESH_UPSTREAM_TIMEOUT_MS?: string;
3233
CODEX_REFRESH_LOCK_TIMEOUT_MS?: string;
3334
CODEX_CLIENT_ID?: string;
35+
CODEX_EXPECTED_SCOPES?: string;
3436
}
3537

3638
const DEFAULT_UPSTREAM_URL = 'https://auth.openai.com/oauth/token';
@@ -207,16 +209,20 @@ export class CodexRefreshLock extends DurableObject<CodexRefreshEnv> {
207209
}
208210

209211
// Parse new tokens from OpenAI response.
210-
const newTokens: Record<string, string> = await upstreamResponse.json();
212+
const newTokens: Record<string, unknown> = await upstreamResponse.json();
213+
214+
// Scope validation — warn (not block) when upstream returns unexpected scopes.
215+
// This catches scope escalation or drift without breaking legacy tokens.
216+
this.validateUpstreamScopes(newTokens, userId);
211217

212218
// Update the stored auth.json with new tokens.
213219
if (!storedAuth.tokens || typeof storedAuth.tokens !== 'object') {
214220
storedAuth.tokens = {};
215221
}
216222
const authTokens = storedAuth.tokens as Record<string, string>;
217-
if (newTokens.access_token) authTokens.access_token = newTokens.access_token;
218-
if (newTokens.refresh_token) authTokens.refresh_token = newTokens.refresh_token;
219-
if (newTokens.id_token) authTokens.id_token = newTokens.id_token;
223+
if (typeof newTokens.access_token === 'string') authTokens.access_token = newTokens.access_token;
224+
if (typeof newTokens.refresh_token === 'string') authTokens.refresh_token = newTokens.refresh_token;
225+
if (typeof newTokens.id_token === 'string') authTokens.id_token = newTokens.id_token;
220226
authTokens.last_refresh = new Date().toISOString();
221227

222228
// Re-encrypt with fresh IV and update the database.
@@ -232,14 +238,57 @@ export class CodexRefreshLock extends DurableObject<CodexRefreshEnv> {
232238
// Return the new tokens to Codex.
233239
return new Response(
234240
JSON.stringify({
235-
access_token: newTokens.access_token ?? null,
236-
refresh_token: newTokens.refresh_token ?? null,
237-
id_token: newTokens.id_token ?? null,
241+
access_token: (typeof newTokens.access_token === 'string' ? newTokens.access_token : null),
242+
refresh_token: (typeof newTokens.refresh_token === 'string' ? newTokens.refresh_token : null),
243+
id_token: (typeof newTokens.id_token === 'string' ? newTokens.id_token : null),
238244
}),
239245
{ status: 200, headers: { 'Content-Type': 'application/json' } }
240246
);
241247
}
242248

249+
/**
250+
* Validate scopes in the upstream token response.
251+
* Logs a warning if unexpected scopes are present — does NOT block the refresh.
252+
* This catches scope escalation or drift without breaking backward compatibility
253+
* with legacy tokens that may not include a scope field.
254+
*/
255+
private validateUpstreamScopes(tokens: Record<string, unknown>, userId: string): void {
256+
const scope = tokens.scope;
257+
if (scope === undefined || scope === null) {
258+
// No scope in response — common for legacy tokens. Nothing to validate.
259+
return;
260+
}
261+
262+
if (typeof scope !== 'string') {
263+
log.warn('codex_refresh.scope_validation', {
264+
userId,
265+
scopeType: typeof scope,
266+
message: 'Non-string scope in upstream response',
267+
});
268+
return;
269+
}
270+
271+
// Parse expected scopes from env (comma-separated) or use empty set (accept all).
272+
const expectedScopesEnv = this.env.CODEX_EXPECTED_SCOPES;
273+
if (!expectedScopesEnv) {
274+
// No expected scopes configured — skip validation (accept all).
275+
return;
276+
}
277+
278+
const expectedScopes = new Set(expectedScopesEnv.split(',').map(s => s.trim()).filter(Boolean));
279+
const returnedScopes = scope.split(' ').filter(Boolean);
280+
const unexpected = returnedScopes.filter(s => !expectedScopes.has(s));
281+
282+
if (unexpected.length > 0) {
283+
log.warn('codex_refresh.unexpected_scopes', {
284+
userId,
285+
expectedScopes: [...expectedScopes].join(','),
286+
returnedScopes: returnedScopes.join(' '),
287+
unexpectedScopes: unexpected.join(','),
288+
});
289+
}
290+
}
291+
243292
/**
244293
* Look up the active openai-codex oauth-token credential for the given user.
245294
*/

apps/api/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ export interface Env {
127127
RATE_LIMIT_ANONYMOUS?: string;
128128
RATE_LIMIT_IDENTITY_TOKEN?: string;
129129
RATE_LIMIT_IDENTITY_TOKEN_WINDOW_SECONDS?: string;
130+
RATE_LIMIT_CODEX_REFRESH?: string;
131+
RATE_LIMIT_CODEX_REFRESH_WINDOW_SECONDS?: string;
130132
IDENTITY_TOKEN_CACHE_BUFFER_SECONDS?: string;
131133
IDENTITY_TOKEN_CACHE_MIN_TTL_SECONDS?: string;
132134
// Hierarchy limits
@@ -335,6 +337,7 @@ export interface Env {
335337
CODEX_REFRESH_UPSTREAM_URL?: string; // OpenAI token endpoint (default: https://auth.openai.com/oauth/token)
336338
CODEX_REFRESH_UPSTREAM_TIMEOUT_MS?: string; // Upstream request timeout (default: 10000)
337339
CODEX_CLIENT_ID?: string; // OpenAI OAuth client_id (default: app_EMoamEEZ73f0CkXaXp7hrann)
340+
CODEX_EXPECTED_SCOPES?: string; // Comma-separated expected scopes for upstream validation (warning log only)
338341
// Google OAuth (for GCP OIDC integration)
339342
GOOGLE_CLIENT_ID?: string;
340343
GOOGLE_CLIENT_SECRET?: string;

apps/api/src/middleware/rate-limit.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const DEFAULT_RATE_LIMITS = {
3737
CLIENT_ERRORS: 200,
3838
IDENTITY_TOKEN: 60,
3939
ANALYTICS_INGEST: 60,
40+
CODEX_REFRESH: 30,
4041
} as const;
4142

4243
/** Default time window (1 hour in seconds) */
@@ -94,6 +95,10 @@ export function getCurrentWindowStart(windowSeconds: number): number {
9495

9596
/**
9697
* Check and update rate limit.
98+
*
99+
* NOTE: This read-increment-write pattern is not atomic. KV has no CAS primitive.
100+
* Under concurrent requests from the same identifier, the true count may exceed
101+
* `limit` by a small amount. For strict enforcement, use a Durable Object counter.
97102
*/
98103
export async function checkRateLimit(
99104
kv: KVNamespace,
@@ -226,3 +231,25 @@ export function rateLimitAnonymous(env: Env): MiddlewareHandler<{ Bindings: Env
226231
useIp: true,
227232
});
228233
}
234+
235+
/**
236+
* Check rate limit for Codex refresh endpoint.
237+
* Uses workspaceId as the identifier (extracted from verified callback token).
238+
* Default: 30 requests per hour per workspace.
239+
*
240+
* Returns null if allowed, or a Response if rate-limited.
241+
* This is a direct check (not middleware) because the codex-refresh endpoint
242+
* uses callback token auth, not session auth.
243+
*/
244+
export async function checkCodexRefreshRateLimit(
245+
env: Env,
246+
workspaceId: string,
247+
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
248+
const limit = getRateLimit(env, 'CODEX_REFRESH');
249+
const envWindow = parseInt(env.RATE_LIMIT_CODEX_REFRESH_WINDOW_SECONDS || '', 10);
250+
const windowSeconds = Number.isFinite(envWindow) && envWindow > 0 ? envWindow : DEFAULT_WINDOW_SECONDS;
251+
const windowStart = getCurrentWindowStart(windowSeconds);
252+
const key = createRateLimitKey('codex-refresh', workspaceId, windowStart);
253+
254+
return checkRateLimit(env.KV, key, limit, windowSeconds);
255+
}

apps/api/src/routes/codex-refresh.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Hono } from 'hono';
1616
import * as schema from '../db/schema';
1717
import type { Env } from '../index';
1818
import { log } from '../lib/logger';
19+
import { checkCodexRefreshRateLimit } from '../middleware/rate-limit';
1920
import { verifyCallbackToken } from '../services/jwt';
2021

2122
const codexRefreshRoutes = new Hono<{ Bindings: Env }>();
@@ -68,6 +69,17 @@ codexRefreshRoutes.post('/codex-refresh', async (c) => {
6869

6970
const workspaceId = tokenPayload.workspace;
7071

72+
// Rate limit per workspace (prevents abuse via stolen callback tokens).
73+
const rateLimitResult = await checkCodexRefreshRateLimit(c.env, workspaceId);
74+
if (!rateLimitResult.allowed) {
75+
const retryAfter = rateLimitResult.resetAt - Math.floor(Date.now() / 1000);
76+
log.warn('codex_refresh.rate_limited', { workspaceId });
77+
return c.json(
78+
{ error: 'rate_limit_exceeded', message: 'Too many refresh requests' },
79+
{ status: 429, headers: { 'Retry-After': Math.max(1, retryAfter).toString() } },
80+
);
81+
}
82+
7183
// Parse the request body (Codex sends hardcoded format).
7284
let body: { client_id?: string; grant_type?: string; refresh_token?: string };
7385
try {

apps/api/tests/unit/durable-objects/codex-refresh-lock.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ vi.mock('../../../src/lib/secrets', () => ({
3333
getCredentialEncryptionKey: vi.fn().mockReturnValue('test-encryption-key'),
3434
}));
3535

36+
// Mock logger
37+
const mockLogWarn = vi.fn();
38+
vi.mock('../../../src/lib/logger', () => ({
39+
log: {
40+
info: vi.fn(),
41+
warn: mockLogWarn,
42+
error: vi.fn(),
43+
},
44+
}));
45+
3646
const { CodexRefreshLock } = await import(
3747
'../../../src/durable-objects/codex-refresh-lock'
3848
);
@@ -360,6 +370,158 @@ describe('CodexRefreshLock', () => {
360370
expect(json.error).toBe('upstream_error');
361371
});
362372

373+
// -----------------------------------------------------------------------
374+
// Scope validation
375+
// -----------------------------------------------------------------------
376+
377+
it('succeeds without warning when upstream returns no scope field', async () => {
378+
const { do: doInstance, env } = createDO({
379+
CODEX_EXPECTED_SCOPES: 'openid,offline_access',
380+
});
381+
setupCredentialFound(env);
382+
mockLogWarn.mockClear();
383+
384+
vi.mocked(fetch).mockResolvedValue(
385+
new Response(
386+
JSON.stringify({
387+
access_token: 'new-access',
388+
refresh_token: 'new-refresh',
389+
id_token: 'new-id',
390+
}),
391+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
392+
),
393+
);
394+
395+
const res = await doInstance.fetch(
396+
makeRequest({ refreshToken: 'stored-refresh', userId: 'user-1' }),
397+
);
398+
expect(res.status).toBe(200);
399+
expect(mockLogWarn).not.toHaveBeenCalledWith(
400+
'codex_refresh.unexpected_scopes',
401+
expect.anything(),
402+
);
403+
});
404+
405+
it('warns when upstream returns unexpected scopes', async () => {
406+
const { do: doInstance, env } = createDO({
407+
CODEX_EXPECTED_SCOPES: 'openid,offline_access',
408+
});
409+
setupCredentialFound(env);
410+
mockLogWarn.mockClear();
411+
412+
vi.mocked(fetch).mockResolvedValue(
413+
new Response(
414+
JSON.stringify({
415+
access_token: 'new-access',
416+
refresh_token: 'new-refresh',
417+
id_token: 'new-id',
418+
scope: 'openid offline_access admin:write',
419+
}),
420+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
421+
),
422+
);
423+
424+
const res = await doInstance.fetch(
425+
makeRequest({ refreshToken: 'stored-refresh', userId: 'user-1' }),
426+
);
427+
// Should still succeed (warning only, not blocking)
428+
expect(res.status).toBe(200);
429+
const json = await res.json();
430+
expect(json.access_token).toBe('new-access');
431+
432+
expect(mockLogWarn).toHaveBeenCalledWith(
433+
'codex_refresh.unexpected_scopes',
434+
expect.objectContaining({
435+
unexpectedScopes: 'admin:write',
436+
}),
437+
);
438+
});
439+
440+
it('does not warn when scopes match expected', async () => {
441+
const { do: doInstance, env } = createDO({
442+
CODEX_EXPECTED_SCOPES: 'openid,offline_access',
443+
});
444+
setupCredentialFound(env);
445+
mockLogWarn.mockClear();
446+
447+
vi.mocked(fetch).mockResolvedValue(
448+
new Response(
449+
JSON.stringify({
450+
access_token: 'new-access',
451+
refresh_token: 'new-refresh',
452+
scope: 'openid offline_access',
453+
}),
454+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
455+
),
456+
);
457+
458+
const res = await doInstance.fetch(
459+
makeRequest({ refreshToken: 'stored-refresh', userId: 'user-1' }),
460+
);
461+
expect(res.status).toBe(200);
462+
expect(mockLogWarn).not.toHaveBeenCalledWith(
463+
'codex_refresh.unexpected_scopes',
464+
expect.anything(),
465+
);
466+
});
467+
468+
it('skips scope validation when CODEX_EXPECTED_SCOPES is not configured', async () => {
469+
const { do: doInstance, env } = createDO();
470+
// No CODEX_EXPECTED_SCOPES set
471+
setupCredentialFound(env);
472+
mockLogWarn.mockClear();
473+
474+
vi.mocked(fetch).mockResolvedValue(
475+
new Response(
476+
JSON.stringify({
477+
access_token: 'new-access',
478+
refresh_token: 'new-refresh',
479+
scope: 'openid offline_access admin:write some:other:scope',
480+
}),
481+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
482+
),
483+
);
484+
485+
const res = await doInstance.fetch(
486+
makeRequest({ refreshToken: 'stored-refresh', userId: 'user-1' }),
487+
);
488+
expect(res.status).toBe(200);
489+
expect(mockLogWarn).not.toHaveBeenCalledWith(
490+
'codex_refresh.unexpected_scopes',
491+
expect.anything(),
492+
);
493+
});
494+
495+
it('warns on non-string scope in upstream response', async () => {
496+
const { do: doInstance, env } = createDO({
497+
CODEX_EXPECTED_SCOPES: 'openid',
498+
});
499+
setupCredentialFound(env);
500+
mockLogWarn.mockClear();
501+
502+
vi.mocked(fetch).mockResolvedValue(
503+
new Response(
504+
JSON.stringify({
505+
access_token: 'new-access',
506+
refresh_token: 'new-refresh',
507+
scope: 42, // non-string scope
508+
}),
509+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
510+
),
511+
);
512+
513+
const res = await doInstance.fetch(
514+
makeRequest({ refreshToken: 'stored-refresh', userId: 'user-1' }),
515+
);
516+
expect(res.status).toBe(200);
517+
expect(mockLogWarn).toHaveBeenCalledWith(
518+
'codex_refresh.scope_validation',
519+
expect.objectContaining({
520+
scopeType: 'number',
521+
}),
522+
);
523+
});
524+
363525
// -----------------------------------------------------------------------
364526
// Configurable upstream URL and client_id
365527
// -----------------------------------------------------------------------

0 commit comments

Comments
 (0)