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