1use super::OAuthProviderPlugin;
3use alibi_core::endpoint::{EndpointResponse, ServerEndpoint};
4use alibi_core::session::SessionRequest;
5use alibi_core::utils::json::JsValue;
6use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, AuthUser};
7use base64::Engine;
8use hmac::{Hmac, KeyInit, Mac};
9use serde_json::{Value, json};
10use sha2::Sha256;
11
12struct AuthorizationRequest<'a, R> {
16 original: &'a R,
17 call: alibi_core::endpoint::EndpointCall,
18}
19struct AuthorizationAdmission {
20 query: serde_json::Map<String, Value>,
21 validation: Result<AuthorizationParameters, (&'static str, String)>,
22 nonfinite_values: std::collections::BTreeMap<String, JsValue>,
23 reserved_error: Option<(&'static str, &'static str)>,
24}
25#[derive(Default)]
26pub(super) struct AuthorizationSettings {
27 pub initial: bool,
28 pub post_login_cleared_for_session: Option<String>,
29}
30impl<R: SessionRequest> SessionRequest for AuthorizationRequest<'_, R> {
31 fn endpoint_call(&self) -> Option<&alibi_core::endpoint::EndpointCall> {
32 Some(&self.call)
33 }
34 fn physical_request(&self) -> Option<&alibi_core::AuthRequest> {
35 self.original.physical_request()
36 }
37 fn session_headers(&self) -> &std::collections::HashMap<String, String> {
38 self.original.session_headers()
39 }
40 fn session_method(&self) -> &alibi_core::HttpMethod {
41 self.original.session_method()
42 }
43 fn session_query_truthy(&self, name: &str) -> bool {
44 self.call.session_query_truthy(name)
45 }
46 fn extensions(&self) -> &alibi_core::types::RequestExtensions {
47 self.original.extensions()
48 }
49 fn virtual_session<S: AuthSchema>(
50 &self,
51 ctx: &AuthContext<S>,
52 ) -> Option<alibi_core::SessionView> {
53 self.original.virtual_session(ctx)
54 }
55 fn authenticated_user<S: AuthSchema>(&self, ctx: &AuthContext<S>) -> Option<S::User> {
56 self.original.authenticated_user(ctx)
57 }
58 fn take_response_headers(&self) -> alibi_core::Headers {
59 self.original.take_response_headers()
60 }
61 fn queue_response_header(&self, name: impl Into<String>, value: impl Into<String>) {
62 self.original.queue_response_header(name, value);
63 }
64 fn set_session_hook_snapshot(
65 &self,
66 user: alibi_core::UserView,
67 session: alibi_core::SessionView,
68 ) {
69 self.original.set_session_hook_snapshot(user, session);
70 }
71}
72impl OAuthProviderPlugin {
73 pub fn oauth2_authorize_endpoint(
75 query: &impl serde::Serialize,
76 ) -> AuthResult<ServerEndpoint<Value>> {
77 ServerEndpoint::new("oauth-provider", "oauth2Authorize").with_query(query)
78 }
79 pub(super) async fn authorize<S: AuthSchema>(
80 &self,
81 input: Option<JsValue>,
82 req: &impl SessionRequest,
83 ctx: &AuthContext<S>,
84 ) -> AuthResult<EndpointResponse> {
85 Box::pin(self.authorize_flow(
86 input,
87 req,
88 ctx,
89 AuthorizationSettings {
90 initial: true,
91 ..Default::default()
92 },
93 ))
94 .await
95 }
96 pub(super) async fn authorize_flow<S: AuthSchema>(
97 &self,
98 mut input: Option<JsValue>,
99 req: &impl SessionRequest,
100 ctx: &AuthContext<S>,
101 settings: AuthorizationSettings,
102 ) -> AuthResult<EndpointResponse> {
103 if !self
104 .config
105 .grant_types
106 .iter()
107 .any(|grant| grant == "authorization_code")
108 {
109 return Ok(EndpointResponse::error(AuthError::Api {
110 status: 404,
111 code: None,
112 message: String::new(),
113 })
114 .without_error_body()
115 .with_header("Content-Type", "application/json"));
116 }
117 if req.physical_request().is_none() {
118 return super::clients::status_protocol_error(
119 401,
120 "invalid_request",
121 "request not found",
122 );
123 }
124 let string = |name: &str| {
128 input
129 .as_ref()
130 .and_then(|value| value.get(name))
131 .and_then(JsValue::as_str)
132 };
133 let request_error = match (string("request"), string("request_uri")) {
134 (Some(_), Some(_)) => Some((
135 "invalid_request",
136 "request and request_uri cannot be used together",
137 )),
138 (Some(_), None) => Some(("request_not_supported", "request object not supported")),
139 (None, Some(uri)) => {
140 match (
141 string("client_id").filter(|id| !id.is_empty()),
142 &self.config.request_uri_resolver,
143 ) {
144 (None, _) => Some(("invalid_request", "client_id is required")),
145 (Some(_), None) => {
146 Some(("request_uri_not_supported", "request_uri not supported"))
147 }
148 (Some(client_id), Some(resolver)) => {
149 let context = alibi_core::CallbackContext::new(ctx, req.physical_request());
150 let resolved = resolver
151 .resolve(super::OAuthRequestUriInput {
152 request_uri: uri,
153 client_id,
154 context: &context,
155 })
156 .await
157 .map_err(|error| {
158 if alibi_core::endpoint::is_endpoint_api_error(&error) {
159 error
160 } else {
161 AuthError::CallbackFailure(Box::new(error))
162 }
163 })?;
164 if let Some(mut parameters) = resolved {
165 _ = parameters
166 .insert("client_id".into(), JsValue::String(client_id.into()));
167 input = Some(JsValue::Object(parameters));
168 None
169 } else {
170 Some(("invalid_request_uri", "request_uri is invalid or expired"))
171 }
172 }
173 }
174 }
175 (None, None) => None,
176 };
177 if let Some(call) = alibi_core::endpoint::current_endpoint_call_context() {
181 let effective = AuthorizationRequest {
182 original: req,
183 call: call.handler_context(call.body().cloned(), input.clone()),
184 };
185 return alibi_core::endpoint::with_endpoint_call_context(
186 effective.call.clone(),
187 Box::pin(self.authorize_parameters(
188 input,
189 &effective,
190 ctx,
191 settings,
192 request_error,
193 )),
194 )
195 .await;
196 }
197 Box::pin(self.authorize_parameters(input, req, ctx, settings, request_error)).await
198 }
199 async fn authorize_parameters<S: AuthSchema>(
200 &self,
201 input: Option<JsValue>,
202 req: &impl SessionRequest,
203 ctx: &AuthContext<S>,
204 settings: AuthorizationSettings,
205 reserved_error: Option<(&'static str, &'static str)>,
206 ) -> AuthResult<EndpointResponse> {
207 let validated_max_age = maximum_age(input.as_ref().and_then(|input| input.get("max_age")));
208 let nonfinite_values = nonfinite_query_values(input.as_ref());
209 let mut logical_query = input
210 .clone()
211 .unwrap_or_else(|| JsValue::Object(super::OAuthAuthorizationQuery::new()));
212 let mut query: serde_json::Map<String, Value> = input
213 .and_then(|value| alibi_core::utils::json::from_value(value).ok())
214 .unwrap_or_default();
215 let validation = authorization_parameters(&query, validated_max_age);
216 if reserved_error.is_none()
219 && let Ok(parameters) = &validation
220 && let Some(redirect) = ¶meters.redirect_uri
221 {
222 _ = query.insert("redirect_uri".into(), json!(redirect));
223 if let JsValue::Object(fields) = &mut logical_query {
224 _ = fields.insert("redirect_uri".into(), JsValue::String(redirect.clone()));
225 }
226 }
227 let logical = alibi_core::endpoint::current_endpoint_call_context()
228 .map(|call| call.handler_context(call.body().cloned(), Some(logical_query)));
229 let run = Box::pin(self.authorize_admission(
230 AuthorizationAdmission {
231 query,
232 validation,
233 nonfinite_values,
234 reserved_error,
235 },
236 req,
237 ctx,
238 settings,
239 ));
240 if let Some(logical) = logical {
241 alibi_core::endpoint::with_endpoint_call_context(logical, run).await
242 } else {
243 run.await
244 }
245 }
246 async fn authorize_admission<S: AuthSchema>(
247 &self,
248 input: AuthorizationAdmission,
249 req: &impl SessionRequest,
250 ctx: &AuthContext<S>,
251 settings: AuthorizationSettings,
252 ) -> AuthResult<EndpointResponse> {
253 let AuthorizationAdmission {
254 mut query,
255 validation,
256 nonfinite_values,
257 reserved_error,
258 } = input;
259 let error_mode = authorization_error_mode(&query);
260 let text = |name: &str| {
261 query
262 .get(name)
263 .and_then(Value::as_str)
264 .unwrap_or_default()
265 .to_owned()
266 };
267 let client_id = text("client_id");
268 let redirect_uri = text("redirect_uri");
269 let response_type = text("response_type");
270 let state = text("state");
271 let server_error = format!(
272 "{}{}/error",
273 ctx.config.base_url.trim_end_matches('/'),
274 ctx.config.base_path
275 );
276 if reserved_error.is_none()
277 && response_type == "code"
278 && validation.as_ref().is_ok_and(|parameters| {
279 parameters
280 .prompts
281 .contains(&AuthorizationPrompt::SelectAccount)
282 })
283 && self.select_account_page().is_none_or(str::is_empty)
284 {
285 return browser_redirect(
286 req,
287 &error_url(
288 &server_error,
289 "unsupported_prompt_select_account",
290 "unsupported prompt type",
291 None,
292 None,
293 )?,
294 );
295 }
296 let error_only =
297 reserved_error.is_some() || validation.is_err() || response_type.is_empty();
298 let client = if error_only {
299 if client_id.is_empty() || redirect_uri.is_empty() {
300 None
301 } else {
302 super::discovery::resolve(ctx, &client_id)
303 .await
304 .unwrap_or(None)
305 }
306 } else {
307 super::discovery::resolve(ctx, &client_id).await?
308 };
309 let authorization_code_allowed = client.as_ref().is_some_and(|client| {
310 client
311 .metadata
312 .get("grant_types")
313 .and_then(Value::as_array)
314 .is_some_and(|grants| grants.contains(&json!("authorization_code")))
315 });
316 let trusted = if let Some(client) = client.as_ref().filter(|client| {
320 client.metadata.get("disabled") != Some(&json!(true))
321 && (error_only || authorization_code_allowed)
322 }) {
323 self.authorization_redirect_is_trusted(&client.metadata, &redirect_uri)
324 .await?
325 } else {
326 false
327 };
328 let issuer = self
329 .metadata(ctx)?
330 .get("issuer")
331 .and_then(Value::as_str)
332 .unwrap_or_default()
333 .to_owned();
334 let admission_error = |error: &str, description: &str| {
335 let target = if trusted {
336 redirect_uri.as_str()
337 } else {
338 server_error.as_str()
339 };
340 let url = error_url(
341 target,
342 error,
343 description,
344 if trusted { Some(state.as_str()) } else { None },
345 if trusted { Some(issuer.as_str()) } else { None },
346 )?;
347 browser_redirect(req, &url)
348 };
349 let request_error = |error: &str, description: &str| {
350 if trusted && error_mode == AuthorizationErrorMode::Fragment {
351 browser_redirect(
352 req,
353 &fragment_error_url(
354 &redirect_uri,
355 error,
356 description,
357 Some(&state),
358 Some(&issuer),
359 ),
360 )
361 } else {
362 admission_error(error, description)
363 }
364 };
365 if let Some((error, description)) = reserved_error {
366 return request_error(error, description);
367 }
368 let parameters = match validation {
369 Ok(parameters) => parameters,
370 Err((error, description)) => return request_error(error, &description),
371 };
372 if response_type.is_empty() {
373 return request_error("invalid_request", "response_type is required");
374 }
375 let prompts = parameters.prompts;
376 let max_age = parameters.max_age;
377 let Some(client) = client else {
378 return browser_redirect(
379 req,
380 &error_url(
381 &server_error,
382 "invalid_client",
383 "client_id is required",
384 None,
385 None,
386 )?,
387 );
388 };
389 if client.metadata.get("disabled") == Some(&json!(true)) {
390 return browser_redirect(
391 req,
392 &error_url(
393 &server_error,
394 "client_disabled",
395 "client is disabled",
396 None,
397 None,
398 )?,
399 );
400 }
401 if !authorization_code_allowed {
402 return browser_redirect(
403 req,
404 &error_url(
405 &server_error,
406 "unauthorized_client",
407 "client is not authorized to use the authorization_code grant",
408 None,
409 None,
410 )?,
411 );
412 }
413 if !trusted {
414 return browser_redirect(
415 req,
416 &error_url(
417 &server_error,
418 "invalid_redirect",
419 "invalid redirect uri",
420 None,
421 None,
422 )?,
423 );
424 }
425 let allowed = client
426 .metadata
427 .get("scope")
428 .and_then(Value::as_str)
429 .map_or_else(
430 || self.config.scopes.clone(),
431 |scope| scope.split(' ').map(str::to_owned).collect(),
432 );
433 let scope = query
434 .get("scope")
435 .and_then(Value::as_str)
436 .map_or_else(|| allowed.join(" "), str::to_owned);
437 let requested = scope
438 .split(' ')
439 .filter(|scope| !scope.is_empty())
440 .collect::<Vec<_>>();
441 let invalid = requested
442 .iter()
443 .filter(|scope| !allowed.iter().any(|allowed| allowed == **scope))
444 .copied()
445 .collect::<Vec<_>>();
446 if !invalid.is_empty() {
447 return admission_error(
448 "invalid_scope",
449 &format!("The following scopes are invalid: {}", invalid.join(", ")),
450 );
451 }
452 let requested_user_info_claims = if let Some(claims) = query.get("claims") {
453 if !requested.contains(&"openid") {
454 return admission_error(
455 "invalid_request",
456 "openid scope must be requested when using the claims parameter",
457 );
458 }
459 let Some(claims) = super::claims_request::parse(claims) else {
460 return admission_error(
461 "invalid_request",
462 "claims must be a valid Claims request object",
463 );
464 };
465 if !claims.acr_satisfied {
466 return admission_error("access_denied", "essential acr requirement cannot be met");
467 }
468 let supported = self.supported_claims();
469 claims
470 .user_info
471 .into_iter()
472 .filter(|name| supported.contains(name))
473 .collect::<Vec<_>>()
474 } else {
475 Vec::new()
476 };
477 if let Err(error) = self
478 .admit_resources(ctx, &client_id, &requested, query.get("resource"))
479 .await
480 {
481 if let Some(body) = error.body {
482 let body = body.to_json_value()?;
483 return admission_error(
484 body.get("error")
485 .and_then(Value::as_str)
486 .unwrap_or("server_error"),
487 body.get("error_description")
488 .and_then(Value::as_str)
489 .unwrap_or_default(),
490 );
491 }
492 return Err(error.error);
493 }
494 let challenge = text("code_challenge");
495 let method = text("code_challenge_method");
496 let public = client
497 .metadata
498 .get("token_endpoint_auth_method")
499 .and_then(Value::as_str)
500 == Some("none");
501 let nonce = text("nonce");
502 let pkce = if public {
503 Some("pkce is required for public clients")
504 } else if requested.contains(&"offline_access")
505 && (!requested.contains(&"openid") || nonce.is_empty())
506 {
507 Some("pkce or OIDC nonce is required when requesting offline_access scope")
508 } else if client.metadata.get("require_pkce") != Some(&json!(false)) {
509 Some("pkce is required for this client")
510 } else {
511 None
512 };
513 if let Some(reason) = pkce
514 && (challenge.is_empty() || method.is_empty())
515 {
516 return admission_error("invalid_request", reason);
517 }
518 if challenge.is_empty() != method.is_empty() {
519 return admission_error(
520 "invalid_request",
521 "code_challenge and code_challenge_method must both be provided",
522 );
523 }
524 let session = Box::pin(ctx.require_cached_session(req)).await.ok();
525
526 let within_max_age = if let (Some((_, session)), Some(age)) = (&session, max_age) {
527 let elapsed = json!(
528 chrono::Utc::now().timestamp_millis() - session.created_at.timestamp_millis()
529 )
530 .as_f64()
531 .ok_or_else(|| AuthError::internal("Invalid session authentication time"))?;
532 age > 0.0 && elapsed <= age * 1000.0
533 } else {
534 false
535 };
536 let force_login = session.is_none()
537 || max_age.is_some() && !within_max_age
538 || prompts.contains(&AuthorizationPrompt::Login)
539 || prompts.contains(&AuthorizationPrompt::Create);
540 if let Some(age) = max_age {
541 if !force_login && within_max_age {
542 _ = query.remove("max_age");
543 } else {
544 _ = query.insert("max_age".into(), json!(age));
545 }
546 }
547 _ = query.insert("scope".into(), json!(scope));
548 if !force_login
549 && settings.initial
550 && prompts.contains(&AuthorizationPrompt::SelectAccount)
551 && let Some(page) = self.select_account_page()
552 {
553 let params = self.sign_authorization_query(query, &nonfinite_values, None, ctx)?;
554 return browser_redirect(req, &format!("{page}?{params}"));
555 }
556 if !force_login
557 && settings.initial
558 && let Some(selection) = &self.config.select_account
559 && let Some((user, session)) = &session
560 {
561 let user = ctx.user_view(user);
562 let scopes = requested
563 .iter()
564 .map(|scope| (*scope).to_owned())
565 .collect::<Vec<_>>();
566 if selection
567 .should_redirect
568 .should_redirect(super::OAuthInteractionContext {
569 headers: &req
570 .physical_request()
571 .ok_or_else(|| AuthError::internal("request not found"))?
572 .headers,
573 user: &user,
574 session,
575 scopes: &scopes,
576 })
577 .await
578 .map_err(|error| {
579 if alibi_core::endpoint::is_endpoint_api_error(&error) {
580 error
581 } else {
582 AuthError::CallbackFailure(Box::new(error))
583 }
584 })?
585 {
586 if prompts.contains(&AuthorizationPrompt::None) {
587 return admission_error(
588 "account_selection_required",
589 "End-User account selection is required",
590 );
591 }
592 let page = self
593 .select_account_page()
594 .unwrap_or(&self.config.login_page);
595 let params = self.sign_authorization_query(query, &nonfinite_values, None, ctx)?;
596 return browser_redirect(req, &format!("{page}?{params}"));
597 }
598 }
599 if !force_login
600 && let Some(policy) = self
601 .config
602 .signup
603 .as_ref()
604 .and_then(|signup| signup.should_redirect.as_ref())
605 && let Some((user, session)) = &session
606 {
607 let user = ctx.user_view(user);
608 let scopes = requested
609 .iter()
610 .map(|scope| (*scope).to_owned())
611 .collect::<Vec<_>>();
612 let decision = policy
613 .should_redirect(super::OAuthSignupContext {
614 headers: &req
616 .physical_request()
617 .ok_or_else(|| AuthError::internal("request not found"))?
618 .headers,
619 user: &user,
620 session,
621 scopes: &scopes,
622 })
623 .await
624 .map_err(|error| {
625 if alibi_core::endpoint::is_endpoint_api_error(&error) {
626 error
627 } else {
628 AuthError::CallbackFailure(Box::new(error))
629 }
630 })?;
631 let page = match decision {
632 super::OAuthSignupRedirect::Continue => None,
633 super::OAuthSignupRedirect::Page(page) if page.is_empty() => None,
634 super::OAuthSignupRedirect::Page(page) => Some(page),
635 super::OAuthSignupRedirect::ConfiguredPage => Some(self.signup_page().to_owned()),
636 };
637 if let Some(page) = page {
638 if prompts.contains(&AuthorizationPrompt::None) {
639 return admission_error(
640 "interaction_required",
641 "End-User interaction is required",
642 );
643 }
644 let params = self.sign_authorization_query(query, &nonfinite_values, None, ctx)?;
645 return browser_redirect(req, &format!("{page}?{params}"));
646 }
647 }
648 if !force_login
649 && let Some(post_login) = &self.config.post_login
650 && let Some((user, session)) = &session
651 && settings.post_login_cleared_for_session.as_deref() != Some(session.id.as_str())
652 {
653 let user = ctx.user_view(user);
654 let scopes = requested
655 .iter()
656 .map(|scope| (*scope).to_owned())
657 .collect::<Vec<_>>();
658 if post_login
659 .should_redirect
660 .should_redirect(super::OAuthPostLoginContext {
661 headers: &req
662 .physical_request()
663 .ok_or_else(|| AuthError::internal("request not found"))?
664 .headers,
665 user: &user,
666 session,
667 scopes: &scopes,
668 })
669 .await
670 .map_err(|error| {
671 if alibi_core::endpoint::is_endpoint_api_error(&error) {
672 error
673 } else {
674 AuthError::CallbackFailure(Box::new(error))
675 }
676 })?
677 {
678 if prompts.contains(&AuthorizationPrompt::None) {
679 return admission_error(
680 "interaction_required",
681 "End-User interaction is required",
682 );
683 }
684 if post_login.page.is_empty() {
685 return Ok(EndpointResponse::error(AuthError::Api {
686 status: 500,
687 code: None,
688 message: String::new(),
689 })
690 .with_error_body(JsValue::from(
691 json!({"error_description":"postLogin should have been defined"}),
692 )));
693 }
694 let params = self.sign_authorization_query(query, &nonfinite_values, None, ctx)?;
695 return browser_redirect(req, &format!("{}?{params}", post_login.page));
696 }
697 }
698 let reference_id = if !force_login
699 && !prompts.contains(&AuthorizationPrompt::Consent)
700 && let Some((user, session)) = &session
701 {
702 let scopes = requested
703 .iter()
704 .map(|scope| (*scope).to_owned())
705 .collect::<Vec<_>>();
706 self.consent_reference_id(&ctx.user_view(user), session, &scopes)
707 .await?
708 } else {
709 None
710 };
711 let remembered = if !force_login
712 && !prompts.contains(&AuthorizationPrompt::Consent)
713 && client.metadata.get("skip_consent") != Some(&json!(true))
714 && let Some((user, _)) = &session
715 {
716 let requested_resources = super::resource_policy::resource_list(query.get("resource"));
717 ctx.database
718 .list_oauth_consents_by_user(user.id().as_ref())
719 .await?
720 .into_iter()
721 .find(|consent| {
722 consent.client_id == client_id
723 && reference_id
724 .as_deref()
725 .filter(|id| !id.is_empty())
726 .is_none_or(|id| {
727 consent.metadata.get("referenceId").and_then(Value::as_str)
728 == Some(id)
729 })
730 })
731 .is_some_and(|consent| {
732 requested_user_info_claims.iter().all(|name| {
733 consent
734 .metadata
735 .get("requestedUserInfoClaims")
736 .and_then(Value::as_array)
737 .is_some_and(|names| {
738 names.iter().any(|value| value.as_str() == Some(name))
739 })
740 }) && scope.split(' ').all(|scope| {
741 consent
742 .metadata
743 .get("scopes")
744 .and_then(Value::as_array)
745 .is_some_and(|scopes| {
746 scopes.iter().any(|value| value.as_str() == Some(scope))
747 })
748 }) && requested_resources.iter().all(|resource| {
749 consent
750 .metadata
751 .get("resources")
752 .and_then(Value::as_array)
753 .is_some_and(|resources| {
754 resources
755 .iter()
756 .any(|value| value.as_str() == Some(resource))
757 })
758 })
759 })
760 } else {
761 false
762 };
763 if !force_login
764 && !prompts.contains(&AuthorizationPrompt::Consent)
765 && (client.metadata.get("skip_consent") == Some(&json!(true)) || remembered)
766 && let Some((user, session)) = &session
767 {
768 let code = rand::RngExt::sample_iter(rand::rng(), rand::distr::Alphanumeric)
769 .map(char::from)
770 .take(32)
771 .collect::<String>();
772 let now = chrono::Utc::now().timestamp();
773 let created = chrono::DateTime::from_timestamp(now, 0)
774 .ok_or_else(|| AuthError::internal("Invalid authorization code issue time"))?;
775 let seconds = json!(now)
776 .as_f64()
777 .ok_or_else(|| AuthError::internal("Invalid authorization code issue time"))?;
778 let expires_ms = ((seconds + self.config.code_expires_in) * 1000.0)
779 .trunc()
780 .to_string()
781 .parse::<i64>()
782 .map_err(|_| AuthError::internal("Invalid authorization code expiration"))?;
783 let expires = chrono::DateTime::from_timestamp_millis(expires_ms)
784 .ok_or_else(|| AuthError::internal("Invalid authorization code expiration"))?;
785 let identifier = self.hash_token(&code, "authorization_code").await?;
786 let resources = query.get("resource").map_or_else(Vec::new, |value| {
787 value
788 .as_array()
789 .cloned()
790 .unwrap_or_else(|| vec![value.clone()])
791 });
792 let mut value = json!({"type":"authorization_code","query":query,"userId":user.id().as_ref(),"sessionId":session.id,"authTime":session.created_at.timestamp_millis(),"resource":resources});
793 if let Some(reference_id) = reference_id {
794 _ = value
795 .as_object_mut()
796 .ok_or_else(|| AuthError::internal("Invalid authorization code payload"))?
797 .insert("referenceId".into(), json!(reference_id));
798 }
799 let value = serde_json::to_string(&value)?;
800 _ = ctx
801 .verifications()
802 .create(alibi_core::verification::VerificationCreation {
803 id: None,
804 identifier,
805 value,
806 expires_at: expires,
807 created_at: created,
808 updated_at: created,
809 })
810 .await?;
811 let mut target = url::Url::parse(&redirect_uri)
812 .map_err(|error| AuthError::internal(error.to_string()))?;
813 let mut pairs = target
814 .query_pairs()
815 .map(|(name, value)| (name.into_owned(), value.into_owned()))
816 .collect::<Vec<_>>();
817 for (name, value) in [
818 ("code", code.as_str()),
819 ("state", state.as_str()),
820 ("iss", issuer.as_str()),
821 ] {
822 if name == "state" && value.is_empty() {
823 continue;
824 }
825 let mut replaced = false;
828 pairs.retain_mut(|(key, previous)| {
829 if key.as_str() != name {
830 return true;
831 }
832 if replaced {
833 return false;
834 }
835 *previous = value.into();
836 replaced = true;
837 true
838 });
839 if !replaced {
840 pairs.push((name.into(), value.into()));
841 }
842 }
843 _ = target.query_pairs_mut().clear().extend_pairs(pairs);
844 return browser_redirect(req, target.as_str());
845 }
846 if prompts.contains(&AuthorizationPrompt::None) {
847 return admission_error(
848 if force_login {
849 "login_required"
850 } else {
851 "consent_required"
852 },
853 if force_login {
854 "authentication required"
855 } else {
856 "End-User consent is required"
857 },
858 );
859 }
860 let page = if force_login {
861 if prompts.contains(&AuthorizationPrompt::Create) {
862 self.signup_page()
863 } else {
864 &self.config.login_page
865 }
866 } else {
867 &self.config.consent_page
868 };
869 let cleared_session = if !force_login && self.config.post_login.is_some() {
870 session.as_ref().map(|(_, session)| session.id.as_str())
871 } else {
872 None
873 };
874 let params =
875 self.sign_authorization_query(query, &nonfinite_values, cleared_session, ctx)?;
876 browser_redirect(req, &format!("{page}?{params}"))
877 }
878 fn signup_page(&self) -> &str {
879 self.config
880 .signup
881 .as_ref()
882 .and_then(|signup| signup.page.as_deref())
883 .unwrap_or(&self.config.login_page)
884 }
885 fn select_account_page(&self) -> Option<&str> {
886 self.config
887 .select_account
888 .as_ref()
889 .and_then(|selection| selection.page.as_deref())
890 .or(self.config.select_account_page.as_deref())
891 }
892 async fn authorization_redirect_is_trusted(
893 &self,
894 metadata: &Value,
895 requested: &str,
896 ) -> AuthResult<bool> {
897 let default_result = registered_redirect(metadata, requested);
898 let Some(policy) = &self.config.validate_redirect_uri else {
899 return Ok(default_result);
900 };
901 if !super::clients::safe_uri_issues(alibi_core::utils::javascript::trim(requested))
904 .is_empty()
905 {
906 return Ok(false);
907 }
908 let parsed = url::Url::parse(requested).map_err(|error| {
909 AuthError::CallbackFailure(Box::new(AuthError::internal(error.to_string())))
910 })?;
911 if !parsed.username().is_empty()
912 || parsed
913 .password()
914 .is_some_and(|password| !password.is_empty())
915 {
916 return Ok(false);
917 }
918 let registered = metadata
919 .get("redirect_uris")
920 .and_then(Value::as_array)
921 .into_iter()
922 .flatten()
923 .filter_map(Value::as_str)
924 .map(str::to_owned)
925 .collect::<Vec<_>>();
926 Ok(policy
929 .validate(requested, ®istered, default_result)
930 .await
931 .unwrap_or(false))
932 }
933 fn sign_authorization_query<S: AuthSchema>(
934 &self,
935 query: serde_json::Map<String, Value>,
936 nonfinite_values: &std::collections::BTreeMap<String, JsValue>,
937 post_login_cleared_for_session: Option<&str>,
938 ctx: &AuthContext<S>,
939 ) -> AuthResult<String> {
940 let now = chrono::Utc::now().timestamp_millis();
941 let seconds = json!(now / 1000)
942 .as_f64()
943 .ok_or_else(|| AuthError::internal("Invalid authorization timestamp"))?;
944 let mut pairs = Vec::new();
945 for (name, value) in query {
946 if ["sig", "exp", "ba_iat", "ba_param", "ba_pl"].contains(&name.as_str()) {
947 continue;
948 }
949 if let Some(value) = nonfinite_values.get(&name) {
952 if let JsValue::Array(values) = value {
953 for value in values {
954 pairs.push((name.clone(), js_parameter_text(value)?));
955 }
956 } else {
957 pairs.push((name, js_parameter_text(value)?));
958 }
959 continue;
960 }
961 match value {
962 Value::Null => {}
963 Value::Array(values) => {
964 for value in values {
965 pairs.push((name.clone(), parameter_text(&value)?));
966 }
967 }
968 value if name == "claims" && value.is_object() => {
969 pairs.push((name, alibi_core::utils::json::to_string(&value)?));
970 }
971 value => pairs.push((name, parameter_text(&value)?)),
972 }
973 }
974 pairs.push((
975 "exp".into(),
976 (seconds + self.config.code_expires_in).to_string(),
977 ));
978 pairs.push(("ba_iat".into(), now.to_string()));
979 if let Some(session_id) = post_login_cleared_for_session {
980 pairs.push(("ba_pl".into(), session_id.into()));
981 }
982 let mut names = pairs
983 .iter()
984 .map(|(name, _)| name.clone())
985 .collect::<Vec<_>>();
986 names.push("ba_param".into());
987 names.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
988 names.dedup();
989 for name in names {
990 pairs.push(("ba_param".into(), name));
991 }
992 let mut canonical = pairs.clone();
993 canonical.sort_by(|(a, av), (b, bv)| {
994 a.encode_utf16()
995 .cmp(b.encode_utf16())
996 .then_with(|| av.encode_utf16().cmp(bv.encode_utf16()))
997 });
998 let mut mac = Hmac::<Sha256>::new_from_slice(ctx.config.current_secret().as_bytes())
999 .map_err(|_| AuthError::internal("Invalid authorization signing key"))?;
1000 mac.update(encode_pairs(&canonical).as_bytes());
1001 let signature =
1002 base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
1003 pairs.push(("sig".into(), signature));
1004 Ok(encode_pairs(&pairs))
1005 }
1006}
1007fn parameter_text(value: &Value) -> AuthResult<String> {
1008 js_parameter_text(&JsValue::from(value.clone()))
1009}
1010
1011fn js_parameter_text(value: &JsValue) -> AuthResult<String> {
1012 value
1013 .coerce_string()
1014 .map_err(|message| AuthError::CallbackFailure(Box::new(AuthError::internal(message))))
1015}
1016
1017fn nonfinite_query_values(input: Option<&JsValue>) -> std::collections::BTreeMap<String, JsValue> {
1018 let Some(values) = input.and_then(JsValue::as_object) else {
1019 return std::collections::BTreeMap::default();
1020 };
1021 values
1022 .iter()
1023 .filter(|(_, value)| has_nonfinite_array_value(value))
1024 .map(|(name, value)| (name.clone(), value.clone()))
1025 .collect()
1026}
1027
1028fn has_nonfinite_array_value(value: &JsValue) -> bool {
1031 let mut pending = match value {
1032 JsValue::Number(number) => return !number.is_finite(),
1033 JsValue::Array(values) => values.iter().collect::<Vec<_>>(),
1034 _ => return false,
1035 };
1036 while let Some(value) = pending.pop() {
1037 match value {
1038 JsValue::Number(number) if !number.is_finite() => return true,
1039 JsValue::Array(values) => pending.extend(values.iter()),
1040 _ => {}
1041 }
1042 }
1043 false
1044}
1045#[derive(PartialEq, Eq)]
1046enum AuthorizationErrorMode {
1047 Query,
1048 Fragment,
1049}
1050
1051fn authorization_error_mode(query: &serde_json::Map<String, Value>) -> AuthorizationErrorMode {
1052 match query.get("response_mode").and_then(Value::as_str) {
1053 Some("fragment") => AuthorizationErrorMode::Fragment,
1054 Some("query") => AuthorizationErrorMode::Query,
1055 _ => {
1056 let implicit = query
1057 .get("response_type")
1058 .and_then(Value::as_str)
1059 .is_some_and(|response_type| {
1060 response_type
1061 .split(|character: char| {
1062 !character.is_ascii_alphanumeric() && character != '_'
1063 })
1064 .any(|word| matches!(word, "token" | "id_token"))
1065 });
1066 if implicit {
1067 AuthorizationErrorMode::Fragment
1068 } else {
1069 AuthorizationErrorMode::Query
1070 }
1071 }
1072 }
1073}
1074
1075#[derive(PartialEq, Eq)]
1076enum AuthorizationPrompt {
1077 None,
1078 Consent,
1079 Login,
1080 Create,
1081 SelectAccount,
1082}
1083
1084#[derive(Default)]
1085struct AuthorizationParameters {
1086 prompts: Vec<AuthorizationPrompt>,
1087 max_age: Option<f64>,
1088 redirect_uri: Option<String>,
1089}
1090
1091fn authorization_parameters(
1092 query: &serde_json::Map<String, Value>,
1093 max_age: Result<Option<f64>, &'static str>,
1094) -> Result<AuthorizationParameters, (&'static str, String)> {
1095 let mut parameters = AuthorizationParameters::default();
1096 let invalid = |description: String| ("invalid_request", description);
1097 for name in [
1100 "response_type",
1101 "request",
1102 "request_uri",
1103 "redirect_uri",
1104 "scope",
1105 "state",
1106 "client_id",
1107 "prompt",
1108 "display",
1109 "ui_locales",
1110 "max_age",
1111 "acr_values",
1112 "login_hint",
1113 "id_token_hint",
1114 "code_challenge",
1115 "code_challenge_method",
1116 "nonce",
1117 "claims",
1118 "dpop_jkt",
1119 "resource",
1120 ] {
1121 let Some(value) = query.get(name) else {
1122 if name == "client_id" {
1123 return Err(invalid("client_id is required".into()));
1124 }
1125 continue;
1126 };
1127 match name {
1128 "max_age" => {
1129 parameters.max_age = max_age.map_err(|description| invalid(description.into()))?;
1130 continue;
1131 }
1132 "claims" => {
1133 if !value.is_string() && !value.is_object() {
1134 return Err(invalid("claims: Invalid input".into()));
1135 }
1136 continue;
1137 }
1138 "resource" => {
1139 if let Some(description) = authorization_resource_error(Some(value)) {
1140 return Err(invalid(description));
1141 }
1142 continue;
1143 }
1144 _ => {}
1145 }
1146 let Some(text) = value.as_str() else {
1147 return Err(invalid(if value.is_array() {
1148 format!("{name} must not appear more than once")
1149 } else {
1150 format!("{name} must be a string")
1151 }));
1152 };
1153 match name {
1154 "response_type" if text != "code" => {
1155 return Err((
1156 "unsupported_response_type",
1157 "response_type must be one of: code".into(),
1158 ));
1159 }
1160 "redirect_uri" => {
1161 let text = alibi_core::utils::javascript::trim(text);
1162 if let Some(issue) = super::clients::safe_uri_issues(text).into_iter().next() {
1163 return Err(invalid(format!("redirect_uri: {issue}")));
1164 }
1165 parameters.redirect_uri = Some(text.into());
1166 }
1167 "client_id" if text.is_empty() => {
1168 return Err(invalid("client_id: client_id is required".into()));
1169 }
1170 "prompt" => parameters.prompts = authorization_prompts(Some(text)).map_err(invalid)?,
1171 "code_challenge_method" if text != "S256" => {
1172 return Err(invalid("code_challenge_method must be one of: S256".into()));
1173 }
1174 "dpop_jkt"
1175 if text.len() != 43
1176 || !text.bytes().all(|byte| {
1177 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')
1178 }) =>
1179 {
1180 return Err(invalid(
1181 "dpop_jkt: dpop_jkt must be a base64url-encoded SHA-256 JWK thumbprint".into(),
1182 ));
1183 }
1184 _ => {}
1185 }
1186 }
1187 Ok(parameters)
1188}
1189
1190fn authorization_prompts(input: Option<&str>) -> Result<Vec<AuthorizationPrompt>, String> {
1191 let Some(input) = input else {
1192 return Ok(Vec::new());
1193 };
1194 let mut prompts = Vec::new();
1195 for token in input
1196 .split(' ')
1197 .map(alibi_core::utils::javascript::trim)
1198 .filter(|token| !token.is_empty())
1199 {
1200 let prompt = match token {
1201 "none" => AuthorizationPrompt::None,
1202 "consent" => AuthorizationPrompt::Consent,
1203 "login" => AuthorizationPrompt::Login,
1204 "create" => AuthorizationPrompt::Create,
1205 "select_account" => AuthorizationPrompt::SelectAccount,
1206 _ => return Err(format!("prompt: unsupported prompt value: {token}")),
1207 };
1208 if !prompts.contains(&prompt) {
1209 prompts.push(prompt);
1210 }
1211 }
1212 if prompts.is_empty() {
1213 return Err("prompt: prompt must include at least one value".into());
1214 }
1215 if prompts.contains(&AuthorizationPrompt::None) && prompts.len() > 1 {
1216 return Err("prompt: prompt=none cannot be combined with other prompt values".into());
1217 }
1218 Ok(prompts)
1219}
1220
1221fn maximum_age(input: Option<&JsValue>) -> Result<Option<f64>, &'static str> {
1222 let Some(input) = input else { return Ok(None) };
1223 let value = match input {
1224 JsValue::Number(value) if value.is_finite() => *value,
1225 JsValue::String(value) => {
1226 let value = alibi_core::utils::javascript::trim(value);
1227 if value.is_empty() {
1228 return Err("max_age: Too small: expected string to have >=1 characters");
1229 }
1230 alibi_core::utils::javascript::string_to_number(value).unwrap_or(f64::NAN)
1231 }
1232 _ => return Err("max_age: Invalid input"),
1233 };
1234 if !value.is_finite() || value < 0.0 || value.fract() > 0.0 {
1235 return Err("max_age: max_age must be a non-negative integer");
1236 }
1237 Ok(Some(value))
1238}
1239
1240fn authorization_resource_error(value: Option<&Value>) -> Option<String> {
1241 let value = value?;
1242 let values = match value {
1243 Value::String(_) => vec![value],
1244 Value::Array(values) if values.is_empty() => {
1245 return Some("resource: Too small: expected array to have >=1 items".into());
1246 }
1247 Value::Array(values) => values.iter().collect(),
1248 _ => return Some("resource: Invalid input".into()),
1249 };
1250 let Some(parsed) = values
1253 .iter()
1254 .map(|value| {
1255 value
1256 .as_str()
1257 .and_then(|text| url::Url::parse(text).ok().map(|uri| (text, uri)))
1258 })
1259 .collect::<Option<Vec<_>>>()
1260 else {
1261 return Some("resource: Invalid input".into());
1262 };
1263 for (index, (text, uri)) in parsed.iter().enumerate() {
1264 let description = if text.contains('#') {
1265 "resource must not contain a fragment"
1266 } else if ["javascript", "data", "vbscript"].contains(&uri.scheme()) {
1267 "resource cannot use javascript:, data:, or vbscript: scheme"
1268 } else {
1269 continue;
1270 };
1271 let field = if value.is_array() {
1272 format!("resource[{index}]")
1273 } else {
1274 "resource".into()
1275 };
1276 return Some(format!("{field}: {description}"));
1277 }
1278 None
1279}
1280fn encode_pairs(pairs: &[(String, String)]) -> String {
1281 let mut encoded = url::form_urlencoded::Serializer::new(String::new());
1282 for (name, value) in pairs {
1283 _ = encoded.append_pair(name, value);
1284 }
1285 encoded.finish()
1286}
1287fn registered_redirect(metadata: &Value, requested: &str) -> bool {
1288 let Ok(parsed) = url::Url::parse(requested) else {
1289 return false;
1290 };
1291 if requested.contains('#')
1292 || !parsed.username().is_empty()
1293 || parsed
1294 .password()
1295 .is_some_and(|password| !password.is_empty())
1296 {
1297 return false;
1298 }
1299 let requested_without_port = loopback_without_port(requested);
1300 metadata
1301 .get("redirect_uris")
1302 .and_then(Value::as_array)
1303 .is_some_and(|uris| {
1304 uris.iter().filter_map(Value::as_str).any(|uri| {
1305 uri == requested
1306 || requested_without_port.as_ref().is_some_and(|requested| {
1307 loopback_without_port(uri).as_ref() == Some(requested)
1308 })
1309 })
1310 })
1311}
1312
1313fn loopback_without_port(uri: &str) -> Option<String> {
1315 let parsed = url::Url::parse(uri).ok()?;
1316 let host = parsed.host_str()?.trim_matches(['[', ']']);
1317 let loopback = host == "localhost"
1318 || host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
1319 std::net::IpAddr::V4(ip) => ip.is_loopback(),
1320 std::net::IpAddr::V6(ip) => {
1321 ip.is_loopback() || ip.to_ipv4_mapped().is_some_and(|ip| ip.is_loopback())
1322 }
1323 });
1324 if parsed.scheme() != "http" || !loopback {
1325 return None;
1326 }
1327 let start = uri.find("://")? + 3;
1328 let end = start
1329 + uri
1330 .get(start..)?
1331 .find(['/', '?', '#'])
1332 .unwrap_or(uri.len() - start);
1333 let authority = uri.get(start..end)?;
1334 let port_start = if authority.starts_with('[') {
1335 let bracket = authority.find(']')?;
1336 if authority.as_bytes().get(bracket + 1) != Some(&b':') {
1337 return Some(uri.into());
1338 }
1339 bracket + 1
1340 } else if let Some(colon) = authority.rfind(':') {
1341 colon
1342 } else {
1343 return Some(uri.into());
1344 };
1345 if !authority
1346 .get(port_start + 1..)?
1347 .bytes()
1348 .all(|byte| byte.is_ascii_digit())
1349 {
1350 return None;
1351 }
1352 Some(format!(
1353 "{}{}",
1354 uri.get(..start + port_start)?,
1355 uri.get(end..)?
1356 ))
1357}
1358fn fragment_error_url(
1359 target: &str,
1360 error: &str,
1361 description: &str,
1362 state: Option<&str>,
1363 issuer: Option<&str>,
1364) -> String {
1365 let mut params = url::form_urlencoded::Serializer::new(String::new());
1366 _ = params.append_pair("error", error);
1367 _ = params.append_pair("error_description", description);
1368 if let Some(state) = state.filter(|state| !state.is_empty()) {
1369 _ = params.append_pair("state", state);
1370 }
1371 if let Some(issuer) = issuer.filter(|issuer| !issuer.is_empty()) {
1372 _ = params.append_pair("iss", issuer);
1373 }
1374 format!("{target}#{}", params.finish())
1375}
1376
1377fn error_url(
1378 target: &str,
1379 error: &str,
1380 description: &str,
1381 state: Option<&str>,
1382 issuer: Option<&str>,
1383) -> AuthResult<String> {
1384 let mut url =
1385 url::Url::parse(target).map_err(|error| AuthError::internal(error.to_string()))?;
1386 {
1387 let mut params = url.query_pairs_mut();
1388 _ = params.append_pair("error", error);
1389 _ = params.append_pair("error_description", description);
1390 if let Some(state) = state.filter(|state| !state.is_empty()) {
1391 _ = params.append_pair("state", state);
1392 }
1393 if let Some(issuer) = issuer {
1394 _ = params.append_pair("iss", issuer);
1395 }
1396 }
1397 Ok(url.into())
1398}
1399fn browser_redirect(req: &impl SessionRequest, target: &str) -> AuthResult<EndpointResponse> {
1400 let json = req.session_headers().iter().any(|(name, value)| {
1401 name.eq_ignore_ascii_case("accept") && value.contains("application/json")
1402 });
1403 let fetch = req.physical_request().is_some_and(|request| {
1404 request
1405 .header("sec-fetch-mode")
1406 .is_some_and(|value| value == "cors")
1407 });
1408 if json || fetch {
1409 EndpointResponse::json(&json!({"redirect":true,"url":target}))
1410 } else {
1411 Ok(EndpointResponse::redirect(target).with_header("Content-Type", "application/json"))
1412 }
1413}
1414
1415pub(super) fn verify_authorization_query(query: &str, secret: &str) -> AuthResult<bool> {
1416 let pairs = url::form_urlencoded::parse(query.as_bytes())
1417 .map(|(k, v)| (k.into_owned(), v.into_owned()))
1418 .collect::<Vec<_>>();
1419 let signatures = pairs.iter().filter(|(k, _)| k == "sig").collect::<Vec<_>>();
1420 let [(_, signature_text)] = signatures.as_slice() else {
1421 return Ok(false);
1422 };
1423 if signature_text.is_empty() {
1424 return Ok(false);
1425 }
1426 let Some(exp) = pairs
1427 .iter()
1428 .find(|(k, _)| k == "exp")
1429 .and_then(|(_, v)| v.parse::<f64>().ok())
1430 else {
1431 return Ok(false);
1432 };
1433 if !exp.is_finite()
1434 || exp * 1000.0
1435 < json!(chrono::Utc::now().timestamp_millis())
1436 .as_f64()
1437 .ok_or_else(|| AuthError::internal("Invalid authorization timestamp"))?
1438 {
1439 return Ok(false);
1440 }
1441 let mut canonical = pairs
1442 .iter()
1443 .filter(|(k, _)| k != "sig")
1444 .cloned()
1445 .collect::<Vec<_>>();
1446 canonical.sort_by(|(a, av), (b, bv)| {
1447 a.encode_utf16()
1448 .cmp(b.encode_utf16())
1449 .then_with(|| av.encode_utf16().cmp(bv.encode_utf16()))
1450 });
1451 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
1452 .map_err(|_| AuthError::internal("Invalid authorization signing key"))?;
1453 mac.update(encode_pairs(&canonical).as_bytes());
1454 let Ok(signature) = base64::engine::general_purpose::STANDARD.decode(signature_text) else {
1455 return Ok(false);
1456 };
1457 if base64::engine::general_purpose::STANDARD.encode(&signature) != *signature_text {
1458 return Ok(false);
1459 }
1460 Ok(mac.verify_slice(&signature).is_ok())
1461}