Skip to main content

alibi_plugins/oauth/providers/
cognito.rs

1//! Cognito's configured hosted domain, authenticated subject and profile fallback.
2use super::{
3    OAuthAuthorizationPolicy, OAuthProvider, OAuthScopeEncoding, OAuthTokenEndpointAuth,
4    OAuthUserInfo, OAuthUserInfoHandler, OAuthUserInfoRequest, OAuthUserInfoResponse,
5};
6use crate::oauth::{
7    HttpOAuthJwksSource, OAuthIdTokenConfig, OAuthJwksSelection, OAuthJwksSource,
8    OAuthNonceComparison,
9};
10use async_trait::async_trait;
11use base64::Engine;
12use serde_json::Map;
13use serde_json::Value;
14use std::sync::Arc;
15
16/// Immutable Cognito settings. Application callback and signup policies remain
17/// configurable on the returned provider.
18#[derive(Clone)]
19pub struct CognitoOptions {
20    pub client_ids: Vec<String>,
21    pub client_secret: Option<String>,
22    /// Forwarded only during authorization-code exchange, as the pinned helper
23    /// ignores this option during refresh.
24    pub client_key: Option<String>,
25    pub domain: String,
26    pub region: String,
27    pub user_pool_id: String,
28    pub require_client_secret: bool,
29    pub scope: Vec<String>,
30    pub disable_default_scope: bool,
31    pub prompt: Option<String>,
32    pub identity_provider: Option<String>,
33    pub authorization_endpoint: Option<String>,
34    pub redirect_uri: Option<String>,
35    /// Application-owned transport override; token claims cannot select it.
36    pub jwks_source: Option<Arc<dyn OAuthJwksSource>>,
37    /// Trusted user-info transport override retaining Cognito's profile mapping.
38    pub user_info_endpoint: Option<String>,
39    /// Receives the original profile, with the ID-token name fallback applied.
40    /// Its mapped ID cannot replace the authenticated raw `sub`.
41    pub map_profile_to_user: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
42}
43impl CognitoOptions {
44    #[must_use]
45    pub fn new(
46        client_id: impl Into<String>,
47        client_secret: Option<String>,
48        domain: impl Into<String>,
49        region: impl Into<String>,
50        user_pool_id: impl Into<String>,
51    ) -> Self {
52        Self {
53            client_ids: vec![client_id.into()],
54            client_secret,
55            client_key: None,
56            domain: domain.into(),
57            region: region.into(),
58            user_pool_id: user_pool_id.into(),
59            require_client_secret: false,
60            scope: Vec::new(),
61            disable_default_scope: false,
62            prompt: None,
63            identity_provider: None,
64            authorization_endpoint: None,
65            redirect_uri: None,
66            jwks_source: None,
67            user_info_endpoint: None,
68            map_profile_to_user: None,
69        }
70    }
71}
72impl std::fmt::Debug for CognitoOptions {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("CognitoOptions")
75            .field("client_ids", &self.client_ids)
76            .field("domain", &self.domain)
77            .field("region", &self.region)
78            .field("user_pool_id", &self.user_pool_id)
79            .field("scope", &self.scope)
80            .field("disable_default_scope", &self.disable_default_scope)
81            .field("require_client_secret", &self.require_client_secret)
82            .finish_non_exhaustive()
83    }
84}
85impl OAuthProvider {
86    /// Builds the published Cognito provider from application-owned configuration.
87    ///
88    /// # Errors
89    /// Returns an error if the domain, region or user pool is empty.
90    pub fn cognito(options: CognitoOptions) -> Result<Self, String> {
91        if options.domain.is_empty() || options.region.is_empty() || options.user_pool_id.is_empty()
92        {
93            return Err("DOMAIN_AND_REGION_REQUIRED".into());
94        }
95        let domain = options
96            .domain
97            .strip_prefix("https://")
98            .or_else(|| options.domain.strip_prefix("http://"))
99            .unwrap_or(&options.domain);
100        let issuer = format!(
101            "https://cognito-idp.{}.amazonaws.com/{}",
102            options.region, options.user_pool_id
103        );
104        let endpoint = options
105            .user_info_endpoint
106            .unwrap_or_else(|| format!("https://{domain}/oauth2/userinfo"));
107        let verification = OAuthIdTokenConfig {
108            issuers: vec![issuer.clone()],
109            audience: None,
110            client_ids: Some(options.client_ids.clone()),
111            jwks_source: options.jwks_source.unwrap_or_else(|| {
112                Arc::new(HttpOAuthJwksSource::new(format!(
113                    "{issuer}/.well-known/jwks.json"
114                )))
115            }),
116            max_age_secs: Some(3600),
117            allow_opaque_token: false,
118            algorithm: None,
119            algorithms: Some(vec![jsonwebtoken::Algorithm::RS256]),
120            default_key_algorithm: None,
121            selection: OAuthJwksSelection::ExactKid,
122            nonce_comparison: OAuthNonceComparison::Exact,
123            verify_claims: None,
124        };
125        let mut clients = options.client_ids.into_iter();
126        let token_endpoint_auth = if options
127            .client_secret
128            .as_ref()
129            .is_some_and(|value| !value.is_empty())
130        {
131            OAuthTokenEndpointAuth::ClientSecretPost
132        } else {
133            OAuthTokenEndpointAuth::None
134        };
135        Ok(Self {
136            client_id: clients.next().unwrap_or_default(),
137            additional_client_ids: clients.collect(),
138            client_secret: options.client_secret.unwrap_or_default(),
139            hosted_domain: None,
140            require_email_verification: false,
141            auth_url: options
142                .authorization_endpoint
143                .filter(|value| !value.is_empty())
144                .unwrap_or_else(|| format!("https://{domain}/oauth2/authorize")),
145            token_url: format!("https://{domain}/oauth2/token"),
146            user_info_url: Some(endpoint.clone()),
147            scopes: vec!["openid".into(), "profile".into(), "email".into()],
148            authorization: Some(OAuthAuthorizationPolicy {
149                configured_scopes: options.scope,
150                scope_encoding: OAuthScopeEncoding::UriComponent,
151                disable_default_scopes: options.disable_default_scope,
152                require_client_id: true,
153                require_client_secret: options.require_client_secret,
154                token_endpoint_auth: Some(token_endpoint_auth),
155                authorization_code_client_key: options.client_key,
156                prompt: options.prompt,
157                redirect_uri: options.redirect_uri,
158                login_hint: false,
159                ..OAuthAuthorizationPolicy::default()
160            }),
161            allowed_request_params: Vec::new(),
162            authorization_params: options
163                .identity_provider
164                .map(|value| vec![("identity_provider".into(), value)])
165                .unwrap_or_default(),
166            map_user_info: None,
167            account_subject: Some(subject),
168            get_user_info: Some(Arc::new(CognitoUserInfo {
169                endpoint,
170                mapper: options.map_profile_to_user,
171            })),
172            refresh_access_token: None,
173            verify_id_token: None,
174            id_token: Some(verification),
175            disable_id_token_sign_in: false,
176            disable_implicit_sign_up: false,
177            disable_sign_up: false,
178            allow_idp_initiated: false,
179            override_user_info_on_sign_in: false,
180        })
181    }
182}
183struct CognitoUserInfo {
184    endpoint: String,
185    mapper: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
186}
187#[async_trait]
188impl OAuthUserInfoHandler for CognitoUserInfo {
189    async fn get_user_info(
190        &self,
191        request: OAuthUserInfoRequest,
192    ) -> Result<OAuthUserInfoResponse, String> {
193        if let Some(token) = request.id_token.filter(|value| !value.is_empty())
194            && let Ok(mut profile) = decode_profile(&token)
195        {
196            let name = profile_name(&profile);
197            if let Some(object) = profile.as_object_mut() {
198                _ = object.insert("name".into(), name);
199            }
200            // Only decoding or the application's mapper may select the access-token
201            // fallback. An invalid raw subject remains an admission failure.
202            if let Ok(mapped) = self
203                .mapper
204                .map(|mapper| mapper(profile.clone()))
205                .transpose()
206            {
207                return finish_profile(profile, mapped);
208            }
209        }
210        let access_token = request
211            .access_token
212            .filter(|value| !value.is_empty())
213            .ok_or("Missing Cognito access token")?;
214        let profile: Value = reqwest::Client::new()
215            .get(&self.endpoint)
216            .bearer_auth(access_token)
217            .send()
218            .await
219            .map_err(|error| error.to_string())?
220            .error_for_status()
221            .map_err(|error| error.to_string())?
222            .json()
223            .await
224            .map_err(|error| error.to_string())?;
225        let mapped = self
226            .mapper
227            .map(|mapper| mapper(profile.clone()))
228            .transpose()?;
229        finish_profile(profile, mapped)
230    }
231}
232fn decode_profile(token: &str) -> Result<Value, String> {
233    let parts: Vec<_> = token.split('.').collect();
234    let [_, payload, _] = parts.as_slice() else {
235        return Err("Invalid Cognito ID token".into());
236    };
237    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
238        .decode(payload)
239        .map_err(|error| error.to_string())?;
240    let profile: Value =
241        alibi_core::utils::json::from_slice(&bytes).map_err(|error| error.to_string())?;
242    if !profile.is_object() {
243        return Err("Invalid Cognito ID-token claims set".into());
244    }
245    Ok(profile)
246}
247fn scalar(value: Option<&Value>) -> Result<Option<String>, String> {
248    match value {
249        None | Some(Value::Null) => Ok(None),
250        Some(Value::String(value)) => Ok(Some(value.clone())),
251        Some(Value::Number(value)) => alibi_core::utils::json::number_to_string(value)
252            .map(Some)
253            .map_err(|error| error.to_string()),
254        Some(Value::Bool(value)) => Ok(Some(value.to_string())),
255        Some(Value::Array(_) | Value::Object(_)) => Err("Invalid Cognito profile field".into()),
256    }
257}
258fn truthy(value: &Value) -> bool {
259    match value {
260        Value::Null | Value::Bool(false) => false,
261        Value::Number(value) => value.as_f64() != Some(0.0),
262        Value::String(value) => !value.is_empty(),
263        Value::Bool(true) | Value::Array(_) | Value::Object(_) => true,
264    }
265}
266fn profile_name(profile: &Value) -> Value {
267    ["name", "given_name", "username"]
268        .into_iter()
269        .filter_map(|key| profile.get(key))
270        .find(|value| truthy(value))
271        .cloned()
272        .unwrap_or_else(|| Value::String(String::new()))
273}
274fn finish_profile(
275    profile: Value,
276    mapped: Option<OAuthUserInfo>,
277) -> Result<OAuthUserInfoResponse, String> {
278    let id = scalar(profile.get("sub"))?.unwrap_or_default();
279    let user_output = mapped.as_ref().map(|user| user.public_profile(true));
280    let user = match mapped {
281        Some(user) => user,
282        None => OAuthUserInfo {
283            additional_fields: Map::default(),
284            id,
285            email: profile
286                .get("email")
287                .and_then(Value::as_str)
288                .unwrap_or_default()
289                .into(),
290            name: Some(scalar(Some(&profile_name(&profile)))?.unwrap_or_default()),
291            image: scalar(profile.get("picture"))?,
292            email_verified: profile
293                .get("email_verified")
294                .and_then(Value::as_bool)
295                .unwrap_or(false),
296        },
297    };
298    Ok(OAuthUserInfoResponse {
299        user_output,
300        user,
301        data: profile,
302    })
303}
304fn subject(profile: &Value) -> Result<String, String> {
305    let id = scalar(profile.get("sub"))?.ok_or("Missing Cognito subject")?;
306    if id
307        .trim_matches(|character: char| {
308            (character.is_whitespace() && character != '\u{85}') || character == '\u{feff}'
309        })
310        .is_empty()
311        || id == "null"
312        || id == "undefined"
313    {
314        return Err("Invalid Cognito subject".into());
315    }
316    Ok(id)
317}