Skip to main content

alibi_api/plugins/oauth/providers/
generic.rs

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