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