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
213            .configured_scopes
214            .splice(0..0, std::mem::take(&mut self.provider.scopes));
215        policy.end_session = if self.disable_provider_logout {
216            None
217        } else {
218            metadata
219                .end_session_endpoint
220                .clone()
221                .map(|endpoint| super::OAuthEndSessionConfig {
222                    endpoint,
223                    post_logout_redirect_uri: self.post_logout_redirect_uri,
224                })
225        };
226        policy.verify_grant_id_token = metadata.jwks_url.is_some();
227        policy.id_token_nonce_binding =
228            policy.verify_grant_id_token && !self.disable_id_token_nonce_binding;
229        policy.discovery_openid_scope = is_oidc;
230        policy.source_profile_exceptions = true;
231        self.provider.auth_url = self.authorization_url.unwrap_or_default();
232        self.provider.token_url = self.token_url.unwrap_or_default();
233        self.provider.user_info_url.clone_from(&self.user_info_url);
234        if self.provider.account_subject.is_none() {
235            self.provider.account_subject = Some(if policy.discovery_openid_scope {
236                oidc_subject
237            } else {
238                oauth_subject
239            });
240        }
241        if self.provider.get_user_info.is_none() {
242            self.provider.get_user_info = Some(Arc::new(GenericUserInfo {
243                url: self.user_info_url,
244                mapper: self.map_profile,
245            }));
246        }
247        Ok(Some(GenericOAuthResolved {
248            provider: self.provider,
249            metadata,
250        }))
251    }
252}
253fn string(value: &Value, key: &str) -> Option<String> {
254    value.get(key).and_then(Value::as_str).map(str::to_owned)
255}
256async fn fetch_discovery(url: &str, headers: &[(String, String)]) -> Option<Value> {
257    let mut request = reqwest::Client::new().get(url);
258    for (key, value) in headers {
259        request = request.header(key, value);
260    }
261    let document: Value = request
262        .send()
263        .await
264        .ok()?
265        .error_for_status()
266        .ok()?
267        .json()
268        .await
269        .ok()?;
270    if let Some(issuer) = string(&document, "issuer").filter(|issuer| !issuer.is_empty())
271        && url::Url::parse(&issuer).is_err()
272    {
273        return None;
274    }
275    Some(document)
276}
277fn oauth_subject(profile: &Value) -> Result<String, String> {
278    super::remaining_profile::raw_subject(profile.get("id"))
279}
280fn oidc_subject(profile: &Value) -> Result<String, String> {
281    super::remaining_profile::raw_subject(profile.get("sub"))
282}
283struct GenericUserInfo {
284    url: Option<String>,
285    mapper: Option<Arc<dyn OAuthProfileMapper>>,
286}
287#[async_trait]
288impl OAuthUserInfoHandler for GenericUserInfo {
289    fn errors_are_exceptions(&self) -> bool {
290        false
291    }
292    async fn get_user_info(
293        &self,
294        request: OAuthUserInfoRequest,
295    ) -> Result<OAuthUserInfoResponse, String> {
296        // Grant tokens have already been verified when discovery supplies keys;
297        // direct client tokens are always verified by the shared admission path.
298        let decoded = request
299            .id_token
300            .as_deref()
301            .and_then(|token| token.split('.').nth(1))
302            .and_then(|payload| {
303                base64::engine::general_purpose::URL_SAFE_NO_PAD
304                    .decode(payload)
305                    .ok()
306            })
307            .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
308            .filter(|profile| {
309                profile
310                    .get("sub")
311                    .is_some_and(super::remaining_profile::truthy)
312                    && profile
313                        .get("email")
314                        .is_some_and(super::remaining_profile::truthy)
315            });
316        let mut raw = if let Some(mut profile) = decoded {
317            if let Some(object) = profile.as_object_mut() {
318                let id = object.get("sub").cloned().unwrap_or(Value::Null);
319                _ = object.entry("id").or_insert(id);
320                let verified = object.get("email_verified").cloned().unwrap_or(Value::Null);
321                _ = object.entry("emailVerified").or_insert(verified);
322                if let Some(image) = object.get("picture").cloned() {
323                    _ = object.entry("image").or_insert(image);
324                }
325            }
326            profile
327        } else {
328            let url = self
329                .url
330                .as_ref()
331                .ok_or("Generic OAuth user info unavailable")?;
332            let mut profile: Value = reqwest::Client::new()
333                .get(url)
334                .bearer_auth(request.access_token.as_deref().unwrap_or("undefined"))
335                .send()
336                .await
337                .map_err(|_| "Generic OAuth user info unavailable")?
338                .error_for_status()
339                .map_err(|_| "Generic OAuth user info unavailable")?
340                .json()
341                .await
342                .map_err(|_| "Generic OAuth user info unavailable")?;
343            if let Some(object) = profile.as_object_mut() {
344                _ = object.insert(
345                    "emailVerified".into(),
346                    object
347                        .get("email_verified")
348                        .cloned()
349                        .unwrap_or(Value::Bool(false)),
350                );
351                if let Some(image) = object.get("picture").cloned() {
352                    _ = object.insert("image".into(), image);
353                } else {
354                    _ = object.remove("image");
355                }
356            }
357            profile
358        };
359        let user = OAuthUserInfo {
360            additional_fields: Map::default(),
361            id: string(&raw, "id").unwrap_or_default(),
362            email: string(&raw, "email").unwrap_or_default(),
363            name: string(&raw, "name"),
364            image: string(&raw, "image"),
365            email_verified: raw
366                .get("emailVerified")
367                .is_some_and(super::remaining_profile::truthy),
368        };
369        let mut response = OAuthUserInfoResponse {
370            user_output: None,
371            user,
372            data: raw.take(),
373        };
374        if let Some(mapper) = &self.mapper {
375            let mapped = mapper
376                .map_profile(response.data.clone())
377                .await
378                .map_err(|_| {
379                    format!(
380                        "{}Generic OAuth mapping failed",
381                        super::remaining_profile::PROFILE_EXCEPTION_PREFIX
382                    )
383                })?;
384            super::apply_application_mapping(&mut response, mapped)?;
385        }
386        Ok(response)
387    }
388}
389struct DiscoveryVerifier {
390    keys: Arc<dyn OAuthJwksSource>,
391    issuer: String,
392    audience: String,
393    algorithms: Option<Vec<String>>,
394}
395#[async_trait]
396impl OAuthIdTokenVerifier for DiscoveryVerifier {
397    async fn verify_id_token(&self, token: &str, nonce: Option<&str>) -> Result<bool, String> {
398        Ok(self.verify(token, nonce).await.unwrap_or(false))
399    }
400}
401impl DiscoveryVerifier {
402    async fn verify(&self, token: &str, nonce: Option<&str>) -> Option<bool> {
403        let header = jsonwebtoken::decode_header(token).ok()?;
404        let alg_name = serde_json::to_value(header.alg).ok()?;
405        if self.algorithms.as_ref().is_some_and(|algorithms| {
406            !algorithms
407                .iter()
408                .any(|alg| Some(alg.as_str()) == alg_name.as_str())
409        }) {
410            return Some(false);
411        }
412        // Remote discovery keys are public verification keys, never HMAC secrets.
413        if matches!(
414            header.alg,
415            Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512
416        ) {
417            return Some(false);
418        }
419        let raw_header: Value = serde_json::from_slice(
420            &base64::engine::general_purpose::URL_SAFE_NO_PAD
421                .decode(token.split('.').next()?)
422                .ok()?,
423        )
424        .ok()?;
425        if let Some(critical) = raw_header.get("crit") {
426            let names = critical.as_array()?;
427            if names.is_empty()
428                || names.iter().any(|name| name.as_str() != Some("b64"))
429                || raw_header.get("b64").and_then(Value::as_bool) != Some(true)
430            {
431                return Some(false);
432            }
433        }
434        let keys = self.keys.fetch_keys().await.ok()?;
435        let matching: Vec<_> = keys
436            .iter()
437            .filter(|key| {
438                header
439                    .kid
440                    .as_ref()
441                    .is_none_or(|kid| key.get("kid").and_then(Value::as_str) == Some(kid))
442                    && key.get("d").is_none()
443                    && key.get("alg").is_none_or(|alg| alg == &alg_name)
444                    && key
445                        .get("use")
446                        .is_none_or(|usage| usage.as_str() == Some("sig"))
447                    && key.get("key_ops").is_none_or(|ops| {
448                        ops.as_array().is_some_and(|ops| {
449                            ops.iter().any(|op| op.as_str() == Some("verify"))
450                                && ops.iter().enumerate().all(|(index, op)| {
451                                    op.is_string()
452                                        && ops.iter().take(index).all(|previous| previous != op)
453                                })
454                        })
455                    })
456                    && match header.alg {
457                        Algorithm::RS256
458                        | Algorithm::RS384
459                        | Algorithm::RS512
460                        | Algorithm::PS256
461                        | Algorithm::PS384
462                        | Algorithm::PS512 => key.get("kty").and_then(Value::as_str) == Some("RSA"),
463                        Algorithm::ES256 | Algorithm::ES384 => {
464                            key.get("kty").and_then(Value::as_str) == Some("EC")
465                        }
466                        Algorithm::EdDSA => key.get("kty").and_then(Value::as_str) == Some("OKP"),
467                        _ => false,
468                    }
469            })
470            .collect();
471        if matching.len() != 1 {
472            return Some(false);
473        }
474        if matching.first()?.get("kty").and_then(Value::as_str) == Some("RSA")
475            && !crate::oauth::id_token::remote_rsa_public_key(matching.first()?)
476        {
477            return Some(false);
478        }
479        let jwk =
480            serde_json::from_value::<jsonwebtoken::jwk::Jwk>((*matching.first()?).clone()).ok()?;
481        let key = DecodingKey::from_jwk(&jwk).ok()?;
482        let (signed, signature) = token.rsplit_once('.')?;
483        if !jsonwebtoken::crypto::verify(signature, signed.as_bytes(), &key, header.alg).ok()? {
484            return Some(false);
485        }
486        let claims: Value = serde_json::from_slice(
487            &base64::engine::general_purpose::URL_SAFE_NO_PAD
488                .decode(token.split('.').nth(1)?)
489                .ok()?,
490        )
491        .ok()?;
492        if claims.get("iss").and_then(Value::as_str) != Some(self.issuer.as_str()) {
493            return Some(false);
494        }
495        let audience = claims.get("aud")?;
496        if audience.as_str() != Some(self.audience.as_str())
497            && !audience.as_array().is_some_and(|values| {
498                values
499                    .iter()
500                    .any(|value| value.as_str() == Some(self.audience.as_str()))
501            })
502        {
503            return Some(false);
504        }
505        if claims.get("iat").is_some_and(|iat| !iat.is_number()) {
506            return Some(false);
507        }
508        let now = std::time::SystemTime::now()
509            .duration_since(std::time::UNIX_EPOCH)
510            .ok()?
511            .as_secs_f64();
512        if let Some(exp) = claims.get("exp")
513            && exp.as_f64()? <= now
514        {
515            return Some(false);
516        }
517        if let Some(nbf) = claims.get("nbf")
518            && nbf.as_f64()? > now
519        {
520            return Some(false);
521        }
522        Some(
523            nonce
524                .filter(|nonce| !nonce.is_empty())
525                .is_none_or(|nonce| claims.get("nonce").and_then(Value::as_str) == Some(nonce)),
526        )
527    }
528}