iachat/enterprise/app/services/captain/codex/auth_service.rb
Rodribm10 928b1ec6b9 feat(captain): Codex OAuth auth module + proxy controller
Implementa Fases 1+2 do plano Captain Codex OAuth.

Fase 1 — Auth módulo:
- Migration captain_codex_credentials (tokens AR-encrypted)
- Model Captain::CodexCredential (singleton-ish com .current)
- Captain::Codex::AuthService com device flow completo:
  start_device_login, poll_once, exchange_for_credential,
  valid_access_token (auto-refresh), refresh!
- Rake task captain:codex:{login,status,refresh}
- Sidekiq job Captain::Codex::RefreshTokensJob rodando a cada 30min

Fase 2 — Proxy Chat Completions → Responses:
- Captain::Codex::Translator (chat ↔ responses, tools, tool_calls)
- Captain::Codex::Client (streaming SSE → agregado)
- Api::Internal::CodexProxyController expondo
  POST /codex/v1/chat/completions
- 10 specs do Translator passando

Próximo: Fase 3 (feature flag + fallback) e reconfiguração dos
clientes RubyLLM/Agents/ruby-openai pra apontarem pro proxy quando
CAPTAIN_LLM_PROVIDER=openai_codex_oauth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:07:01 -03:00

172 lines
5.6 KiB
Ruby

require 'faraday'
# Gerencia credenciais OAuth do OpenAI Codex (ChatGPT Plus).
#
# Fluxo device code:
# 1. start_device_login → retorna code + URL pro usuário
# 2. usuário autoriza no browser
# 3. poll_for_authorization → espera o usuário aprovar
# 4. exchange_for_credential → salva tokens no DB
#
# Durante uso normal do Captain:
# - valid_access_token → retorna access_token, refrescando se próximo de expirar
class Captain::Codex::AuthService
CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'.freeze
ISSUER = 'https://auth.openai.com'.freeze
USERCODE_URL = "#{ISSUER}/api/accounts/deviceauth/usercode".freeze
DEVICE_TOKEN_URL = "#{ISSUER}/api/accounts/deviceauth/token".freeze
OAUTH_TOKEN_URL = "#{ISSUER}/oauth/token".freeze
REDIRECT_URI = "#{ISSUER}/deviceauth/callback".freeze
VERIFY_URL = "#{ISSUER}/codex/device".freeze
REFRESH_SKEW_SECONDS = 120
class AuthError < StandardError; end
class PendingAuthorization < StandardError; end
class << self
# Passo 1: solicita um device code.
def start_device_login
response = http.post(USERCODE_URL) do |req|
req.headers['Content-Type'] = 'application/json'
req.body = { client_id: CLIENT_ID }.to_json
end
raise AuthError, "Failed to request device code: #{response.status} #{response.body}" unless response.success?
data = JSON.parse(response.body)
{
user_code: data.fetch('user_code'),
device_auth_id: data.fetch('device_auth_id'),
poll_interval: [3, data['interval'].to_i].max,
verify_url: VERIFY_URL
}
end
# Passo 3: polling. Retorna { authorization_code, code_verifier } ou raises.
def poll_once(device_auth_id:, user_code:)
response = http.post(DEVICE_TOKEN_URL) do |req|
req.headers['Content-Type'] = 'application/json'
req.body = { device_auth_id: device_auth_id, user_code: user_code }.to_json
end
case response.status
when 200
data = JSON.parse(response.body)
{ authorization_code: data.fetch('authorization_code'), code_verifier: data.fetch('code_verifier') }
when 403, 404
raise PendingAuthorization
else
raise AuthError, "Polling failed: #{response.status} #{response.body}"
end
end
# Passo 4: troca o authorization_code por tokens e persiste.
def exchange_for_credential(authorization_code:, code_verifier:)
response = http.post(OAUTH_TOKEN_URL) do |req|
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
req.body = URI.encode_www_form(
grant_type: 'authorization_code',
code: authorization_code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: code_verifier
)
end
raise AuthError, "Token exchange failed: #{response.status} #{response.body}" unless response.success?
tokens = JSON.parse(response.body)
save_credential!(tokens)
end
# Retorna access_token válido, fazendo refresh se necessário.
# Lança AuthError se não houver credencial ativa.
def valid_access_token
cred = Captain::CodexCredential.current
raise AuthError, 'No active Codex credential. Run: rails captain:codex:login' if cred.nil?
cred = refresh!(cred) if cred.needs_refresh?(skew_seconds: REFRESH_SKEW_SECONDS)
cred.access_token
end
def refresh!(credential)
response = post_refresh_request(credential.refresh_token)
unless response.success?
credential.update!(status: 'expired')
raise AuthError, "Refresh failed: #{response.status} #{response.body}. Re-run: rails captain:codex:login"
end
tokens = JSON.parse(response.body)
credential.update!(refresh_attributes(tokens, credential))
credential
end
def post_refresh_request(refresh_token)
http.post(OAUTH_TOKEN_URL) do |req|
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
req.body = URI.encode_www_form(
grant_type: 'refresh_token',
refresh_token: refresh_token,
client_id: CLIENT_ID
)
end
end
def refresh_attributes(tokens, credential)
{
access_token: tokens.fetch('access_token'),
refresh_token: tokens['refresh_token'].presence || credential.refresh_token,
expires_at: Time.current + tokens['expires_in'].to_i.seconds,
last_refresh_at: Time.current,
status: 'active'
}
end
private
def save_credential!(tokens)
id_claims = decode_id_token(tokens['id_token'])
attrs = credential_attributes(tokens, id_claims)
cred = Captain::CodexCredential.current || Captain::CodexCredential.new
cred.assign_attributes(attrs)
cred.save!
cred
end
def credential_attributes(tokens, id_claims)
openai_auth = id_claims['https://api.openai.com/auth'] || {}
{
access_token: tokens.fetch('access_token'),
refresh_token: tokens.fetch('refresh_token'),
expires_at: Time.current + tokens['expires_in'].to_i.seconds,
last_refresh_at: Time.current,
chatgpt_account_id: openai_auth['chatgpt_account_id'],
chatgpt_plan_type: openai_auth['chatgpt_plan_type'],
email: id_claims['email'],
status: 'active'
}
end
def decode_id_token(jwt)
return {} if jwt.blank?
payload_b64 = jwt.split('.')[1]
return {} if payload_b64.blank?
JSON.parse(Base64.urlsafe_decode64(payload_b64 + ('=' * (-payload_b64.length % 4))))
rescue StandardError
{}
end
def http
Faraday.new do |f|
f.options.timeout = 30
f.options.open_timeout = 15
end
end
end
end