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