Skip to content

Commit 1169b3f

Browse files
simple-agent-manager[bot]raphaeltmclaude
authored
fix(codex): demote scope validation to warn-only (raphaeltm#772)
* fix(codex): demote scope validation to warn-only to unblock token refresh The scope validation added in PR raphaeltm#756 (MEDIUM #6) blocks Codex OAuth token refresh with 502 when OpenAI returns any scopes beyond the hardcoded allowlist (openid, profile, email, offline_access). Since we don't yet know the full set of scopes OpenAI returns in practice, this was silently breaking all Codex token refreshes — expired tokens couldn't be renewed, causing "Authentication required" errors. Changes: - Default CODEX_SCOPE_VALIDATION_MODE to 'warn' (log but allow) - Add CODEX_SCOPE_VALIDATION_MODE env var to opt back into 'block' mode - Update tests to cover both warn and block behaviors - The unexpected_scopes_allowed log line will reveal what scopes OpenAI actually returns, so we can update the allowlist and re-enable blocking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: trigger CI with updated PR body --------- Co-authored-by: Raphaël Titsworth-Morin <raphael@raphaeltm.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b1dbdde commit 1169b3f

3 files changed

Lines changed: 110 additions & 24 deletions

File tree

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

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ interface CodexRefreshEnv {
5858
* Empty string disables scope validation. Unset uses DEFAULT_EXPECTED_SCOPES.
5959
*/
6060
CODEX_EXPECTED_SCOPES?: string;
61+
/**
62+
* 'warn' (default) or 'block'. Controls whether unexpected scopes block
63+
* the refresh (502) or just log a warning and allow it to proceed.
64+
*/
65+
CODEX_SCOPE_VALIDATION_MODE?: string;
6166
/**
6267
* Rate limit: max refresh requests per user per window. Defaults to 30.
6368
*/
@@ -290,16 +295,30 @@ export class CodexRefreshLock extends DurableObject<CodexRefreshEnv> {
290295
// Parse new tokens from OpenAI response.
291296
const newTokens: Record<string, unknown> = await upstreamResponse.json();
292297

293-
// Scope validation (MEDIUM #6) — block instead of warn-only when upstream returns
294-
// unexpected scopes. A scope change signals either provider drift or an escalation
295-
// attempt; either way we refuse to store the new tokens and return 502 so the
296-
// caller stays on the previous (validated) credential.
298+
// Scope validation (MEDIUM #6) — warn-only mode.
299+
// Previously this blocked with 502 when upstream returned unexpected scopes,
300+
// but we don't yet know the full set of scopes OpenAI returns in practice.
301+
// If the allowlist was incomplete, every refresh would silently fail, leaving
302+
// Codex with expired tokens and "Authentication required" errors.
303+
// Demoted to warn-only until we capture real-world scope data from logs.
304+
// Re-enable blocking by setting CODEX_SCOPE_VALIDATION_MODE=block.
297305
const scopeResult = this.validateUpstreamScopes(newTokens, userId);
298306
if (!scopeResult.ok) {
299-
return new Response(
300-
JSON.stringify({ error: 'upstream_unexpected_scope', message: scopeResult.reason }),
301-
{ status: 502, headers: { 'Content-Type': 'application/json' } }
302-
);
307+
const validationMode = this.env.CODEX_SCOPE_VALIDATION_MODE ?? 'warn';
308+
if (validationMode === 'block') {
309+
return new Response(
310+
JSON.stringify({ error: 'upstream_unexpected_scope', message: scopeResult.reason }),
311+
{ status: 502, headers: { 'Content-Type': 'application/json' } }
312+
);
313+
}
314+
// Warn-only: log the unexpected scopes but allow the refresh to proceed.
315+
// This lets us discover what scopes OpenAI actually returns without
316+
// breaking token refresh for all users.
317+
log.warn('codex_refresh.unexpected_scopes_allowed', {
318+
userId,
319+
reason: scopeResult.reason,
320+
validationMode,
321+
});
303322
}
304323

305324
// Update the stored auth.json with new tokens.
@@ -363,13 +382,12 @@ export class CodexRefreshLock extends DurableObject<CodexRefreshEnv> {
363382
/**
364383
* Validate scopes in the upstream token response.
365384
*
366-
* Default behavior (MEDIUM #6 fix): enforce a conservative allowlist derived from
367-
* known Codex OAuth flow scopes. Unexpected scopes cause the refresh to fail with
368-
* 502 — the previous token remains valid so the caller can continue operating
369-
* until an admin investigates.
385+
* Returns { ok: false } when scopes don't match the allowlist. The caller
386+
* decides whether to block (502) or warn based on CODEX_SCOPE_VALIDATION_MODE.
387+
* Default mode is 'warn' — unexpected scopes are logged but refresh proceeds.
370388
*
371-
* Override with CODEX_EXPECTED_SCOPES (comma-separated). Empty string disables
372-
* validation entirely (escape hatch for provider rollouts that add new scopes).
389+
* Override allowlist with CODEX_EXPECTED_SCOPES (comma-separated). Empty
390+
* string disables validation entirely.
373391
*/
374392
private validateUpstreamScopes(
375393
tokens: Record<string, unknown>,

apps/api/src/env.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,8 @@ export interface Env {
316316
CODEX_REFRESH_UPSTREAM_URL?: string; // OpenAI token endpoint (default: https://auth.openai.com/oauth/token)
317317
CODEX_REFRESH_UPSTREAM_TIMEOUT_MS?: string; // Upstream request timeout (default: 10000)
318318
CODEX_CLIENT_ID?: string; // OpenAI OAuth client_id (default: app_EMoamEEZ73f0CkXaXp7hrann)
319-
CODEX_EXPECTED_SCOPES?: string; // Comma-separated scope allowlist; unset = default allowlist enforced (openid,profile,email,offline_access); empty string disables validation; unexpected scopes block refresh with 502 (MEDIUM #6)
319+
CODEX_EXPECTED_SCOPES?: string; // Comma-separated scope allowlist; unset = default allowlist enforced (openid,profile,email,offline_access); empty string disables validation
320+
CODEX_SCOPE_VALIDATION_MODE?: string; // 'warn' (default) or 'block' — controls whether unexpected scopes block refresh (502) or just log a warning
320321
// Google OAuth (for GCP OIDC integration)
321322
GOOGLE_CLIENT_ID?: string;
322323
GOOGLE_CLIENT_SECRET?: string;

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

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* fallback, absent row falls through to user-scoped
99
* - MEDIUM #5: rate-limit state held in `ctx.storage` (atomic per-user), 429 with
1010
* Retry-After on exceed
11-
* - MEDIUM #6: upstream scope validation blocks (502) instead of warn-only
11+
* - MEDIUM #6: upstream scope validation (warn-only by default; block when CODEX_SCOPE_VALIDATION_MODE=block)
1212
* - Decrypt/parse failure handling
1313
* - Upstream error paths (timeout, network, filtered error body)
1414
*/
@@ -599,13 +599,45 @@ describe('CodexRefreshLock', () => {
599599
});
600600

601601
// -----------------------------------------------------------------------
602-
// MEDIUM #6 — Scope validation now BLOCKS (502), not warn-only
602+
// MEDIUM #6 — Scope validation (warn-only by default, block when opted in)
603603
// -----------------------------------------------------------------------
604604

605-
describe('MEDIUM #6 — scope validation blocks', () => {
606-
it('blocks with 502 on unexpected scope in upstream response', async () => {
605+
describe('MEDIUM #6 — scope validation', () => {
606+
it('warns but allows refresh on unexpected scope by default (warn mode)', async () => {
607607
const { do: doInstance, env } = createDO({
608608
CODEX_EXPECTED_SCOPES: 'openid,offline_access',
609+
// CODEX_SCOPE_VALIDATION_MODE not set — defaults to 'warn'
610+
});
611+
setupCredentialFound(env);
612+
613+
vi.mocked(fetch).mockResolvedValue(
614+
new Response(
615+
JSON.stringify({
616+
access_token: 'new-access',
617+
refresh_token: 'new-refresh',
618+
id_token: 'new-id',
619+
scope: 'openid offline_access admin:write',
620+
}),
621+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
622+
),
623+
);
624+
625+
const res = await doInstance.fetch(
626+
makeRequest({ refreshToken: 'stored-refresh', userId: 'user-1' }),
627+
);
628+
// Warn mode: refresh succeeds despite unexpected scopes
629+
expect(res.status).toBe(200);
630+
expect(vi.mocked(encrypt)).toHaveBeenCalled();
631+
expect(mockLogWarn).toHaveBeenCalledWith(
632+
'codex_refresh.unexpected_scopes_allowed',
633+
expect.objectContaining({ validationMode: 'warn' }),
634+
);
635+
});
636+
637+
it('blocks with 502 on unexpected scope when CODEX_SCOPE_VALIDATION_MODE=block', async () => {
638+
const { do: doInstance, env } = createDO({
639+
CODEX_EXPECTED_SCOPES: 'openid,offline_access',
640+
CODEX_SCOPE_VALIDATION_MODE: 'block',
609641
});
610642
setupCredentialFound(env);
611643

@@ -628,7 +660,7 @@ describe('CodexRefreshLock', () => {
628660
const json = await res.json();
629661
expect(json.error).toBe('upstream_unexpected_scope');
630662

631-
// MUST NOT persist tokens that fail validation.
663+
// MUST NOT persist tokens that fail validation in block mode.
632664
expect(vi.mocked(encrypt)).not.toHaveBeenCalled();
633665
expect(mockLogWarn).toHaveBeenCalledWith(
634666
'codex_refresh.unexpected_scopes_blocked',
@@ -660,7 +692,7 @@ describe('CodexRefreshLock', () => {
660692
expect(vi.mocked(encrypt)).toHaveBeenCalled();
661693
});
662694

663-
it('uses default scope allowlist when CODEX_EXPECTED_SCOPES is unset', async () => {
695+
it('warns by default when CODEX_EXPECTED_SCOPES is unset (uses default allowlist in warn mode)', async () => {
664696
// Omit CODEX_EXPECTED_SCOPES entirely — DO must apply DEFAULT_EXPECTED_SCOPES.
665697
const env = createMockEnv();
666698
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
@@ -683,8 +715,13 @@ describe('CodexRefreshLock', () => {
683715
const res = await doInstance.fetch(
684716
makeRequest({ refreshToken: 'stored-refresh', userId: 'user-1' }),
685717
);
686-
// With defaults active, admin:write blocks.
687-
expect(res.status).toBe(502);
718+
// Default is warn mode — refresh succeeds, warning logged
719+
expect(res.status).toBe(200);
720+
expect(vi.mocked(encrypt)).toHaveBeenCalled();
721+
expect(mockLogWarn).toHaveBeenCalledWith(
722+
'codex_refresh.unexpected_scopes_allowed',
723+
expect.objectContaining({ validationMode: 'warn' }),
724+
);
688725
});
689726

690727
it('allows all scopes when CODEX_EXPECTED_SCOPES is set to empty string (escape hatch)', async () => {
@@ -709,9 +746,10 @@ describe('CodexRefreshLock', () => {
709746
expect(vi.mocked(encrypt)).toHaveBeenCalled();
710747
});
711748

712-
it('blocks non-string scope values', async () => {
749+
it('blocks non-string scope values in block mode', async () => {
713750
const { do: doInstance, env } = createDO({
714751
CODEX_EXPECTED_SCOPES: 'openid',
752+
CODEX_SCOPE_VALIDATION_MODE: 'block',
715753
});
716754
setupCredentialFound(env);
717755

@@ -737,6 +775,35 @@ describe('CodexRefreshLock', () => {
737775
expect(vi.mocked(encrypt)).not.toHaveBeenCalled();
738776
});
739777

778+
it('warns but allows non-string scope values in default warn mode', async () => {
779+
const { do: doInstance, env } = createDO({
780+
CODEX_EXPECTED_SCOPES: 'openid',
781+
});
782+
setupCredentialFound(env);
783+
784+
vi.mocked(fetch).mockResolvedValue(
785+
new Response(
786+
JSON.stringify({
787+
access_token: 'new-access',
788+
refresh_token: 'new-refresh',
789+
scope: 42, // non-string scope
790+
}),
791+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
792+
),
793+
);
794+
795+
const res = await doInstance.fetch(
796+
makeRequest({ refreshToken: 'stored-refresh', userId: 'user-1' }),
797+
);
798+
// Warn mode: refresh succeeds despite non-string scope
799+
expect(res.status).toBe(200);
800+
expect(mockLogWarn).toHaveBeenCalledWith(
801+
'codex_refresh.scope_validation_nonstring',
802+
expect.objectContaining({ scopeType: 'number' }),
803+
);
804+
expect(vi.mocked(encrypt)).toHaveBeenCalled();
805+
});
806+
740807
it('allows responses with no scope field', async () => {
741808
const { do: doInstance, env } = createDO({
742809
CODEX_EXPECTED_SCOPES: 'openid,offline_access',

0 commit comments

Comments
 (0)