mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
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": [
|
||||||
{
|
{
|
||||||
|
|||||||
+16
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
+16
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
+18
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
-1
@@ -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, };
|
||||||
+5
@@ -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,
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
Reference in new issue
Block a user