Skip to main content

confium_oidc/
lib.rs

1//! `confium-oidc` — OIDC token verifier for Mode 4 (Keyless Threshold).
2//!
3//! Verifies OIDC tokens from any standards-compliant issuer (GitHub
4//! Actions, Google, Okta, Azure AD, Auth0, etc.) and extracts the
5//! identity claims the Fulcio-style CA needs to bind to the joint
6//! ephemeral threshold key.
7//!
8//! ## Quickstart
9//!
10//! ```no_run
11//! use confium_oidc::{OidcVerifier, OidcIssuer};
12//!
13//! let verifier = OidcVerifier::new();
14//! let issuer = OidcIssuer::GitHubActions;
15//! let claims = verifier.verify(&issuer, "eyJhbGciOi...")
16//!     .expect("OIDC token verifies");
17//! println!("{} ({})", claims.subject, claims.email.unwrap_or_default());
18//! ```
19//!
20//! ## What this enables
21//!
22//! Combined with `confium-tc-cmp20` + a Fulcio-style CA, this crate
23//! powers **keyless threshold signing ceremonies** where each signer
24//! authenticates via OIDC and the joint ephemeral key is bound to
25//! the OIDC identities. See
26//! `docs/architecture/mode-4-keyless-threshold.mdx` for the full
27//! design.
28
29#![forbid(unsafe_code)]
30#![allow(missing_docs)] // TODO: document before 1.0
31#![allow(rustdoc::broken_intra_doc_links)]
32#![allow(rustdoc::bare_urls)]
33#![allow(rustdoc::redundant_explicit_links)]
34#![allow(rustdoc::private_intra_doc_links)]
35#![allow(rustdoc::invalid_html_tags)]
36
37use std::collections::HashMap;
38
39use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
40use serde::{Deserialize, Serialize};
41
42/// Errors returned by the verifier.
43#[derive(Debug, thiserror::Error)]
44pub enum OidcError {
45    /// Issuer not known to this verifier.
46    #[error("unknown OIDC issuer: {0}")]
47    UnknownIssuer(String),
48    /// Token validation failed.
49    #[error("token validation failed: {0}")]
50    Validation(String),
51    /// Failed to fetch JWKS (issuer's public keys).
52    #[error("JWKS fetch failed: {0}")]
53    JwksFetch(String),
54}
55
56/// Known OIDC issuer. Each entry maps to a JWKS URL and a
57/// signature algorithm. The defaults cover the issuers Confium
58/// has been tested against; users can add their own via
59/// [`OidcVerifier::with_issuer`].
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub enum OidcIssuer {
62    /// GitHub Actions (`https://token.actions.githubusercontent.com`).
63    GitHubActions,
64    /// Google Workspace / Google Cloud (`https://accounts.google.com`).
65    Google,
66    /// GitLab CI (`https://gitlab.com`).
67    GitLab,
68    /// Okta tenant — specify the tenant URL.
69    Okta(String),
70    /// Azure AD — specify the tenant ID.
71    AzureAd(String),
72    /// Custom issuer identified by its JWKS URL.
73    Custom { issuer: String, jwks_url: String },
74}
75
76impl OidcIssuer {
77    /// The `iss` claim value expected in tokens from this issuer.
78    pub fn issuer_url(&self) -> &str {
79        match self {
80            OidcIssuer::GitHubActions => "https://token.actions.githubusercontent.com",
81            OidcIssuer::Google => "https://accounts.google.com",
82            OidcIssuer::GitLab => "https://gitlab.com",
83            OidcIssuer::Okta(tenant) => tenant.as_str(),
84            OidcIssuer::AzureAd(tenant_id) => tenant_id.as_str(),
85            OidcIssuer::Custom { issuer, .. } => issuer.as_str(),
86        }
87    }
88
89    /// JWKS URL — where to fetch the issuer's signing keys.
90    pub fn jwks_url(&self) -> String {
91        match self {
92            OidcIssuer::GitHubActions => {
93                "https://token.actions.githubusercontent.com/.well-known/jwks".to_string()
94            }
95            OidcIssuer::Google => "https://www.googleapis.com/oauth2/v3/certs".to_string(),
96            OidcIssuer::GitLab => "https://gitlab.com/-/jwks".to_string(),
97            OidcIssuer::Okta(tenant) => format!("{tenant}/oauth2/default/v1/keys"),
98            OidcIssuer::AzureAd(tenant_id) => {
99                format!("https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys")
100            }
101            OidcIssuer::Custom { jwks_url, .. } => jwks_url.clone(),
102        }
103    }
104}
105
106/// Claims extracted from a verified OIDC token. The fields are the
107/// minimum the Fulcio-style CA needs to bind to the joint ephemeral
108/// threshold key.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct OidcClaims {
111    /// Subject — stable identifier for the signer at the issuer.
112    pub subject: String,
113    /// Issuer URL.
114    pub issuer: String,
115    /// Audience — who the token was issued for.
116    pub audience: Vec<String>,
117    /// Expiry (Unix epoch seconds).
118    pub expires_at: i64,
119    /// Issued-at (Unix epoch seconds).
120    pub issued_at: i64,
121    /// Email claim, if present (Google, Okta, Azure AD).
122    pub email: Option<String>,
123    /// GitHub-specific claims: repository, workflow, ref, etc.
124    pub github: Option<GithubClaims>,
125    /// All raw claims, for callers that need fields not exposed above.
126    pub raw: HashMap<String, serde_json::Value>,
127}
128
129/// GitHub Actions-specific OIDC claims.
130#[derive(Debug, Clone, Serialize, Deserialize, Default)]
131pub struct GithubClaims {
132    /// Repository in `owner/name` form (e.g. `confium/confium`).
133    pub repository: String,
134    /// Workflow file path (e.g. `.github/workflows/release.yml`).
135    pub workflow: String,
136    /// Git ref being built (e.g. `refs/tags/v1.0.0`).
137    pub ref_: String,
138    /// SHA of the commit being built.
139    pub sha: String,
140    /// GitHub actor (the user/app that triggered the run).
141    pub actor: String,
142}
143
144/// OIDC token verifier. Cheap to construct; caches JWKS keys per
145/// issuer in memory.
146pub struct OidcVerifier {
147    http: reqwest::blocking::Client,
148    jwks_cache: std::sync::Mutex<HashMap<String, std::sync::Arc<JwksSet>>>,
149}
150
151impl Default for OidcVerifier {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157impl OidcVerifier {
158    /// Construct a new verifier with empty JWKS cache.
159    pub fn new() -> Self {
160        Self {
161            http: reqwest::blocking::Client::builder()
162                .timeout(std::time::Duration::from_secs(10))
163                .build()
164                .expect("reqwest client"),
165            jwks_cache: std::sync::Mutex::new(HashMap::new()),
166        }
167    }
168
169    /// Verify an OIDC token. Fetches the issuer's JWKS (cached on
170    /// first call), validates the signature, checks `iss`/`aud`/
171    /// `exp`, and returns the extracted claims.
172    pub fn verify(&self, issuer: &OidcIssuer, token: &str) -> Result<OidcClaims, OidcError> {
173        let expected_iss = issuer.issuer_url();
174        let jwks = self.fetch_jwks(issuer)?;
175
176        // Decode the header to find the key ID, then verify.
177        let header = jsonwebtoken::decode_header(token)
178            .map_err(|e| OidcError::Validation(format!("header decode: {e}")))?;
179        let kid = header
180            .kid
181            .ok_or_else(|| OidcError::Validation("token missing kid header".into()))?;
182
183        let jwk = jwks
184            .get(&kid)
185            .ok_or_else(|| OidcError::Validation(format!("issuer has no key with kid={kid}")))?;
186
187        let decoding_key = DecodingKey::from_rsa_components(&jwk.modulus, &jwk.exponent)
188            .map_err(|e| OidcError::Validation(format!("JWK decode: {e}")))?;
189
190        let mut validation = Validation::new(Algorithm::RS256);
191        validation.set_issuer(&[expected_iss]);
192        // We accept any audience the issuer set; the caller validates
193        // `aud` against their expected audience (e.g. their own
194        // signing service).
195        validation.validate_aud = false;
196
197        let token_data =
198            decode::<HashMap<String, serde_json::Value>>(token, &decoding_key, &validation)
199                .map_err(|e| OidcError::Validation(format!("token verify: {e}")))?;
200
201        let raw = token_data.claims;
202        let subject = raw
203            .get("sub")
204            .and_then(|v| v.as_str())
205            .ok_or_else(|| OidcError::Validation("missing sub".into()))?
206            .to_string();
207        let email = raw.get("email").and_then(|v| v.as_str()).map(String::from);
208        let audience: Vec<String> = raw
209            .get("aud")
210            .map(|v| match v {
211                serde_json::Value::String(s) => vec![s.clone()],
212                serde_json::Value::Array(arr) => arr
213                    .iter()
214                    .filter_map(|x| x.as_str().map(String::from))
215                    .collect(),
216                _ => vec![],
217            })
218            .unwrap_or_default();
219        let expires_at = raw.get("exp").and_then(|v| v.as_i64()).unwrap_or(0);
220        let issued_at = raw.get("iat").and_then(|v| v.as_i64()).unwrap_or(0);
221
222        let github = if raw.contains_key("repository") {
223            Some(GithubClaims {
224                repository: raw
225                    .get("repository")
226                    .and_then(|v| v.as_str())
227                    .unwrap_or("")
228                    .to_string(),
229                workflow: raw
230                    .get("workflow")
231                    .and_then(|v| v.as_str())
232                    .unwrap_or("")
233                    .to_string(),
234                ref_: raw
235                    .get("ref")
236                    .and_then(|v| v.as_str())
237                    .unwrap_or("")
238                    .to_string(),
239                sha: raw
240                    .get("sha")
241                    .and_then(|v| v.as_str())
242                    .unwrap_or("")
243                    .to_string(),
244                actor: raw
245                    .get("actor")
246                    .and_then(|v| v.as_str())
247                    .unwrap_or("")
248                    .to_string(),
249            })
250        } else {
251            None
252        };
253
254        Ok(OidcClaims {
255            subject,
256            issuer: expected_iss.to_string(),
257            audience,
258            expires_at,
259            issued_at,
260            email,
261            github,
262            raw,
263        })
264    }
265
266    fn fetch_jwks(&self, issuer: &OidcIssuer) -> Result<std::sync::Arc<JwksSet>, OidcError> {
267        let key = issuer.issuer_url().to_string();
268        {
269            let cache = self.jwks_cache.lock().unwrap();
270            if let Some(jwks) = cache.get(&key) {
271                return Ok(jwks.clone());
272            }
273        }
274        let url = issuer.jwks_url();
275        let resp = self
276            .http
277            .get(&url)
278            .send()
279            .map_err(|e| OidcError::JwksFetch(e.to_string()))?;
280        let body: serde_json::Value = resp
281            .json()
282            .map_err(|e| OidcError::JwksFetch(format!("JWKS parse: {e}")))?;
283        let mut jwks = JwksSet::default();
284        if let Some(keys) = body.get("keys").and_then(|v| v.as_array()) {
285            for k in keys {
286                let kid = k
287                    .get("kid")
288                    .and_then(|v| v.as_str())
289                    .unwrap_or("")
290                    .to_string();
291                let modulus = k
292                    .get("n")
293                    .and_then(|v| v.as_str())
294                    .unwrap_or("")
295                    .to_string();
296                let exponent = k
297                    .get("e")
298                    .and_then(|v| v.as_str())
299                    .unwrap_or("")
300                    .to_string();
301                if !kid.is_empty() {
302                    jwks.keys.insert(kid, Jwk { modulus, exponent });
303                }
304            }
305        }
306        let arc = std::sync::Arc::new(jwks);
307        self.jwks_cache.lock().unwrap().insert(key, arc.clone());
308        Ok(arc)
309    }
310}
311
312#[derive(Debug, Default)]
313struct JwksSet {
314    keys: HashMap<String, Jwk>,
315}
316
317impl JwksSet {
318    fn get(&self, kid: &str) -> Option<&Jwk> {
319        self.keys.get(kid)
320    }
321}
322
323#[derive(Debug, Clone)]
324struct Jwk {
325    modulus: String,
326    exponent: String,
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn github_issuer_has_known_jwks_url() {
335        assert_eq!(
336            OidcIssuer::GitHubActions.jwks_url(),
337            "https://token.actions.githubusercontent.com/.well-known/jwks"
338        );
339    }
340
341    #[test]
342    fn google_issuer_uses_googleapis_certs_endpoint() {
343        assert_eq!(
344            OidcIssuer::Google.jwks_url(),
345            "https://www.googleapis.com/oauth2/v3/certs"
346        );
347    }
348
349    #[test]
350    fn okta_issuer_includes_tenant_in_url() {
351        let i = OidcIssuer::Okta("https://example.okta.com".into());
352        assert_eq!(
353            i.jwks_url(),
354            "https://example.okta.com/oauth2/default/v1/keys"
355        );
356    }
357
358    #[test]
359    fn custom_issuer_passes_through() {
360        let i = OidcIssuer::Custom {
361            issuer: "https://custom.example.com".into(),
362            jwks_url: "https://custom.example.com/jwks".into(),
363        };
364        assert_eq!(i.issuer_url(), "https://custom.example.com");
365        assert_eq!(i.jwks_url(), "https://custom.example.com/jwks");
366    }
367
368    #[test]
369    fn verifier_rejects_garbage_token() {
370        let v = OidcVerifier::new();
371        let result = v.verify(&OidcIssuer::Google, "not a token");
372        assert!(result.is_err());
373    }
374}