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
7use axum::extract::{Query, State};
8use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
9use axum::response::{IntoResponse, Redirect, Response};
10use serde::Deserialize;
11use std::str::FromStr;
12use std::sync::Arc;
13use systemprompt_identifiers::{
14    AuthorizationCode, ClientId, RefreshTokenId, SessionSource, UserId,
15};
16use systemprompt_models::Config;
17use systemprompt_models::auth::{AuthenticatedUser, Permission, parse_permissions};
18
19use crate::routes::oauth::extractors::OAuthRepo;
20use systemprompt_oauth::OAuthState;
21use systemprompt_oauth::repository::{OAuthRepository, RefreshTokenParams};
22
23#[derive(Debug, Deserialize)]
24pub struct CallbackQuery {
25    pub code: String,
26    pub state: Option<String>,
27}
28
29pub async fn handle_callback(
30    Query(params): Query<CallbackQuery>,
31    State(state): State<OAuthState>,
32    OAuthRepo(repo): OAuthRepo,
33    headers: HeaderMap,
34) -> impl IntoResponse {
35    let config = match Config::get() {
36        Ok(c) => c,
37        Err(e) => {
38            return (
39                StatusCode::INTERNAL_SERVER_ERROR,
40                format!("Failed to load config: {e}"),
41            )
42                .into_response();
43        },
44    };
45
46    let server_base_url = &config.api_external_url;
47    let redirect_uri = format!("{server_base_url}/api/v1/core/oauth/callback");
48
49    let browser_client = match find_browser_client(&repo, &redirect_uri).await {
50        Ok(client) => client,
51        Err(e) => {
52            return (
53                StatusCode::INTERNAL_SERVER_ERROR,
54                format!("Failed to find OAuth client: {e}"),
55            )
56                .into_response();
57        },
58    };
59
60    let code = AuthorizationCode::new(&params.code);
61    let client_id = ClientId::new(&browser_client.client_id);
62    let token_response = match exchange_code_for_token(
63        &repo,
64        CodeExchangeParams {
65            code: &code,
66            client_id: &client_id,
67            redirect_uri: &redirect_uri,
68            headers: &headers,
69        },
70        &state,
71    )
72    .await
73    {
74        Ok(response) => response,
75        Err(e) => {
76            return (
77                StatusCode::UNAUTHORIZED,
78                format!("Failed to exchange code for token: {e}"),
79            )
80                .into_response();
81        },
82    };
83
84    let redirect_destination =
85        match resolve_redirect_destination(&repo, params.state.as_deref()).await {
86            Ok(destination) => destination,
87            Err(response) => return response,
88        };
89
90    session_cookie_redirect(&token_response.access_token, &redirect_destination)
91}
92
93async fn resolve_redirect_destination(
94    repo: &OAuthRepository,
95    state_token: Option<&str>,
96) -> Result<String, Response> {
97    let Some(state_token) = state_token.filter(|s| !s.is_empty()) else {
98        return Err((StatusCode::BAD_REQUEST, "Missing state parameter").into_response());
99    };
100    match repo.consume_state_binding(state_token).await {
101        Ok(Some(binding)) => Ok(binding.return_to),
102        Ok(None) => {
103            tracing::warn!("state binding missing, expired, or already consumed");
104            Err((StatusCode::BAD_REQUEST, "Invalid state parameter").into_response())
105        },
106        Err(e) => {
107            tracing::error!(error = %e, "state binding lookup failed");
108            Err((
109                StatusCode::INTERNAL_SERVER_ERROR,
110                "Failed to validate state",
111            )
112                .into_response())
113        },
114    }
115}
116
117fn session_cookie_redirect(access_token: &str, destination: &str) -> Response {
118    let cookie = format!(
119        "access_token={access_token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age={}",
120        systemprompt_oauth::constants::token::COOKIE_MAX_AGE_SECONDS
121    );
122
123    let mut response = Redirect::to(destination).into_response();
124    if let Ok(cookie_value) = HeaderValue::from_str(&cookie) {
125        response
126            .headers_mut()
127            .insert(header::SET_COOKIE, cookie_value);
128    }
129
130    response
131}
132
133async fn find_browser_client(
134    repo: &OAuthRepository,
135    redirect_uri: &str,
136) -> anyhow::Result<BrowserClient> {
137    let client = repo
138        .find_client_by_redirect_uri_with_scope(redirect_uri, &["admin", "user"])
139        .await?
140        .ok_or_else(|| anyhow::anyhow!("No suitable browser client found"))?;
141
142    Ok(BrowserClient {
143        client_id: client.client_id.to_string(),
144    })
145}
146
147struct CodeExchangeParams<'a> {
148    code: &'a AuthorizationCode,
149    client_id: &'a ClientId,
150    redirect_uri: &'a str,
151    headers: &'a HeaderMap,
152}
153
154async fn exchange_code_for_token(
155    repo: &OAuthRepository,
156    params: CodeExchangeParams<'_>,
157    state: &OAuthState,
158) -> anyhow::Result<TokenResponse> {
159    use systemprompt_oauth::services::{
160        JwtConfig, JwtSigningParams, generate_access_token_jti, generate_jwt, generate_secure_token,
161    };
162
163    let validation_result = repo
164        .validate_authorization_code(
165            params.code,
166            params.client_id,
167            Some(params.redirect_uri),
168            None,
169        )
170        .await?;
171
172    let user = load_authenticated_user(&validation_result.user_id, state.user_provider()).await?;
173
174    let permissions = parse_permissions(&validation_result.scope)?;
175
176    let mut session_service = systemprompt_oauth::services::SessionCreationService::new(
177        Arc::clone(state.analytics_provider()),
178        Arc::clone(state.user_provider()),
179    );
180    if let Some(publisher) = state.event_publisher() {
181        session_service = session_service.with_event_publisher(Arc::clone(publisher));
182    }
183    let session_id = session_service
184        .create_authenticated_session(
185            &validation_result.user_id,
186            params.headers,
187            SessionSource::Oauth,
188        )
189        .await?;
190
191    let access_token_jti = generate_access_token_jti();
192    let global_config = Config::get()?;
193    let config = JwtConfig {
194        permissions: permissions.clone(),
195        audience: global_config.jwt_audiences.clone(),
196        ..Default::default()
197    };
198    let signing = JwtSigningParams {
199        issuer: &global_config.jwt_issuer,
200    };
201    let access_token = generate_jwt(&user, config, access_token_jti, &session_id, &signing)?;
202
203    let refresh_token_value = generate_secure_token("rt");
204    let refresh_token_id = RefreshTokenId::new(&refresh_token_value);
205    let refresh_expires_at = chrono::Utc::now().timestamp()
206        + (systemprompt_oauth::constants::token::SECONDS_PER_DAY
207            * systemprompt_oauth::constants::token::REFRESH_TOKEN_EXPIRY_DAYS);
208
209    let refresh_params = RefreshTokenParams::builder(
210        &refresh_token_id,
211        params.client_id,
212        &validation_result.user_id,
213        &validation_result.scope,
214        refresh_expires_at,
215    )
216    .build();
217    repo.store_refresh_token(refresh_params).await?;
218
219    if let Err(e) = repo
220        .link_auth_code_to_refresh_token(params.code, refresh_token_id.as_str())
221        .await
222    {
223        tracing::warn!(error = %e, "Failed to link auth code to refresh token");
224    }
225
226    Ok(TokenResponse { access_token })
227}
228
229async fn load_authenticated_user(
230    user_id: &UserId,
231    user_provider: &Arc<dyn systemprompt_traits::UserProvider>,
232) -> anyhow::Result<AuthenticatedUser> {
233    let user = user_provider
234        .find_by_id(user_id)
235        .await
236        .map_err(|e| anyhow::anyhow!("{}", e))?
237        .ok_or_else(|| anyhow::anyhow!("User not found: {user_id}"))?;
238
239    let permissions: Vec<Permission> = user
240        .roles
241        .iter()
242        .filter_map(|s| {
243            Permission::from_str(s)
244                .map_err(|e| {
245                    tracing::warn!(
246                        user_id = %user.id,
247                        role = %s,
248                        error = %e,
249                        "Invalid role in user record"
250                    );
251                    e
252                })
253                .ok()
254        })
255        .collect();
256
257    let user_uuid = uuid::Uuid::parse_str(user.id.as_str())
258        .map_err(|_e| anyhow::anyhow!("Invalid user UUID: {}", user.id))?;
259
260    Ok(AuthenticatedUser::new_with_roles(
261        user_uuid,
262        user.name,
263        user.email,
264        permissions,
265        user.roles,
266    ))
267}
268
269#[derive(Debug)]
270struct BrowserClient {
271    client_id: String,
272}
273
274#[derive(Debug, serde::Deserialize)]
275struct TokenResponse {
276    access_token: String,
277}