systemprompt_cloud/operator/
login.rs1use std::sync::Arc;
7use std::time::Duration;
8
9use axum::Router;
10use axum::extract::{Query, State};
11use axum::response::Html;
12use axum::routing::get;
13use base64::prelude::*;
14use chrono::Utc;
15use serde::Deserialize;
16use sha2::{Digest, Sha256};
17use systemprompt_identifiers::{ClientId, CloudAuthToken, Email};
18use systemprompt_logging::CliService;
19use systemprompt_models::net::{HTTP_CONNECT_TIMEOUT, HTTP_DEFAULT_TIMEOUT};
20use tokio::sync::{Mutex, oneshot};
21use uuid::Uuid;
22
23use super::endpoints::{AuthorizeParams, CodeRedemption, authorize_url, discover, redeem_code};
24use super::{OperatorCredentials, identity_claims};
25use crate::OAuthTemplates;
26use crate::constants::oauth::{CALLBACK_PORT, CALLBACK_TIMEOUT_SECS};
27use crate::error::{CloudError, CloudResult};
28
29#[derive(Debug, Clone)]
30pub struct OperatorLoginRequest {
31 pub issuer: String,
32 pub client_id: ClientId,
33 pub scopes: Vec<String>,
34}
35
36#[derive(Deserialize)]
37struct CallbackParams {
38 code: Option<String>,
39 state: Option<String>,
40 error: Option<String>,
41 error_description: Option<String>,
42}
43
44struct CallbackState {
45 expected_state: String,
46 tx: Mutex<Option<oneshot::Sender<CloudResult<String>>>>,
47 success_html: &'static str,
48 error_html: &'static str,
49}
50
51fn random_token() -> String {
52 format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
53}
54
55fn pkce_challenge(verifier: &str) -> String {
56 BASE64_URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
57}
58
59async fn callback_handler(
60 State(state): State<Arc<CallbackState>>,
61 Query(params): Query<CallbackParams>,
62) -> Html<&'static str> {
63 let result = if let Some(error) = params.error {
64 Err(CloudError::operator_auth(format!(
65 "{error}: {}",
66 params.error_description.unwrap_or_default()
67 )))
68 } else if params.state.as_deref() != Some(state.expected_state.as_str()) {
69 Err(CloudError::operator_auth("callback state did not match"))
70 } else {
71 params
72 .code
73 .ok_or_else(|| CloudError::operator_auth("callback carried no authorization code"))
74 };
75 let is_success = result.is_ok();
76 let sender = state.tx.lock().await.take();
77 if let Some(sender) = sender
78 && sender.send(result).is_err()
79 {
80 tracing::warn!("Operator login receiver dropped before the callback arrived");
81 }
82 Html(if is_success {
83 state.success_html
84 } else {
85 state.error_html
86 })
87}
88
89async fn await_code(
90 listener: tokio::net::TcpListener,
91 app: Router,
92 rx: oneshot::Receiver<CloudResult<String>>,
93) -> CloudResult<String> {
94 let server = axum::serve(listener, app);
95 tokio::select! {
96 result = rx => {
97 result.map_err(|_e| CloudError::operator_auth("sign-in cancelled"))?
98 }
99 _ = server => Err(CloudError::operator_auth("callback server stopped unexpectedly")),
100 () = tokio::time::sleep(Duration::from_secs(CALLBACK_TIMEOUT_SECS)) => {
101 Err(CloudError::operator_auth(format!(
102 "sign-in timed out after {CALLBACK_TIMEOUT_SECS} seconds"
103 )))
104 }
105 }
106}
107
108pub async fn run_operator_login(
109 request: &OperatorLoginRequest,
110 templates: OAuthTemplates,
111) -> CloudResult<OperatorCredentials> {
112 let client = reqwest::Client::builder()
113 .connect_timeout(HTTP_CONNECT_TIMEOUT)
114 .timeout(HTTP_DEFAULT_TIMEOUT)
115 .build()?;
116 let discovery = discover(&client, &request.issuer).await?;
117
118 let verifier = random_token();
119 let state = random_token();
120 let nonce = random_token();
121 let redirect_uri = format!("http://127.0.0.1:{CALLBACK_PORT}/callback");
122
123 let (tx, rx) = oneshot::channel();
124 let callback = Arc::new(CallbackState {
125 expected_state: state.clone(),
126 tx: Mutex::new(Some(tx)),
127 success_html: templates.success_html,
128 error_html: templates.error_html,
129 });
130 let app = Router::new()
131 .route("/callback", get(callback_handler))
132 .with_state(callback);
133 let listener = crate::callback_listener::bind_callback_listener(CALLBACK_PORT)?;
134
135 let url = authorize_url(
136 &discovery,
137 request,
138 AuthorizeParams {
139 redirect_uri: &redirect_uri,
140 state: &state,
141 nonce: &nonce,
142 challenge: &pkce_challenge(&verifier),
143 },
144 );
145 CliService::info(&format!("Opening browser to sign in at {}", request.issuer));
146 if let Err(e) = open::that(&url) {
147 CliService::warning(&format!("Could not open browser automatically: {e}"));
148 CliService::key_value("URL", &url);
149 }
150 CliService::info("Waiting for sign-in...");
151
152 let code = await_code(listener, app, rx).await?;
153
154 let id_token = redeem_code(
155 &client,
156 &discovery,
157 request,
158 CodeRedemption {
159 code: &code,
160 redirect_uri: &redirect_uri,
161 verifier: &verifier,
162 },
163 )
164 .await?;
165
166 let claims = identity_claims(&id_token)?;
167 if claims.nonce.as_deref() != Some(nonce.as_str()) {
168 return Err(CloudError::operator_auth("id_token nonce did not match"));
169 }
170 let email = claims
171 .email
172 .map(Email::try_new)
173 .transpose()
174 .map_err(|source| CloudError::OperatorEmail { source })?;
175
176 Ok(OperatorCredentials {
177 issuer: request.issuer.trim_end_matches('/').to_owned(),
178 client_id: request.client_id.clone(),
179 id_token: CloudAuthToken::new(id_token),
180 email,
181 authenticated_at: Utc::now(),
182 })
183}