Skip to main content

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