Skip to main content

tablo_core/auth/oidc/
provider.rs

1//! [`OidcProvider`]: one OpenID Connect provider, the requests a panel sends it, and the checks
2//! its ID token passes before the panel reads who it names.
3
4use std::{sync::Arc, time::Duration};
5
6use data_encoding::{BASE64, BASE64URL_NOPAD};
7use serde::Deserialize;
8use sha2::{Digest, Sha256};
9
10use super::ProviderIdentity;
11use crate::Text;
12
13/// How long a request to a provider may take before the sign-in reads as unavailable.
14const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
15
16/// The most bytes a provider's answer may hold: a discovery document or a token response is a
17/// few kilobytes.
18const MAX_RESPONSE_BYTES: u64 = 256 * 1024;
19
20/// How far a provider's clock may run from the panel's when an ID token says when it was
21/// issued and when it expires.
22const CLOCK_SKEW_SECS: i64 = 300;
23
24/// What the panel asks every provider for: an ID token, the account's email and its name.
25const SCOPES: &str = "openid email profile";
26
27/// The authorization parameters the panel sets itself, which [`OidcProvider::param`] may not.
28pub(super) const RESERVED_PARAMS: [&str; 8] = [
29    "response_type",
30    "client_id",
31    "redirect_uri",
32    "scope",
33    "state",
34    "nonce",
35    "code_challenge",
36    "code_challenge_method",
37];
38
39/// One OpenID Connect provider a panel signs users in through, installed with
40/// [`Oidc::provider`](super::Oidc::provider).
41///
42/// [`OidcProvider::google`] is Google. [`OidcProvider::new`] is any other provider that publishes
43/// a discovery document at `{issuer}/.well-known/openid-configuration` and returns its ID token
44/// from the token endpoint: the panel reads the endpoints from that document on the first sign-in
45/// and keeps them.
46///
47/// The panel registers `{base_url}{prefix}/sign-in/{key}/callback` as its redirect URI, where
48/// `base_url` is [`Oidc::new`](super::Oidc::new)'s; register the same URI with the provider.
49///
50/// The panel authenticates to the token endpoint with HTTP Basic (`client_secret_basic`), which
51/// every provider supports. A multi-tenant issuer whose discovery document names a placeholder
52/// issuer, such as Microsoft's `common` endpoint, fails the discovery check: name the tenant's
53/// own issuer instead.
54pub struct OidcProvider {
55    pub(super) key: String,
56    pub(super) label: Text,
57    issuer: String,
58    /// Every `iss` an ID token of this provider may carry: the issuer, and for Google its
59    /// scheme-less spelling.
60    issuers: Vec<String>,
61    client_id: String,
62    client_secret: String,
63    /// The scopes asked for beyond [`SCOPES`].
64    scopes: Vec<String>,
65    /// Extra authorization parameters, such as Google's `prompt` or `hd`.
66    pub(super) params: Vec<(String, String)>,
67    endpoints: Arc<tokio::sync::OnceCell<Endpoints>>,
68}
69
70impl OidcProvider {
71    /// Google, signing in the client `client_id` with `client_secret` from a Google Cloud
72    /// project's OAuth client of the "Web application" type. Its key is `google`, so its redirect
73    /// URI is `{base_url}{prefix}/sign-in/google/callback`.
74    #[must_use]
75    pub fn google(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
76        let provider = Self::new(
77            "google",
78            "Google",
79            "https://accounts.google.com",
80            client_id,
81            client_secret,
82        );
83        // Google's endpoints are fixed, so a sign-in skips the discovery request.
84        let endpoints = Endpoints {
85            authorization: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
86            token: "https://oauth2.googleapis.com/token".to_string(),
87        };
88        Self {
89            // Google's own documentation says its ID tokens carry either spelling.
90            issuers: vec![
91                "https://accounts.google.com".to_string(),
92                "accounts.google.com".to_string(),
93            ],
94            endpoints: Arc::new(tokio::sync::OnceCell::new_with(Some(endpoints))),
95            ..provider
96        }
97    }
98
99    /// The provider whose issuer is `issuer`, such as `https://login.example.com`, signing in the
100    /// client `client_id` with `client_secret`. `key` names it in its routes, such as `okta`:
101    /// lowercase ASCII letters, digits and `-`. `label` is the name its button shows.
102    ///
103    /// `issuer` is an `https` URL, or an `http` one on a loopback host for a provider running on
104    /// the same machine; anything else is a declaration error. It is spelled exactly as the
105    /// provider's ID tokens carry it, trailing `/` included where the provider writes one.
106    #[must_use]
107    pub fn new(
108        key: impl Into<String>,
109        label: impl Into<Text>,
110        issuer: impl Into<String>,
111        client_id: impl Into<String>,
112        client_secret: impl Into<String>,
113    ) -> Self {
114        let issuer = issuer.into();
115        Self {
116            key: key.into(),
117            label: label.into(),
118            issuers: vec![issuer.clone()],
119            issuer,
120            client_id: client_id.into(),
121            client_secret: client_secret.into(),
122            scopes: Vec::new(),
123            params: Vec::new(),
124            endpoints: Arc::new(tokio::sync::OnceCell::new()),
125        }
126    }
127
128    /// Also asks for `scope`, beyond `openid email profile`.
129    #[must_use]
130    pub fn scope(mut self, scope: impl Into<String>) -> Self {
131        self.scopes.push(scope.into());
132        self
133    }
134
135    /// Sends `name=value` with every authorization request, such as Google's
136    /// `.param("prompt", "select_account")`, or `.param("hd", "example.com")` to offer the
137    /// accounts of one Workspace domain. A parameter only shapes the provider's page: a linker
138    /// that admits one domain alone checks the identity's `hd` claim itself. Naming a parameter
139    /// the panel sets, such as `state` or `redirect_uri`, is a declaration error.
140    #[must_use]
141    pub fn param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
142        self.params.push((name.into(), value.into()));
143        self
144    }
145
146    /// Whether the key may name a route segment.
147    pub(super) fn key_is_valid(&self) -> bool {
148        !self.key.is_empty()
149            && self
150                .key
151                .bytes()
152                .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
153    }
154
155    /// Whether the issuer is a URL the panel may send requests to.
156    pub(super) fn issuer_is_valid(&self) -> bool {
157        secure_url(&self.issuer)
158    }
159
160    /// The URL the browser leaves for to sign in, asking for a code sent back to `redirect_uri`.
161    pub(super) async fn authorization_url(
162        &self,
163        redirect_uri: &str,
164        pending: &Pending,
165    ) -> Result<String, Failure> {
166        let endpoints = self.endpoints().await?;
167        let scope = std::iter::once(SCOPES)
168            .chain(self.scopes.iter().map(String::as_str))
169            .collect::<Vec<_>>()
170            .join(" ");
171        let mut query = form_urlencoded::Serializer::new(String::new());
172        query
173            .append_pair("response_type", "code")
174            .append_pair("client_id", &self.client_id)
175            .append_pair("redirect_uri", redirect_uri)
176            .append_pair("scope", &scope)
177            .append_pair("state", &pending.state)
178            .append_pair("nonce", &pending.nonce)
179            .append_pair("code_challenge", &code_challenge(&pending.verifier))
180            .append_pair("code_challenge_method", "S256");
181        for (name, value) in &self.params {
182            query.append_pair(name, value);
183        }
184        let query = query.finish();
185        let separator = if endpoints.authorization.contains('?') {
186            '&'
187        } else {
188            '?'
189        };
190        Ok(format!("{}{separator}{query}", endpoints.authorization))
191    }
192
193    /// The identity `code` signs in: the code traded at the token endpoint for an ID token, which
194    /// [`identity`] checks against `pending`.
195    pub(super) async fn redeem(
196        &self,
197        code: &str,
198        redirect_uri: &str,
199        pending: &Pending,
200    ) -> Result<ProviderIdentity, Failure> {
201        let endpoints = self.endpoints().await?;
202        let form = [
203            ("grant_type".to_string(), "authorization_code".to_string()),
204            ("code".to_string(), code.to_string()),
205            ("redirect_uri".to_string(), redirect_uri.to_string()),
206            ("code_verifier".to_string(), pending.verifier.clone()),
207        ];
208        // RFC 6749 §2.3.1: the client's id and secret are form-encoded before they are joined.
209        let encode =
210            |value: &str| form_urlencoded::byte_serialize(value.as_bytes()).collect::<String>();
211        let credentials = format!(
212            "{}:{}",
213            encode(&self.client_id),
214            encode(&self.client_secret)
215        );
216        let authorization = format!("Basic {}", BASE64.encode(credentials.as_bytes()));
217        let token = endpoints.token.clone();
218        let answer: TokenResponse = request(move |agent| {
219            agent
220                .post(&token)
221                .header("authorization", &authorization)
222                .header("accept", "application/json")
223                .send_form(
224                    form.iter()
225                        .map(|(key, value)| (key.as_str(), value.as_str())),
226                )
227        })
228        .await?;
229        let now = jiff::Timestamp::now().as_second();
230        identity(self, &answer.id_token, &pending.nonce, now).ok_or(Failure::Refused)
231    }
232
233    /// The provider's endpoints, read from its discovery document on first use. A failed read is
234    /// not kept, so the next sign-in tries again.
235    async fn endpoints(&self) -> Result<&Endpoints, Failure> {
236        self.endpoints
237            .get_or_try_init(|| discover(self.issuer.clone()))
238            .await
239    }
240}
241
242/// Redacts the client secret, so a provider logged with `?` writes none to the log.
243impl std::fmt::Debug for OidcProvider {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        f.debug_struct("OidcProvider")
246            .field("key", &self.key)
247            .field("issuer", &self.issuer)
248            .field("client_id", &self.client_id)
249            .field("client_secret", &"<redacted>")
250            .finish_non_exhaustive()
251    }
252}
253
254/// Where a provider signs a browser in and trades a code for tokens.
255#[derive(Debug)]
256struct Endpoints {
257    authorization: String,
258    token: String,
259}
260
261/// What a sign-in remembers between leaving for the provider and coming back: the secrets the
262/// answer must match.
263#[derive(Debug, Clone, serde::Serialize, Deserialize)]
264pub(super) struct Pending {
265    /// The panel the sign-in started on.
266    pub(super) panel: String,
267    /// The provider's key.
268    pub(super) provider: String,
269    /// Returned by the provider with the code: binds the answer to this browser.
270    pub(super) state: String,
271    /// Carried in the ID token: binds the token to this sign-in.
272    pub(super) nonce: String,
273    /// The PKCE verifier the token request proves the code was asked for here with.
274    pub(super) verifier: String,
275    /// Where to land once signed in, already a same-origin path.
276    pub(super) next: String,
277    /// When the sign-in started, in seconds since the epoch: the panel refuses an answer past
278    /// the sign-in's lifetime, whatever the browser kept.
279    pub(super) started: i64,
280}
281
282impl Pending {
283    /// A sign-in on `panel` through `provider`, with fresh secrets.
284    pub(super) fn new(panel: String, provider: String, next: String) -> Self {
285        Self {
286            panel,
287            provider,
288            state: secret(),
289            nonce: secret(),
290            verifier: secret(),
291            next,
292            started: jiff::Timestamp::now().as_second(),
293        }
294    }
295}
296
297/// Why a provider did not sign the browser in.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub(crate) enum Failure {
300    /// The provider could not be reached, or answered with a server error.
301    Unavailable,
302    /// The provider refused, or its answer failed a check.
303    Refused,
304}
305
306/// 256 random bits in URL-safe base64: a state, a nonce or a PKCE verifier.
307fn secret() -> String {
308    let mut bytes = [0; 32];
309    getrandom::fill(&mut bytes).expect("the sign-in randomness source failed");
310    BASE64URL_NOPAD.encode(&bytes)
311}
312
313/// The PKCE `S256` challenge of `verifier` (RFC 7636 §4.2).
314pub(super) fn code_challenge(verifier: &str) -> String {
315    BASE64URL_NOPAD.encode(&Sha256::digest(verifier.as_bytes()))
316}
317
318/// Whether the panel may send a request to `url`: `https`, or `http` on a loopback host.
319pub(super) fn secure_url(url: &str) -> bool {
320    let Ok(parsed) = url::Url::parse(url) else {
321        return false;
322    };
323    match parsed.scheme() {
324        "https" => parsed.host().is_some(),
325        "http" => match parsed.host() {
326            Some(url::Host::Domain(domain)) => domain == "localhost",
327            Some(url::Host::Ipv4(address)) => address.is_loopback(),
328            Some(url::Host::Ipv6(address)) => address.is_loopback(),
329            None => false,
330        },
331        _ => false,
332    }
333}
334
335/// Reads the endpoints of the provider at `issuer` from its discovery document, which must name
336/// the same issuer.
337async fn discover(issuer: String) -> Result<Endpoints, Failure> {
338    #[derive(Deserialize)]
339    struct Discovery {
340        issuer: String,
341        authorization_endpoint: String,
342        token_endpoint: String,
343    }
344    let url = format!(
345        "{}/.well-known/openid-configuration",
346        issuer.trim_end_matches('/')
347    );
348    let document: Discovery = request(move |agent| agent.get(&url).call()).await?;
349    // OpenID Connect Discovery §4.3: a document naming another issuer is not this provider's, to
350    // the character, since the ID tokens carry the same string.
351    let endpoints_secure =
352        secure_url(&document.authorization_endpoint) && secure_url(&document.token_endpoint);
353    if document.issuer != issuer || !endpoints_secure {
354        tracing::error!(
355            %issuer,
356            discovered = %document.issuer,
357            endpoints_secure,
358            "an OpenID Connect discovery document failed its checks"
359        );
360        return Err(Failure::Refused);
361    }
362    Ok(Endpoints {
363        authorization: document.authorization_endpoint,
364        token: document.token_endpoint,
365    })
366}
367
368/// The token endpoint's answer, of which the panel reads the ID token alone.
369#[derive(Deserialize)]
370struct TokenResponse {
371    id_token: String,
372}
373
374/// Sends the request `send` builds on the blocking pool and reads its JSON answer. A transport
375/// failure or a server error is [`Failure::Unavailable`]; any other answer but a success, or a
376/// body that does not parse, is [`Failure::Refused`].
377async fn request<T, F>(send: F) -> Result<T, Failure>
378where
379    T: serde::de::DeserializeOwned + Send + 'static,
380    F: FnOnce(&ureq::Agent) -> Result<http::Response<ureq::Body>, ureq::Error> + Send + 'static,
381{
382    let answer = tokio::task::spawn_blocking(move || {
383        let agent: ureq::Agent = ureq::Agent::config_builder()
384            .timeout_global(Some(REQUEST_TIMEOUT))
385            .http_status_as_error(false)
386            .max_redirects(0)
387            .build()
388            .into();
389        let mut response = send(&agent).map_err(|error| {
390            tracing::warn!(%error, "an OpenID Connect provider could not be reached");
391            Failure::Unavailable
392        })?;
393        let status = response.status();
394        if status.is_server_error() {
395            tracing::warn!(%status, "an OpenID Connect provider answered with a server error");
396            return Err(Failure::Unavailable);
397        }
398        if !status.is_success() {
399            tracing::warn!(%status, "an OpenID Connect provider refused a request");
400            return Err(Failure::Refused);
401        }
402        response
403            .body_mut()
404            .with_config()
405            .limit(MAX_RESPONSE_BYTES)
406            .read_to_string()
407            .map_err(|_| Failure::Unavailable)
408    })
409    .await
410    .map_err(|_| Failure::Unavailable)??;
411    serde_json::from_str(&answer).map_err(|error| {
412        tracing::warn!(%error, "an OpenID Connect provider's answer does not parse");
413        Failure::Refused
414    })
415}
416
417/// The identity `id_token` names, when its claims pass OpenID Connect Core §3.1.3.7 for
418/// `provider` at `now`, in seconds since the epoch, and carry `nonce`; `None` otherwise.
419///
420/// The signature is not checked: the token came straight from the token endpoint over TLS, which
421/// §3.1.3.7 accepts in its place.
422pub(super) fn identity(
423    provider: &OidcProvider,
424    id_token: &str,
425    nonce: &str,
426    now: i64,
427) -> Option<ProviderIdentity> {
428    #[derive(Deserialize)]
429    #[serde(untagged)]
430    enum Audience {
431        One(String),
432        Many(Vec<String>),
433    }
434    #[derive(Deserialize)]
435    #[serde(untagged)]
436    enum Flag {
437        Bool(bool),
438        // Some providers send booleans as strings.
439        Text(String),
440    }
441    #[derive(Deserialize)]
442    struct Claims {
443        iss: String,
444        sub: String,
445        aud: Audience,
446        azp: Option<String>,
447        exp: i64,
448        iat: i64,
449        nonce: Option<String>,
450        email: Option<String>,
451        email_verified: Option<Flag>,
452        name: Option<String>,
453    }
454    let mut parts = id_token.split('.');
455    let (Some(_header), Some(payload), Some(_signature), None) =
456        (parts.next(), parts.next(), parts.next(), parts.next())
457    else {
458        return None;
459    };
460    let payload = BASE64URL_NOPAD
461        .decode(payload.trim_end_matches('=').as_bytes())
462        .ok()?;
463    let all: serde_json::Map<String, serde_json::Value> = serde_json::from_slice(&payload).ok()?;
464    let claims: Claims = serde_json::from_value(serde_json::Value::Object(all.clone())).ok()?;
465    let audience = match claims.aud {
466        Audience::One(one) => vec![one],
467        Audience::Many(many) => many,
468    };
469    let issued_here = provider.issuers.contains(&claims.iss);
470    let addressed_here = audience.contains(&provider.client_id)
471        && (audience.len() == 1 || claims.azp.is_some())
472        && claims
473            .azp
474            .as_ref()
475            .is_none_or(|azp| *azp == provider.client_id);
476    let live = claims.exp > now - CLOCK_SKEW_SECS && claims.iat < now + CLOCK_SKEW_SECS;
477    let ours = claims.nonce.as_deref().is_some_and(|claimed| {
478        subtle::ConstantTimeEq::ct_eq(claimed.as_bytes(), nonce.as_bytes()).into()
479    });
480    if !(issued_here && addressed_here && live && ours) || claims.sub.is_empty() {
481        return None;
482    }
483    let email_verified = match claims.email_verified {
484        Some(Flag::Bool(verified)) => verified,
485        Some(Flag::Text(text)) => text == "true",
486        None => false,
487    };
488    Some(ProviderIdentity {
489        provider: provider.key.clone(),
490        issuer: provider.issuer.clone(),
491        subject: claims.sub,
492        email: claims.email.filter(|email| !email.is_empty()),
493        email_verified,
494        name: claims.name.filter(|name| !name.is_empty()),
495        claims: all,
496    })
497}