Skip to main content

isb_server/auth/
oauth.rs

1//! External sign-in providers: GitHub (OAuth 2.0), Google and generic OpenID
2//! Connect, all with the authorization code flow, PKCE (S256), and a state
3//! value bound to the browser that started the flow (see [`super::http`]).
4//!
5//! - **OIDC** (Google and generic): the discovery document and JWKS are
6//!   fetched on first use and cached for an hour; an ID token signed with an
7//!   unknown key id triggers one JWKS refetch (at most every 10s). The ID
8//!   token is verified ([`super::oidc`]); when it carries no email, the
9//!   userinfo endpoint is asked (its `sub` must match).
10//! - **GitHub**: the token endpoint, then `/user` and `/user/emails`; only
11//!   the primary email counts, and only if GitHub has verified it.
12//!
13//! HTTP is blocking `ureq` over rustls, 10s per request, 1 MiB per answer.
14//! Every provider URL must be https, except loopback (tests, local IdPs).
15
16use 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
31/// Reads a client secret when it is needed (from the secrets store), so a
32/// rotated secret applies without a restart.
33pub 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        /// `https://github.com`, or a GitHub Enterprise Server.
65        web_url: String,
66        /// `https://api.github.com`, or `<server>/api/v3`.
67        api_url: String,
68    },
69    Oidc {
70        issuer: String,
71    },
72}
73
74/// One configured provider.
75#[derive(Debug, Clone)]
76pub struct ProviderConfig {
77    /// `github`, `google` or `oidc`: the URL segment and the button's id.
78    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    /// `oauth2` or `oidc`, for the login page.
141    pub fn kind_name(&self) -> &'static str {
142        match self.kind {
143            Kind::GitHub { .. } => "oauth2",
144            Kind::Oidc { .. } => "oidc",
145        }
146    }
147
148    /// The `provider` recorded on identities. A generic OIDC provider is
149    /// recorded with its issuer, so pointing `ISB_OIDC_ISSUER` elsewhere can
150    /// never sign anyone into an account by a colliding subject.
151    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/// Raw provider settings from flags and `serve.env`, before secrets are
174/// resolved. Client secrets come only from the environment (never argv) or
175/// the secrets store.
176#[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
201/// The secret names looked up in the default org when the variable is unset.
202pub 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    /// The client secret variables, read from the environment.
208    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    /// Build the enabled providers. `lookup(name)` returns a reader when a
217    /// secret of that name exists in the default org. A provider with a
218    /// client id but no secret is left out, with a note saying why.
219    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
293/// A provider at run time: its config and its OIDC caches.
294pub 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
306/// PKCE: a verifier (43 base64url characters of randomness) and its S256
307/// challenge.
308pub 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    /// The JWKS, cached; `refresh` fetches again unless that happened in
342    /// the last 10s.
343    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    /// Where to send the browser.
366    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    /// Trade the code for the user's identity.
394    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        // Only the primary address, and only once GitHub has verified it.
489        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
530/// https, or http to a loopback host.
531pub 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        // OAuth errors are JSON {error, error_description}; say which.
584        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        // RFC 6749 2.3.1: both halves are form-encoded before base64.
626        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
636/// Percent-encode everything but RFC 3986 unreserved characters.
637pub 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
649/// `k=v&k=v`, encoded.
650pub 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
680/// A query string's pairs, decoded.
681pub 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}