systemprompt_api/routes/oauth/endpoints/
callback.rs1use 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(¶ms.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}