alibi_plugins/oauth/providers/
cognito.rs1use 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#[derive(Clone)]
19pub struct CognitoOptions {
20 pub client_ids: Vec<String>,
21 pub client_secret: Option<String>,
22 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 pub jwks_source: Option<Arc<dyn OAuthJwksSource>>,
37 pub user_info_endpoint: Option<String>,
39 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 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 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}