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 v["label"] = json!(cfg.map_or(i.provider.clone(), |c| c.cfg.label.clone()));
451 v
452 })
453 .collect();
454 Ok(Response::json(200, &json!({"identities": list})))
455 }
456
457 pub(super) fn delete_identity(&self, p: &Principal, id: &str) -> Result<Response, AuthError> {
458 let uid = session_only(p, "unlink a provider")?;
459 let id = parse_id(id)?;
460 if !self.store.unlink_identity(uid, id)? {
461 return Err(AuthError::NotFound(format!("identity {id}")));
462 }
463 Ok(Response::new(204))
464 }
465
466 fn rp(&self) -> Result<&webauthn::RelyingParty, AuthError> {
469 self.rp.as_ref().ok_or_else(|| {
470 AuthError::Invalid("passkeys need ISB_PUBLIC_URL (https, or http://localhost)".into())
471 })
472 }
473
474 pub(super) fn passkey_register_options(&self, p: &Principal) -> Result<Response, AuthError> {
475 let rp = self.rp()?;
476 let uid = session_only(p, "add a passkey")?;
477 let handle = match self.store.passkey_user_handle(uid)? {
478 Some(h) => h,
479 None => secret::random_bytes::<16>()?.to_vec(),
480 };
481 let challenge = secret::random_bytes::<32>()?;
482 let exclude: Vec<Value> = self
483 .store
484 .list_passkeys(uid)?
485 .into_iter()
486 .map(|k| json!({"type": "public-key", "id": k.credential_id, "transports": k.transports}))
487 .collect();
488 let now = self.store.now();
489 self.challenges.insert(
490 b64(&challenge),
491 now + CHALLENGE_TTL,
492 Challenge::Register {
493 user_id: uid,
494 handle: handle.clone(),
495 },
496 now,
497 )?;
498 let display = if p.user.name.is_empty() {
499 &p.user.email
500 } else {
501 &p.user.name
502 };
503 let params: Vec<Value> = webauthn::ALGORITHMS
504 .iter()
505 .map(|a| json!({"type": "public-key", "alg": a}))
506 .collect();
507 Ok(Response::json(
508 200,
509 &json!({"publicKey": {
510 "rp": {"id": rp.id, "name": rp.name},
511 "user": {"id": b64(&handle), "name": p.user.email, "displayName": display},
512 "challenge": b64(&challenge),
513 "pubKeyCredParams": params,
514 "timeout": CHALLENGE_TTL * 1000,
515 "attestation": "none",
516 "authenticatorSelection": {
517 "residentKey": "required",
518 "requireResidentKey": true,
519 "userVerification": "required",
520 },
521 "excludeCredentials": exclude,
522 }}),
523 ))
524 }
525
526 pub(super) fn passkey_register_verify(
527 &self,
528 req: &Request,
529 p: &Principal,
530 ) -> Result<Response, AuthError> {
531 #[derive(Deserialize)]
532 struct Resp {
533 #[serde(rename = "clientDataJSON")]
534 client_data: String,
535 #[serde(rename = "attestationObject")]
536 attestation: String,
537 #[serde(default)]
538 transports: Vec<String>,
539 }
540 #[derive(Deserialize)]
541 struct Cred {
542 response: Resp,
543 }
544 #[derive(Deserialize)]
545 struct B {
546 #[serde(default)]
547 name: Option<String>,
548 credential: Cred,
549 }
550 let rp = self.rp()?;
551 let uid = session_only(p, "add a passkey")?;
552 let b: B = super::body(req)?;
553 let reject = |e: String| AuthError::PasskeyRejected(e);
554 let cd = unb64(&b.credential.response.client_data)
555 .map_err(|e| reject(format!("clientDataJSON: {e}")))?;
556 let att = unb64(&b.credential.response.attestation)
557 .map_err(|e| reject(format!("attestationObject: {e}")))?;
558 let challenge = webauthn::client_challenge(&cd)
559 .ok_or_else(|| reject("clientDataJSON has no challenge".into()))?;
560 let handle = match self.challenges.take(&b64(&challenge), self.store.now()) {
561 Some(Challenge::Register { user_id, handle }) if user_id == uid => handle,
562 _ => {
563 return Err(reject(
564 "the challenge is unknown, used or expired; start again".into(),
565 ));
566 }
567 };
568 let reg = webauthn::verify_registration(rp, &challenge, &cd, &att, true).map_err(reject)?;
569 let name = b
570 .name
571 .filter(|n| !n.trim().is_empty())
572 .unwrap_or_else(|| "Passkey".into());
573 let k =
574 self.store
575 .add_passkey(uid, &handle, ®, &name, &b.credential.response.transports)?;
576 Ok(Response::json(201, &json!({"passkey": k})))
577 }
578
579 pub(super) fn passkey_login_options(&self, req: &Request) -> Result<Response, AuthError> {
580 #[derive(Deserialize, Default)]
581 struct B {
582 #[serde(default)]
583 email: Option<String>,
584 }
585 let rp = self.rp()?;
586 self.store.limit_ip(client_ip(req).as_deref())?;
587 let b: B = body_or_default(req)?;
588 let user = match b.email.as_deref().filter(|e| !e.trim().is_empty()) {
589 Some(e) => self.store.user_by_email(e)?,
590 None => None,
591 };
592 let allow: Vec<Value> = match &user {
593 Some(u) => self
594 .store
595 .list_passkeys(u.id)?
596 .into_iter()
597 .map(|k| json!({"type": "public-key", "id": k.credential_id, "transports": k.transports}))
598 .collect(),
599 None => vec![],
600 };
601 let challenge = secret::random_bytes::<32>()?;
602 let now = self.store.now();
603 self.challenges.insert(
604 b64(&challenge),
605 now + CHALLENGE_TTL,
606 Challenge::Login {
607 user_id: user.map(|u| u.id),
608 },
609 now,
610 )?;
611 Ok(Response::json(
612 200,
613 &json!({"publicKey": {
614 "challenge": b64(&challenge),
615 "rpId": rp.id,
616 "timeout": CHALLENGE_TTL * 1000,
617 "userVerification": "required",
618 "allowCredentials": allow,
619 }}),
620 ))
621 }
622
623 pub(super) fn passkey_login_verify(&self, req: &Request) -> Result<Response, AuthError> {
624 #[derive(Deserialize)]
625 struct Resp {
626 #[serde(rename = "clientDataJSON")]
627 client_data: String,
628 #[serde(rename = "authenticatorData")]
629 authenticator_data: String,
630 signature: String,
631 #[serde(default, rename = "userHandle")]
632 user_handle: Option<String>,
633 }
634 #[derive(Deserialize)]
635 struct Cred {
636 #[serde(default)]
637 id: Option<String>,
638 #[serde(default, rename = "rawId")]
639 raw_id: Option<String>,
640 response: Resp,
641 }
642 #[derive(Deserialize)]
643 struct B {
644 credential: Cred,
645 }
646 let rp = self.rp()?;
647 self.store.limit_ip(client_ip(req).as_deref())?;
648 let b: B = super::body(req)?;
649 let reject = |e: &str| AuthError::PasskeyRejected(e.to_string());
650 let r = &b.credential.response;
651 let cred_id = b
652 .credential
653 .raw_id
654 .as_deref()
655 .or(b.credential.id.as_deref())
656 .and_then(|s| unb64(s).ok())
657 .ok_or_else(|| reject("the credential has no id"))?;
658 let cd = unb64(&r.client_data).map_err(|_| reject("clientDataJSON is not base64url"))?;
659 let ad = unb64(&r.authenticator_data)
660 .map_err(|_| reject("authenticatorData is not base64url"))?;
661 let sig = unb64(&r.signature).map_err(|_| reject("signature is not base64url"))?;
662 let challenge = webauthn::client_challenge(&cd)
663 .ok_or_else(|| reject("clientDataJSON has no challenge"))?;
664 let bound = match self.challenges.take(&b64(&challenge), self.store.now()) {
665 Some(Challenge::Login { user_id }) => user_id,
666 _ => {
667 return Err(reject(
668 "the challenge is unknown, used or expired; start again",
669 ));
670 }
671 };
672 let stored = self
673 .store
674 .passkey_by_credential(&cred_id)?
675 .ok_or_else(|| reject("this passkey is not registered here"))?;
676 let k = &stored.passkey;
677 if bound.is_some_and(|u| u != k.user_id) {
678 return Err(reject("this passkey belongs to another account"));
679 }
680 if let Some(h) = r.user_handle.as_deref().filter(|h| !h.is_empty()) {
681 let h = unb64(h).map_err(|_| reject("userHandle is not base64url"))?;
682 if !secret::ct_eq(&h, &stored.user_handle) {
683 return Err(reject("the user handle does not match the passkey"));
684 }
685 }
686 let new_count = webauthn::verify_assertion(
687 rp,
688 &challenge,
689 &stored.public_key,
690 k.sign_count,
691 &cd,
692 &ad,
693 &sig,
694 true,
695 )
696 .map_err(|e| {
697 eprintln!("isb serve: passkey {} of user {}: {e}", k.id, k.user_id);
698 AuthError::PasskeyRejected(e)
699 })?;
700 if self.store.user(k.user_id)?.disabled {
701 return Err(reject("this account is disabled"));
702 }
703 self.store.use_passkey(k.id, k.sign_count, new_count)?;
704 let s = self.store.start_session(k.user_id, meta(req))?;
705 self.session_response(req, 200, &s)
706 }
707
708 pub(super) fn passkeys(&self, p: &Principal) -> Result<Response, AuthError> {
709 let list = self.store.list_passkeys(p.user.id)?;
710 Ok(Response::json(200, &json!({"passkeys": list})))
711 }
712
713 pub(super) fn delete_passkey(&self, p: &Principal, id: &str) -> Result<Response, AuthError> {
714 let uid = session_only(p, "remove a passkey")?;
715 let id = parse_id(id)?;
716 if !self.store.delete_passkey(uid, id)? {
717 return Err(AuthError::NotFound(format!("passkey {id}")));
718 }
719 Ok(Response::new(204))
720 }
721}
722
723#[cfg(test)]
724mod tests;