Skip to main content

codex_login/
success_page.rs

1use base64::Engine;
2use serde_json::Value as JsonValue;
3use url::Url;
4
5use crate::server::DEFAULT_ISSUER;
6
7pub const CODEX_OPEN_APP_URL: &str = "https://chatgpt.com/codex/open-app";
8
9#[derive(Debug, Clone, Default, Eq, PartialEq)]
10pub enum LoginSuccessPage {
11    #[default]
12    Local,
13    Hosted {
14        url: Url,
15        app_brand: LoginSuccessPageBrand,
16    },
17}
18
19#[derive(Debug, Clone, Copy, Eq, PartialEq)]
20pub enum LoginSuccessPageBrand {
21    Codex,
22    Chatgpt,
23}
24
25impl LoginSuccessPageBrand {
26    fn as_str(self) -> &'static str {
27        match self {
28            Self::Codex => "codex",
29            Self::Chatgpt => "chatgpt",
30        }
31    }
32}
33
34#[derive(Debug, Eq, PartialEq)]
35pub(crate) enum LoginSuccessRedirect {
36    Local(String),
37    Hosted(String),
38}
39
40pub(crate) fn compose_success_url(
41    port: u16,
42    issuer: &str,
43    id_token: &str,
44    access_token: &str,
45    codex_streamlined_login: bool,
46    login_success_page: &LoginSuccessPage,
47) -> LoginSuccessRedirect {
48    let token_claims = jwt_auth_claims(id_token);
49
50    let org_id = token_claims
51        .get("organization_id")
52        .and_then(|value| value.as_str())
53        .unwrap_or("");
54    let project_id = token_claims
55        .get("project_id")
56        .and_then(|value| value.as_str())
57        .unwrap_or("");
58    let completed_onboarding = token_claims
59        .get("completed_platform_onboarding")
60        .and_then(JsonValue::as_bool)
61        .unwrap_or(false);
62    let is_org_owner = token_claims
63        .get("is_org_owner")
64        .and_then(JsonValue::as_bool)
65        .unwrap_or(false);
66    let needs_setup = !completed_onboarding && is_org_owner;
67    if !needs_setup && let LoginSuccessPage::Hosted { url, app_brand } = login_success_page {
68        let mut success_url = url.clone();
69        success_url.set_query(None);
70        success_url
71            .query_pairs_mut()
72            .append_pair("source", "login")
73            .append_pair("app_brand", app_brand.as_str());
74        return LoginSuccessRedirect::Hosted(success_url.into());
75    }
76
77    let access_claims = jwt_auth_claims(access_token);
78    let plan_type = access_claims
79        .get("chatgpt_plan_type")
80        .and_then(|value| value.as_str())
81        .unwrap_or("");
82    let platform_url = if issuer == DEFAULT_ISSUER {
83        "https://platform.openai.com"
84    } else {
85        "https://platform.api.openai.org"
86    };
87    let mut params = vec![
88        ("id_token", id_token.to_string()),
89        ("needs_setup", needs_setup.to_string()),
90        ("org_id", org_id.to_string()),
91        ("project_id", project_id.to_string()),
92        ("plan_type", plan_type.to_string()),
93        ("platform_url", platform_url.to_string()),
94    ];
95    if codex_streamlined_login {
96        params.push(("codex_streamlined_login", "true".to_string()));
97    }
98    let query = params
99        .into_iter()
100        .map(|(key, value)| format!("{key}={}", urlencoding::encode(&value)))
101        .collect::<Vec<_>>()
102        .join("&");
103    LoginSuccessRedirect::Local(format!("http://localhost:{port}/success?{query}"))
104}
105
106pub(crate) fn jwt_auth_claims(jwt: &str) -> serde_json::Map<String, serde_json::Value> {
107    let mut parts = jwt.split('.');
108    let (_header, payload, _signature) = match (parts.next(), parts.next(), parts.next()) {
109        (Some(header), Some(payload), Some(signature))
110            if !header.is_empty() && !payload.is_empty() && !signature.is_empty() =>
111        {
112            (header, payload, signature)
113        }
114        _ => {
115            eprintln!("Invalid JWT format while extracting claims");
116            return serde_json::Map::new();
117        }
118    };
119    match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) {
120        Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
121            Ok(mut value) => {
122                if let Some(claims) = value
123                    .get_mut("https://api.openai.com/auth")
124                    .and_then(JsonValue::as_object_mut)
125                {
126                    return claims.clone();
127                }
128                eprintln!("JWT payload missing expected 'https://api.openai.com/auth' object");
129            }
130            Err(error) => {
131                eprintln!("Failed to parse JWT JSON payload: {error}");
132            }
133        },
134        Err(error) => {
135            eprintln!("Failed to base64url-decode JWT payload: {error}");
136        }
137    }
138    serde_json::Map::new()
139}
140
141#[cfg(test)]
142#[path = "success_page_tests.rs"]
143mod tests;