From 1b83d97dd31ea43db78e70563dd12bca01faf63b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 10 Jun 2026 19:21:33 -0300 Subject: [PATCH] security(oauth): migrate 5 public client_ids to resolvePublicCred (#3493) --- CHANGELOG.md | 1 + open-sse/config/providerRegistry.ts | 8 ++-- open-sse/utils/publicCreds.ts | 24 ++++++++++ scripts/check/check-public-creds.mjs | 25 +++------- src/lib/oauth/codexDeviceFlow.ts | 7 ++- src/lib/oauth/constants/oauth.ts | 10 ++-- tests/unit/public-client-ids-3493.test.ts | 58 +++++++++++++++++++++++ 7 files changed, 103 insertions(+), 30 deletions(-) create mode 100644 tests/unit/public-client-ids-3493.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 05968bdc24..1517d4c3d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - **docs(discovery):** mark `DISCOVERY_TOOL_DESIGN.md`'s API Endpoints table with an explicit "⚠️ Not yet implemented — Phase 2" banner — the discovery routes are a design proposal (Phase-1 stub only), and the banner makes clear the `KNOWN_STALE_DOC_REFS` gate suppression is intentional, not stale drift. ([#3498](https://github.com/diegosouzapw/OmniRoute/issues/3498)) - **fix(agent-bridge):** add the missing `POST /api/tools/agent-bridge/upstream-ca/test` route — the UpstreamCaField "Test" button POSTed to it but it didn't exist (404). The new validate-only route checks the CA file exists and is a parseable PEM certificate (returns the subject/expiry) **without** persisting the path or activating it; it inherits the `/api/tools/agent-bridge/` LOCAL_ONLY classification. ([#3488](https://github.com/diegosouzapw/OmniRoute/issues/3488)) - **fix(gamification):** the dashboard Profile page no longer hits three 404s — added the missing `GET /api/gamification/{level,badges,badges/earned}` routes (management-scoped). The page is operator-wide (no `apiKeyId`), so `level`/`badges/earned` aggregate across all keys (with an optional `?apiKeyId` for a single key), and `badges` seeds the built-in catalog first (idempotent) so the grid is populated even on installs that never seeded it (see #3472). ([#3484](https://github.com/diegosouzapw/OmniRoute/issues/3484)) +- **security(oauth):** migrate the five public OAuth client_ids (Claude, Codex, Qwen, Kimi, GitHub Copilot — 9 server-side call-sites in `providerRegistry.ts` + `oauth.ts`) from string literals to `resolvePublicCred()` (Hard Rule #11), matching the existing Gemini/Antigravity pattern. The values decode byte-for-byte to the same public client_ids (env overrides still win), so OAuth flows are unchanged; the `check-public-creds` allowlist is now empty. The browser-bundled `codexDeviceFlow.ts` copy stays a literal by necessity (it cannot import `open-sse`). ([#3493](https://github.com/diegosouzapw/OmniRoute/issues/3493)) --- diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 4638eda889..278593bac2 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -656,7 +656,7 @@ const _REGISTRY_EAGER: Record = { }, oauth: { clientIdEnv: "CLAUDE_OAUTH_CLIENT_ID", - clientIdDefault: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + clientIdDefault: resolvePublicCred("claude_id"), tokenUrl: "https://api.anthropic.com/v1/oauth/token", }, models: [ @@ -834,7 +834,7 @@ const _REGISTRY_EAGER: Record = { headers: getCodexDefaultHeaders(), oauth: { clientIdEnv: "CODEX_OAUTH_CLIENT_ID", - clientIdDefault: "app_EMoamEEZ73f0CkXaXp7hrann", + clientIdDefault: resolvePublicCred("codex_id"), clientSecretEnv: "CODEX_OAUTH_CLIENT_SECRET", clientSecretDefault: "", tokenUrl: "https://auth.openai.com/oauth/token", @@ -928,7 +928,7 @@ const _REGISTRY_EAGER: Record = { headers: getQwenOauthHeaders(), oauth: { clientIdEnv: "QWEN_OAUTH_CLIENT_ID", - clientIdDefault: "f0304373b74a44d2b584a3fb70ca9e56", + clientIdDefault: resolvePublicCred("qwen_id"), tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", authUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", }, @@ -1970,7 +1970,7 @@ const _REGISTRY_EAGER: Record = { authType: "oauth", oauth: { clientIdEnv: "KIMI_CODING_OAUTH_CLIENT_ID", - clientIdDefault: "17e5f671-d194-4dfb-9706-5516cb48c098", + clientIdDefault: resolvePublicCred("kimi_id"), tokenUrl: "https://auth.kimi.com/api/oauth/token", refreshUrl: "https://auth.kimi.com/api/oauth/token", authUrl: "https://auth.kimi.com/api/oauth/device_authorization", diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index 04cac84fb7..821c9f7538 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -155,6 +155,30 @@ const EMBEDDED_DEFAULTS = { 46, 36, 20, 8, 33, 22, 55, 4, 41, 121, 53, 50, 49, 24, 92, 90, 108, 35, 97, 36, 21, 44, 11, 69, 3, 60, 35, 15, 126, 53, 71, 56, 52, 56, 43, 26, 27, 86, 58, ], + // Claude Code CLI — anthropic oauth client (public, PKCE) + claude_id: [ + 86, 9, 95, 10, 64, 90, 69, 21, 72, 72, 70, 68, 0, 65, 93, 87, 73, 79, 28, 87, 85, 11, 13, 95, + 90, 76, 64, 81, 73, 65, 76, 84, 94, 15, 86, 72, + ], + // Codex CLI — openai oauth client (public, PKCE) + codex_id: [ + 14, 29, 30, 54, 55, 34, 26, 21, 8, 104, 53, 47, 85, 95, 15, 83, 110, 29, 105, 14, 53, 30, 94, + 26, 29, 20, 26, 11, + ], + // Qwen Code CLI — qwen oauth client (public, device flow) + qwen_id: [ + 9, 93, 93, 89, 70, 92, 66, 71, 7, 26, 68, 20, 86, 88, 13, 81, 79, 67, 9, 91, 12, 93, 15, 16, 88, + 69, 23, 4, 20, 21, 64, 84, + ], + // Kimi coding CLI — moonshot oauth client (public) + kimi_id: [ + 94, 90, 11, 92, 20, 89, 66, 69, 72, 73, 65, 76, 86, 65, 93, 7, 75, 20, 28, 86, 90, 94, 95, 95, + 90, 64, 69, 83, 78, 18, 65, 90, 15, 89, 90, 21, + ], + // GitHub Copilot CLI — github oauth app id (public, device flow) + github_copilot_id: [ + 38, 27, 95, 71, 16, 90, 69, 67, 4, 29, 72, 22, 90, 91, 12, 0, 75, 19, 8, 87, + ], } as const; export type EmbeddedDefaultKey = keyof typeof EMBEDDED_DEFAULTS; diff --git a/scripts/check/check-public-creds.mjs b/scripts/check/check-public-creds.mjs index f5407d19a4..6432405012 100644 --- a/scripts/check/check-public-creds.mjs +++ b/scripts/check/check-public-creds.mjs @@ -52,25 +52,12 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/; // arquivos, então congelar por valor cobre ambas as cópias). Para congelar um valor // só num arquivo:linha específico, use a chave "arquivo:linha:valor". // -// Tracking: estes 5 valores (9 call-sites) devem virar uma issue de segurança e -// migrar para resolvePublicCred() — Gemini/Antigravity já seguem o padrão correto. -export const KNOWN_LITERAL_CREDS = new Set([ - // Claude — CLAUDE_OAUTH_CLIENT_ID (public, PKCE auth-code flow) - // providerRegistry.ts:659 + oauth.ts:37 - "9d1c250a-e61b-44d9-88ed-5944d1962f5e", - // Codex (OpenAI) — CODEX_OAUTH_CLIENT_ID (public, PKCE) - // providerRegistry.ts:831 + oauth.ts:54 - "app_EMoamEEZ73f0CkXaXp7hrann", - // Qwen — QWEN_OAUTH_CLIENT_ID (public, device-code + PKCE) - // providerRegistry.ts:925 + oauth.ts:101 - "f0304373b74a44d2b584a3fb70ca9e56", - // Kimi Coding — KIMI_CODING_OAUTH_CLIENT_ID (public, device-code) - // providerRegistry.ts:1961 + oauth.ts:136 - "17e5f671-d194-4dfb-9706-5516cb48c098", - // GitHub Copilot — GITHUB_OAUTH_CLIENT_ID (public, device-code) - // oauth.ts:238 - "Iv1.b507a08c87ecfe98", -]); +// All five public client_ids (9 call-sites) were migrated to resolvePublicCred() in +// #3493 (embedded as claude_id/codex_id/qwen_id/kimi_id/github_copilot_id in +// open-sse/utils/publicCreds.ts), matching the Gemini/Antigravity pattern. The +// allowlist is now empty — any new literal public client_id must be embedded via +// resolvePublicCred(), not frozen here. +export const KNOWN_LITERAL_CREDS = new Set([]); /** * Encontra atribuições de uma chave de credencial a uma string literal não-vazia. diff --git a/src/lib/oauth/codexDeviceFlow.ts b/src/lib/oauth/codexDeviceFlow.ts index 2e6508ef88..2ad9583570 100644 --- a/src/lib/oauth/codexDeviceFlow.ts +++ b/src/lib/oauth/codexDeviceFlow.ts @@ -24,8 +24,11 @@ * * NOTE: this module must stay free of server-only imports (e.g. CODEX_CONFIG, * open-sse) so it can be bundled for the browser. The client_id below is the - * public Codex CLI client identifier (same literal as CODEX_CONFIG.clientId); - * it relies on PKCE, not secrecy (RFC 8252). + * public Codex CLI client identifier (same value as CODEX_CONFIG.clientId); + * it relies on PKCE, not secrecy (RFC 8252). The server-side copies were moved to + * resolvePublicCred() in #3493 (Rule #11); this browser-bundled copy stays a literal + * by necessity — it cannot import open-sse's publicCreds without pulling server code + * into the browser bundle. */ const BASE_URL = "https://auth.openai.com"; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 586bc4abb4..c372f385df 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -34,7 +34,7 @@ import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "../gitla // Claude OAuth Configuration (Authorization Code Flow with PKCE) export const CLAUDE_CONFIG = { - clientId: process.env.CLAUDE_OAUTH_CLIENT_ID || "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + clientId: resolvePublicCred("claude_id", "CLAUDE_OAUTH_CLIENT_ID"), authorizeUrl: "https://claude.ai/oauth/authorize", tokenUrl: "https://api.anthropic.com/v1/oauth/token", redirectUri: @@ -51,7 +51,7 @@ export const CLAUDE_CONFIG = { // Codex (OpenAI) OAuth Configuration (Authorization Code Flow with PKCE) export const CODEX_CONFIG = { - clientId: process.env.CODEX_OAUTH_CLIENT_ID || "app_EMoamEEZ73f0CkXaXp7hrann", + clientId: resolvePublicCred("codex_id", "CODEX_OAUTH_CLIENT_ID"), authorizeUrl: "https://auth.openai.com/oauth/authorize", tokenUrl: "https://auth.openai.com/oauth/token", scope: "openid profile email offline_access", @@ -98,7 +98,7 @@ export const GEMINI_CONFIG = { // Qwen OAuth Configuration (Device Code Flow with PKCE) export const QWEN_CONFIG = { - clientId: process.env.QWEN_OAUTH_CLIENT_ID || "f0304373b74a44d2b584a3fb70ca9e56", + clientId: resolvePublicCred("qwen_id", "QWEN_OAUTH_CLIENT_ID"), deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", scope: "openid profile email model.completion", @@ -133,7 +133,7 @@ export const QODER_CONFIG = { // Kimi Coding OAuth Configuration (Device Code Flow) export const KIMI_CODING_CONFIG = { - clientId: process.env.KIMI_CODING_OAUTH_CLIENT_ID || "17e5f671-d194-4dfb-9706-5516cb48c098", + clientId: resolvePublicCred("kimi_id", "KIMI_CODING_OAUTH_CLIENT_ID"), deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization", tokenUrl: "https://auth.kimi.com/api/oauth/token", }; @@ -235,7 +235,7 @@ export const OPENAI_CONFIG = { // GitHub Copilot OAuth Configuration (Device Code Flow) export const GITHUB_CONFIG = { - clientId: process.env.GITHUB_OAUTH_CLIENT_ID || "Iv1.b507a08c87ecfe98", + clientId: resolvePublicCred("github_copilot_id", "GITHUB_OAUTH_CLIENT_ID"), deviceCodeUrl: "https://github.com/login/device/code", tokenUrl: "https://github.com/login/oauth/access_token", userInfoUrl: "https://api.github.com/user", diff --git a/tests/unit/public-client-ids-3493.test.ts b/tests/unit/public-client-ids-3493.test.ts new file mode 100644 index 0000000000..5141f6abb6 --- /dev/null +++ b/tests/unit/public-client-ids-3493.test.ts @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolvePublicCred } from "../../open-sse/utils/publicCreds.ts"; + +// #3493 — five public OAuth client_ids were migrated from string literals to +// resolvePublicCred() (Hard Rule #11). These assertions guard that the embedded +// masked-byte defaults still decode to the exact public client_ids, so the OAuth +// flows are byte-for-byte unchanged, and that env overrides still win. + +const EXPECTED_CLIENT_IDS: Record = { + claude_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + codex_id: "app_EMoamEEZ73f0CkXaXp7hrann", + qwen_id: "f0304373b74a44d2b584a3fb70ca9e56", + kimi_id: "17e5f671-d194-4dfb-9706-5516cb48c098", + github_copilot_id: "Iv1.b507a08c87ecfe98", +}; + +test("#3493 embedded public client_ids decode to their original literals", () => { + for (const [key, expected] of Object.entries(EXPECTED_CLIENT_IDS)) { + assert.equal( + resolvePublicCred(key as never), + expected, + `${key} must decode to its public client_id (OAuth flow unchanged)` + ); + } +}); + +test("#3493 env override takes priority over the embedded default", () => { + const prev = process.env.CLAUDE_OAUTH_CLIENT_ID; + process.env.CLAUDE_OAUTH_CLIENT_ID = "custom-override-id"; + try { + assert.equal( + resolvePublicCred("claude_id" as never, "CLAUDE_OAUTH_CLIENT_ID"), + "custom-override-id" + ); + } finally { + if (prev === undefined) delete process.env.CLAUDE_OAUTH_CLIENT_ID; + else process.env.CLAUDE_OAUTH_CLIENT_ID = prev; + } +}); + +test("#3493 the migrated OAuth/registry configs resolve to the expected client_ids", async () => { + const { CLAUDE_CONFIG, CODEX_CONFIG, QWEN_CONFIG } = await import( + "../../src/lib/oauth/constants/oauth.ts" + ); + // Only assert when env doesn't override (CI/dev may set these); skip the assert + // for any that are env-overridden so the test stays deterministic. + if (!process.env.CLAUDE_OAUTH_CLIENT_ID) { + assert.equal(CLAUDE_CONFIG.clientId, EXPECTED_CLIENT_IDS.claude_id); + } + if (!process.env.CODEX_OAUTH_CLIENT_ID) { + assert.equal(CODEX_CONFIG.clientId, EXPECTED_CLIENT_IDS.codex_id); + } + if (!process.env.QWEN_OAUTH_CLIENT_ID) { + assert.equal(QWEN_CONFIG.clientId, EXPECTED_CLIENT_IDS.qwen_id); + } +});