Expose onboarding hints in login completion notifications (#36635)

## What changed

- Accept the allowlisted `.onboarding_entrypoint=life_sciences` suffix on a valid OAuth state while continuing to reject unknown or malformed suffixes.
- Return the parsed callback metadata from the login server without including the suffix in the token exchange redirect URI.
- Add the optional `onboardingEntrypoint` field and `life_sciences` value to the v2 `account/login/completed` notification and generated protocol exports.

## Testing

- Cover valid, invalid, and repeated OAuth state suffixes.
- Verify the end-to-end login callback result, token redirect URI, and app-server notification payload.

GitOrigin-RevId: c2ee146328ddd43c03a503260d5d492e186fa889
This commit is contained in:
Daniel Wolf authored and copyberry committed 2026-08-02 21:01:04 +00:00
1 parent 9949245d1d
commit 5157493c23
20 files changed
+292 -42

No files matched your search

@@ -19,6 +19,16 @@
"null" "null"
] ]
}, },
"onboardingEntrypoint": {
"anyOf": [
{
"$ref": "#/definitions/DesktopOnboardingEntrypoint"
},
{
"type": "null"
}
]
},
"success": { "success": {
"type": "boolean" "type": "boolean"
} }
@@ -1082,6 +1092,12 @@
], ],
"type": "object" "type": "object"
}, },
"DesktopOnboardingEntrypoint": {
"enum": [
"life_sciences"
],
"type": "string"
},
"DynamicToolCallOutputContentItem": { "DynamicToolCallOutputContentItem": {
"oneOf": [ "oneOf": [
{ {
@@ -6220,6 +6220,16 @@
"null" "null"
] ]
}, },
"onboardingEntrypoint": {
"anyOf": [
{
"$ref": "#/definitions/v2/DesktopOnboardingEntrypoint"
},
{
"type": "null"
}
]
},
"success": { "success": {
"type": "boolean" "type": "boolean"
} }
@@ -9395,6 +9405,12 @@
"title": "DeprecationNoticeNotification", "title": "DeprecationNoticeNotification",
"type": "object" "type": "object"
}, },
"DesktopOnboardingEntrypoint": {
"enum": [
"life_sciences"
],
"type": "string"
},
"DynamicToolCallOutputContentItem": { "DynamicToolCallOutputContentItem": {
"oneOf": [ "oneOf": [
{ {
@@ -87,6 +87,16 @@
"null" "null"
] ]
}, },
"onboardingEntrypoint": {
"anyOf": [
{
"$ref": "#/definitions/DesktopOnboardingEntrypoint"
},
{
"type": "null"
}
]
},
"success": { "success": {
"type": "boolean" "type": "boolean"
} }
@@ -5568,6 +5578,12 @@
"title": "DeprecationNoticeNotification", "title": "DeprecationNoticeNotification",
"type": "object" "type": "object"
}, },
"DesktopOnboardingEntrypoint": {
"enum": [
"life_sciences"
],
"type": "string"
},
"DynamicToolCallOutputContentItem": { "DynamicToolCallOutputContentItem": {
"oneOf": [ "oneOf": [
{ {
@@ -1,5 +1,13 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"DesktopOnboardingEntrypoint": {
"enum": [
"life_sciences"
],
"type": "string"
}
},
"properties": { "properties": {
"error": { "error": {
"type": [ "type": [
@@ -13,6 +21,16 @@
"null" "null"
] ]
}, },
"onboardingEntrypoint": {
"anyOf": [
{
"$ref": "#/definitions/DesktopOnboardingEntrypoint"
},
{
"type": "null"
}
]
},
"success": { "success": {
"type": "boolean" "type": "boolean"
} }
@@ -1,5 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND! // GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DesktopOnboardingEntrypoint } from "./DesktopOnboardingEntrypoint";
export type AccountLoginCompletedNotification = { loginId: string | null, success: boolean, error: string | null, }; export type AccountLoginCompletedNotification = { loginId: string | null, success: boolean, error: string | null, onboardingEntrypoint: DesktopOnboardingEntrypoint | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DesktopOnboardingEntrypoint = "life_sciences";
@@ -98,6 +98,7 @@ export type { ConsumeAccountRateLimitResetCreditResponse } from "./ConsumeAccoun
export type { ContextCompactedNotification } from "./ContextCompactedNotification"; export type { ContextCompactedNotification } from "./ContextCompactedNotification";
export type { CreditsSnapshot } from "./CreditsSnapshot"; export type { CreditsSnapshot } from "./CreditsSnapshot";
export type { DeprecationNoticeNotification } from "./DeprecationNoticeNotification"; export type { DeprecationNoticeNotification } from "./DeprecationNoticeNotification";
export type { DesktopOnboardingEntrypoint } from "./DesktopOnboardingEntrypoint";
export type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputContentItem"; export type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputContentItem";
export type { DynamicToolCallParams } from "./DynamicToolCallParams"; export type { DynamicToolCallParams } from "./DynamicToolCallParams";
export type { DynamicToolCallResponse } from "./DynamicToolCallResponse"; export type { DynamicToolCallResponse } from "./DynamicToolCallResponse";
@@ -672,4 +672,13 @@ pub struct AccountLoginCompletedNotification {
pub login_id: Option<String>, pub login_id: Option<String>,
pub success: bool, pub success: bool,
pub error: Option<String>, pub error: Option<String>,
pub onboarding_entrypoint: Option<DesktopOnboardingEntrypoint>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub enum DesktopOnboardingEntrypoint {
LifeSciences,
} }
+3 -1
View File
@@ -2213,9 +2213,11 @@ Field notes:
page artwork; omitted or `null` values default to `"codex"`. page artwork; omitted or `null` values default to `"codex"`.
3. Wait for notifications: 3. Wait for notifications:
```json ```json
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null } } { "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null, "onboardingEntrypoint": "life_sciences" } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } } { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } }
``` ```
`onboardingEntrypoint` is optional and is only emitted when the OAuth callback carries a
recognized onboarding hint.
### 3) Log in with an Amazon Bedrock API key ### 3) Log in with an Amazon Bedrock API key
@@ -760,6 +760,7 @@ mod tests {
login_id: Some(Uuid::nil().to_string()), login_id: Some(Uuid::nil().to_string()),
success: true, success: true,
error: None, error: None,
onboarding_entrypoint: None,
}); });
let jsonrpc_notification = let jsonrpc_notification =
@@ -774,6 +775,7 @@ mod tests {
"loginId": Uuid::nil().to_string(), "loginId": Uuid::nil().to_string(),
"success": true, "success": true,
"error": null, "error": null,
"onboardingEntrypoint": null,
}, },
"emittedAtMs": 1_234, "emittedAtMs": 1_234,
}), }),
@@ -790,6 +792,7 @@ mod tests {
login_id: Some(Uuid::nil().to_string()), login_id: Some(Uuid::nil().to_string()),
success: true, success: true,
error: None, error: None,
onboarding_entrypoint: None,
}); });
assert_eq!( assert_eq!(
@@ -799,6 +802,7 @@ mod tests {
"loginId": Uuid::nil().to_string(), "loginId": Uuid::nil().to_string(),
"success": true, "success": true,
"error": null, "error": null,
"onboardingEntrypoint": null,
}, },
}), }),
serde_json::to_value(notification) serde_json::to_value(notification)
@@ -4,6 +4,8 @@ use super::*;
use crate::auth_mode::auth_mode_to_api; use crate::auth_mode::auth_mode_to_api;
use crate::external_auth::ExternalAuthBridge; use crate::external_auth::ExternalAuthBridge;
use chrono::DateTime; use chrono::DateTime;
use codex_app_server_protocol::DesktopOnboardingEntrypoint;
use codex_login::LoginOnboardingEntrypoint;
use codex_model_provider::is_supported_amazon_bedrock_region; use codex_model_provider::is_supported_amazon_bedrock_region;
mod rate_limit_resets; mod rate_limit_resets;
@@ -555,17 +557,25 @@ impl AccountRequestProcessor {
let active_login = self.active_login.clone(); let active_login = self.active_login.clone();
let auth_url = server.auth_url.clone(); let auth_url = server.auth_url.clone();
tokio::spawn(async move { tokio::spawn(async move {
let (success, error_msg) = match tokio::time::timeout( let (success, error_msg, onboarding_entrypoint) = match tokio::time::timeout(
LOGIN_CHATGPT_TIMEOUT, LOGIN_CHATGPT_TIMEOUT,
server.block_until_done(), server.block_until_done_with_callback_result(),
) )
.await .await
{ {
Ok(Ok(())) => (true, None), Ok(Ok(result)) => (
Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), true,
None,
result
.onboarding_entrypoint
.map(|LoginOnboardingEntrypoint::LifeSciences| {
DesktopOnboardingEntrypoint::LifeSciences
}),
),
Ok(Err(err)) => (false, Some(format!("Login server error: {err}")), None),
Err(_elapsed) => { Err(_elapsed) => {
shutdown_handle.shutdown(); shutdown_handle.shutdown();
(false, Some("Login timed out".to_string())) (false, Some("Login timed out".to_string()), None)
} }
}; };
@@ -574,9 +584,12 @@ impl AccountRequestProcessor {
config_manager, config_manager,
thread_manager, thread_manager,
config, config,
login_id, AccountLoginCompletedNotification {
success, login_id: Some(login_id.to_string()),
error_msg, success,
error: error_msg,
onboarding_entrypoint,
},
) )
.await; .await;
@@ -650,9 +663,12 @@ impl AccountRequestProcessor {
config_manager, config_manager,
thread_manager, thread_manager,
config, config,
login_id, AccountLoginCompletedNotification {
success, login_id: Some(login_id.to_string()),
error_msg, success,
error: error_msg,
onboarding_entrypoint: None,
},
) )
.await; .await;
@@ -785,6 +801,7 @@ impl AccountRequestProcessor {
login_id: login_id.map(|id| id.to_string()), login_id: login_id.map(|id| id.to_string()),
success: true, success: true,
error: None, error: None,
onboarding_entrypoint: None,
}; };
self.outgoing self.outgoing
.send_server_notification(ServerNotification::AccountLoginCompleted( .send_server_notification(ServerNotification::AccountLoginCompleted(
@@ -804,15 +821,9 @@ impl AccountRequestProcessor {
config_manager: ConfigManager, config_manager: ConfigManager,
thread_manager: Arc<ThreadManager>, thread_manager: Arc<ThreadManager>,
config: Arc<Config>, config: Arc<Config>,
login_id: Uuid, payload_v2: AccountLoginCompletedNotification,
success: bool,
error_msg: Option<String>,
) { ) {
let payload_v2 = AccountLoginCompletedNotification { let success = payload_v2.success;
login_id: Some(login_id.to_string()),
success,
error: error_msg,
};
outgoing outgoing
.send_server_notification(ServerNotification::AccountLoginCompleted(payload_v2)) .send_server_notification(ServerNotification::AccountLoginCompleted(payload_v2))
.await; .await;
+41 -5
View File
@@ -21,6 +21,7 @@ use codex_app_server_protocol::CancelLoginAccountStatus;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::DesktopOnboardingEntrypoint;
use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusParams;
@@ -1115,6 +1116,7 @@ async fn login_amazon_bedrock_replaces_primary_auth_and_persists_provider() -> R
login_id: None, login_id: None,
success: true, success: true,
error: None, error: None,
onboarding_entrypoint: None,
} }
); );
assert_account_updated(&mut mcp, Some(AuthMode::BedrockApiKey)).await?; assert_account_updated(&mut mcp, Some(AuthMode::BedrockApiKey)).await?;
@@ -2103,7 +2105,7 @@ async fn login_account_chatgpt_redirects_to_hosted_success_page() -> Result<()>
.await?; .await?;
let login: LoginAccountResponse = let login: LoginAccountResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let LoginAccountResponse::Chatgpt { auth_url, .. } = login else { let LoginAccountResponse::Chatgpt { login_id, auth_url } = login else {
bail!("unexpected login response: {login:?}"); bail!("unexpected login response: {login:?}");
}; };
let auth_url = Url::parse(&auth_url)?; let auth_url = Url::parse(&auth_url)?;
@@ -2119,16 +2121,50 @@ async fn login_account_chatgpt_redirects_to_hosted_success_page() -> Result<()>
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.build()?; .build()?;
let response = client let token_redirect_uri = callback_url.clone();
.get(format!("{callback_url}?code=test-code&state={state}")) let mut callback_url = Url::parse(&callback_url)?;
.send() let callback_state = format!("{state}.onboarding_entrypoint=life_sciences");
.await?; callback_url
.query_pairs_mut()
.append_pair("code", "test-code")
.append_pair("state", &callback_state);
let response = client.get(callback_url).send().await?;
assert_eq!(response.status(), 302); assert_eq!(response.status(), 302);
assert_eq!( assert_eq!(
response.headers()["location"].to_str()?, response.headers()["location"].to_str()?,
"http://localhost:3000/codex/open-app?source=login&app_brand=chatgpt" "http://localhost:3000/codex/open-app?source=login&app_brand=chatgpt"
); );
let requests = mock_server
.received_requests()
.await
.ok_or_else(|| anyhow::anyhow!("failed to read OAuth requests"))?;
let token_request = requests
.iter()
.find(|request| request.url.path() == "/oauth/token")
.ok_or_else(|| anyhow::anyhow!("missing OAuth token request"))?;
let token_form: std::collections::HashMap<_, _> =
url::form_urlencoded::parse(&token_request.body)
.into_owned()
.collect();
assert_eq!(token_form.get("redirect_uri"), Some(&token_redirect_uri),);
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("account/login/completed"),
)
.await??;
let ServerNotification::AccountLoginCompleted(payload) = notification.try_into()? else {
bail!("unexpected notification")
};
assert_eq!(
payload,
AccountLoginCompletedNotification {
login_id: Some(login_id),
success: true,
error: None,
onboarding_entrypoint: Some(DesktopOnboardingEntrypoint::LifeSciences),
}
);
Ok(()) Ok(())
} }
+29
View File
@@ -0,0 +1,29 @@
const LIFE_SCIENCES_OAUTH_STATE_SUFFIX: &str = ".onboarding_entrypoint=life_sciences";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LoginOnboardingEntrypoint {
LifeSciences,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct LoginCallbackResult {
pub onboarding_entrypoint: Option<LoginOnboardingEntrypoint>,
}
pub(crate) fn login_callback_result_from_state(
callback_state: &str,
expected_state: &str,
) -> Option<LoginCallbackResult> {
if callback_state == expected_state {
return Some(LoginCallbackResult::default());
}
(callback_state.strip_suffix(LIFE_SCIENCES_OAUTH_STATE_SUFFIX) == Some(expected_state))
.then_some(LoginCallbackResult {
onboarding_entrypoint: Some(LoginOnboardingEntrypoint::LifeSciences),
})
}
#[cfg(test)]
#[path = "callback_params_tests.rs"]
mod tests;
@@ -0,0 +1,49 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn accepts_the_original_oauth_state() {
assert_eq!(
login_callback_result_from_state("expected-state", "expected-state"),
Some(LoginCallbackResult::default())
);
}
#[test]
fn accepts_the_allowlisted_life_sciences_suffix() {
assert_eq!(
login_callback_result_from_state(
"expected-state.onboarding_entrypoint=life_sciences",
"expected-state",
),
Some(LoginCallbackResult {
onboarding_entrypoint: Some(LoginOnboardingEntrypoint::LifeSciences),
})
);
}
#[test]
fn rejects_a_suffix_when_the_nonce_does_not_match() {
assert_eq!(
login_callback_result_from_state(
"different-state.onboarding_entrypoint=life_sciences",
"expected-state",
),
None
);
}
#[test]
fn rejects_unrecognized_or_repeated_suffixes() {
for callback_state in [
"expected-state.onboarding_entrypoint=unknown",
"expected-state.onboarding_entrypoint=life_sciences.onboarding_entrypoint=life_sciences",
"expected-state.extra=value.onboarding_entrypoint=life_sciences",
] {
assert_eq!(
login_callback_result_from_state(callback_state, "expected-state"),
None,
"unexpectedly accepted {callback_state}",
);
}
}
+3
View File
@@ -3,12 +3,15 @@ pub mod auth_env_telemetry;
pub mod test_support; pub mod test_support;
pub mod token_data; pub mod token_data;
mod callback_params;
mod device_code_auth; mod device_code_auth;
mod outbound_proxy; mod outbound_proxy;
mod pkce; mod pkce;
mod server; mod server;
mod success_page; mod success_page;
pub use callback_params::LoginCallbackResult;
pub use callback_params::LoginOnboardingEntrypoint;
pub use codex_config::types::AuthCredentialsStoreMode; pub use codex_config::types::AuthCredentialsStoreMode;
pub use codex_http_client::BuildCustomCaTransportError as BuildLoginHttpClientError; pub use codex_http_client::BuildCustomCaTransportError as BuildLoginHttpClientError;
pub use device_code_auth::DeviceCode; pub use device_code_auth::DeviceCode;
+37 -14
View File
@@ -27,6 +27,8 @@ use std::time::Duration;
use crate::auth::AuthDotJson; use crate::auth::AuthDotJson;
use crate::auth::AuthKeyringBackendKind; use crate::auth::AuthKeyringBackendKind;
use crate::auth::save_auth; use crate::auth::save_auth;
use crate::callback_params::LoginCallbackResult;
use crate::callback_params::login_callback_result_from_state;
use crate::default_client::create_raw_auth_client; use crate::default_client::create_raw_auth_client;
use crate::default_client::originator; use crate::default_client::originator;
use crate::outbound_proxy::AuthRouteConfig; use crate::outbound_proxy::AuthRouteConfig;
@@ -111,13 +113,20 @@ impl ServerOptions {
pub struct LoginServer { pub struct LoginServer {
pub auth_url: String, pub auth_url: String,
pub actual_port: u16, pub actual_port: u16,
server_handle: tokio::task::JoinHandle<io::Result<()>>, server_handle: tokio::task::JoinHandle<io::Result<LoginCallbackResult>>,
shutdown_handle: ShutdownHandle, shutdown_handle: ShutdownHandle,
} }
impl LoginServer { impl LoginServer {
/// Waits for the login callback loop to finish. /// Waits for the login callback loop to finish.
pub async fn block_until_done(self) -> io::Result<()> { pub async fn block_until_done(self) -> io::Result<()> {
self.block_until_done_with_callback_result()
.await
.map(|_| ())
}
/// Waits for login to finish and returns allowlisted callback metadata.
pub async fn block_until_done_with_callback_result(self) -> io::Result<LoginCallbackResult> {
self.server_handle self.server_handle
.await .await
.map_err(|err| io::Error::other(format!("login server thread panicked: {err:?}")))? .map_err(|err| io::Error::other(format!("login server thread panicked: {err:?}")))?
@@ -201,6 +210,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let shutdown_notify = shutdown_notify.clone(); let shutdown_notify = shutdown_notify.clone();
let server = server; let server = server;
tokio::spawn(async move { tokio::spawn(async move {
let mut callback_result = LoginCallbackResult::default();
let result = loop { let result = loop {
tokio::select! { tokio::select! {
_ = shutdown_notify.notified() => { _ = shutdown_notify.notified() => {
@@ -228,7 +238,8 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await; let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
None None
} }
HandledRequest::RedirectWithHeader(header) => { HandledRequest::RedirectWithHeader { header, result } => {
callback_result = result;
let redirect = Response::empty(302).with_header(header); let redirect = Response::empty(302).with_header(header);
let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await; let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await;
None None
@@ -247,9 +258,9 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
) )
}) })
.await; .await;
Some(result) Some(result.map(|()| callback_result))
} }
HandledRequest::RedirectAndExit(header) => { HandledRequest::RedirectAndExit { header, result } => {
match tokio::task::spawn_blocking(move || { match tokio::task::spawn_blocking(move || {
send_response_with_disconnect( send_response_with_disconnect(
req, req,
@@ -268,7 +279,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
warn!("hosted login redirect task failed: {err}"); warn!("hosted login redirect task failed: {err}");
} }
} }
Some(Ok(())) Some(Ok(result))
} }
}; };
@@ -297,8 +308,14 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
/// Internal callback handling outcome. /// Internal callback handling outcome.
enum HandledRequest { enum HandledRequest {
Response(Response<Cursor<Vec<u8>>>), Response(Response<Cursor<Vec<u8>>>),
RedirectWithHeader(Header), RedirectWithHeader {
RedirectAndExit(Header), header: Header,
result: LoginCallbackResult,
},
RedirectAndExit {
header: Header,
result: LoginCallbackResult,
},
ResponseAndExit { ResponseAndExit {
headers: Vec<Header>, headers: Vec<Header>,
body: Vec<u8>, body: Vec<u8>,
@@ -332,7 +349,10 @@ async fn process_request(
let has_code = params.get("code").is_some_and(|code| !code.is_empty()); let has_code = params.get("code").is_some_and(|code| !code.is_empty());
let has_state = params.get("state").is_some_and(|state| !state.is_empty()); let has_state = params.get("state").is_some_and(|state| !state.is_empty());
let has_error = params.get("error").is_some_and(|error| !error.is_empty()); let has_error = params.get("error").is_some_and(|error| !error.is_empty());
let state_valid = params.get("state").map(String::as_str) == Some(state); let callback_result = params
.get("state")
.and_then(|callback_state| login_callback_result_from_state(callback_state, state));
let state_valid = callback_result.is_some();
info!( info!(
path = %path, path = %path,
has_code, has_code,
@@ -380,6 +400,7 @@ async fn process_request(
); );
} }
}; };
let callback_result = callback_result.unwrap_or_default();
match exchange_code_for_tokens( match exchange_code_for_tokens(
&opts.issuer, &opts.issuer,
@@ -446,12 +467,14 @@ async fn process_request(
}; };
match tiny_http::Header::from_bytes(&b"Location"[..], url.as_bytes()) { match tiny_http::Header::from_bytes(&b"Location"[..], url.as_bytes()) {
Ok(header) => match redirect { Ok(header) => match redirect {
LoginSuccessRedirect::Local(_) => { LoginSuccessRedirect::Local(_) => HandledRequest::RedirectWithHeader {
HandledRequest::RedirectWithHeader(header) header,
} result: callback_result,
LoginSuccessRedirect::Hosted(_) => { },
HandledRequest::RedirectAndExit(header) LoginSuccessRedirect::Hosted(_) => HandledRequest::RedirectAndExit {
} header,
result: callback_result,
},
}, },
Err(_) => login_error_response( Err(_) => login_error_response(
"Sign-in completed but redirecting back to Codex failed.", "Sign-in completed but redirecting back to Codex failed.",
+12 -2
View File
@@ -11,6 +11,8 @@ use base64::Engine;
use codex_config::types::AuthCredentialsStoreMode; use codex_config::types::AuthCredentialsStoreMode;
use codex_http_client::HttpClientBuilder; use codex_http_client::HttpClientBuilder;
use codex_login::AuthKeyringBackendKind; use codex_login::AuthKeyringBackendKind;
use codex_login::LoginCallbackResult;
use codex_login::LoginOnboardingEntrypoint;
use codex_login::LoginSuccessPage; use codex_login::LoginSuccessPage;
use codex_login::LoginSuccessPageBrand; use codex_login::LoginSuccessPageBrand;
use codex_login::ServerOptions; use codex_login::ServerOptions;
@@ -149,7 +151,9 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
let client = HttpClientBuilder::new() let client = HttpClientBuilder::new()
.without_redirects() .without_redirects()
.build_direct()?; .build_direct()?;
let url = format!("http://127.0.0.1:{login_port}/auth/callback?code=abc&state=test_state_123"); let url = format!(
"http://127.0.0.1:{login_port}/auth/callback?code=abc&state=test_state_123.onboarding_entrypoint=life_sciences"
);
let resp = client.get(&url).send().await?; let resp = client.get(&url).send().await?;
assert_eq!(resp.status(), 302); assert_eq!(resp.status(), 302);
let success_url = resp.headers()["location"].to_str()?; let success_url = resp.headers()["location"].to_str()?;
@@ -161,7 +165,13 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
assert!(success_resp.status().is_success()); assert!(success_resp.status().is_success());
// Wait for server shutdown // Wait for server shutdown
server.block_until_done().await?; let callback_result = server.block_until_done_with_callback_result().await?;
assert_eq!(
callback_result,
LoginCallbackResult {
onboarding_entrypoint: Some(LoginOnboardingEntrypoint::LifeSciences),
}
);
// Validate auth.json // Validate auth.json
let auth_path = codex_home.join("auth.json"); let auth_path = codex_home.join("auth.json");
+1
View File
@@ -1359,6 +1359,7 @@ mod tests {
login_id: Some("login-1".to_string()), login_id: Some("login-1".to_string()),
success: true, success: true,
error: None, error: None,
onboarding_entrypoint: None,
}); });
assert!(matches!( assert!(matches!(