Skip to main content

alibi_plugins/passkey/
endpoint.rs

1//! Public server operations over the shared passkey engines.
2use super::{
3    PasskeyHandlerOutcome, PasskeyPlugin, delete_passkey_core, list_user_passkeys_core,
4    update_passkey_core,
5};
6use alibi_core::endpoint::{
7    EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse, ServerEndpoint,
8};
9use alibi_core::{AuthContext, AuthResult, AuthSchema, HttpMethod};
10use serde_json::Value;
11
12pub(super) fn definitions() -> Vec<EndpointDefinition> {
13    vec![
14        crate::endpoint::definition(
15            "verifyPasskeyAuthentication",
16            "passkeyVerifyAuthentication",
17            Some("/passkey/verify-authentication"),
18            HttpMethod::Post,
19        ),
20        crate::endpoint::definition(
21            "verifyPasskeyRegistration",
22            "passkeyVerifyRegistration",
23            Some("/passkey/verify-registration"),
24            HttpMethod::Post,
25        ),
26        crate::endpoint::definition(
27            "generatePasskeyAuthenticationOptions",
28            "passkeyGenerateAuthenticateOptions",
29            Some("/passkey/generate-authenticate-options"),
30            HttpMethod::Get,
31        ),
32        crate::endpoint::definition(
33            "generatePasskeyRegistrationOptions",
34            "generatePasskeyRegistrationOptions",
35            Some("/passkey/generate-register-options"),
36            HttpMethod::Get,
37        ),
38        crate::endpoint::definition(
39            "updatePasskey",
40            "updatePasskey",
41            Some("/passkey/update-passkey"),
42            HttpMethod::Post,
43        ),
44        crate::endpoint::definition(
45            "listPasskeys",
46            "listPasskeys",
47            Some("/passkey/list-user-passkeys"),
48            HttpMethod::Get,
49        ),
50        crate::endpoint::definition(
51            "deletePasskey",
52            "deletePasskey",
53            Some("/passkey/delete-passkey"),
54            HttpMethod::Post,
55        ),
56    ]
57}
58
59/// The caller's registered credential was deleted.
60#[derive(Debug, serde::Deserialize)]
61pub struct PasskeyDeleted {
62    pub status: bool,
63}
64
65/// A credential after its owner changed its display name.
66#[derive(Debug, serde::Deserialize)]
67pub struct PasskeyUpdated {
68    pub passkey: Value,
69}
70
71/// Authenticator response and optional registration preferences.
72#[derive(Debug, serde::Serialize)]
73#[serde(rename_all = "camelCase")]
74pub struct PasskeyRegistrationInput {
75    pub response: alibi_core::utils::json::JsValue,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub name: Option<String>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub create_session: Option<bool>,
80}
81
82fn registration_input(call: &EndpointCall) -> AuthResult<alibi_core::utils::json::JsValue> {
83    use crate::authentication_helpers::{JsonField, JsonFieldKind, field_issue, json_type};
84    use alibi_core::utils::json::JsValue;
85    let Some(object) = call.body().and_then(JsValue::as_object) else {
86        return Err(crate::endpoint::validation(format!(
87            "[body] Invalid input: expected object, received {}",
88            json_type(call.body())
89        )));
90    };
91    let mut issues = Vec::new();
92    if !object.contains_key("response") {
93        issues.push(
94            "[body.response] Invalid input: expected nonoptional, received undefined".to_owned(),
95        );
96    }
97    for field in [
98        JsonField::string("name", false),
99        JsonField {
100            name: "createSession",
101            kind: JsonFieldKind::Boolean,
102            required: false,
103        },
104    ] {
105        if let Some(value) = object.get(field.name)
106            && let Some(issue) = field_issue(field.kind, Some(value), json_type(Some(value)))
107        {
108            issues.push(format!("[body.{}] {issue}", field.name));
109        }
110    }
111    if !issues.is_empty() {
112        return Err(crate::endpoint::validation(issues.join("; ")));
113    }
114    Ok(JsValue::Object(
115        object
116            .iter()
117            .filter(|(name, _)| matches!(name.as_str(), "response" | "name" | "createSession"))
118            .map(|(name, value)| {
119                let value = if name == "name" {
120                    JsValue::String(
121                        super::registration::trim_name(value.as_str().unwrap_or_default())
122                            .to_owned(),
123                    )
124                } else {
125                    value.clone()
126                };
127                (name.clone(), value)
128            })
129            .collect(),
130    ))
131}
132
133/// The issued session and credential owner's public user projection.
134#[derive(Debug, serde::Deserialize)]
135pub struct PasskeyAuthenticated {
136    pub session: Value,
137    pub user: Value,
138}
139
140fn verification_response(output: alibi_core::AuthResponse) -> AuthResult<EndpointResponse> {
141    let body =
142        alibi_core::utils::json::from_slice::<alibi_core::utils::json::JsValue>(&output.body).ok();
143    let mut response = if output.status >= 400 {
144        let response = EndpointResponse::error(crate::endpoint::error_response(output.clone()));
145        if let Some(body) = body {
146            response.with_error_body(body)
147        } else {
148            response.without_error_body()
149        }
150    } else {
151        EndpointResponse::json(&body)?
152    };
153    response.merge_headers(output.headers);
154    Ok(response)
155}
156
157pub(super) fn validate(call: &EndpointCall) -> AuthResult<EndpointInput> {
158    Ok(EndpointInput {
159        body: if call.operation_id() == "passkeyVerifyAuthentication" {
160            Some(crate::endpoint::validate_fields(
161                call.body(),
162                "body",
163                &[crate::authentication_helpers::JsonField {
164                    name: "response",
165                    kind: crate::authentication_helpers::JsonFieldKind::Record,
166                    required: true,
167                }],
168            )?)
169        } else if call.operation_id() == "passkeyVerifyRegistration" {
170            Some(registration_input(call)?)
171        } else if call.operation_id() == "deletePasskey" {
172            Some(crate::endpoint::validate_fields(
173                call.body(),
174                "body",
175                &[crate::authentication_helpers::JsonField::string("id", true)],
176            )?)
177        } else if call.operation_id() == "updatePasskey" {
178            let mut body = crate::endpoint::validate_fields(
179                call.body(),
180                "body",
181                &[
182                    crate::authentication_helpers::JsonField::string("id", true),
183                    crate::authentication_helpers::JsonField::string("name", true),
184                ],
185            )?;
186            let name = super::registration::trim_name(
187                body.get("name")
188                    .and_then(alibi_core::utils::json::JsValue::as_str)
189                    .unwrap_or_default(),
190            );
191            if name.is_empty() {
192                return Err(crate::endpoint::validation(
193                    "[body.name] Too small: expected string to have >=1 characters",
194                ));
195            }
196            let name = name.to_owned();
197            if let alibi_core::utils::json::JsValue::Object(object) = &mut body {
198                _ = object.insert(
199                    "name".into(),
200                    alibi_core::utils::json::JsValue::String(name),
201                );
202            }
203            Some(body)
204        } else {
205            call.body().cloned()
206        },
207        query: if call.operation_id() == "generatePasskeyRegistrationOptions" {
208            call.query()
209                .map(|query| {
210                    crate::endpoint::validate_fields(
211                        Some(query),
212                        "query",
213                        &[
214                            crate::authentication_helpers::JsonField {
215                                name: "authenticatorAttachment",
216                                kind: crate::authentication_helpers::JsonFieldKind::OneOf(&[
217                                    "platform",
218                                    "cross-platform",
219                                ]),
220                                required: false,
221                            },
222                            crate::authentication_helpers::JsonField::string("name", false),
223                            crate::authentication_helpers::JsonField::string("context", false),
224                        ],
225                    )
226                })
227                .transpose()?
228        } else {
229            call.query().cloned()
230        },
231    })
232}
233
234impl PasskeyPlugin {
235    /// Verify a signed authentication response and issue its credential owner's session.
236    #[must_use]
237    pub fn verify_passkey_authentication_endpoint(
238        response: alibi_core::utils::json::JsValue,
239    ) -> ServerEndpoint<PasskeyAuthenticated> {
240        ServerEndpoint::new("passkey", "verifyPasskeyAuthentication").with_body_value(
241            alibi_core::utils::json::JsValue::Object(
242                [("response".into(), response)].into_iter().collect(),
243            ),
244        )
245    }
246
247    pub(super) async fn verify_authentication_endpoint<S: AuthSchema>(
248        &self,
249        call: &EndpointCall,
250        ctx: &AuthContext<S>,
251    ) -> AuthResult<EndpointResponse> {
252        verification_response(
253            self.verify_authentication(&call.body_as()?, call, ctx)
254                .await?,
255        )
256    }
257
258    /// Verify a real registration response against the issued challenge and owner.
259    #[must_use]
260    pub fn verify_passkey_registration_endpoint(
261        input: PasskeyRegistrationInput,
262    ) -> ServerEndpoint<Value> {
263        use alibi_core::utils::json::JsValue;
264        let mut body = [("response".into(), input.response)]
265            .into_iter()
266            .collect::<indexmap::IndexMap<_, _>>();
267        if let Some(name) = input.name {
268            _ = body.insert("name".into(), JsValue::String(name));
269        }
270        if let Some(create_session) = input.create_session {
271            _ = body.insert("createSession".into(), JsValue::Bool(create_session));
272        }
273        ServerEndpoint::new("passkey", "verifyPasskeyRegistration")
274            .with_body_value(JsValue::Object(body))
275    }
276
277    pub(super) async fn verify_registration_endpoint<S: AuthSchema>(
278        &self,
279        call: &EndpointCall,
280        ctx: &AuthContext<S>,
281    ) -> AuthResult<EndpointResponse> {
282        let output = self
283            .verify_registration(&call.body_as()?, call, ctx)
284            .await
285            .map_err(crate::helpers::unauthorized_if_unauthenticated)?;
286        verification_response(output)
287    }
288
289    /// Issue an authentication challenge, optionally limiting browser hints to the signed owner.
290    #[must_use]
291    pub fn generate_passkey_authentication_options_endpoint() -> ServerEndpoint<Value> {
292        ServerEndpoint::new("passkey", "generatePasskeyAuthenticationOptions")
293    }
294
295    pub(super) async fn authentication_options_endpoint<S: AuthSchema>(
296        &self,
297        call: &EndpointCall,
298        ctx: &AuthContext<S>,
299    ) -> AuthResult<EndpointResponse> {
300        let output = self.generate_authentication_options(call, ctx).await?;
301        let mut response = EndpointResponse::json(&alibi_core::utils::json::from_slice::<
302            alibi_core::utils::json::JsValue,
303        >(&output.body)?)?;
304        response.merge_headers(output.headers);
305        Ok(response)
306    }
307
308    /// Generate a challenge for registering a credential under the current owner.
309    #[must_use]
310    pub fn generate_passkey_registration_options_endpoint() -> ServerEndpoint<Value> {
311        ServerEndpoint::new("passkey", "generatePasskeyRegistrationOptions")
312    }
313
314    pub(super) async fn registration_options_endpoint<S: AuthSchema>(
315        &self,
316        call: &EndpointCall,
317        ctx: &AuthContext<S>,
318    ) -> AuthResult<EndpointResponse> {
319        let query = call
320            .query()
321            .and_then(alibi_core::utils::json::JsValue::as_object)
322            .map(|object| {
323                object
324                    .iter()
325                    .filter_map(|(name, value)| {
326                        value.as_str().map(|value| (name.clone(), value.to_owned()))
327                    })
328                    .collect()
329            })
330            .unwrap_or_default();
331        let output = self
332            .generate_register_options(call, &query, ctx)
333            .await
334            .map_err(crate::helpers::unauthorized_if_unauthenticated)?;
335        if output.status >= 400 {
336            let headers = output.headers.clone();
337            let mut response = EndpointResponse::error(crate::endpoint::error_response(output));
338            response.merge_headers(headers);
339            Ok(response)
340        } else {
341            let mut response = EndpointResponse::json(&alibi_core::utils::json::from_slice::<
342                alibi_core::utils::json::JsValue,
343            >(&output.body)?)?;
344            response.merge_headers(output.headers);
345            Ok(response)
346        }
347    }
348
349    /// Rename a registered credential under its owner's signed session.
350    #[must_use]
351    pub fn update_passkey_endpoint(
352        id: impl Into<String>,
353        name: impl Into<String>,
354    ) -> ServerEndpoint<PasskeyUpdated> {
355        ServerEndpoint::new("passkey", "updatePasskey").with_body_value(
356            alibi_core::utils::json::JsValue::Object(
357                [
358                    (
359                        "id".into(),
360                        alibi_core::utils::json::JsValue::String(id.into()),
361                    ),
362                    (
363                        "name".into(),
364                        alibi_core::utils::json::JsValue::String(name.into()),
365                    ),
366                ]
367                .into_iter()
368                .collect(),
369            ),
370        )
371    }
372
373    pub(super) async fn update_endpoint<S: AuthSchema>(
374        &self,
375        call: &EndpointCall,
376        ctx: &AuthContext<S>,
377    ) -> AuthResult<EndpointResponse> {
378        let (user, _) = crate::helpers::ordinary_session(call, ctx).await?;
379        match update_passkey_core(&call.body_as()?, &user, ctx).await? {
380            PasskeyHandlerOutcome::Success(value) => EndpointResponse::json(&value),
381            PasskeyHandlerOutcome::Response(output) => {
382                let headers = output.headers.clone();
383                let mut response = EndpointResponse::error(crate::endpoint::error_response(output));
384                response.merge_headers(headers);
385                Ok(response)
386            }
387        }
388    }
389
390    /// Delete a registered credential under its owner's signed session.
391    #[must_use]
392    pub fn delete_passkey_endpoint(id: impl Into<String>) -> ServerEndpoint<PasskeyDeleted> {
393        ServerEndpoint::new("passkey", "deletePasskey").with_body_value(
394            alibi_core::utils::json::JsValue::Object(
395                [(
396                    "id".into(),
397                    alibi_core::utils::json::JsValue::String(id.into()),
398                )]
399                .into_iter()
400                .collect(),
401            ),
402        )
403    }
404
405    pub(super) async fn delete_endpoint<S: AuthSchema>(
406        &self,
407        call: &EndpointCall,
408        ctx: &AuthContext<S>,
409    ) -> AuthResult<EndpointResponse> {
410        let (user, _) = crate::helpers::ordinary_session(call, ctx).await?;
411        match delete_passkey_core(&call.body_as()?, &user, ctx).await? {
412            PasskeyHandlerOutcome::Success(value) => EndpointResponse::json(&value),
413            PasskeyHandlerOutcome::Response(output) => {
414                let mut response = EndpointResponse::error(alibi_core::AuthError::Api {
415                    status: output.status,
416                    code: None,
417                    message: String::new(),
418                })
419                .without_error_body();
420                response.merge_headers(output.headers);
421                Ok(response)
422            }
423        }
424    }
425
426    /// List the caller's registered credentials under its signed session.
427    #[must_use]
428    pub fn list_passkeys_endpoint() -> ServerEndpoint<Vec<Value>> {
429        ServerEndpoint::new("passkey", "listPasskeys")
430    }
431
432    pub(super) async fn list_endpoint<S: AuthSchema>(
433        &self,
434        call: &EndpointCall,
435        ctx: &AuthContext<S>,
436    ) -> AuthResult<EndpointResponse> {
437        let (user, _) = crate::helpers::ordinary_session(call, ctx).await?;
438        EndpointResponse::json(&list_user_passkeys_core(&user, ctx).await?)
439    }
440}