Skip to main content

systemprompt_api/routes/oauth/endpoints/
callback.rs

1//! OAuth callback endpoint for the server's own browser client.
2//!
3//! Exchanges the returned authorization code for tokens, establishes an
4//! authenticated session, sets the access-token cookie, and redirects to the
5//! origin-validated `return_to` recovered from the consumed state binding.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use 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(&params.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}