Skip to main content

systemprompt_api/routes/oauth/endpoints/
webauthn_complete.rs

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