mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-05 08:10:39 +00:00
Rename fabro-openai-oauth to fabro-oauth
This commit is contained in:
parent
aa4855ab7d
commit
0337e5ee96
9 changed files with 347 additions and 457 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -1492,7 +1492,7 @@ dependencies = [
|
|||
"fabro-macros",
|
||||
"fabro-mcp",
|
||||
"fabro-model",
|
||||
"fabro-openai-oauth",
|
||||
"fabro-oauth",
|
||||
"fabro-proctitle",
|
||||
"fabro-retro",
|
||||
"fabro-sandbox",
|
||||
|
|
@ -1726,7 +1726,7 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-openai-oauth"
|
||||
name = "fabro-oauth"
|
||||
version = "0.176.2"
|
||||
dependencies = [
|
||||
"axum",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ workspace = true
|
|||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-openai-oauth = { path = "../fabro-openai-oauth" }
|
||||
fabro-oauth = { path = "../fabro-oauth" }
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-devcontainer = { path = "../fabro-devcontainer" }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
pub(crate) mod github;
|
||||
pub(crate) mod openai_jwt;
|
||||
pub(crate) mod provider_auth;
|
||||
mod utilities;
|
||||
|
||||
|
|
|
|||
138
lib/crates/fabro-cli/src/shared/openai_jwt.rs
Normal file
138
lib/crates/fabro-cli/src/shared/openai_jwt.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub(crate) const DEFAULT_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub(crate) const DEFAULT_ISSUER: &str = "https://auth.openai.com";
|
||||
pub(crate) const OAUTH_PORT: u16 = 1455;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JwtPayload {
|
||||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
#[serde(default, rename = "https://api.openai.com/auth")]
|
||||
auth_claim: Option<AuthClaim>,
|
||||
#[serde(default)]
|
||||
organizations: Option<Vec<Organization>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthClaim {
|
||||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Organization {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_jwt_payload(token: &str) -> Option<JwtPayload> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).ok()?;
|
||||
serde_json::from_slice(&payload_bytes).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn extract_account_id(id_token: &str) -> Option<String> {
|
||||
let payload = parse_jwt_payload(id_token)?;
|
||||
payload
|
||||
.chatgpt_account_id
|
||||
.or_else(|| {
|
||||
payload
|
||||
.auth_claim
|
||||
.and_then(|claim| claim.chatgpt_account_id)
|
||||
})
|
||||
.or_else(|| {
|
||||
payload
|
||||
.organizations
|
||||
.and_then(|orgs| orgs.into_iter().next())
|
||||
.and_then(|org| org.id)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_test_jwt(claims: &serde_json::Value) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap());
|
||||
format!("{header}.{payload}.signature")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_with_chatgpt_account_id() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"chatgpt_account_id": "acct_123"
|
||||
}));
|
||||
let payload = parse_jwt_payload(&jwt).unwrap();
|
||||
assert_eq!(payload.chatgpt_account_id.as_deref(), Some("acct_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_with_nested_auth_claim() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acct_nested"
|
||||
}
|
||||
}));
|
||||
let payload = parse_jwt_payload(&jwt).unwrap();
|
||||
assert_eq!(
|
||||
payload
|
||||
.auth_claim
|
||||
.and_then(|claim| claim.chatgpt_account_id)
|
||||
.as_deref(),
|
||||
Some("acct_nested")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_invalid_format() {
|
||||
assert!(parse_jwt_payload("not-a-jwt").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_invalid_base64() {
|
||||
assert!(parse_jwt_payload("header.!!!invalid!!!.sig").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_account_id_prefers_top_level() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"chatgpt_account_id": "top_level",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "nested"
|
||||
},
|
||||
"organizations": [{"id": "org"}]
|
||||
}));
|
||||
assert_eq!(extract_account_id(&jwt).as_deref(), Some("top_level"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_account_id_falls_back_to_nested() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "nested"
|
||||
}
|
||||
}));
|
||||
assert_eq!(extract_account_id(&jwt).as_deref(), Some("nested"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_account_id_falls_back_to_first_organization() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"organizations": [{"id": "org_456"}]
|
||||
}));
|
||||
assert_eq!(extract_account_id(&jwt).as_deref(), Some("org_456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_account_id_none_when_missing() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({}));
|
||||
assert!(extract_account_id(&jwt).is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ use fabro_util::terminal::Styles;
|
|||
use tokio::task::spawn_blocking;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::openai_jwt;
|
||||
use crate::commands::doctor;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -80,20 +81,27 @@ pub(crate) async fn run_openai_oauth_or_api_key(s: &Styles) -> Result<Vec<(Strin
|
|||
" {}",
|
||||
s.dim.apply_to("Opening browser for OpenAI login...")
|
||||
);
|
||||
match fabro_openai_oauth::run_browser_flow(
|
||||
fabro_openai_oauth::DEFAULT_ISSUER,
|
||||
fabro_openai_oauth::DEFAULT_CLIENT_ID,
|
||||
match fabro_oauth::run_browser_flow(
|
||||
openai_jwt::DEFAULT_ISSUER,
|
||||
openai_jwt::DEFAULT_CLIENT_ID,
|
||||
"openid profile email offline_access",
|
||||
openai_jwt::OAUTH_PORT,
|
||||
"/auth/callback",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tokens) => {
|
||||
tracing::info!("OpenAI OAuth browser flow completed");
|
||||
let account_id = fabro_openai_oauth::extract_account_id(&tokens);
|
||||
let pairs = openai_oauth_env_pairs(
|
||||
&tokens.access_token,
|
||||
&tokens.refresh_token,
|
||||
account_id.as_deref(),
|
||||
);
|
||||
let account_id = tokens
|
||||
.id_token
|
||||
.as_deref()
|
||||
.and_then(openai_jwt::extract_account_id);
|
||||
let refresh_token = tokens
|
||||
.refresh_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("OpenAI did not return a refresh token"))?;
|
||||
let pairs =
|
||||
openai_oauth_env_pairs(&tokens.access_token, refresh_token, account_id.as_deref());
|
||||
eprintln!(
|
||||
" {} OpenAI configured via browser login",
|
||||
s.green.apply_to("✔")
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
[package]
|
||||
name = "fabro-openai-oauth"
|
||||
name = "fabro-oauth"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description = "OpenAI OAuth PKCE token acquisition for Fabro"
|
||||
description = "Generic OAuth 2.0 PKCE token acquisition for Fabro"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
32
lib/crates/fabro-oauth/examples/login.rs
Normal file
32
lib/crates/fabro-oauth/examples/login.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use std::env;
|
||||
|
||||
use fabro_oauth::run_browser_flow;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let issuer = env::var("OAUTH_ISSUER").expect("set OAUTH_ISSUER");
|
||||
let client_id = env::var("OAUTH_CLIENT_ID").expect("set OAUTH_CLIENT_ID");
|
||||
let scope = env::var("OAUTH_SCOPE").unwrap_or_else(|_| "openid profile email".to_string());
|
||||
let port: u16 = env::var("OAUTH_PORT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let callback_path = env::var("OAUTH_CALLBACK_PATH").unwrap_or_else(|_| "/callback".to_string());
|
||||
|
||||
match run_browser_flow(&issuer, &client_id, &scope, port, &callback_path).await {
|
||||
Ok(tokens) => {
|
||||
println!("Login successful!");
|
||||
println!(
|
||||
"Access token: {}...",
|
||||
&tokens.access_token[..20.min(tokens.access_token.len())]
|
||||
);
|
||||
if let Some(expires_in) = tokens.expires_in {
|
||||
println!("Expires in: {expires_in}s");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Login failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,11 +10,6 @@ use serde::Deserialize;
|
|||
use sha2::{Digest, Sha256};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::time;
|
||||
|
||||
pub const DEFAULT_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub const DEFAULT_ISSUER: &str = "https://auth.openai.com";
|
||||
pub const OAUTH_PORT: u16 = 1455;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE
|
||||
|
|
@ -79,6 +74,7 @@ pub fn build_authorize_url(
|
|||
issuer: &str,
|
||||
client_id: &str,
|
||||
redirect_uri: &str,
|
||||
scope: &str,
|
||||
pkce: &PkceCodes,
|
||||
state: &str,
|
||||
) -> String {
|
||||
|
|
@ -86,7 +82,7 @@ pub fn build_authorize_url(
|
|||
("response_type", "code"),
|
||||
("client_id", client_id),
|
||||
("redirect_uri", redirect_uri),
|
||||
("scope", "openid profile email offline_access"),
|
||||
("scope", scope),
|
||||
("code_challenge", &pkce.challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
("state", state),
|
||||
|
|
@ -100,72 +96,12 @@ pub fn build_authorize_url(
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TokenResponse {
|
||||
pub id_token: String,
|
||||
pub id_token: Option<String>,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT claims
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct IdTokenClaims {
|
||||
pub chatgpt_account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JwtPayload {
|
||||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
#[serde(default, rename = "https://api.openai.com/auth")]
|
||||
auth_claim: Option<AuthClaim>,
|
||||
#[serde(default)]
|
||||
organizations: Option<Vec<Organization>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthClaim {
|
||||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Organization {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_jwt_payload(token: &str) -> Option<JwtPayload> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).ok()?;
|
||||
serde_json::from_slice(&payload_bytes).ok()
|
||||
}
|
||||
|
||||
pub fn parse_jwt_claims(token: &str) -> Option<IdTokenClaims> {
|
||||
let payload = parse_jwt_payload(token)?;
|
||||
let chatgpt_account_id = payload
|
||||
.chatgpt_account_id
|
||||
.or_else(|| payload.auth_claim.and_then(|a| a.chatgpt_account_id));
|
||||
Some(IdTokenClaims { chatgpt_account_id })
|
||||
}
|
||||
|
||||
pub fn extract_account_id(tokens: &TokenResponse) -> Option<String> {
|
||||
let payload = parse_jwt_payload(&tokens.id_token)?;
|
||||
payload
|
||||
.chatgpt_account_id
|
||||
.or_else(|| payload.auth_claim.and_then(|a| a.chatgpt_account_id))
|
||||
.or_else(|| {
|
||||
payload
|
||||
.organizations
|
||||
.and_then(|orgs| orgs.into_iter().next())
|
||||
.and_then(|org| org.id)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token exchange
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -256,107 +192,6 @@ pub async fn refresh_access_token(
|
|||
Ok(tokens)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceAuthResponse {
|
||||
pub device_auth_id: String,
|
||||
pub user_code: String,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
pub async fn initiate_device_flow(
|
||||
client: &reqwest::Client,
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
) -> Result<DeviceAuthResponse, String> {
|
||||
let url = format!("{issuer}/api/accounts/deviceauth/usercode");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "client_id": client_id }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow initiation failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"Device flow initiation failed ({status}): {body_text}"
|
||||
));
|
||||
}
|
||||
|
||||
let device: DeviceAuthResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse device flow response: {e}"))?;
|
||||
|
||||
tracing::info!("Device flow initiated");
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DevicePollResponse {
|
||||
#[serde(default)]
|
||||
code: Option<String>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn poll_device_flow(
|
||||
client: &reqwest::Client,
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
device: &DeviceAuthResponse,
|
||||
) -> Result<TokenResponse, String> {
|
||||
let poll_url = format!("{issuer}/api/accounts/deviceauth/token");
|
||||
let redirect_uri = format!("http://localhost:{OAUTH_PORT}/auth/callback");
|
||||
let mut attempt = 0u32;
|
||||
|
||||
loop {
|
||||
attempt += 1;
|
||||
let resp = client
|
||||
.post(&poll_url)
|
||||
.json(&serde_json::json!({
|
||||
"client_id": client_id,
|
||||
"device_auth_id": device.device_auth_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow poll failed: {e}"))?;
|
||||
|
||||
let poll: DevicePollResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse device poll response: {e}"))?;
|
||||
|
||||
if let Some(code) = poll.code {
|
||||
tracing::info!("Device flow completed");
|
||||
return exchange_code_for_tokens(client, issuer, client_id, &code, &redirect_uri, "")
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some(ref error) = poll.error {
|
||||
if error == "authorization_pending" {
|
||||
tracing::debug!(attempt, "Device flow authorization pending");
|
||||
if device.interval > 0 {
|
||||
time::sleep(std::time::Duration::from_secs(device.interval)).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if error == "expired_token" {
|
||||
tracing::error!("Device flow expired");
|
||||
return Err("Device flow authorization expired".to_string());
|
||||
}
|
||||
return Err(format!("Device flow error: {error}"));
|
||||
}
|
||||
|
||||
return Err("Unexpected device poll response".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Callback server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -369,10 +204,36 @@ struct CallbackParams {
|
|||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
fn validate_callback_path(path: &str) -> Result<(), String> {
|
||||
if path.is_empty() {
|
||||
return Err("Callback path must not be empty".to_string());
|
||||
}
|
||||
if !path.starts_with('/') {
|
||||
return Err(format!("Callback path must start with '/': {path}"));
|
||||
}
|
||||
if path
|
||||
.split('/')
|
||||
.skip(1)
|
||||
.any(|segment| segment.starts_with(':') || segment.starts_with('*'))
|
||||
{
|
||||
return Err(format!(
|
||||
"Callback path must not contain route parameters: {path}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_redirect_uri(port: u16, path: &str) -> String {
|
||||
format!("http://localhost:{port}{path}")
|
||||
}
|
||||
|
||||
pub async fn start_callback_server(
|
||||
port: u16,
|
||||
path: &str,
|
||||
expected_state: String,
|
||||
) -> Result<(u16, oneshot::Receiver<Result<String, String>>), String> {
|
||||
validate_callback_path(path)?;
|
||||
|
||||
let listener = TcpListener::bind(format!("localhost:{port}"))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind callback server: {e}"))?;
|
||||
|
|
@ -387,9 +248,10 @@ pub async fn start_callback_server(
|
|||
let code_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(code_tx)));
|
||||
let shutdown_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_tx)));
|
||||
let expected_state = std::sync::Arc::new(expected_state);
|
||||
let callback_path = path.to_string();
|
||||
|
||||
let app = axum::Router::new().route(
|
||||
"/auth/callback",
|
||||
callback_path.as_str(),
|
||||
get(
|
||||
move |Query(params): Query<CallbackParams>| async move {
|
||||
if params.state != *expected_state {
|
||||
|
|
@ -463,7 +325,7 @@ pub async fn start_callback_server(
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Arc</title>
|
||||
<title>Authorization</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f6f8fa; color: #1f2328; }
|
||||
.card { text-align: center; background: #fff; border: 1px solid #d1d9e0; border-radius: 12px; padding: 48px; max-width: 420px; }
|
||||
|
|
@ -503,15 +365,25 @@ pub async fn start_callback_server(
|
|||
// Browser flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn run_browser_flow(issuer: &str, client_id: &str) -> Result<TokenResponse, String> {
|
||||
pub async fn run_browser_flow(
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
scope: &str,
|
||||
port: u16,
|
||||
callback_path: &str,
|
||||
) -> Result<TokenResponse, String> {
|
||||
let pkce = generate_pkce();
|
||||
let state = generate_state();
|
||||
let redirect_uri = format!("http://localhost:{OAUTH_PORT}/auth/callback");
|
||||
|
||||
let (_port, code_rx) = start_callback_server(OAUTH_PORT, state.clone()).await?;
|
||||
let auth_url = build_authorize_url(issuer, client_id, &redirect_uri, &pkce, &state);
|
||||
let (actual_port, code_rx) = start_callback_server(port, callback_path, state.clone()).await?;
|
||||
let redirect_uri = build_redirect_uri(actual_port, callback_path);
|
||||
let auth_url = build_authorize_url(issuer, client_id, &redirect_uri, scope, &pkce, &state);
|
||||
|
||||
tracing::info!(port = OAUTH_PORT, "OAuth browser flow started");
|
||||
tracing::info!(
|
||||
port = actual_port,
|
||||
callback_path,
|
||||
"OAuth browser flow started"
|
||||
);
|
||||
|
||||
if let Err(e) = open::that(&auth_url) {
|
||||
tracing::warn!("Could not open browser: {e}");
|
||||
|
|
@ -607,17 +479,22 @@ mod tests {
|
|||
fn authorize_url_has_required_params() {
|
||||
let pkce = generate_pkce();
|
||||
let state = generate_state();
|
||||
let scope = "openid profile email offline_access";
|
||||
let url = build_authorize_url(
|
||||
"https://auth.openai.com",
|
||||
"test-client",
|
||||
"http://127.0.0.1:1455/callback",
|
||||
scope,
|
||||
&pkce,
|
||||
&state,
|
||||
);
|
||||
assert!(url.contains("response_type=code"), "missing response_type");
|
||||
assert!(url.contains("client_id=test-client"), "missing client_id");
|
||||
assert!(url.contains("redirect_uri="), "missing redirect_uri");
|
||||
assert!(url.contains("scope="), "missing scope");
|
||||
assert!(
|
||||
url.contains(&format!("scope={}", percent_encode_param(scope))),
|
||||
"missing scope"
|
||||
);
|
||||
assert!(
|
||||
url.contains(&format!("code_challenge={}", pkce.challenge)),
|
||||
"missing code_challenge"
|
||||
|
|
@ -637,10 +514,10 @@ mod tests {
|
|||
"https://auth.openai.com",
|
||||
"test-client",
|
||||
"http://127.0.0.1:1455/callback",
|
||||
"openid profile email offline_access",
|
||||
&pkce,
|
||||
&state,
|
||||
);
|
||||
// Should only contain standard OAuth 2.0 PKCE params
|
||||
assert!(
|
||||
!url.contains("id_token_add_organizations"),
|
||||
"should not contain OpenAI-specific params"
|
||||
|
|
@ -663,6 +540,7 @@ mod tests {
|
|||
"https://auth.openai.com",
|
||||
"test-client",
|
||||
"http://127.0.0.1:1455/callback",
|
||||
"openid profile email offline_access",
|
||||
&pkce,
|
||||
&state,
|
||||
);
|
||||
|
|
@ -672,108 +550,21 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 4: JWT claims
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn make_test_jwt(claims: &serde_json::Value) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap());
|
||||
format!("{header}.{payload}.signature")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_with_chatgpt_account_id() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"chatgpt_account_id": "acct_123"
|
||||
}));
|
||||
let claims = parse_jwt_claims(&jwt).unwrap();
|
||||
assert_eq!(claims.chatgpt_account_id.as_deref(), Some("acct_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_with_nested_auth_claim() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acct_nested"
|
||||
}
|
||||
}));
|
||||
let claims = parse_jwt_claims(&jwt).unwrap();
|
||||
assert_eq!(claims.chatgpt_account_id.as_deref(), Some("acct_nested"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_with_organizations() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"organizations": [{"id": "org_456"}]
|
||||
}));
|
||||
let tokens = TokenResponse {
|
||||
id_token: jwt,
|
||||
access_token: String::new(),
|
||||
refresh_token: String::new(),
|
||||
expires_in: None,
|
||||
};
|
||||
assert_eq!(extract_account_id(&tokens).as_deref(), Some("org_456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_invalid_format() {
|
||||
assert!(parse_jwt_claims("not-a-jwt").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_invalid_base64() {
|
||||
assert!(parse_jwt_claims("header.!!!invalid!!!.sig").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_account_id_prefers_top_level() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"chatgpt_account_id": "top_level",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "nested"
|
||||
},
|
||||
"organizations": [{"id": "org"}]
|
||||
}));
|
||||
let tokens = TokenResponse {
|
||||
id_token: jwt,
|
||||
access_token: String::new(),
|
||||
refresh_token: String::new(),
|
||||
expires_in: None,
|
||||
};
|
||||
assert_eq!(extract_account_id(&tokens).as_deref(), Some("top_level"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_account_id_falls_back_to_nested() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "nested"
|
||||
}
|
||||
}));
|
||||
let tokens = TokenResponse {
|
||||
id_token: jwt,
|
||||
access_token: String::new(),
|
||||
refresh_token: String::new(),
|
||||
expires_in: None,
|
||||
};
|
||||
assert_eq!(extract_account_id(&tokens).as_deref(), Some("nested"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_account_id_none_when_missing() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({}));
|
||||
let tokens = TokenResponse {
|
||||
id_token: jwt,
|
||||
access_token: String::new(),
|
||||
refresh_token: String::new(),
|
||||
expires_in: None,
|
||||
};
|
||||
assert!(extract_account_id(&tokens).is_none());
|
||||
fn build_redirect_uri_constructs_expected_uri() {
|
||||
assert_eq!(
|
||||
build_redirect_uri(1455, "/auth/callback"),
|
||||
"http://localhost:1455/auth/callback"
|
||||
);
|
||||
assert_eq!(
|
||||
build_redirect_uri(8080, "/oauth/done"),
|
||||
"http://localhost:8080/oauth/done"
|
||||
);
|
||||
assert_eq!(build_redirect_uri(1, "/"), "http://localhost:1/");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 5: Token exchange
|
||||
// Phase 4: Token exchange
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -816,14 +607,50 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tokens.id_token, "id-tok");
|
||||
assert_eq!(tokens.id_token.as_deref(), Some("id-tok"));
|
||||
assert_eq!(tokens.access_token, "access-tok");
|
||||
assert_eq!(tokens.refresh_token, "refresh-tok");
|
||||
assert_eq!(tokens.refresh_token.as_deref(), Some("refresh-tok"));
|
||||
assert_eq!(tokens.expires_in, Some(3600));
|
||||
|
||||
mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exchange_code_allows_missing_optional_tokens() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
|
||||
server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST").path("/oauth/token");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"access_token": "access-tok"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let tokens = exchange_code_for_tokens(
|
||||
&client,
|
||||
&server.url(""),
|
||||
"test-client",
|
||||
"test-code",
|
||||
"http://localhost/cb",
|
||||
"test-verifier",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tokens.id_token, None);
|
||||
assert_eq!(tokens.access_token, "access-tok");
|
||||
assert_eq!(tokens.refresh_token, None);
|
||||
assert_eq!(tokens.expires_in, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exchange_code_error_response() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
|
|
@ -851,7 +678,7 @@ mod tests {
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 6: Token refresh
|
||||
// Phase 5: Token refresh
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -885,8 +712,9 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tokens.id_token.as_deref(), Some("new-id"));
|
||||
assert_eq!(tokens.access_token, "new-access");
|
||||
assert_eq!(tokens.refresh_token, "new-refresh");
|
||||
assert_eq!(tokens.refresh_token.as_deref(), Some("new-refresh"));
|
||||
assert_eq!(tokens.expires_in, Some(7200));
|
||||
|
||||
mock.assert_async().await;
|
||||
|
|
@ -912,150 +740,22 @@ mod tests {
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 7: Device flow
|
||||
// Phase 6: Callback server
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn initiate_device_flow_success() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
|
||||
let mock = server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST")
|
||||
.path("/api/accounts/deviceauth/usercode");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"device_auth_id": "dev-123",
|
||||
"user_code": "ABCD-1234",
|
||||
"interval": 5
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let device = initiate_device_flow(&client, &server.url(""), "test-client")
|
||||
async fn callback_server_binds_ephemeral_port_and_receives_code() {
|
||||
let callback_path = "/custom/path";
|
||||
let (port, code_rx) = start_callback_server(0, callback_path, "test-state".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(device.device_auth_id, "dev-123");
|
||||
assert_eq!(device.user_code, "ABCD-1234");
|
||||
assert_eq!(device.interval, 5);
|
||||
|
||||
mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initiate_device_flow_error() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
|
||||
server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST")
|
||||
.path("/api/accounts/deviceauth/usercode");
|
||||
then.status(500).body("Internal Server Error");
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let err = initiate_device_flow(&client, &server.url(""), "test-client")
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("500"), "error should contain status: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_device_flow_success() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
|
||||
server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST").path("/api/accounts/deviceauth/token");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(serde_json::json!({"code": "auth-code-123"}).to_string());
|
||||
})
|
||||
.await;
|
||||
|
||||
server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST").path("/oauth/token");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"id_token": "dev-id",
|
||||
"access_token": "dev-access",
|
||||
"refresh_token": "dev-refresh",
|
||||
"expires_in": 3600
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
let device = DeviceAuthResponse {
|
||||
device_auth_id: "dev-123".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
interval: 0,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let tokens = poll_device_flow(&client, &server.url(""), "test-client", &device)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tokens.access_token, "dev-access");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_device_flow_expired() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
|
||||
server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST").path("/api/accounts/deviceauth/token");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(serde_json::json!({"error": "expired_token"}).to_string());
|
||||
})
|
||||
.await;
|
||||
|
||||
let device = DeviceAuthResponse {
|
||||
device_auth_id: "dev-expired".to_string(),
|
||||
user_code: "XXXX-0000".to_string(),
|
||||
interval: 0,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let err = poll_device_flow(&client, &server.url(""), "test-client", &device)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.contains("expired"),
|
||||
"error should mention expiry: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 8: Callback server
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_server_receives_code() {
|
||||
let (port, code_rx) = start_callback_server(0, "test-state".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(port, 0);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.get(format!(
|
||||
"http://localhost:{port}/auth/callback?code=abc&state=test-state"
|
||||
"http://localhost:{port}{callback_path}?code=abc&state=test-state"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1066,15 +766,35 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_server_validates_state() {
|
||||
let (port, _code_rx) = start_callback_server(0, "correct-state".to_string())
|
||||
async fn callback_server_routes_non_default_path() {
|
||||
let callback_path = "/oauth/done";
|
||||
let (port, _code_rx) = start_callback_server(0, callback_path, "test-state".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"http://localhost:{port}/auth/callback?code=abc&state=wrong-state"
|
||||
"http://localhost:{port}{callback_path}?code=abc&state=test-state"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_server_validates_state() {
|
||||
let callback_path = "/oauth/done";
|
||||
let (port, _code_rx) = start_callback_server(0, callback_path, "correct-state".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"http://localhost:{port}{callback_path}?code=abc&state=wrong-state"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1085,23 +805,38 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn callback_server_returns_success_html() {
|
||||
let (port, _code_rx) = start_callback_server(0, "test-state".to_string())
|
||||
let callback_path = "/oauth/done";
|
||||
let (port, _code_rx) = start_callback_server(0, callback_path, "test-state".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"http://localhost:{port}/auth/callback?code=abc&state=test-state"
|
||||
"http://localhost:{port}{callback_path}?code=abc&state=test-state"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = resp.text().await.unwrap();
|
||||
assert!(
|
||||
body.contains("<title>Authorization</title>"),
|
||||
"response should contain updated title: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("Authorization Successful"),
|
||||
"response should contain success message: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_server_rejects_invalid_paths() {
|
||||
for path in ["", "no-leading-slash", "/:param", "/*wildcard"] {
|
||||
let err = start_callback_server(0, path, "test-state".to_string())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!err.is_empty(), "expected error for path {path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
use fabro_openai_oauth::{DEFAULT_CLIENT_ID, DEFAULT_ISSUER, extract_account_id, run_browser_flow};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
match run_browser_flow(DEFAULT_ISSUER, DEFAULT_CLIENT_ID).await {
|
||||
Ok(tokens) => {
|
||||
println!("Login successful!");
|
||||
if let Some(account_id) = extract_account_id(&tokens) {
|
||||
println!("Account ID: {account_id}");
|
||||
}
|
||||
println!(
|
||||
"Access token: {}...",
|
||||
&tokens.access_token[..20.min(tokens.access_token.len())]
|
||||
);
|
||||
if let Some(expires_in) = tokens.expires_in {
|
||||
println!("Expires in: {expires_in}s");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Login failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue