systemprompt_api/routes/oauth/endpoints/
callback.rs1use axum::extract::{Query, State};
11use axum::http::{HeaderMap, StatusCode};
12use axum::response::Response;
13use serde::Deserialize;
14use std::sync::Arc;
15use systemprompt_identifiers::{
16 AccessTokenId, AuthorizationCode, ClientId, RefreshTokenId, SessionSource,
17};
18use systemprompt_manifest::Config;
19use systemprompt_models::auth::parse_permissions;
20
21use crate::routes::oauth::cookie::session_cookie_redirect;
22use crate::routes::oauth::extractors::OAuthRepo;
23use crate::routes::oauth::{OAuthHttpError, internal};
24use crate::services::middleware::client_addr::ClientIp;
25use systemprompt_oauth::OAuthState;
26use systemprompt_oauth::repository::{OAuthRepository, RefreshTokenParams};
27use systemprompt_oauth::services::load_interactive_user;
28use systemprompt_traits::ExtractSignals;
29
30#[derive(Debug, Deserialize)]
31pub struct CallbackQuery {
32 pub code: String,
33 pub state: Option<String>,
34}
35
36pub async fn handle_callback(
37 Query(params): Query<CallbackQuery>,
38 State(state): State<OAuthState>,
39 OAuthRepo(repo): OAuthRepo,
40 ClientIp(caller_ip): ClientIp,
41 headers: HeaderMap,
42) -> Result<Response, OAuthHttpError> {
43 let config = Config::get()?;
44
45 let server_base_url = &config.api_external_url;
46 let redirect_uri = format!("{server_base_url}/api/v1/core/oauth/callback");
47
48 let browser_client = find_browser_client(&repo, &redirect_uri)
49 .await
50 .map_err(|e| internal::server_error("Failed to find OAuth client", e))?;
51
52 let code = AuthorizationCode::new(¶ms.code);
53 let client_id = ClientId::new(&browser_client.client_id);
54 let token_response = exchange_code_for_token(
55 &repo,
56 CodeExchangeParams {
57 caller_ip,
58 code: &code,
59 client_id: &client_id,
60 redirect_uri: &redirect_uri,
61 headers: &headers,
62 },
63 &state,
64 )
65 .await
66 .map_err(|e| {
67 internal::rejected(
68 OAuthHttpError::invalid_grant("Failed to exchange code for token")
69 .with_status(StatusCode::UNAUTHORIZED),
70 e,
71 )
72 })?;
73
74 let redirect_destination = resolve_redirect_destination(&repo, params.state.as_deref()).await?;
75
76 Ok(session_cookie_redirect(
77 &token_response.access_token,
78 &redirect_destination,
79 systemprompt_oauth::constants::token::COOKIE_MAX_AGE_SECONDS,
80 ))
81}
82
83async fn resolve_redirect_destination(
84 repo: &OAuthRepository,
85 state_token: Option<&str>,
86) -> Result<String, OAuthHttpError> {
87 let Some(state_token) = state_token.filter(|s| !s.is_empty()) else {
88 return Err(OAuthHttpError::invalid_request("Missing state parameter"));
89 };
90 match repo.consume_state_binding(state_token).await {
91 Ok(Some(binding)) => Ok(binding.return_to),
92 Ok(None) => {
93 tracing::warn!("state binding missing, expired, or already consumed");
94 Err(OAuthHttpError::invalid_request("Invalid state parameter"))
95 },
96 Err(e) => Err(internal::server_error("Failed to validate state", e)),
97 }
98}
99
100async fn find_browser_client(
101 repo: &OAuthRepository,
102 redirect_uri: &str,
103) -> anyhow::Result<BrowserClient> {
104 let client = repo
105 .find_client_by_redirect_uri_with_scope(redirect_uri, &["admin", "user"])
106 .await?
107 .ok_or_else(|| anyhow::anyhow!("No suitable browser client found"))?;
108
109 Ok(BrowserClient {
110 client_id: client.client_id.to_string(),
111 })
112}
113
114struct CodeExchangeParams<'a> {
115 caller_ip: Option<std::net::IpAddr>,
116 code: &'a AuthorizationCode,
117 client_id: &'a ClientId,
118 redirect_uri: &'a str,
119 headers: &'a HeaderMap,
120}
121
122async fn exchange_code_for_token(
123 repo: &OAuthRepository,
124 params: CodeExchangeParams<'_>,
125 state: &OAuthState,
126) -> anyhow::Result<TokenResponse> {
127 use systemprompt_oauth::services::{
128 JwtConfig, JwtSigningParams, generate_jwt, generate_secure_token,
129 };
130
131 let validation_result = repo
132 .validate_authorization_code(params.code, params.client_id, params.redirect_uri, "")
133 .await?;
134
135 let user =
136 load_interactive_user(state.user_provider().as_ref(), &validation_result.user_id).await?;
137
138 let permissions = parse_permissions(&validation_result.scope)?;
139
140 let session_service = systemprompt_oauth::services::SessionCreationService::new(
141 Arc::clone(state.session_provider()),
142 Arc::clone(state.user_provider()),
143 );
144 let analytics = state.analytics_provider().extract_analytics(
145 params.headers,
146 ExtractSignals {
147 caller_ip: params.caller_ip,
148 ..Default::default()
149 },
150 );
151 let session_id = session_service
152 .create_authenticated_session(&validation_result.user_id, &analytics, SessionSource::Oauth)
153 .await?;
154
155 let access_token_jti = AccessTokenId::generate();
156 let global_config = Config::get()?;
157 let config = JwtConfig {
158 permissions: permissions.clone(),
159 audience: global_config.jwt_audiences.clone(),
160 client_id: Some(params.client_id.clone()),
161 ..Default::default()
162 };
163 let signing = JwtSigningParams {
164 issuer: &global_config.jwt_issuer,
165 };
166 let access_token = generate_jwt(&user, config, access_token_jti, &session_id, &signing)?;
167
168 let refresh_token_value = generate_secure_token("rt");
169 let refresh_token_id = RefreshTokenId::new(&refresh_token_value);
170 let refresh_expires_at = chrono::Utc::now().timestamp()
171 + (systemprompt_oauth::constants::token::SECONDS_PER_DAY
172 * systemprompt_oauth::constants::token::REFRESH_TOKEN_EXPIRY_DAYS);
173
174 let refresh_params = RefreshTokenParams::builder(
175 &refresh_token_id,
176 params.client_id,
177 &validation_result.user_id,
178 &validation_result.scope,
179 refresh_expires_at,
180 )
181 .build();
182 repo.store_refresh_token(refresh_params).await?;
183
184 if let Err(e) = repo
185 .link_auth_code_to_refresh_token(params.code, &refresh_token_id)
186 .await
187 {
188 tracing::warn!(error = %e, "Failed to link auth code to refresh token");
189 }
190
191 Ok(TokenResponse { access_token })
192}
193
194#[derive(Debug)]
195struct BrowserClient {
196 client_id: String,
197}
198
199#[derive(Debug, serde::Deserialize)]
200struct TokenResponse {
201 access_token: String,
202}