Skip to main content

alibi_plugins/oauth/providers/
generic.rs

1//! One-time discovery from trusted application configuration, matching genericOAuth.
2use super::OAuthTokenEndpointAuth::{
3    ClientSecretBasic, ClientSecretPost, None as Public, PrivateKeyJwt,
4};
5use super::{
6    OAuthAuthorizationPolicy, OAuthIdTokenVerifier, OAuthProfileMapper, OAuthProvider,
7    OAuthScopeOrder, OAuthUserInfo, OAuthUserInfoHandler, OAuthUserInfoRequest,
8    OAuthUserInfoResponse,
9};
10use crate::oauth::id_token::{CLOCK_TOLERANCE_SECS, REQUIRED_CLAIMS};
11use crate::oauth::{HttpOAuthJwksSource, OAuthJwksSource};
12use async_trait::async_trait;
13use base64::Engine;
14use jsonwebtoken::{Algorithm, DecodingKey};
15use serde_json::Map;
16use serde_json::Value;
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::{Arc, OnceLock};
19use std::time::Duration;
20
21/// Discovery is an operator-configured authority, never a request-selected URL.
22/// Resolve once before registering the provider with `OAuthPlugin`, or register
23/// the configuration with `OAuthPlugin::add_generic_provider` to retry discovery
24/// that fails at startup.
25#[derive(Clone)]
26pub struct GenericOAuthConfig {
27    pub provider: OAuthProvider,
28    pub discovery_url: Option<String>,
29    pub discovery_headers: Vec<(String, String)>,
30    /// `None` permits discovery; `Some`, including empty, retains the override.
31    pub authorization_url: Option<String>,
32    pub token_url: Option<String>,
33    pub user_info_url: Option<String>,
34    pub end_session_endpoint: Option<String>,
35    /// Default return URI, resolved against the auth base URL at sign-out.
36    pub post_logout_redirect_uri: Option<String>,
37    /// Keep local sign-out without a provider logout URL.
38    pub disable_provider_logout: bool,
39    pub require_id_token_verification: bool,
40    pub disable_id_token_nonce_binding: bool,
41    pub map_profile: Option<Arc<dyn OAuthProfileMapper>>,
42    /// Fallback expiry in seconds when a default or custom grant omits it.
43    pub access_token_expires_in: Option<f64>,
44    pub account_key: Option<super::OAuthAccountKey>,
45}
46/// Metadata retained independently from the chosen endpoint overrides.
47#[derive(Debug, Clone, Default)]
48pub struct GenericOAuthMetadata {
49    pub issuer: Option<String>,
50    pub jwks_url: Option<String>,
51    pub signing_algorithms: Vec<String>,
52    pub end_session_endpoint: Option<String>,
53}
54pub struct GenericOAuthResolved {
55    pub provider: OAuthProvider,
56    pub metadata: GenericOAuthMetadata,
57}
58/// Configuration failures contain no operator endpoints or transport errors.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum GenericOAuthError {
61    RequiredVerificationUnavailable,
62    InvalidTokenAuthentication,
63}
64impl std::fmt::Display for GenericOAuthError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.write_str(match self {
67            Self::RequiredVerificationUnavailable => {
68                "Generic OAuth requires discovery ID-token verification metadata"
69            }
70            Self::InvalidTokenAuthentication => {
71                "Generic OAuth token authentication conflicts with configured credentials"
72            }
73        })
74    }
75}
76impl std::error::Error for GenericOAuthError {}
77impl GenericOAuthConfig {
78    #[must_use]
79    pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
80        Self {
81            provider: OAuthProvider {
82                client_id: client_id.into(),
83                client_secret: client_secret.into(),
84                additional_client_ids: Vec::new(),
85                hosted_domain: None,
86                require_email_verification: false,
87                auth_url: String::new(),
88                token_url: String::new(),
89                user_info_url: None,
90                scopes: Vec::new(),
91                authorization: Some(OAuthAuthorizationPolicy {
92                    scope_order: OAuthScopeOrder::RequestedThenConfigured,
93                    ..Default::default()
94                }),
95                allowed_request_params: Vec::new(),
96                authorization_params: Vec::new(),
97                account_subject: None,
98                map_user_info: None,
99                get_user_info: None,
100                refresh_access_token: None,
101                verify_id_token: None,
102                id_token: None,
103                disable_id_token_sign_in: false,
104                disable_implicit_sign_up: false,
105                disable_sign_up: false,
106                allow_idp_initiated: false,
107                override_user_info_on_sign_in: false,
108            },
109            discovery_url: None,
110            discovery_headers: Vec::new(),
111            authorization_url: None,
112            token_url: None,
113            user_info_url: None,
114            end_session_endpoint: None,
115            post_logout_redirect_uri: None,
116            disable_provider_logout: false,
117            require_id_token_verification: false,
118            disable_id_token_nonce_binding: false,
119            map_profile: None,
120            access_token_expires_in: None,
121            account_key: None,
122        }
123    }
124    /// Failed discovery falls back to configured endpoints. `Ok(None)` skips an
125    /// unusable discovered provider, as the published initialization does.
126    pub async fn resolve(self) -> Result<Option<GenericOAuthResolved>, GenericOAuthError> {
127        self.resolve_within(None).await
128    }
129    async fn resolve_within(
130        mut self,
131        timeout: Option<Duration>,
132    ) -> Result<Option<GenericOAuthResolved>, GenericOAuthError> {
133        let mut metadata = GenericOAuthMetadata::default();
134        let mut is_oidc = false;
135        let discovered = if let Some(url) = &self.discovery_url {
136            fetch_discovery(url, &self.discovery_headers, timeout).await
137        } else {
138            None
139        };
140        if let Some(document) = &discovered {
141            self.authorization_url = self
142                .authorization_url
143                .or_else(|| string(document, "authorization_endpoint"));
144            self.token_url = self
145                .token_url
146                .or_else(|| string(document, "token_endpoint"));
147            self.user_info_url = self
148                .user_info_url
149                .or_else(|| string(document, "userinfo_endpoint"));
150            metadata.issuer = string(document, "issuer");
151            is_oidc = document
152                .get("id_token_signing_alg_values_supported")
153                .and_then(Value::as_array)
154                .is_some_and(|values| !values.is_empty());
155            metadata.signing_algorithms = document
156                .get("id_token_signing_alg_values_supported")
157                .and_then(Value::as_array)
158                .map(|values| {
159                    values
160                        .iter()
161                        .filter_map(Value::as_str)
162                        .map(str::to_owned)
163                        .collect()
164                })
165                .unwrap_or_default();
166            metadata.end_session_endpoint = self
167                .end_session_endpoint
168                .or_else(|| string(document, "end_session_endpoint"));
169            if let (Some(jwks), Some(issuer)) = (string(document, "jwks_uri"), &metadata.issuer)
170                && !jwks.is_empty()
171                && !issuer.is_empty()
172            {
173                let Ok(base) = url::Url::parse(self.discovery_url.as_deref().unwrap_or_default())
174                else {
175                    return Ok(None);
176                };
177                let Ok(url) = base.join(&jwks) else {
178                    return Ok(None);
179                };
180                metadata.jwks_url = Some(url.to_string());
181                self.provider.verify_id_token = Some(Arc::new(DiscoveryVerifier {
182                    keys: Arc::new(HttpOAuthJwksSource::new(url.to_string())),
183                    issuer: issuer.clone(),
184                    audience: self.provider.client_id.clone(),
185                    algorithms: is_oidc.then(|| metadata.signing_algorithms.clone()),
186                }));
187            }
188        } else {
189            metadata.end_session_endpoint = self.end_session_endpoint;
190        }
191        let policy = self
192            .provider
193            .authorization
194            .get_or_insert_with(Default::default);
195        if self.account_key.is_some() {
196            policy.account_key = self.account_key;
197        }
198        policy.allow_missing_access_token = true;
199        policy.default_access_token_expires_in = self.access_token_expires_in;
200        if self.discovery_url.is_some()
201            && (self.authorization_url.as_ref().is_none_or(String::is_empty)
202                || self.token_url.as_ref().is_none_or(String::is_empty)
203                    && policy.authorization_code.is_none())
204        {
205            return Ok(None);
206        }
207        if self.require_id_token_verification && metadata.jwks_url.is_none() {
208            return if self.discovery_url.is_some() {
209                Ok(None)
210            } else {
211                Err(GenericOAuthError::RequiredVerificationUnavailable)
212            };
213        }
214        if matches!(policy.token_endpoint_auth, Some(Public | PrivateKeyJwt))
215            && !self.provider.client_secret.is_empty()
216            || matches!(
217                policy.token_endpoint_auth,
218                Some(ClientSecretBasic | ClientSecretPost)
219            ) && self.provider.client_secret.is_empty()
220        {
221            return Err(GenericOAuthError::InvalidTokenAuthentication);
222        }
223        // Generic OAuth merges request scopes before its configured scopes.
224        policy.scope_order = OAuthScopeOrder::RequestedThenConfigured;
225        _ = policy
226            .configured_scopes
227            .splice(0..0, std::mem::take(&mut self.provider.scopes));
228        policy.end_session = if self.disable_provider_logout {
229            None
230        } else {
231            metadata
232                .end_session_endpoint
233                .clone()
234                .map(|endpoint| super::OAuthEndSessionConfig {
235                    endpoint,
236                    post_logout_redirect_uri: self.post_logout_redirect_uri,
237                })
238        };
239        policy.issuer.clone_from(&metadata.issuer);
240        policy.verify_grant_id_token = metadata.jwks_url.is_some();
241        policy.id_token_nonce_binding =
242            policy.verify_grant_id_token && !self.disable_id_token_nonce_binding;
243        policy.discovery_openid_scope = is_oidc;
244        policy.source_profile_exceptions = true;
245        self.provider.auth_url = self.authorization_url.unwrap_or_default();
246        self.provider.token_url = self.token_url.unwrap_or_default();
247        self.provider.user_info_url.clone_from(&self.user_info_url);
248        if self.provider.account_subject.is_none() {
249            self.provider.account_subject = Some(if policy.discovery_openid_scope {
250                oidc_subject
251            } else {
252                oauth_subject
253            });
254        }
255        if self.provider.get_user_info.is_none() {
256            self.provider.get_user_info = Some(Arc::new(GenericUserInfo {
257                url: self.user_info_url,
258                mapper: self.map_profile,
259            }));
260        }
261        Ok(Some(GenericOAuthResolved {
262            provider: self.provider,
263            metadata,
264        }))
265    }
266}
267/// A request waits on a discovery retry, so bound it.
268const DISCOVERY_RETRY_TIMEOUT: Duration = Duration::from_secs(10);
269
270/// A generic provider skipped because its startup discovery left it unusable.
271pub(crate) struct DeferredDiscovery {
272    config: GenericOAuthConfig,
273    provider: OnceLock<OAuthProvider>,
274    attempts: AtomicU64,
275    attempt: tokio::sync::Mutex<()>,
276}
277impl Clone for DeferredDiscovery {
278    fn clone(&self) -> Self {
279        let clone = Self::new(self.config.clone());
280        if let Some(provider) = self.provider.get() {
281            _ = clone.provider.set(provider.clone());
282        }
283        clone
284    }
285}
286impl DeferredDiscovery {
287    pub(crate) fn new(config: GenericOAuthConfig) -> Self {
288        Self {
289            config,
290            provider: OnceLock::new(),
291            attempts: AtomicU64::new(0),
292            attempt: tokio::sync::Mutex::new(()),
293        }
294    }
295    pub(crate) fn provider(&self) -> Option<&OAuthProvider> {
296        self.provider.get()
297    }
298    pub(crate) async fn retry(&self) {
299        let seen = self.attempts.load(Ordering::SeqCst);
300        let _attempt = self.attempt.lock().await;
301        // A request that waited on another request's attempt shares its outcome.
302        if self.provider.get().is_some() || self.attempts.load(Ordering::SeqCst) != seen {
303            return;
304        }
305        if let Ok(Some(resolved)) = self
306            .config
307            .clone()
308            .resolve_within(Some(DISCOVERY_RETRY_TIMEOUT))
309            .await
310        {
311            _ = self.provider.set(resolved.provider);
312        }
313        _ = self.attempts.fetch_add(1, Ordering::SeqCst);
314    }
315}
316fn string(value: &Value, key: &str) -> Option<String> {
317    value.get(key).and_then(Value::as_str).map(str::to_owned)
318}
319async fn fetch_discovery(
320    url: &str,
321    headers: &[(String, String)],
322    timeout: Option<Duration>,
323) -> Option<Value> {
324    let mut request = reqwest::Client::new().get(url);
325    if let Some(timeout) = timeout {
326        request = request.timeout(timeout);
327    }
328    for (key, value) in headers {
329        request = request.header(key, value);
330    }
331    let document: Value = request
332        .send()
333        .await
334        .ok()?
335        .error_for_status()
336        .ok()?
337        .json()
338        .await
339        .ok()?;
340    if let Some(issuer) = string(&document, "issuer").filter(|issuer| !issuer.is_empty())
341        && url::Url::parse(&issuer).is_err()
342    {
343        return None;
344    }
345    Some(document)
346}
347fn oauth_subject(profile: &Value) -> Result<String, String> {
348    super::remaining_profile::raw_subject(profile.get("id"))
349}
350fn oidc_subject(profile: &Value) -> Result<String, String> {
351    super::remaining_profile::raw_subject(profile.get("sub"))
352}
353struct GenericUserInfo {
354    url: Option<String>,
355    mapper: Option<Arc<dyn OAuthProfileMapper>>,
356}
357#[async_trait]
358impl OAuthUserInfoHandler for GenericUserInfo {
359    fn errors_are_exceptions(&self) -> bool {
360        false
361    }
362    async fn get_user_info(
363        &self,
364        request: OAuthUserInfoRequest,
365    ) -> Result<OAuthUserInfoResponse, String> {
366        // Grant tokens have already been verified when discovery supplies keys;
367        // direct client tokens are always verified by the shared admission path.
368        let decoded = request
369            .id_token
370            .as_deref()
371            .and_then(|token| token.split('.').nth(1))
372            .and_then(|payload| {
373                base64::engine::general_purpose::URL_SAFE_NO_PAD
374                    .decode(payload)
375                    .ok()
376            })
377            .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
378            .filter(|profile| {
379                profile
380                    .get("sub")
381                    .is_some_and(super::remaining_profile::truthy)
382                    && profile
383                        .get("email")
384                        .is_some_and(super::remaining_profile::truthy)
385            });
386        let mut raw = if let Some(mut profile) = decoded {
387            if let Some(object) = profile.as_object_mut() {
388                let id = object.get("sub").cloned().unwrap_or(Value::Null);
389                _ = object.entry("id").or_insert(id);
390                let verified = object.get("email_verified").cloned().unwrap_or(Value::Null);
391                _ = object.entry("emailVerified").or_insert(verified);
392                if let Some(image) = object.get("picture").cloned() {
393                    _ = object.entry("image").or_insert(image);
394                }
395            }
396            profile
397        } else {
398            let url = self
399                .url
400                .as_ref()
401                .ok_or("Generic OAuth user info unavailable")?;
402            let mut profile: Value = reqwest::Client::new()
403                .get(url)
404                .bearer_auth(request.access_token.as_deref().unwrap_or("undefined"))
405                .send()
406                .await
407                .map_err(|_| "Generic OAuth user info unavailable")?
408                .error_for_status()
409                .map_err(|_| "Generic OAuth user info unavailable")?
410                .json()
411                .await
412                .map_err(|_| "Generic OAuth user info unavailable")?;
413            if let Some(object) = profile.as_object_mut() {
414                _ = object.insert(
415                    "emailVerified".into(),
416                    object
417                        .get("email_verified")
418                        .cloned()
419                        .unwrap_or(Value::Bool(false)),
420                );
421                if let Some(image) = object.get("picture").cloned() {
422                    _ = object.insert("image".into(), image);
423                } else {
424                    _ = object.remove("image");
425                }
426            }
427            profile
428        };
429        let user = OAuthUserInfo {
430            additional_fields: Map::default(),
431            id: string(&raw, "id").unwrap_or_default(),
432            email: string(&raw, "email").unwrap_or_default(),
433            name: string(&raw, "name"),
434            image: string(&raw, "image"),
435            email_verified: raw
436                .get("emailVerified")
437                .is_some_and(super::remaining_profile::truthy),
438        };
439        let mut response = OAuthUserInfoResponse {
440            user_output: None,
441            user,
442            data: raw.take(),
443        };
444        if let Some(mapper) = &self.mapper {
445            let mapped = mapper
446                .map_profile(response.data.clone())
447                .await
448                .map_err(|_| {
449                    format!(
450                        "{}Generic OAuth mapping failed",
451                        super::remaining_profile::PROFILE_EXCEPTION_PREFIX
452                    )
453                })?;
454            super::apply_application_mapping(&mut response, mapped)?;
455        }
456        Ok(response)
457    }
458}
459struct DiscoveryVerifier {
460    keys: Arc<dyn OAuthJwksSource>,
461    issuer: String,
462    audience: String,
463    algorithms: Option<Vec<String>>,
464}
465#[async_trait]
466impl OAuthIdTokenVerifier for DiscoveryVerifier {
467    async fn verify_id_token(&self, token: &str, nonce: Option<&str>) -> Result<bool, String> {
468        Ok(self.verify(token, nonce).await.unwrap_or(false))
469    }
470}
471impl DiscoveryVerifier {
472    async fn verify(&self, token: &str, nonce: Option<&str>) -> Option<bool> {
473        let header = jsonwebtoken::decode_header(token).ok()?;
474        let alg_name = serde_json::to_value(header.alg).ok()?;
475        if self.algorithms.as_ref().is_some_and(|algorithms| {
476            !algorithms
477                .iter()
478                .any(|alg| Some(alg.as_str()) == alg_name.as_str())
479        }) {
480            return Some(false);
481        }
482        // Remote discovery keys are public verification keys, never HMAC secrets.
483        if matches!(
484            header.alg,
485            Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512
486        ) {
487            return Some(false);
488        }
489        let raw_header: Value = serde_json::from_slice(
490            &base64::engine::general_purpose::URL_SAFE_NO_PAD
491                .decode(token.split('.').next()?)
492                .ok()?,
493        )
494        .ok()?;
495        if let Some(critical) = raw_header.get("crit") {
496            let names = critical.as_array()?;
497            if names.is_empty()
498                || names.iter().any(|name| name.as_str() != Some("b64"))
499                || raw_header.get("b64").and_then(Value::as_bool) != Some(true)
500            {
501                return Some(false);
502            }
503        }
504        let keys = self.keys.fetch_keys().await.ok()?;
505        let matching: Vec<_> = keys
506            .iter()
507            .filter(|key| {
508                header
509                    .kid
510                    .as_ref()
511                    .is_none_or(|kid| key.get("kid").and_then(Value::as_str) == Some(kid))
512                    && key.get("d").is_none()
513                    && key.get("alg").is_none_or(|alg| alg == &alg_name)
514                    && key
515                        .get("use")
516                        .is_none_or(|usage| usage.as_str() == Some("sig"))
517                    && key.get("key_ops").is_none_or(|ops| {
518                        ops.as_array().is_some_and(|ops| {
519                            ops.iter().any(|op| op.as_str() == Some("verify"))
520                                && ops.iter().enumerate().all(|(index, op)| {
521                                    op.is_string()
522                                        && ops.iter().take(index).all(|previous| previous != op)
523                                })
524                        })
525                    })
526                    && match header.alg {
527                        Algorithm::RS256
528                        | Algorithm::RS384
529                        | Algorithm::RS512
530                        | Algorithm::PS256
531                        | Algorithm::PS384
532                        | Algorithm::PS512 => key.get("kty").and_then(Value::as_str) == Some("RSA"),
533                        Algorithm::ES256 | Algorithm::ES384 => {
534                            key.get("kty").and_then(Value::as_str) == Some("EC")
535                        }
536                        Algorithm::EdDSA => key.get("kty").and_then(Value::as_str) == Some("OKP"),
537                        _ => false,
538                    }
539            })
540            .collect();
541        if matching.len() != 1 {
542            return Some(false);
543        }
544        if matching.first()?.get("kty").and_then(Value::as_str) == Some("RSA")
545            && !crate::oauth::id_token::remote_rsa_public_key(matching.first()?)
546        {
547            return Some(false);
548        }
549        let jwk =
550            serde_json::from_value::<jsonwebtoken::jwk::Jwk>((*matching.first()?).clone()).ok()?;
551        let key = DecodingKey::from_jwk(&jwk).ok()?;
552        let (signed, signature) = token.rsplit_once('.')?;
553        if !jsonwebtoken::crypto::verify(signature, signed.as_bytes(), &key, header.alg).ok()? {
554            return Some(false);
555        }
556        let claims: Value = serde_json::from_slice(
557            &base64::engine::general_purpose::URL_SAFE_NO_PAD
558                .decode(token.split('.').nth(1)?)
559                .ok()?,
560        )
561        .ok()?;
562        if REQUIRED_CLAIMS
563            .iter()
564            .any(|claim| claims.get(claim).is_none())
565            || claims.get("iss").and_then(Value::as_str) != Some(self.issuer.as_str())
566        {
567            return Some(false);
568        }
569        let audience = claims.get("aud")?;
570        if audience.as_str() != Some(self.audience.as_str())
571            && !audience.as_array().is_some_and(|values| {
572                values
573                    .iter()
574                    .any(|value| value.as_str() == Some(self.audience.as_str()))
575            })
576        {
577            return Some(false);
578        }
579        if claims.get("iat").is_some_and(|iat| !iat.is_number()) {
580            return Some(false);
581        }
582        let now = std::time::SystemTime::now()
583            .duration_since(std::time::UNIX_EPOCH)
584            .ok()?
585            .as_secs_f64();
586        if let Some(exp) = claims.get("exp")
587            && exp.as_f64()? <= now - CLOCK_TOLERANCE_SECS
588        {
589            return Some(false);
590        }
591        if let Some(nbf) = claims.get("nbf")
592            && nbf.as_f64()? > now + CLOCK_TOLERANCE_SECS
593        {
594            return Some(false);
595        }
596        Some(
597            nonce
598                .filter(|nonce| !nonce.is_empty())
599                .is_none_or(|nonce| claims.get("nonce").and_then(Value::as_str) == Some(nonce)),
600        )
601    }
602}