systemprompt_api/routes/oauth/endpoints/
webauthn_complete.rs1use axum::Json;
9use axum::extract::{Query, State};
10use axum::http::HeaderMap;
11use axum::response::{IntoResponse, Redirect, Response};
12use serde::{Deserialize, Serialize};
13use std::sync::Arc;
14
15use crate::routes::oauth::OAuthHttpError;
16use crate::routes::oauth::extractors::OAuthRepo;
17use crate::services::request_base_url::RequestBaseUrl;
18use systemprompt_identifiers::{AuthorizationCode, ClientId, UserId};
19use systemprompt_models::oauth::OAuthServerConfig;
20use systemprompt_oauth::OAuthState;
21use systemprompt_oauth::repository::{AuthCodeParams, OAuthRepository};
22use systemprompt_oauth::services::webauthn::WebAuthnRegistry;
23use systemprompt_oauth::services::{generate_secure_token, is_browser_request};
24
25#[derive(Debug, Deserialize)]
26pub struct WebAuthnCompleteQuery {
27 pub user_id: UserId,
28 pub auth_token: Option<String>,
29 pub response_type: Option<String>,
30 pub client_id: Option<ClientId>,
31 pub redirect_uri: Option<String>,
32 pub scope: Option<String>,
33 pub state: Option<String>,
34 pub code_challenge: Option<String>,
35 pub code_challenge_method: Option<String>,
36 pub response_mode: Option<String>,
37 pub resource: Option<String>,
38}
39
40async fn verify_completion(
41 params: &WebAuthnCompleteQuery,
42 state: &OAuthState,
43 repo: &OAuthRepository,
44) -> Result<(UserId, String), OAuthHttpError> {
45 let auth_token = params
46 .auth_token
47 .as_deref()
48 .ok_or_else(|| OAuthHttpError::invalid_request("Missing auth_token parameter"))?;
49
50 let webauthn_service =
51 WebAuthnRegistry::get_or_create_service(repo.clone(), Arc::clone(state.user_provider()))
52 .await
53 .map_err(|e| {
54 OAuthHttpError::server_error(format!("WebAuthn service initialization failed: {e}"))
55 })?;
56
57 let verified_user_id = webauthn_service
58 .consume_verified_authentication(auth_token)
59 .await
60 .map_err(|_e| OAuthHttpError::access_denied("Invalid or expired authentication token"))?;
61
62 if params.user_id != verified_user_id {
63 return Err(OAuthHttpError::access_denied(
64 "User identity verification failed",
65 ));
66 }
67
68 if params.client_id.is_none() {
69 return Err(OAuthHttpError::invalid_request(
70 "Missing client_id parameter",
71 ));
72 }
73
74 let redirect_uri = params
75 .redirect_uri
76 .clone()
77 .ok_or_else(|| OAuthHttpError::invalid_request("Missing redirect_uri parameter"))?;
78
79 Ok((verified_user_id, redirect_uri))
80}
81
82pub async fn handle_webauthn_complete(
83 headers: HeaderMap,
84 base: RequestBaseUrl,
85 Query(params): Query<WebAuthnCompleteQuery>,
86 State(state): State<OAuthState>,
87 OAuthRepo(repo): OAuthRepo,
88) -> Result<Response, OAuthHttpError> {
89 let (verified_user_id, redirect_uri) = verify_completion(¶ms, &state, &repo).await?;
90
91 let user = state.user_provider().find_by_id(&verified_user_id).await?;
92 if user.is_none() {
93 return Err(OAuthHttpError::access_denied("User not found"));
94 }
95
96 let authorization_code = generate_secure_token("auth_code");
97 store_authorization_code(&repo, &authorization_code, ¶ms).await?;
98
99 let issuer = OAuthServerConfig::from_api_server_url(base.as_str()).issuer;
103
104 Ok(create_successful_response(
105 &headers,
106 &redirect_uri,
107 &authorization_code,
108 ¶ms,
109 &issuer,
110 ))
111}
112
113async fn store_authorization_code(
114 repo: &OAuthRepository,
115 code_str: &str,
116 query: &WebAuthnCompleteQuery,
117) -> Result<(), OAuthHttpError> {
118 let client_id = query
119 .client_id
120 .as_ref()
121 .ok_or_else(|| OAuthHttpError::invalid_request("client_id is required"))?;
122 let redirect_uri = query
123 .redirect_uri
124 .as_ref()
125 .ok_or_else(|| OAuthHttpError::invalid_request("redirect_uri is required"))?;
126 let scope = query.scope.as_ref().map_or_else(
127 || {
128 let default_roles = OAuthRepository::get_default_roles();
129 if default_roles.is_empty() {
130 "user".to_owned()
131 } else {
132 default_roles.join(" ")
133 }
134 },
135 Clone::clone,
136 );
137
138 let code = AuthorizationCode::new(code_str);
139
140 let mut builder =
141 AuthCodeParams::builder(&code, client_id, &query.user_id, redirect_uri, &scope);
142
143 if let (Some(challenge), Some(method)) = (
144 query.code_challenge.as_deref(),
145 query
146 .code_challenge_method
147 .as_deref()
148 .filter(|s| !s.is_empty()),
149 ) {
150 builder = builder.with_pkce(challenge, method);
151 }
152
153 if let Some(resource) = query.resource.as_deref() {
154 builder = builder.with_resource(resource);
155 }
156
157 repo.store_authorization_code(builder.build()).await?;
158 Ok(())
159}
160
161#[derive(Debug, Serialize)]
162pub struct WebAuthnCompleteResponse {
163 pub authorization_code: String,
164 pub state: String,
165 pub redirect_uri: String,
166 pub client_id: ClientId,
167}
168
169fn create_successful_response(
170 headers: &HeaderMap,
171 redirect_uri: &str,
172 authorization_code: &str,
173 params: &WebAuthnCompleteQuery,
174 issuer: &str,
175) -> Response {
176 let state = params.state.as_deref().filter(|s| !s.is_empty());
177
178 if is_browser_request(headers) {
179 let mut target = format!("{redirect_uri}?code={authorization_code}");
180
181 if let Some(client_id_val) = params.client_id.as_ref() {
182 target.push_str(&format!(
183 "&client_id={}",
184 urlencoding::encode(client_id_val.as_str())
185 ));
186 }
187
188 if let Some(state_val) = state {
189 target.push_str(&format!("&state={}", urlencoding::encode(state_val)));
190 }
191 target.push_str(&format!("&iss={}", urlencoding::encode(issuer)));
192 Redirect::to(&target).into_response()
193 } else {
194 let response_data = WebAuthnCompleteResponse {
195 authorization_code: authorization_code.to_owned(),
196 state: state.unwrap_or("").to_owned(),
197 redirect_uri: redirect_uri.to_owned(),
198 client_id: params
199 .client_id
200 .clone()
201 .unwrap_or_else(|| ClientId::new("")),
202 };
203
204 Json(response_data).into_response()
205 }
206}