codex_login/
success_page.rs1use 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;