didbot_serve/tree/
oidc.rs1use 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
22const JWKS_TTL: std::time::Duration = std::time::Duration::from_secs(10 * 60);
24
25const JWKS_ISSUERS: usize = 256;
29
30pub const DEFAULT_OIDC_FRESHNESS: Duration = Duration::minutes(5);
36
37#[derive(Debug, Default)]
40pub struct JwksCache {
41 sets: Mutex<HashMap<String, (jsonwebtoken::jwk::JwkSet, Instant)>>,
42}
43
44impl JwksCache {
45 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
94async 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
110async 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
136fn 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
159fn 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 other => {
174 let rendered = other.to_string();
175 rendered == claim.value
176 }
177 })
178 })
179}
180
181pub(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 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 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}