Skip to main content

isb_server/auth/http/
external.rs

1//! The endpoints for external sign-in and passkeys: providers, the OAuth
2//! redirect flow, linked identities, and the WebAuthn ceremonies.
3//!
4//! **OAuth state** is a pending record in memory, keyed by the random
5//! `state` sent to the provider, holding the PKCE verifier, the nonce, where
6//! to go next, and the SHA-256 of a random **binding** value that also sits
7//! in the `isb_oauth` cookie (HttpOnly, SameSite=Lax, scoped to
8//! `/api/v1/auth/oauth/`, 10 minutes). The callback needs both: the state
9//! from the provider and the cookie from the browser that started the flow,
10//! so a code obtained by someone else cannot be replayed into a victim's
11//! browser (login CSRF). Each start makes a new binding, so of two sign-ins
12//! started at once in one browser only the later one completes. Records are
13//! single use and expire in 10 minutes; a daemon restart forgets them (sign
14//! in again).
15//!
16//! **Passkey challenges** are 32 random bytes, single use, 5 minutes, also
17//! in memory, found again by the challenge inside `clientDataJSON`.
18
19use std::collections::HashMap;
20use std::sync::Mutex;
21
22use serde::Deserialize;
23use serde_json::{Value, json};
24
25use super::{AuthApi, client_ip, cookie, meta, parse_id, plain_loopback_http, session_cookie};
26use crate::auth::external::ExternalIdentity;
27use crate::auth::oauth::{self, Provider};
28use crate::auth::secret;
29use crate::auth::webauthn::{self, b64, unb64};
30use crate::auth::{AuthError, Principal};
31use crate::server::http::{Request, Response};
32
33/// The cookie binding an OAuth flow to the browser that started it.
34pub const OAUTH_COOKIE: &str = "isb_oauth";
35const OAUTH_COOKIE_PATH: &str = "/api/v1/auth/oauth/";
36const FLOW_TTL: i64 = 600;
37const CHALLENGE_TTL: i64 = 300;
38const MAX_PENDING: usize = 10_000;
39/// Where a failed browser sign-in lands, with `?error=CODE`.
40pub const LOGIN_PAGE: &str = "/login";
41
42/// Short-lived, single-use records in memory.
43pub(super) struct Pending<T> {
44    map: Mutex<HashMap<String, (i64, T)>>,
45}
46
47impl<T> Default for Pending<T> {
48    fn default() -> Self {
49        Pending {
50            map: Mutex::new(HashMap::new()),
51        }
52    }
53}
54
55impl<T> Pending<T> {
56    fn insert(&self, key: String, expires: i64, v: T, now: i64) -> Result<(), AuthError> {
57        let mut m = self.map.lock().unwrap_or_else(|e| e.into_inner());
58        if m.len() >= MAX_PENDING / 2 {
59            m.retain(|_, (exp, _)| *exp > now);
60        }
61        if m.len() >= MAX_PENDING {
62            return Err(AuthError::RateLimited { retry_after: 60 });
63        }
64        m.insert(key, (expires, v));
65        Ok(())
66    }
67
68    /// Remove and return the record, if it has not expired.
69    fn take(&self, key: &str, now: i64) -> Option<T> {
70        let mut m = self.map.lock().unwrap_or_else(|e| e.into_inner());
71        m.remove(key).filter(|(exp, _)| now < *exp).map(|(_, v)| v)
72    }
73}
74
75pub(super) struct Flow {
76    provider: String,
77    binding: Vec<u8>,
78    verifier: String,
79    nonce: String,
80    next: String,
81    invite: Option<String>,
82    /// Linking to this signed-in user rather than signing in.
83    link_user: Option<i64>,
84}
85
86pub(super) enum Challenge {
87    Register { user_id: i64, handle: Vec<u8> },
88    Login { user_id: Option<i64> },
89}
90
91/// A same-origin path to go to after sign-in: starts with one `/`, no
92/// backslash, no whitespace or control characters. Never an absolute URL or
93/// a protocol-relative `//host`.
94pub fn safe_next(s: &str) -> Option<String> {
95    let ok = s.len() <= 2048
96        && s.starts_with('/')
97        && !s.starts_with("//")
98        && !s.contains('\\')
99        && !s.chars().any(|c| c.is_control() || c.is_whitespace());
100    ok.then(|| s.to_string())
101}
102
103fn random_b64() -> Result<String, AuthError> {
104    Ok(b64(&secret::random_bytes::<32>()?))
105}
106
107fn session_only(p: &Principal, what: &str) -> Result<i64, AuthError> {
108    p.session_id().map(|_| p.user.id).ok_or_else(|| {
109        AuthError::Forbidden(format!(
110            "{what} from a signed-in browser session, not with an API token"
111        ))
112    })
113}
114
115/// A JSON body, or the default when the body is empty.
116fn body_or_default<T: Default + serde::de::DeserializeOwned>(
117    req: &Request,
118) -> Result<T, AuthError> {
119    if req.body.iter().all(u8::is_ascii_whitespace) {
120        return Ok(T::default());
121    }
122    super::body(req)
123}
124
125fn redirect(location: &str) -> Response {
126    Response::new(303).header("Location", location)
127}
128
129impl AuthApi {
130    fn provider(&self, id: &str) -> Result<&Provider, AuthError> {
131        self.providers
132            .iter()
133            .find(|p| p.cfg.id == id)
134            .map(|p| p.as_ref())
135            .ok_or_else(|| AuthError::NotFound(format!("sign-in provider {id}")))
136    }
137
138    fn redirect_uri(&self, provider: &str) -> String {
139        let base = self
140            .cfg
141            .public_url
142            .as_deref()
143            .unwrap_or("")
144            .trim_end_matches('/');
145        format!("{base}{}oauth/{provider}/callback", super::PREFIX)
146    }
147
148    fn oauth_cookie(&self, req: &Request, binding: &str) -> String {
149        let secure = if plain_loopback_http(req) {
150            ""
151        } else {
152            "; Secure"
153        };
154        format!(
155            "{OAUTH_COOKIE}={binding}; Path={OAUTH_COOKIE_PATH}; HttpOnly; SameSite=Lax; Max-Age={FLOW_TTL}{secure}"
156        )
157    }
158
159    // ---- providers ----
160
161    pub(super) fn providers_list(&self) -> Result<Response, AuthError> {
162        let list: Vec<Value> = self
163            .providers
164            .iter()
165            .map(|p| {
166                json!({
167                    "id": p.cfg.id,
168                    "label": p.cfg.label,
169                    "kind": p.cfg.kind_name(),
170                    "start": format!("{}oauth/{}/start", super::PREFIX, p.cfg.id),
171                })
172            })
173            .collect();
174        Ok(Response::json(
175            200,
176            &json!({
177                "providers": list,
178                "password": true,
179                "passkeys": self.rp.is_some(),
180                "open_signup": self.cfg.open_signup,
181            }),
182        ))
183    }
184
185    // ---- the OAuth flow ----
186
187    /// Set up a flow; returns the provider URL and the binding cookie.
188    fn oauth_begin(
189        &self,
190        req: &Request,
191        provider: &str,
192        next: Option<&str>,
193        invite: Option<String>,
194        intent: Option<&str>,
195    ) -> Result<(String, String), AuthError> {
196        self.store.limit_ip(client_ip(req).as_deref())?;
197        let p = self.provider(provider)?;
198        let next = match next.filter(|n| !n.is_empty()) {
199            None => "/".to_string(),
200            Some(n) => safe_next(n).ok_or_else(|| {
201                AuthError::Invalid(format!(
202                    "next {n:?} must be a path on this site, like /orgs/default"
203                ))
204            })?,
205        };
206        let invite = invite.filter(|t| !t.is_empty());
207        if let Some(t) = &invite {
208            if !secret::well_formed(t, secret::TokenKind::Invitation) {
209                return Err(AuthError::InvalidToken("invitation"));
210            }
211        }
212        let link_user = match intent.filter(|i| !i.is_empty()) {
213            None | Some("login") => None,
214            Some("link") => {
215                let who = self.principal(req).ok_or_else(|| {
216                    AuthError::Forbidden("sign in before linking a provider".into())
217                })?;
218                Some(session_only(&who, "link a provider")?)
219            }
220            Some(i) => return Err(AuthError::Invalid(format!("intent {i:?}: login or link"))),
221        };
222        // A fresh binding per flow, never one the browser already holds:
223        // a cookie planted by a sibling subdomain must not bind a flow the
224        // attacker started. (A second tab's sign-in replaces the first's.)
225        let binding = random_b64()?;
226        let state = random_b64()?;
227        let nonce = random_b64()?;
228        let (verifier, challenge) = oauth::pkce()?;
229        let url = p
230            .authorize_url(&self.redirect_uri(provider), &state, &nonce, &challenge)
231            .map_err(|e| {
232                eprintln!("isb serve: sign-in with {provider}: {e}");
233                AuthError::Refused {
234                    code: "provider_unavailable",
235                    message: format!("{} sign-in is unavailable right now", p.cfg.label),
236                }
237            })?;
238        let now = self.store.now();
239        self.flows.insert(
240            state,
241            now + FLOW_TTL,
242            Flow {
243                provider: provider.to_string(),
244                binding: secret::hash_token(&binding),
245                verifier,
246                nonce,
247                next,
248                invite,
249                link_user,
250            },
251            now,
252        )?;
253        Ok((url, self.oauth_cookie(req, &binding)))
254    }
255
256    /// `GET oauth/{p}/start?next=/path&invite=TOKEN&intent=link`: a browser
257    /// navigation, so errors redirect to the login page too.
258    pub(super) fn oauth_start_get(&self, req: &Request, provider: &str) -> Response {
259        let q = oauth::parse_query(req.query.as_deref().unwrap_or(""));
260        let get = |k: &str| q.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str());
261        match self.oauth_begin(
262            req,
263            provider,
264            get("next"),
265            get("invite").map(str::to_string),
266            get("intent"),
267        ) {
268            Ok((url, c)) => redirect(&url).header("Set-Cookie", c),
269            Err(e) => self.fail(None, &e),
270        }
271    }
272
273    /// `POST oauth/{p}/start` `{next?, invite?, intent?}` → `{url}`: for a
274    /// page that keeps the invitation token out of URLs.
275    pub(super) fn oauth_start_post(
276        &self,
277        req: &Request,
278        provider: &str,
279    ) -> Result<Response, AuthError> {
280        #[derive(Deserialize, Default)]
281        struct B {
282            #[serde(default)]
283            next: Option<String>,
284            #[serde(default)]
285            invite: Option<String>,
286            #[serde(default)]
287            intent: Option<String>,
288        }
289        let b: B = body_or_default(req)?;
290        let (url, c) = self.oauth_begin(
291            req,
292            provider,
293            b.next.as_deref(),
294            b.invite,
295            b.intent.as_deref(),
296        )?;
297        Ok(Response::json(200, &json!({"url": url})).header("Set-Cookie", c))
298    }
299
300    /// Where a failed browser flow goes: the login page with `error=CODE`
301    /// (and `next`), or for linking, back to `next` with `error=CODE`.
302    fn fail(&self, flow: Option<&Flow>, e: &AuthError) -> Response {
303        let code = match e {
304            AuthError::Refused { code, .. } => code,
305            AuthError::InvalidToken(_) => "invalid_invitation",
306            AuthError::RateLimited { .. } => "rate_limited",
307            AuthError::Invalid(_) => "invalid_request",
308            AuthError::NotFound(_) => "unknown_provider",
309            AuthError::Forbidden(_) => "forbidden",
310            AuthError::Conflict(_) => "conflict",
311            _ => {
312                eprintln!("isb serve: external sign-in: {e}");
313                "internal"
314            }
315        };
316        let location = match flow {
317            Some(f) if f.link_user.is_some() => {
318                let sep = if f.next.contains('?') { '&' } else { '?' };
319                format!("{}{sep}error={code}", f.next)
320            }
321            Some(f) if f.next != "/" => format!(
322                "{LOGIN_PAGE}?{}",
323                oauth::form(&[("error", code), ("next", &f.next)])
324            ),
325            _ => format!("{LOGIN_PAGE}?error={code}"),
326        };
327        let message = match e {
328            AuthError::Internal(_) | AuthError::Db(_) => "internal error".to_string(),
329            e => e.to_string(),
330        };
331        redirect(&location)
332            .header("Content-Type", "application/json")
333            .body(
334                serde_json::to_vec(&json!({"error": code, "message": message})).unwrap_or_default(),
335            )
336    }
337
338    /// `GET oauth/{p}/callback?code&state`: finish the flow and redirect.
339    pub(super) fn oauth_callback(&self, req: &Request, provider: &str) -> Response {
340        let refuse = |code: &'static str, message: &str| AuthError::Refused {
341            code,
342            message: message.to_string(),
343        };
344        if let Err(e) = self.store.limit_ip(client_ip(req).as_deref()) {
345            return self.fail(None, &e);
346        }
347        let q = oauth::parse_query(req.query.as_deref().unwrap_or(""));
348        let get = |k: &str| q.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str());
349        let now = self.store.now();
350        let Some(flow) = get("state").and_then(|s| self.flows.take(s, now)) else {
351            return self.fail(
352                None,
353                &refuse(
354                    "state_invalid",
355                    "this sign-in link is unknown or has expired; start again",
356                ),
357            );
358        };
359        let bound = cookie(req, OAUTH_COOKIE)
360            .is_some_and(|b| secret::ct_eq(&secret::hash_token(&b), &flow.binding));
361        if flow.provider != provider || !bound {
362            return self.fail(
363                Some(&flow),
364                &refuse(
365                    "state_mismatch",
366                    "this sign-in was started in another browser; start again",
367                ),
368            );
369        }
370        if let Some(err) = get("error") {
371            eprintln!("isb serve: sign-in with {provider}: the provider answered {err:?}");
372            return self.fail(
373                Some(&flow),
374                &refuse("provider_denied", "the provider did not sign you in"),
375            );
376        }
377        let Some(code) = get("code").filter(|c| !c.is_empty()) else {
378            return self.fail(
379                Some(&flow),
380                &refuse("provider_error", "the provider sent no code"),
381            );
382        };
383        let r = self.provider(provider).and_then(|p| {
384            p.exchange(
385                code,
386                &self.redirect_uri(provider),
387                &flow.verifier,
388                &flow.nonce,
389            )
390            .map_err(|e| {
391                eprintln!("isb serve: sign-in with {provider}: {e}");
392                refuse(
393                    "provider_error",
394                    "the provider's answer could not be verified",
395                )
396            })
397        });
398        match r.and_then(|ext| self.finish(req, &flow, &ext)) {
399            Ok(resp) => resp,
400            Err(e) => self.fail(Some(&flow), &e),
401        }
402    }
403
404    fn finish(
405        &self,
406        req: &Request,
407        flow: &Flow,
408        ext: &ExternalIdentity,
409    ) -> Result<Response, AuthError> {
410        if let Some(uid) = flow.link_user {
411            // Still the same signed-in user who asked to link.
412            let still = self
413                .principal(req)
414                .is_some_and(|p| p.user.id == uid && p.session_id().is_some());
415            if !still {
416                return Err(AuthError::Forbidden(
417                    "sign in again to link a provider".into(),
418                ));
419            }
420            self.store.link_identity(uid, ext)?;
421            super::note_user(uid);
422            return Ok(redirect(&flow.next));
423        }
424        let (user, how) =
425            self.store
426                .external_sign_in(ext, flow.invite.as_deref(), self.cfg.open_signup)?;
427        if how == crate::auth::external::SignIn::Created {
428            eprintln!("isb serve: {} signed up with {}", user.email, ext.provider);
429        }
430        let s = self.store.start_session(user.id, meta(req))?;
431        super::note_user(user.id);
432        let max_age = (s.session.expires_at - self.store.now()).max(0);
433        Ok(redirect(&flow.next).header("Set-Cookie", session_cookie(req, &s.token, max_age)))
434    }
435
436    // ---- identities ----
437
438    pub(super) fn identities(&self, p: &Principal) -> Result<Response, AuthError> {
439        let list: Vec<Value> = self
440            .store
441            .list_identities(p.user.id)?
442            .into_iter()
443            .map(|i| {
444                let cfg = self
445                    .providers
446                    .iter()
447                    .find(|x| x.cfg.identity_provider() == i.provider);
448                let mut v = serde_json::to_value(&i).unwrap_or_default();
449                v["provider_id"] = json!(cfg.map(|c| c.cfg.id.clone()));
450                let edge = crate::auth::edge::provider_label(&i.provider).map(String::from);
451                v["label"] = json!(
452                    cfg.map(|c| c.cfg.label.clone())
453                        .or(edge)
454                        .unwrap_or_else(|| i.provider.clone())
455                );
456                v
457            })
458            .collect();
459        Ok(Response::json(200, &json!({"identities": list})))
460    }
461
462    pub(super) fn delete_identity(&self, p: &Principal, id: &str) -> Result<Response, AuthError> {
463        let uid = session_only(p, "unlink a provider")?;
464        let id = parse_id(id)?;
465        if !self.store.unlink_identity(uid, id)? {
466            return Err(AuthError::NotFound(format!("identity {id}")));
467        }
468        Ok(Response::new(204))
469    }
470
471    // ---- passkeys ----
472
473    fn rp(&self) -> Result<&webauthn::RelyingParty, AuthError> {
474        self.rp.as_ref().ok_or_else(|| {
475            AuthError::Invalid("passkeys need ISB_PUBLIC_URL (https, or http://localhost)".into())
476        })
477    }
478
479    pub(super) fn passkey_register_options(&self, p: &Principal) -> Result<Response, AuthError> {
480        let rp = self.rp()?;
481        let uid = session_only(p, "add a passkey")?;
482        let handle = match self.store.passkey_user_handle(uid)? {
483            Some(h) => h,
484            None => secret::random_bytes::<16>()?.to_vec(),
485        };
486        let challenge = secret::random_bytes::<32>()?;
487        let exclude: Vec<Value> = self
488            .store
489            .list_passkeys(uid)?
490            .into_iter()
491            .map(|k| json!({"type": "public-key", "id": k.credential_id, "transports": k.transports}))
492            .collect();
493        let now = self.store.now();
494        self.challenges.insert(
495            b64(&challenge),
496            now + CHALLENGE_TTL,
497            Challenge::Register {
498                user_id: uid,
499                handle: handle.clone(),
500            },
501            now,
502        )?;
503        let display = if p.user.name.is_empty() {
504            &p.user.email
505        } else {
506            &p.user.name
507        };
508        let params: Vec<Value> = webauthn::ALGORITHMS
509            .iter()
510            .map(|a| json!({"type": "public-key", "alg": a}))
511            .collect();
512        Ok(Response::json(
513            200,
514            &json!({"publicKey": {
515                "rp": {"id": rp.id, "name": rp.name},
516                "user": {"id": b64(&handle), "name": p.user.email, "displayName": display},
517                "challenge": b64(&challenge),
518                "pubKeyCredParams": params,
519                "timeout": CHALLENGE_TTL * 1000,
520                "attestation": "none",
521                "authenticatorSelection": {
522                    "residentKey": "required",
523                    "requireResidentKey": true,
524                    "userVerification": "required",
525                },
526                "excludeCredentials": exclude,
527            }}),
528        ))
529    }
530
531    pub(super) fn passkey_register_verify(
532        &self,
533        req: &Request,
534        p: &Principal,
535    ) -> Result<Response, AuthError> {
536        #[derive(Deserialize)]
537        struct Resp {
538            #[serde(rename = "clientDataJSON")]
539            client_data: String,
540            #[serde(rename = "attestationObject")]
541            attestation: String,
542            #[serde(default)]
543            transports: Vec<String>,
544        }
545        #[derive(Deserialize)]
546        struct Cred {
547            response: Resp,
548        }
549        #[derive(Deserialize)]
550        struct B {
551            #[serde(default)]
552            name: Option<String>,
553            credential: Cred,
554        }
555        let rp = self.rp()?;
556        let uid = session_only(p, "add a passkey")?;
557        let b: B = super::body(req)?;
558        let reject = |e: String| AuthError::PasskeyRejected(e);
559        let cd = unb64(&b.credential.response.client_data)
560            .map_err(|e| reject(format!("clientDataJSON: {e}")))?;
561        let att = unb64(&b.credential.response.attestation)
562            .map_err(|e| reject(format!("attestationObject: {e}")))?;
563        let challenge = webauthn::client_challenge(&cd)
564            .ok_or_else(|| reject("clientDataJSON has no challenge".into()))?;
565        let handle = match self.challenges.take(&b64(&challenge), self.store.now()) {
566            Some(Challenge::Register { user_id, handle }) if user_id == uid => handle,
567            _ => {
568                return Err(reject(
569                    "the challenge is unknown, used or expired; start again".into(),
570                ));
571            }
572        };
573        let reg = webauthn::verify_registration(rp, &challenge, &cd, &att, true).map_err(reject)?;
574        let name = b
575            .name
576            .filter(|n| !n.trim().is_empty())
577            .unwrap_or_else(|| "Passkey".into());
578        let k =
579            self.store
580                .add_passkey(uid, &handle, &reg, &name, &b.credential.response.transports)?;
581        Ok(Response::json(201, &json!({"passkey": k})))
582    }
583
584    pub(super) fn passkey_login_options(&self, req: &Request) -> Result<Response, AuthError> {
585        #[derive(Deserialize, Default)]
586        struct B {
587            #[serde(default)]
588            email: Option<String>,
589        }
590        let rp = self.rp()?;
591        self.store.limit_ip(client_ip(req).as_deref())?;
592        let b: B = body_or_default(req)?;
593        let user = match b.email.as_deref().filter(|e| !e.trim().is_empty()) {
594            Some(e) => self.store.user_by_email(e)?,
595            None => None,
596        };
597        let allow: Vec<Value> = match &user {
598            Some(u) => self
599                .store
600                .list_passkeys(u.id)?
601                .into_iter()
602                .map(|k| json!({"type": "public-key", "id": k.credential_id, "transports": k.transports}))
603                .collect(),
604            None => vec![],
605        };
606        let challenge = secret::random_bytes::<32>()?;
607        let now = self.store.now();
608        self.challenges.insert(
609            b64(&challenge),
610            now + CHALLENGE_TTL,
611            Challenge::Login {
612                user_id: user.map(|u| u.id),
613            },
614            now,
615        )?;
616        Ok(Response::json(
617            200,
618            &json!({"publicKey": {
619                "challenge": b64(&challenge),
620                "rpId": rp.id,
621                "timeout": CHALLENGE_TTL * 1000,
622                "userVerification": "required",
623                "allowCredentials": allow,
624            }}),
625        ))
626    }
627
628    pub(super) fn passkey_login_verify(&self, req: &Request) -> Result<Response, AuthError> {
629        #[derive(Deserialize)]
630        struct Resp {
631            #[serde(rename = "clientDataJSON")]
632            client_data: String,
633            #[serde(rename = "authenticatorData")]
634            authenticator_data: String,
635            signature: String,
636            #[serde(default, rename = "userHandle")]
637            user_handle: Option<String>,
638        }
639        #[derive(Deserialize)]
640        struct Cred {
641            #[serde(default)]
642            id: Option<String>,
643            #[serde(default, rename = "rawId")]
644            raw_id: Option<String>,
645            response: Resp,
646        }
647        #[derive(Deserialize)]
648        struct B {
649            credential: Cred,
650        }
651        let rp = self.rp()?;
652        self.store.limit_ip(client_ip(req).as_deref())?;
653        let b: B = super::body(req)?;
654        let reject = |e: &str| AuthError::PasskeyRejected(e.to_string());
655        let r = &b.credential.response;
656        let cred_id = b
657            .credential
658            .raw_id
659            .as_deref()
660            .or(b.credential.id.as_deref())
661            .and_then(|s| unb64(s).ok())
662            .ok_or_else(|| reject("the credential has no id"))?;
663        let cd = unb64(&r.client_data).map_err(|_| reject("clientDataJSON is not base64url"))?;
664        let ad = unb64(&r.authenticator_data)
665            .map_err(|_| reject("authenticatorData is not base64url"))?;
666        let sig = unb64(&r.signature).map_err(|_| reject("signature is not base64url"))?;
667        let challenge = webauthn::client_challenge(&cd)
668            .ok_or_else(|| reject("clientDataJSON has no challenge"))?;
669        let bound = match self.challenges.take(&b64(&challenge), self.store.now()) {
670            Some(Challenge::Login { user_id }) => user_id,
671            _ => {
672                return Err(reject(
673                    "the challenge is unknown, used or expired; start again",
674                ));
675            }
676        };
677        let stored = self
678            .store
679            .passkey_by_credential(&cred_id)?
680            .ok_or_else(|| reject("this passkey is not registered here"))?;
681        let k = &stored.passkey;
682        if bound.is_some_and(|u| u != k.user_id) {
683            return Err(reject("this passkey belongs to another account"));
684        }
685        if let Some(h) = r.user_handle.as_deref().filter(|h| !h.is_empty()) {
686            let h = unb64(h).map_err(|_| reject("userHandle is not base64url"))?;
687            if !secret::ct_eq(&h, &stored.user_handle) {
688                return Err(reject("the user handle does not match the passkey"));
689            }
690        }
691        let new_count = webauthn::verify_assertion(
692            rp,
693            &challenge,
694            &stored.public_key,
695            k.sign_count,
696            &cd,
697            &ad,
698            &sig,
699            true,
700        )
701        .map_err(|e| {
702            eprintln!("isb serve: passkey {} of user {}: {e}", k.id, k.user_id);
703            AuthError::PasskeyRejected(e)
704        })?;
705        if self.store.user(k.user_id)?.disabled {
706            return Err(reject("this account is disabled"));
707        }
708        self.store.use_passkey(k.id, k.sign_count, new_count)?;
709        let s = self.store.start_session(k.user_id, meta(req))?;
710        self.session_response(req, 200, &s)
711    }
712
713    pub(super) fn passkeys(&self, p: &Principal) -> Result<Response, AuthError> {
714        let list = self.store.list_passkeys(p.user.id)?;
715        Ok(Response::json(200, &json!({"passkeys": list})))
716    }
717
718    pub(super) fn delete_passkey(&self, p: &Principal, id: &str) -> Result<Response, AuthError> {
719        let uid = session_only(p, "remove a passkey")?;
720        let id = parse_id(id)?;
721        if !self.store.delete_passkey(uid, id)? {
722            return Err(AuthError::NotFound(format!("passkey {id}")));
723        }
724        Ok(Response::new(204))
725    }
726}
727
728#[cfg(test)]
729mod tests;