1use 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
33pub 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;
39pub const LOGIN_PAGE: &str = "/login";
41
42pub(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 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 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
91pub 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
115fn 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 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 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 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 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 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 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 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 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 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 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, ®, &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;