security(oauth): migrate 5 public client_ids to resolvePublicCred (#3493)

This commit is contained in:
diegosouzapw
2026-06-10 19:21:33 -03:00
parent f06438feae
commit 1b83d97dd3
7 changed files with 103 additions and 30 deletions

View File

@@ -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))
---

View File

@@ -656,7 +656,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
},
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<string, RegistryEntry> = {
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<string, RegistryEntry> = {
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<string, RegistryEntry> = {
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",

View File

@@ -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;

View File

@@ -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.

View File

@@ -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";

View File

@@ -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",

View File

@@ -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<string, string> = {
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);
}
});