fix: auth bypass, SPA serving, and provider improvements (#97)
* feat(chat): polish syntax highlighting and tool payload rendering (#94) * [verified] feat(chat): polish syntax highlighting and tool payload rendering * [verified] fix(chat): tighten large tool payload rendering * docs: update data volume path in Docker docs Align documentation with docker-compose.yml change: hermes-web-ui-data -> hermes-web-ui, /app/dist/data -> /root/.hermes-web-ui Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: bundle server build and restructure service modules - Add build-server.mjs script for standalone server compilation - Add logger service with structured output - Restructure auth, gateway-manager, hermes-cli, hermes services - Update docker-compose volume mount path - Update tsconfig and entry point for bundled server Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: separate controllers from routes and centralize route registration - Extract business logic from route handlers into controllers/ - Add centralized route registry in routes/index.ts with public/auth/protected layers - Replace global auth whitelist with sequential middleware registration - Extract shared helpers to services/config-helpers.ts - Allow custom provider name to be user-editable in ProviderFormModal - Deduplicate custom providers by poolKey instead of base_url in getAvailable Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: auth bypass via path case, SPA serving, and provider improvements - Fix auth bypass: path case-insensitive check for /api, /v1, /upload - Fix SPA returning 401: skip auth for non-API paths (static files) - Fix profile switch: use local loading state instead of shared store ref - Auto-append /v1 to base_url when fetching models (frontend + backend) - Guard .env writing to built-in providers only - Add builtin field to provider presets, enable base_url input in form - Print auth token to console on startup (pino only writes to file) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
Zhicheng Han
parent
21296a416b
commit
477af66232
@@ -1,347 +1,8 @@
|
||||
import Router from '@koa/router'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
||||
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
||||
import * as ctrl from '../../controllers/hermes/codex-auth'
|
||||
|
||||
// --- OAuth Constants ---
|
||||
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
|
||||
const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/api/accounts/deviceauth/usercode'
|
||||
const CODEX_DEVICE_TOKEN_URL = 'https://auth.openai.com/api/accounts/deviceauth/token'
|
||||
const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token'
|
||||
const CODEX_DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api/codex'
|
||||
const CODEX_REDIRECT_URI = 'https://auth.openai.com/deviceauth/callback'
|
||||
const CODEX_VERIFICATION_URL = 'https://auth.openai.com/codex/device'
|
||||
const CODEX_HOME = join(homedir(), '.codex')
|
||||
const POLL_MAX_DURATION = 15 * 60 * 1000 // 15 minutes
|
||||
const POLL_DEFAULT_INTERVAL = 5000 // 5 seconds
|
||||
|
||||
// --- Session Store ---
|
||||
interface CodexSession {
|
||||
id: string
|
||||
userCode: string
|
||||
deviceAuthId: string
|
||||
status: 'pending' | 'approved' | 'expired' | 'error'
|
||||
error?: string
|
||||
accessToken?: string
|
||||
refreshToken?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
const sessions = new Map<string, CodexSession>()
|
||||
|
||||
function cleanupExpiredSessions() {
|
||||
const now = Date.now()
|
||||
sessions.forEach((session, id) => {
|
||||
if (now - session.createdAt > POLL_MAX_DURATION + 60000) {
|
||||
sessions.delete(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Auth file helpers ---
|
||||
interface AuthJson {
|
||||
version?: number
|
||||
active_provider?: string
|
||||
providers?: Record<string, any>
|
||||
credential_pool?: Record<string, any[]>
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
function loadAuthJson(authPath: string): AuthJson {
|
||||
try {
|
||||
const raw = readFileSync(authPath, 'utf-8')
|
||||
return JSON.parse(raw) as AuthJson
|
||||
} catch {
|
||||
return { version: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
function saveAuthJson(authPath: string, data: AuthJson): void {
|
||||
data.updated_at = new Date().toISOString()
|
||||
const dir = authPath.substring(0, authPath.lastIndexOf('/'))
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
||||
}
|
||||
|
||||
function saveCodexCliTokens(accessToken: string, refreshToken: string): void {
|
||||
const codexHome = process.env.CODEX_HOME || CODEX_HOME
|
||||
const codexAuthPath = join(codexHome, 'auth.json')
|
||||
const dir = codexAuthPath.substring(0, codexAuthPath.lastIndexOf('/'))
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
const data = {
|
||||
tokens: { access_token: accessToken, refresh_token: refreshToken },
|
||||
last_refresh: new Date().toISOString(),
|
||||
}
|
||||
writeFileSync(codexAuthPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
||||
}
|
||||
|
||||
function decodeJwtExp(token: string): number | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const payload = Buffer.from(parts[1], 'base64url').toString('utf-8')
|
||||
const claims = JSON.parse(payload)
|
||||
return typeof claims.exp === 'number' ? claims.exp : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Background login worker ---
|
||||
async function codexLoginWorker(session: CodexSession, authPath: string): Promise<void> {
|
||||
const startTime = Date.now()
|
||||
const interval = POLL_DEFAULT_INTERVAL
|
||||
|
||||
while (Date.now() - startTime < POLL_MAX_DURATION) {
|
||||
await new Promise(resolve => setTimeout(resolve, interval))
|
||||
|
||||
if (session.status !== 'pending') return
|
||||
|
||||
try {
|
||||
// Step 3: Poll for authorization
|
||||
const pollRes = await fetch(CODEX_DEVICE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
device_auth_id: session.deviceAuthId,
|
||||
user_code: session.userCode,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (pollRes.status === 200) {
|
||||
const pollData = await pollRes.json() as { authorization_code: string; code_verifier: string }
|
||||
|
||||
// Step 4: Exchange authorization code for tokens
|
||||
const tokenRes = await fetch(CODEX_OAUTH_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: pollData.authorization_code,
|
||||
redirect_uri: CODEX_REDIRECT_URI,
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
code_verifier: pollData.code_verifier,
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
const errText = await tokenRes.text()
|
||||
console.error('[Codex Auth] Token exchange failed:', tokenRes.status, errText)
|
||||
session.status = 'error'
|
||||
session.error = `Token exchange failed: ${tokenRes.status}`
|
||||
return
|
||||
}
|
||||
|
||||
const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string }
|
||||
const refreshToken = tokenData.refresh_token || ''
|
||||
|
||||
session.accessToken = tokenData.access_token
|
||||
session.refreshToken = refreshToken
|
||||
session.status = 'approved'
|
||||
|
||||
// Save to auth.json
|
||||
const auth = loadAuthJson(authPath)
|
||||
if (!auth.providers) auth.providers = {}
|
||||
auth.providers['openai-codex'] = {
|
||||
tokens: {
|
||||
access_token: tokenData.access_token,
|
||||
refresh_token: refreshToken,
|
||||
},
|
||||
last_refresh: new Date().toISOString(),
|
||||
auth_mode: 'chatgpt',
|
||||
}
|
||||
|
||||
// Add to credential pool
|
||||
if (!auth.credential_pool) auth.credential_pool = {}
|
||||
auth.credential_pool['openai-codex'] = [{
|
||||
id: `openai-codex-${Date.now()}`,
|
||||
label: 'OpenAI Codex',
|
||||
base_url: CODEX_DEFAULT_BASE_URL,
|
||||
access_token: tokenData.access_token,
|
||||
last_status: null,
|
||||
}]
|
||||
|
||||
saveAuthJson(authPath, auth)
|
||||
|
||||
// Save to ~/.codex/auth.json for CLI sync
|
||||
saveCodexCliTokens(tokenData.access_token, refreshToken)
|
||||
|
||||
console.log('[Codex Auth] Login successful')
|
||||
return
|
||||
}
|
||||
|
||||
if (pollRes.status === 403 || pollRes.status === 404) {
|
||||
// Not yet authorized, keep polling
|
||||
continue
|
||||
}
|
||||
|
||||
// Other error status
|
||||
console.error('[Codex Auth] Poll failed:', pollRes.status)
|
||||
session.status = 'error'
|
||||
session.error = `Poll failed: ${pollRes.status}`
|
||||
return
|
||||
} catch (err: any) {
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||
continue
|
||||
}
|
||||
console.error('[Codex Auth] Poll error:', err.message)
|
||||
session.status = 'error'
|
||||
session.error = err.message
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout
|
||||
session.status = 'expired'
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
export const codexAuthRoutes = new Router()
|
||||
|
||||
codexAuthRoutes.post('/api/hermes/auth/codex/start', async (ctx) => {
|
||||
try {
|
||||
cleanupExpiredSessions()
|
||||
|
||||
// Step 1: Request device code
|
||||
const res = await fetch(CODEX_DEVICE_AUTH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'node-fetch',
|
||||
},
|
||||
body: JSON.stringify({ client_id: CODEX_CLIENT_ID }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
let errorBody: any = null
|
||||
try { errorBody = await res.json() } catch { /* ignore */ }
|
||||
console.error(`[codex-auth] Device code request failed: ${res.status}`, errorBody)
|
||||
|
||||
let errorMessage = `Device code request failed: ${res.status}`
|
||||
if (errorBody?.error?.code === 'unsupported_country_region_territory') {
|
||||
errorMessage = 'OpenAI does not support your region. You may need to use a proxy or VPN to access Codex.'
|
||||
}
|
||||
|
||||
ctx.status = 502
|
||||
ctx.body = { error: errorMessage, code: errorBody?.error?.code }
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json() as { user_code: string; device_auth_id: string; interval?: string }
|
||||
|
||||
const sessionId = randomUUID()
|
||||
const session: CodexSession = {
|
||||
id: sessionId,
|
||||
userCode: data.user_code,
|
||||
deviceAuthId: data.device_auth_id,
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
sessions.set(sessionId, session)
|
||||
|
||||
// Start background worker
|
||||
const authPath = getActiveAuthPath()
|
||||
codexLoginWorker(session, authPath).catch(err => {
|
||||
console.error('[Codex Auth] Worker error:', err)
|
||||
session.status = 'error'
|
||||
session.error = err.message
|
||||
})
|
||||
|
||||
ctx.body = {
|
||||
session_id: sessionId,
|
||||
user_code: data.user_code,
|
||||
verification_url: CODEX_VERIFICATION_URL,
|
||||
expires_in: 900, // 15 minutes
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
codexAuthRoutes.get('/api/hermes/auth/codex/poll/:sessionId', async (ctx) => {
|
||||
const session = sessions.get(ctx.params.sessionId)
|
||||
if (!session) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Session not found' }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
status: session.status,
|
||||
error: session.error || null,
|
||||
}
|
||||
})
|
||||
|
||||
codexAuthRoutes.get('/api/hermes/auth/codex/status', async (ctx) => {
|
||||
try {
|
||||
const authPath = getActiveAuthPath()
|
||||
const auth = loadAuthJson(authPath)
|
||||
const tokens = auth.providers?.['openai-codex']?.tokens
|
||||
|
||||
if (!tokens?.access_token || !auth.providers) {
|
||||
ctx.body = { authenticated: false }
|
||||
return
|
||||
}
|
||||
|
||||
const codexProvider = auth.providers['openai-codex']!
|
||||
|
||||
// Check if token is expired
|
||||
const exp = decodeJwtExp(tokens.access_token)
|
||||
if (exp && exp <= Date.now() / 1000 + 120) {
|
||||
// Try refresh
|
||||
if (tokens.refresh_token) {
|
||||
try {
|
||||
const refreshRes = await fetch(CODEX_OAUTH_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: tokens.refresh_token,
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
|
||||
if (refreshRes.ok) {
|
||||
const newTokens = await refreshRes.json() as { access_token: string; refresh_token?: string }
|
||||
codexProvider.tokens.access_token = newTokens.access_token
|
||||
if (newTokens.refresh_token) {
|
||||
codexProvider.tokens.refresh_token = newTokens.refresh_token
|
||||
}
|
||||
codexProvider.last_refresh = new Date().toISOString()
|
||||
saveAuthJson(authPath, auth)
|
||||
saveCodexCliTokens(newTokens.access_token, newTokens.refresh_token || tokens.refresh_token)
|
||||
|
||||
// Update credential pool too
|
||||
if (auth.credential_pool?.['openai-codex']?.[0]) {
|
||||
auth.credential_pool['openai-codex'][0].access_token = newTokens.access_token
|
||||
saveAuthJson(authPath, auth)
|
||||
}
|
||||
|
||||
ctx.body = { authenticated: true, last_refresh: codexProvider.last_refresh }
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Refresh failed
|
||||
}
|
||||
}
|
||||
|
||||
ctx.body = { authenticated: false }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
authenticated: true,
|
||||
last_refresh: codexProvider.last_refresh,
|
||||
}
|
||||
} catch {
|
||||
ctx.body = { authenticated: false }
|
||||
}
|
||||
})
|
||||
codexAuthRoutes.post('/api/hermes/auth/codex/start', ctrl.start)
|
||||
codexAuthRoutes.get('/api/hermes/auth/codex/poll/:sessionId', ctrl.poll)
|
||||
codexAuthRoutes.get('/api/hermes/auth/codex/status', ctrl.status)
|
||||
|
||||
Reference in New Issue
Block a user