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
8use 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(&params, &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, &params).await?;
98
99    // RFC 9207: the authorization response carries `iss` so the client can bind the
100    // code to this issuer. Derive it the same way discovery does, so the emitted
101    // value is byte-identical to the advertised `issuer`.
102    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        &params,
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}