Skip to main content

didbot_serve/tree/
oidc.rs

1//! The OpenID Connect half of the proof check: an issuer's discovery
2//! document, its key set, and the claims the named account's
3//! `bot.did.credential` identities say must match.
4//!
5//! Every fetch here goes to a URL the token names, so each one is resolved
6//! and pinned through [`didbot_http::pinned_client`].
7
8use std::collections::HashMap;
9use std::sync::Mutex;
10use std::time::Instant;
11
12use didbot_identity::OidcIdentity;
13use serde_json::Value;
14use sha2::{Digest, Sha256};
15use time::{Duration, OffsetDateTime};
16
17use crate::error::ApiError;
18use crate::routes::AppState;
19
20use super::proof::{fetch_json, invalid_proof, spend, Proof};
21
22/// How long a fetched JWKS is reused before it is fetched again.
23const JWKS_TTL: std::time::Duration = std::time::Duration::from_secs(10 * 60);
24
25/// How many issuers' key sets are held at once. Past it the set fetched
26/// longest ago makes room: an issuer a hosted account named is one of a
27/// handful, not thousands.
28const JWKS_ISSUERS: usize = 256;
29
30/// How old an OpenID Connect token's `iat` may be, absent configuration.
31///
32/// **Reasonable range: 60 to 3600 seconds.** Five minutes is clock skew and
33/// a slow network; past an hour a token a run left in a log is still a
34/// session.
35pub const DEFAULT_OIDC_FRESHNESS: Duration = Duration::minutes(5);
36
37/// The key sets of the issuers this server has read, each for ten minutes
38/// (`JWKS_TTL`).
39#[derive(Debug, Default)]
40pub struct JwksCache {
41    sets: Mutex<HashMap<String, (jsonwebtoken::jwk::JwkSet, Instant)>>,
42}
43
44impl JwksCache {
45    /// The issuer's key set: cached, or fetched through its discovery
46    /// document. `refresh` fetches even when a fresh copy is held, for a
47    /// key id the held copy does not name.
48    async fn keys(
49        &self,
50        state: &AppState,
51        issuer: &str,
52        refresh: bool,
53    ) -> Result<jsonwebtoken::jwk::JwkSet, ApiError> {
54        if !refresh {
55            let sets = self.sets.lock().unwrap_or_else(|p| p.into_inner());
56            if let Some((set, fetched_at)) = sets.get(issuer) {
57                if fetched_at.elapsed() < JWKS_TTL {
58                    return Ok(set.clone());
59                }
60            }
61        }
62        let discovery = format!(
63            "{}/.well-known/openid-configuration",
64            issuer.trim_end_matches('/')
65        );
66        let client = issuer_client(state, issuer).await?;
67        let configuration: Value = fetch_json(&client, &discovery).await?;
68        if configuration.get("issuer").and_then(Value::as_str) != Some(issuer) {
69            return Err(invalid_proof(format!(
70                "{discovery} describes an issuer other than {issuer}"
71            )));
72        }
73        let jwks_uri = configuration
74            .get("jwks_uri")
75            .and_then(Value::as_str)
76            .ok_or_else(|| invalid_proof(format!("{discovery} names no jwks_uri")))?;
77        let keys_client = jwks_client(state, issuer, jwks_uri).await?;
78        let set: jsonwebtoken::jwk::JwkSet = fetch_json(&keys_client, jwks_uri).await?;
79        let mut sets = self.sets.lock().unwrap_or_else(|p| p.into_inner());
80        if sets.len() >= JWKS_ISSUERS && !sets.contains_key(issuer) {
81            if let Some(oldest) = sets
82                .iter()
83                .min_by_key(|(_, (_, fetched_at))| *fetched_at)
84                .map(|(issuer, _)| issuer.clone())
85            {
86                sets.remove(&oldest);
87            }
88        }
89        sets.insert(issuer.to_owned(), (set.clone(), Instant::now()));
90        Ok(set)
91    }
92}
93
94/// The client a fetch of `url` goes through: its host resolved once,
95/// refused at a private address, pinned to what it resolved to so the
96/// connection cannot land elsewhere, and following no redirect, which
97/// would land it elsewhere all the same.
98///
99/// The refusal is [`didbot_http::resolve_public`]'s, the same one the
100/// `client_id` fetch goes through: both are an outbound GET at a URL
101/// somebody else named.
102async fn issuer_client(state: &AppState, url: &str) -> Result<reqwest::Client, ApiError> {
103    let parsed =
104        url::Url::parse(url).map_err(|_| invalid_proof(format!("{url:?} is not a URL")))?;
105    didbot_http::pinned_client(&parsed, state.tree.private_addresses())
106        .await
107        .map_err(|error| invalid_proof(error.to_string()))
108}
109
110/// The client the issuer's key set is fetched through. Discovery may put
111/// the keys on another host — Google's sit on `www.googleapis.com` — so
112/// that host is checked and pinned the way the issuer's was. The URL keeps
113/// the issuer's scheme and carries no credentials: `https://issuer@host/`
114/// is a fetch from `host`, whatever it begins with.
115async fn jwks_client(
116    state: &AppState,
117    issuer: &str,
118    jwks_uri: &str,
119) -> Result<reqwest::Client, ApiError> {
120    let url = url::Url::parse(jwks_uri)
121        .map_err(|_| invalid_proof(format!("jwks_uri {jwks_uri:?} is not a URL")))?;
122    let scheme = url::Url::parse(issuer).map(|issuer| issuer.scheme().to_owned());
123    if scheme.as_deref() != Ok(url.scheme()) {
124        return Err(invalid_proof(format!(
125            "jwks_uri {jwks_uri:?} is not over the issuer's scheme"
126        )));
127    }
128    if !url.username().is_empty() || url.password().is_some() {
129        return Err(invalid_proof(format!(
130            "jwks_uri {jwks_uri:?} carries credentials"
131        )));
132    }
133    issuer_client(state, jwks_uri).await
134}
135
136/// Whether `issuer` is one this server will fetch keys from: on the
137/// deployment's list when it has one, over HTTPS — or plain HTTP on this
138/// host where a development stack allows it.
139fn acceptable_issuer(issuer: &str, allowed: &[String], loopback: bool) -> bool {
140    if !allowed.is_empty() && !allowed.iter().any(|allowed| allowed == issuer) {
141        return false;
142    }
143    let Ok(url) = url::Url::parse(issuer) else {
144        return false;
145    };
146    match url.scheme() {
147        "https" => true,
148        "http" => {
149            loopback
150                && matches!(
151                    url.host_str(),
152                    Some("127.0.0.1") | Some("localhost") | Some("[::1]") | Some("::1")
153                )
154        }
155        _ => false,
156    }
157}
158
159/// Whether `claims`, an ID token's payload, carries every claim `identity`
160/// names, with its value. Each is found by its [`Claim::pointer`]: a name
161/// beginning with `/` is a JSON pointer (RFC 6901) into the claims, for a
162/// platform that nests them (`/kubernetes.io/namespace`), and any other name
163/// is a top-level claim.
164///
165/// [`Claim::pointer`]: didbot_identity::document::Claim::pointer
166fn claims_match(identity: &OidcIdentity, claims: &Value) -> bool {
167    identity.claims.iter().all(|claim| {
168        claims
169            .pointer(&claim.pointer())
170            .is_some_and(|value| match value {
171                Value::String(text) => *text == claim.value,
172                // A number or a boolean, compared as the document spells it.
173                other => {
174                    let rendered = other.to_string();
175                    rendered == claim.value
176                }
177            })
178    })
179}
180
181/// Verifies an OpenID Connect ID token against its issuer's keys, and
182/// against the identities of `did`, the account the request named. One of
183/// that account's identities at the token's issuer matching is enough.
184///
185/// Nothing is fetched until the account is hosted here and logs in with an
186/// identity at the token's issuer: anyone may present a token, and nothing
187/// is read from a stranger's issuer.
188pub(super) async fn prove_by_oidc(
189    state: &AppState,
190    token: &str,
191    iss: &str,
192    did: &str,
193    now: OffsetDateTime,
194) -> Result<Proof, ApiError> {
195    if !acceptable_issuer(iss, &state.tree.issuers, state.tree.loopback_fetches) {
196        return Err(invalid_proof(format!(
197            "iss {iss:?} is not an issuer this server accepts"
198        )));
199    }
200    if state.registry.account(did).is_none() {
201        return Err(invalid_proof(format!(
202            "{did} is not an account this server hosts"
203        )));
204    }
205    let identities: Vec<OidcIdentity> = state
206        .registry
207        .credentials(did)
208        .into_iter()
209        .filter_map(|credential| credential.oidc_identity().cloned())
210        .filter(|identity| identity.issuer == iss)
211        .collect();
212    if identities.is_empty() {
213        return Err(invalid_proof(format!(
214            "{did} logs in with no identity at {iss}"
215        )));
216    }
217    let header = jsonwebtoken::decode_header(token)
218        .map_err(|error| invalid_proof(format!("token header: {error}")))?;
219    // Pinned to signatures only an issuer's private key makes: an issuer
220    // that publishes a symmetric key would otherwise let anyone holding it
221    // mint the account's tokens.
222    let alg = match header.alg {
223        alg @ (jsonwebtoken::Algorithm::ES256
224        | jsonwebtoken::Algorithm::RS256
225        | jsonwebtoken::Algorithm::PS256
226        | jsonwebtoken::Algorithm::EdDSA) => alg,
227        other => {
228            return Err(invalid_proof(format!(
229                "alg {other:?} is not ES256, RS256, PS256 or EdDSA"
230            )))
231        }
232    };
233    let find = |set: &jsonwebtoken::jwk::JwkSet| match &header.kid {
234        Some(kid) => set.find(kid).cloned(),
235        None if set.keys.len() == 1 => set.keys.first().cloned(),
236        None => None,
237    };
238    let mut jwk = find(&state.tree.jwks.keys(state, iss, false).await?);
239    if jwk.is_none() {
240        jwk = find(&state.tree.jwks.keys(state, iss, true).await?);
241    }
242    let jwk =
243        jwk.ok_or_else(|| invalid_proof("the issuer publishes no key with the token's kid"))?;
244    let key = jsonwebtoken::DecodingKey::from_jwk(&jwk)
245        .map_err(|error| invalid_proof(format!("the issuer's key: {error}")))?;
246    let mut validation = jsonwebtoken::Validation::new(alg);
247    validation.set_audience(&[state.registry.service_did()]);
248    validation.set_issuer(&[iss]);
249    validation.validate_nbf = true;
250    validation.leeway = 30;
251    let data = jsonwebtoken::decode::<serde_json::Map<String, Value>>(token, &key, &validation)
252        .map_err(|error| invalid_proof(format!("the token does not verify: {error}")))?;
253    let claims = Value::Object(data.claims);
254    if !identities
255        .iter()
256        .any(|identity| claims_match(identity, &claims))
257    {
258        return Err(invalid_proof(format!(
259            "the token does not carry the claims {did} logs in with"
260        )));
261    }
262    let exp = claims
263        .get("exp")
264        .and_then(Value::as_i64)
265        .and_then(|exp| OffsetDateTime::from_unix_timestamp(exp).ok())
266        .ok_or_else(|| invalid_proof("the token carries no exp"))?;
267    let iat = claims
268        .get("iat")
269        .and_then(Value::as_i64)
270        .and_then(|iat| OffsetDateTime::from_unix_timestamp(iat).ok())
271        .ok_or_else(|| invalid_proof("the token carries no iat"))?;
272    if now - iat > state.tree.freshness {
273        return Err(invalid_proof(format!(
274            "the token was issued more than {} seconds ago",
275            state.tree.freshness.whole_seconds()
276        )));
277    }
278    // Spent as the whole token: many issuers mint no `jti`, and the same
279    // token presented twice is a replay whatever its claims say.
280    let replay_key = format!("oidc:{}", hex::encode(Sha256::digest(token.as_bytes())));
281    spend(state, replay_key, exp, now)?;
282    Ok(Proof::Oidc(did.to_owned()))
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn only_listed_or_public_https_issuers_are_accepted() {
291        let none: &[String] = &[];
292        assert!(acceptable_issuer(
293            "https://accounts.google.com",
294            none,
295            false
296        ));
297        assert!(!acceptable_issuer("http://127.0.0.1:8080", none, false));
298        assert!(acceptable_issuer("http://127.0.0.1:8080", none, true));
299        assert!(!acceptable_issuer("http://issuer.example", none, true));
300        assert!(!acceptable_issuer("not a url", none, false));
301        let listed = vec!["https://token.actions.githubusercontent.com".to_owned()];
302        assert!(acceptable_issuer(
303            "https://token.actions.githubusercontent.com",
304            &listed,
305            false
306        ));
307        assert!(!acceptable_issuer(
308            "https://accounts.google.com",
309            &listed,
310            false
311        ));
312    }
313}