1use std::sync::{Arc, Mutex};
17use std::time::{Duration, Instant};
18
19use base64::Engine;
20use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
21use serde_json::Value;
22
23use super::external::ExternalIdentity;
24use super::oidc::{self, Discovery, TokenError};
25
26const CACHE_TTL: Duration = Duration::from_secs(3600);
27const JWKS_REFETCH_MIN: Duration = Duration::from_secs(10);
28const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
29const MAX_BODY: u64 = 1 << 20;
30
31pub type SecretFn = Arc<dyn Fn() -> Result<String, String> + Send + Sync>;
34
35#[derive(Clone)]
36pub enum ClientSecret {
37 Value(String),
38 Lookup { name: String, read: SecretFn },
39}
40
41impl ClientSecret {
42 fn get(&self) -> Result<String, String> {
43 match self {
44 ClientSecret::Value(v) => Ok(v.clone()),
45 ClientSecret::Lookup { name, read } => {
46 read().map_err(|e| format!("read client secret {name} from the secrets store: {e}"))
47 }
48 }
49 }
50}
51
52impl std::fmt::Debug for ClientSecret {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 match self {
55 ClientSecret::Value(_) => f.write_str("Value(<redacted>)"),
56 ClientSecret::Lookup { name, .. } => write!(f, "Lookup({name})"),
57 }
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum Kind {
63 GitHub {
64 web_url: String,
66 api_url: String,
68 },
69 Oidc {
70 issuer: String,
71 },
72}
73
74#[derive(Debug, Clone)]
76pub struct ProviderConfig {
77 pub id: String,
79 pub label: String,
80 pub kind: Kind,
81 pub client_id: String,
82 pub client_secret: ClientSecret,
83 pub scopes: String,
84}
85
86impl ProviderConfig {
87 pub fn github(client_id: &str, secret: ClientSecret) -> Self {
88 Self::github_at(
89 client_id,
90 secret,
91 "https://github.com",
92 "https://api.github.com",
93 )
94 }
95
96 pub fn github_at(client_id: &str, secret: ClientSecret, web: &str, api: &str) -> Self {
97 ProviderConfig {
98 id: "github".into(),
99 label: "GitHub".into(),
100 kind: Kind::GitHub {
101 web_url: web.trim_end_matches('/').into(),
102 api_url: api.trim_end_matches('/').into(),
103 },
104 client_id: client_id.into(),
105 client_secret: secret,
106 scopes: "read:user user:email".into(),
107 }
108 }
109
110 pub fn google(client_id: &str, secret: ClientSecret) -> Self {
111 ProviderConfig {
112 id: "google".into(),
113 label: "Google".into(),
114 kind: Kind::Oidc {
115 issuer: "https://accounts.google.com".into(),
116 },
117 client_id: client_id.into(),
118 client_secret: secret,
119 scopes: "openid email profile".into(),
120 }
121 }
122
123 pub fn oidc(issuer: &str, client_id: &str, secret: ClientSecret, name: Option<&str>) -> Self {
124 ProviderConfig {
125 id: "oidc".into(),
126 label: name
127 .map(str::trim)
128 .filter(|n| !n.is_empty())
129 .unwrap_or("SSO")
130 .into(),
131 kind: Kind::Oidc {
132 issuer: issuer.trim().trim_end_matches('/').into(),
133 },
134 client_id: client_id.into(),
135 client_secret: secret,
136 scopes: "openid email profile".into(),
137 }
138 }
139
140 pub fn kind_name(&self) -> &'static str {
142 match self.kind {
143 Kind::GitHub { .. } => "oauth2",
144 Kind::Oidc { .. } => "oidc",
145 }
146 }
147
148 pub fn identity_provider(&self) -> String {
152 match &self.kind {
153 Kind::Oidc { issuer } if self.id == "oidc" => format!("oidc:{issuer}"),
154 _ => self.id.clone(),
155 }
156 }
157
158 pub fn check(&self) -> Result<(), String> {
159 if self.client_id.trim().is_empty() {
160 return Err(format!("{}: client id is empty", self.id));
161 }
162 match &self.kind {
163 Kind::GitHub { web_url, api_url } => {
164 check_url(web_url)?;
165 check_url(api_url)
166 }
167 Kind::Oidc { issuer } => check_url(issuer),
168 }
169 .map_err(|e| format!("{}: {e}", self.id))
170 }
171}
172
173#[derive(Clone, Default)]
177pub struct OAuthSettings {
178 pub github_client_id: Option<String>,
179 pub github_client_secret: Option<String>,
180 pub github_url: Option<String>,
181 pub github_api_url: Option<String>,
182 pub google_client_id: Option<String>,
183 pub google_client_secret: Option<String>,
184 pub oidc_issuer: Option<String>,
185 pub oidc_client_id: Option<String>,
186 pub oidc_client_secret: Option<String>,
187 pub oidc_name: Option<String>,
188}
189
190impl std::fmt::Debug for OAuthSettings {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 f.debug_struct("OAuthSettings")
193 .field("github_client_id", &self.github_client_id)
194 .field("google_client_id", &self.google_client_id)
195 .field("oidc_issuer", &self.oidc_issuer)
196 .field("oidc_client_id", &self.oidc_client_id)
197 .finish_non_exhaustive()
198 }
199}
200
201pub const GITHUB_SECRET: &str = "ISB_GITHUB_CLIENT_SECRET";
203pub const GOOGLE_SECRET: &str = "ISB_GOOGLE_CLIENT_SECRET";
204pub const OIDC_SECRET: &str = "ISB_OIDC_CLIENT_SECRET";
205
206impl OAuthSettings {
207 pub fn secrets_from_env(mut self) -> Self {
209 let get = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
210 self.github_client_secret = get(GITHUB_SECRET);
211 self.google_client_secret = get(GOOGLE_SECRET);
212 self.oidc_client_secret = get(OIDC_SECRET);
213 self
214 }
215
216 pub fn providers(
220 &self,
221 lookup: &dyn Fn(&str) -> Option<SecretFn>,
222 ) -> (Vec<ProviderConfig>, Vec<String>) {
223 let mut out = Vec::new();
224 let mut notes = Vec::new();
225 let nonempty = |s: &Option<String>| {
226 s.as_deref()
227 .map(str::trim)
228 .filter(|s| !s.is_empty())
229 .map(str::to_string)
230 };
231 let secret = |v: &Option<String>, name: &str| -> Option<ClientSecret> {
232 match nonempty(v) {
233 Some(v) => Some(ClientSecret::Value(v)),
234 None => lookup(name).map(|read| ClientSecret::Lookup {
235 name: name.into(),
236 read,
237 }),
238 }
239 };
240 let mut add = |p: Option<ProviderConfig>, what: &str, secret_name: &str| {
241 match p {
242 Some(p) => match p.check() {
243 Ok(()) => out.push(p),
244 Err(e) => notes.push(format!("{what} sign-in is off: {e}")),
245 },
246 None => notes.push(format!(
247 "{what} sign-in is off: set {secret_name} in serve.env or `isb secret create {secret_name}` in the default org"
248 )),
249 }
250 };
251 if let Some(id) = nonempty(&self.github_client_id) {
252 let p = secret(&self.github_client_secret, GITHUB_SECRET).map(|s| {
253 ProviderConfig::github_at(
254 &id,
255 s,
256 nonempty(&self.github_url)
257 .as_deref()
258 .unwrap_or("https://github.com"),
259 nonempty(&self.github_api_url)
260 .as_deref()
261 .unwrap_or("https://api.github.com"),
262 )
263 });
264 add(p, "GitHub", GITHUB_SECRET);
265 }
266 if let Some(id) = nonempty(&self.google_client_id) {
267 let p = secret(&self.google_client_secret, GOOGLE_SECRET)
268 .map(|s| ProviderConfig::google(&id, s));
269 add(p, "Google", GOOGLE_SECRET);
270 }
271 match (nonempty(&self.oidc_issuer), nonempty(&self.oidc_client_id)) {
272 (Some(iss), Some(id)) => {
273 let p = secret(&self.oidc_client_secret, OIDC_SECRET)
274 .map(|s| ProviderConfig::oidc(&iss, &id, s, self.oidc_name.as_deref()));
275 add(p, "OIDC", OIDC_SECRET);
276 }
277 (Some(_), None) | (None, Some(_)) => notes.push(
278 "OIDC sign-in is off: it needs both ISB_OIDC_ISSUER and ISB_OIDC_CLIENT_ID".into(),
279 ),
280 (None, None) => {}
281 }
282 (out, notes)
283 }
284}
285
286#[derive(Default)]
287struct OidcCache {
288 discovery: Option<(Discovery, Instant)>,
289 jwks: Option<(oidc::Keys, Instant)>,
290 last_jwks_fetch: Option<Instant>,
291}
292
293pub struct Provider {
295 pub cfg: ProviderConfig,
296 cache: Mutex<OidcCache>,
297 clock: super::Clock,
298}
299
300impl std::fmt::Debug for Provider {
301 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302 f.debug_struct("Provider").field("cfg", &self.cfg).finish()
303 }
304}
305
306pub fn pkce() -> Result<(String, String), super::AuthError> {
309 let raw: [u8; 32] = super::secret::random_bytes()?;
310 let verifier = URL_SAFE_NO_PAD.encode(raw);
311 let challenge = URL_SAFE_NO_PAD.encode(super::webauthn::sha256(verifier.as_bytes()));
312 Ok((verifier, challenge))
313}
314
315impl Provider {
316 pub fn new(cfg: ProviderConfig, clock: super::Clock) -> Provider {
317 Provider {
318 cfg,
319 cache: Mutex::new(OidcCache::default()),
320 clock,
321 }
322 }
323
324 fn cache(&self) -> std::sync::MutexGuard<'_, OidcCache> {
325 self.cache.lock().unwrap_or_else(|e| e.into_inner())
326 }
327
328 fn discovery(&self, issuer: &str) -> Result<Discovery, String> {
329 if let Some((d, at)) = &self.cache().discovery {
330 if at.elapsed() < CACHE_TTL {
331 return Ok(d.clone());
332 }
333 }
334 let url = format!("{issuer}/.well-known/openid-configuration");
335 let body = http_get(&url, None).map_err(|e| format!("discovery {url}: {e}"))?;
336 let d = Discovery::parse(&body, issuer)?;
337 self.cache().discovery = Some((d.clone(), Instant::now()));
338 Ok(d)
339 }
340
341 fn jwks(&self, d: &Discovery, refresh: bool) -> Result<oidc::Keys, String> {
344 {
345 let c = self.cache();
346 let fresh = c.jwks.as_ref().filter(|(_, at)| at.elapsed() < CACHE_TTL);
347 let recent = c
348 .last_jwks_fetch
349 .is_some_and(|t| t.elapsed() < JWKS_REFETCH_MIN);
350 match fresh {
351 Some((k, _)) if !refresh || recent => return Ok(k.clone()),
352 _ => {}
353 }
354 if refresh && recent {
355 return Err("the ID token's signing key is not published".into());
356 }
357 }
358 self.cache().last_jwks_fetch = Some(Instant::now());
359 let body = http_get(&d.jwks_uri, None).map_err(|e| format!("JWKS {}: {e}", d.jwks_uri))?;
360 let keys = oidc::parse_jwks(&body)?;
361 self.cache().jwks = Some((keys.clone(), Instant::now()));
362 Ok(keys)
363 }
364
365 pub fn authorize_url(
367 &self,
368 redirect_uri: &str,
369 state: &str,
370 nonce: &str,
371 code_challenge: &str,
372 ) -> Result<String, String> {
373 let (endpoint, oidc) = match &self.cfg.kind {
374 Kind::GitHub { web_url, .. } => (format!("{web_url}/login/oauth/authorize"), false),
375 Kind::Oidc { issuer } => (self.discovery(issuer)?.authorization_endpoint, true),
376 };
377 let mut q = vec![
378 ("response_type", "code"),
379 ("client_id", self.cfg.client_id.as_str()),
380 ("redirect_uri", redirect_uri),
381 ("scope", self.cfg.scopes.as_str()),
382 ("state", state),
383 ("code_challenge", code_challenge),
384 ("code_challenge_method", "S256"),
385 ];
386 if oidc {
387 q.push(("nonce", nonce));
388 }
389 let sep = if endpoint.contains('?') { '&' } else { '?' };
390 Ok(format!("{endpoint}{sep}{}", form(&q)))
391 }
392
393 pub fn exchange(
395 &self,
396 code: &str,
397 redirect_uri: &str,
398 verifier: &str,
399 nonce: &str,
400 ) -> Result<ExternalIdentity, String> {
401 let secret = self.cfg.client_secret.get()?;
402 match &self.cfg.kind {
403 Kind::GitHub { web_url, api_url } => {
404 let tok = http_post_form(
405 &format!("{web_url}/login/oauth/access_token"),
406 &[
407 ("client_id", self.cfg.client_id.as_str()),
408 ("client_secret", secret.as_str()),
409 ("code", code),
410 ("redirect_uri", redirect_uri),
411 ("code_verifier", verifier),
412 ],
413 None,
414 )?;
415 let access = access_token(&tok)?;
416 self.github_identity(api_url, &access)
417 }
418 Kind::Oidc { issuer } => {
419 let d = self.discovery(issuer)?;
420 let mut fields = vec![
421 ("grant_type", "authorization_code"),
422 ("code", code),
423 ("redirect_uri", redirect_uri),
424 ("code_verifier", verifier),
425 ];
426 let basic = if d.prefers_post() {
427 fields.push(("client_id", self.cfg.client_id.as_str()));
428 fields.push(("client_secret", secret.as_str()));
429 None
430 } else {
431 Some((self.cfg.client_id.as_str(), secret.as_str()))
432 };
433 let tok = http_post_form(&d.token_endpoint, &fields, basic)?;
434 let id_token = tok
435 .get("id_token")
436 .and_then(Value::as_str)
437 .ok_or("the token response has no id_token")?;
438 let claims = self.verify(&d, id_token, nonce)?;
439 let mut ext = ExternalIdentity {
440 provider: self.cfg.identity_provider(),
441 subject: claims.sub.clone(),
442 email: claims.email.clone(),
443 email_verified: claims.email_verified,
444 name: claims.name.clone(),
445 };
446 if ext.email.is_none() {
447 if let Some(ui) = &d.userinfo_endpoint {
448 let access = access_token(&tok)?;
449 let info = http_get_json(ui, Some(&access))?;
450 if info.get("sub").and_then(Value::as_str) != Some(claims.sub.as_str()) {
451 return Err("userinfo is about another subject".into());
452 }
453 ext.email = info
454 .get("email")
455 .and_then(Value::as_str)
456 .map(str::to_string);
457 ext.email_verified = oidc::truthy(info.get("email_verified"));
458 if ext.name.is_none() {
459 ext.name = info.get("name").and_then(Value::as_str).map(str::to_string);
460 }
461 }
462 }
463 Ok(ext)
464 }
465 }
466 }
467
468 fn verify(&self, d: &Discovery, id_token: &str, nonce: &str) -> Result<oidc::IdClaims, String> {
469 let now = (self.clock)();
470 let keys = self.jwks(d, false)?;
471 match oidc::verify_id_token(id_token, &keys, &d.issuer, &self.cfg.client_id, nonce, now) {
472 Err(TokenError::UnknownKey) => {
473 let keys = self.jwks(d, true)?;
474 oidc::verify_id_token(id_token, &keys, &d.issuer, &self.cfg.client_id, nonce, now)
475 .map_err(|e| e.to_string())
476 }
477 r => r.map_err(|e| e.to_string()),
478 }
479 }
480
481 fn github_identity(&self, api: &str, token: &str) -> Result<ExternalIdentity, String> {
482 let user = http_get_json(&format!("{api}/user"), Some(token))?;
483 let id = match user.get("id") {
484 Some(Value::Number(n)) => n.to_string(),
485 _ => return Err("GitHub /user has no id".into()),
486 };
487 let emails = http_get_json(&format!("{api}/user/emails"), Some(token))?;
488 let primary = emails.as_array().and_then(|a| {
490 a.iter()
491 .find(|e| e.get("primary").and_then(Value::as_bool) == Some(true))
492 });
493 let (email, verified) = match primary {
494 Some(e) => (
495 e.get("email").and_then(Value::as_str).map(str::to_string),
496 e.get("verified").and_then(Value::as_bool) == Some(true),
497 ),
498 None => (None, false),
499 };
500 let name = user
501 .get("name")
502 .and_then(Value::as_str)
503 .filter(|s| !s.is_empty())
504 .or_else(|| user.get("login").and_then(Value::as_str))
505 .map(str::to_string);
506 Ok(ExternalIdentity {
507 provider: self.cfg.identity_provider(),
508 subject: id,
509 email,
510 email_verified: verified,
511 name,
512 })
513 }
514}
515
516fn access_token(tok: &Value) -> Result<String, String> {
517 if let Some(e) = tok.get("error").and_then(Value::as_str) {
518 let d = tok
519 .get("error_description")
520 .and_then(Value::as_str)
521 .unwrap_or("");
522 return Err(format!("token endpoint: {e} {d}").trim().to_string());
523 }
524 tok.get("access_token")
525 .and_then(Value::as_str)
526 .map(str::to_string)
527 .ok_or_else(|| "the token response has no access_token".into())
528}
529
530pub fn check_url(u: &str) -> Result<(), String> {
532 let (scheme, rest) = u
533 .split_once("://")
534 .ok_or_else(|| format!("{u:?} is not a URL"))?;
535 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
536 let host = if let Some(v6) = authority.strip_prefix('[') {
537 v6.split(']').next().unwrap_or("")
538 } else {
539 authority
540 .rsplit('@')
541 .next()
542 .unwrap_or("")
543 .split(':')
544 .next()
545 .unwrap_or("")
546 };
547 if host.is_empty() || authority.contains('@') {
548 return Err(format!("{u:?} has no usable host"));
549 }
550 match scheme.to_ascii_lowercase().as_str() {
551 "https" => Ok(()),
552 "http"
553 if host.eq_ignore_ascii_case("localhost")
554 || host
555 .parse::<std::net::IpAddr>()
556 .is_ok_and(|ip| ip.is_loopback()) =>
557 {
558 Ok(())
559 }
560 _ => Err(format!("{u:?} must use https")),
561 }
562}
563
564fn agent() -> ureq::Agent {
565 ureq::Agent::config_builder()
566 .timeout_global(Some(HTTP_TIMEOUT))
567 .http_status_as_error(false)
568 .max_redirects(3)
569 .user_agent(concat!("isb/", env!("CARGO_PKG_VERSION")))
570 .build()
571 .into()
572}
573
574fn read(mut resp: ureq::http::Response<ureq::Body>) -> Result<Vec<u8>, String> {
575 let status = resp.status().as_u16();
576 let body = resp
577 .body_mut()
578 .with_config()
579 .limit(MAX_BODY)
580 .read_to_vec()
581 .map_err(|e| e.to_string())?;
582 if !(200..300).contains(&status) {
583 let detail = serde_json::from_slice::<Value>(&body)
585 .ok()
586 .and_then(|v| {
587 let e = v.get("error")?.as_str()?.to_string();
588 let d = v
589 .get("error_description")
590 .and_then(Value::as_str)
591 .unwrap_or("");
592 Some(format!(": {e} {d}").trim_end().to_string())
593 })
594 .unwrap_or_default();
595 return Err(format!("HTTP {status}{detail}"));
596 }
597 Ok(body)
598}
599
600fn http_get(url: &str, bearer: Option<&str>) -> Result<Vec<u8>, String> {
601 check_url(url)?;
602 let mut r = agent()
603 .get(url)
604 .header("Accept", "application/json")
605 .header("X-GitHub-Api-Version", "2022-11-28");
606 if let Some(t) = bearer {
607 r = r.header("Authorization", &format!("Bearer {t}"));
608 }
609 read(r.call().map_err(|e| e.to_string())?)
610}
611
612fn http_get_json(url: &str, bearer: Option<&str>) -> Result<Value, String> {
613 let b = http_get(url, bearer)?;
614 serde_json::from_slice(&b).map_err(|e| format!("{url}: {e}"))
615}
616
617fn http_post_form(
618 url: &str,
619 fields: &[(&str, &str)],
620 basic: Option<(&str, &str)>,
621) -> Result<Value, String> {
622 check_url(url)?;
623 let mut r = agent().post(url).header("Accept", "application/json");
624 if let Some((id, secret)) = basic {
625 let cred = STANDARD.encode(format!("{}:{}", enc(id), enc(secret)));
627 r = r.header("Authorization", &format!("Basic {cred}"));
628 }
629 let resp = r
630 .send_form(fields.iter().copied())
631 .map_err(|e| format!("{url}: {e}"))?;
632 let b = read(resp).map_err(|e| format!("{url}: {e}"))?;
633 serde_json::from_slice(&b).map_err(|e| format!("{url}: {e}"))
634}
635
636pub fn enc(s: &str) -> String {
638 let mut o = String::with_capacity(s.len());
639 for b in s.bytes() {
640 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
641 o.push(b as char);
642 } else {
643 o.push_str(&format!("%{b:02X}"));
644 }
645 }
646 o
647}
648
649pub fn form(pairs: &[(&str, &str)]) -> String {
651 pairs
652 .iter()
653 .map(|(k, v)| format!("{}={}", enc(k), enc(v)))
654 .collect::<Vec<_>>()
655 .join("&")
656}
657
658fn dec(s: &str) -> String {
659 let b = s.as_bytes();
660 let mut out = Vec::with_capacity(b.len());
661 let mut i = 0;
662 let hex = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
663 while i < b.len() {
664 match b[i] {
665 b'+' => out.push(b' '),
666 b'%' if i + 2 < b.len() => match (hex(b[i + 1]), hex(b[i + 2])) {
667 (Some(h), Some(l)) => {
668 out.push(h << 4 | l);
669 i += 2;
670 }
671 _ => out.push(b'%'),
672 },
673 c => out.push(c),
674 }
675 i += 1;
676 }
677 String::from_utf8_lossy(&out).into_owned()
678}
679
680pub fn parse_query(q: &str) -> Vec<(String, String)> {
682 q.split('&')
683 .filter(|p| !p.is_empty())
684 .map(|p| {
685 let (k, v) = p.split_once('=').unwrap_or((p, ""));
686 (dec(k), dec(v))
687 })
688 .collect()
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 #[test]
696 fn query_round_trip() {
697 let s = form(&[("next", "/a b?c=d&e"), ("x", "é/~")]);
698 assert_eq!(s, "next=%2Fa%20b%3Fc%3Dd%26e&x=%C3%A9%2F~");
699 let q = parse_query(&format!("{s}&flag&plus=a+b&bad=%zz&end=%4"));
700 assert_eq!(q[0], ("next".into(), "/a b?c=d&e".into()));
701 assert_eq!(q[1], ("x".into(), "é/~".into()));
702 assert_eq!(q[2], ("flag".into(), "".into()));
703 assert_eq!(q[3], ("plus".into(), "a b".into()));
704 assert_eq!(q[4], ("bad".into(), "%zz".into()));
705 assert_eq!(q[5], ("end".into(), "%4".into()));
706 }
707
708 #[test]
709 fn urls_must_be_https_unless_loopback() {
710 assert!(check_url("https://accounts.google.com").is_ok());
711 assert!(check_url("http://127.0.0.1:9000/x").is_ok());
712 assert!(check_url("http://localhost/x").is_ok());
713 assert!(check_url("http://[::1]:80/").is_ok());
714 assert!(check_url("http://id.example.com").is_err());
715 assert!(check_url("http://127.0.0.1@evil.com/").is_err());
716 assert!(check_url("ftp://x").is_err());
717 assert!(check_url("nonsense").is_err());
718 }
719
720 #[test]
721 fn settings_build_providers() {
722 let s = OAuthSettings {
723 github_client_id: Some("gh".into()),
724 github_client_secret: Some("ghs".into()),
725 google_client_id: Some("gg".into()),
726 oidc_issuer: Some("https://id.example.com/".into()),
727 oidc_client_id: Some("oc".into()),
728 oidc_name: Some("Okta".into()),
729 ..Default::default()
730 };
731 let found = |n: &str| -> Option<SecretFn> {
732 (n == OIDC_SECRET).then(|| Arc::new(|| Ok("from-store".to_string())) as SecretFn)
733 };
734 let (p, notes) = s.providers(&found);
735 let ids: Vec<&str> = p.iter().map(|p| p.id.as_str()).collect();
736 assert_eq!(ids, ["github", "oidc"]);
737 assert!(
738 notes[0].contains("Google") && notes[0].contains(GOOGLE_SECRET),
739 "{notes:?}"
740 );
741 assert_eq!(p[1].label, "Okta");
742 assert_eq!(p[1].identity_provider(), "oidc:https://id.example.com");
743 assert_eq!(p[1].client_secret.get().unwrap(), "from-store");
744 assert_eq!(p[0].identity_provider(), "github");
745 assert!(!format!("{:?}", p[0]).contains("ghs"));
746 let (p, notes) = OAuthSettings {
747 oidc_issuer: Some("http://id.example.com".into()),
748 oidc_client_id: Some("oc".into()),
749 oidc_client_secret: Some("x".into()),
750 ..Default::default()
751 }
752 .providers(&|_| None);
753 assert!(p.is_empty() && notes[0].contains("https"), "{notes:?}");
754 }
755
756 #[test]
757 fn pkce_is_s256() {
758 let (v, c) = pkce().unwrap();
759 assert_eq!(v.len(), 43);
760 assert_eq!(
761 c,
762 URL_SAFE_NO_PAD.encode(super::super::webauthn::sha256(v.as_bytes()))
763 );
764 }
765}