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 pub post_login_continuation: bool,
31}
32impl<R: SessionRequest> SessionRequest for AuthorizationRequest<'_, R> {
33 fn endpoint_call(&self) -> Option<&alibi_core::endpoint::EndpointCall> {
34 Some(&self.call)
35 }
36 fn physical_request(&self) -> Option<&alibi_core::AuthRequest> {
37 self.original.physical_request()
38 }
39 fn session_headers(&self) -> &std::collections::HashMap<String, String> {
40 self.original.session_headers()
41 }
42 fn session_method(&self) -> &alibi_core::HttpMethod {
43 self.original.session_method()
44 }
45 fn session_query_truthy(&self, name: &str) -> bool {
46 self.call.session_query_truthy(name)
47 }
48 fn extensions(&self) -> &alibi_core::types::RequestExtensions {
49 self.original.extensions()
50 }
51 fn virtual_session<S: AuthSchema>(
52 &self,
53 ctx: &AuthContext<S>,
54 ) -> Option<alibi_core::SessionView> {
55 self.original.virtual_session(ctx)
56 }
57 fn authenticated_user<S: AuthSchema>(&self, ctx: &AuthContext<S>) -> Option<S::User> {
58 self.original.authenticated_user(ctx)
59 }
60 fn take_response_headers(&self) -> alibi_core::Headers {
61 self.original.take_response_headers()
62 }
63 fn queue_response_header(&self, name: impl Into<String>, value: impl Into<String>) {
64 self.original.queue_response_header(name, value);
65 }
66 fn set_session_hook_snapshot(
67 &self,
68 user: alibi_core::UserView,
69 session: alibi_core::SessionView,
70 ) {
71 self.original.set_session_hook_snapshot(user, session);
72 }
73}
74impl OAuthProviderPlugin {
75 pub fn oauth2_authorize_endpoint(
77 query: &impl serde::Serialize,
78 ) -> AuthResult<ServerEndpoint<Value>> {
79 ServerEndpoint::new("oauth-provider", "oauth2Authorize").with_query(query)
80 }
81 pub(super) async fn authorize<S: AuthSchema>(
82 &self,
83 input: Option<JsValue>,
84 req: &impl SessionRequest,
85 ctx: &AuthContext<S>,
86 ) -> AuthResult<EndpointResponse> {
87 Box::pin(self.authorize_flow(
88 input,
89 req,
90 ctx,
91 AuthorizationSettings {
92 initial: true,
93 ..Default::default()
94 },
95 ))
96 .await
97 }
98 pub(super) async fn authorize_flow<S: AuthSchema>(
99 &self,
100 mut input: Option<JsValue>,
101 req: &impl SessionRequest,
102 ctx: &AuthContext<S>,
103 settings: AuthorizationSettings,
104 ) -> AuthResult<EndpointResponse> {
105 if !self
106 .config
107 .grant_types
108 .iter()
109 .any(|grant| grant == "authorization_code")
110 {
111 return Ok(EndpointResponse::error(AuthError::Api {
112 status: 404,
113 code: None,
114 message: String::new(),
115 })
116 .without_error_body()
117 .with_header("Content-Type", "application/json"));
118 }
119 if req.physical_request().is_none() {
120 return super::clients::status_protocol_error(
121 401,
122 "invalid_request",
123 "request not found",
124 );
125 }
126 let string = |name: &str| {
130 input
131 .as_ref()
132 .and_then(|value| value.get(name))
133 .and_then(JsValue::as_str)
134 };
135 let request_error = match (string("request"), string("request_uri")) {
136 (Some(_), Some(_)) => Some((
137 "invalid_request",
138 "request and request_uri cannot be used together",
139 )),
140 (Some(_), None) => Some(("request_not_supported", "request object not supported")),
141 (None, Some(uri)) => {
142 match (
143 string("client_id").filter(|id| !id.is_empty()),
144 &self.config.request_uri_resolver,
145 ) {
146 (None, _) => Some(("invalid_request", "client_id is required")),
147 (Some(_), None) => {
148 Some(("request_uri_not_supported", "request_uri not supported"))
149 }
150 (Some(client_id), Some(resolver)) => {
151 let context = alibi_core::CallbackContext::new(ctx, req.physical_request());
152 let resolved = resolver
153 .resolve(super::OAuthRequestUriInput {
154 request_uri: uri,
155 client_id,
156 context: &context,
157 })
158 .await
159 .map_err(|error| {
160 if alibi_core::endpoint::is_endpoint_api_error(&error) {
161 error
162 } else {
163 AuthError::CallbackFailure(Box::new(error))
164 }
165 })?;
166 if let Some(mut parameters) = resolved {
167 _ = parameters
168 .insert("client_id".into(), JsValue::String(client_id.into()));
169 input = Some(JsValue::Object(parameters));
170 None
171 } else {
172 Some(("invalid_request_uri", "request_uri is invalid or expired"))
173 }
174 }
175 }
176 }
177 (None, None) => None,
178 };
179 if let Some(call) = alibi_core::endpoint::current_endpoint_call_context() {
183 let effective = AuthorizationRequest {
184 original: req,
185 call: call.handler_context(call.body().cloned(), input.clone()),
186 };
187 return alibi_core::endpoint::with_endpoint_call_context(
188 effective.call.clone(),
189 Box::pin(self.authorize_parameters(
190 input,
191 &effective,
192 ctx,
193 settings,
194 request_error,
195 )),
196 )
197 .await;
198 }
199 Box::pin(self.authorize_parameters(input, req, ctx, settings, request_error)).await
200 }
201 async fn authorize_parameters<S: AuthSchema>(
202 &self,
203 input: Option<JsValue>,
204 req: &impl SessionRequest,
205 ctx: &AuthContext<S>,
206 settings: AuthorizationSettings,
207 reserved_error: Option<(&'static str, &'static str)>,
208 ) -> AuthResult<EndpointResponse> {
209 let validated_max_age = maximum_age(input.as_ref().and_then(|input| input.get("max_age")));
210 let nonfinite_values = nonfinite_query_values(input.as_ref());
211 let mut logical_query = input
212 .clone()
213 .unwrap_or_else(|| JsValue::Object(super::OAuthAuthorizationQuery::new()));
214 let mut query: serde_json::Map<String, Value> = input
215 .and_then(|value| alibi_core::utils::json::from_value(value).ok())
216 .unwrap_or_default();
217 let validation = authorization_parameters(&query, validated_max_age);
218 if reserved_error.is_none()
221 && let Ok(parameters) = &validation
222 && let Some(redirect) = ¶meters.redirect_uri
223 {
224 _ = query.insert("redirect_uri".into(), json!(redirect));
225 if let JsValue::Object(fields) = &mut logical_query {
226 _ = fields.insert("redirect_uri".into(), JsValue::String(redirect.clone()));
227 }
228 }
229 let logical = alibi_core::endpoint::current_endpoint_call_context()
230 .map(|call| call.handler_context(call.body().cloned(), Some(logical_query)));
231 let run = Box::pin(self.authorize_admission(
232 AuthorizationAdmission {
233 query,
234 validation,
235 nonfinite_values,
236 reserved_error,
237 },
238 req,
239 ctx,
240 settings,
241 ));
242 if let Some(logical) = logical {
243 alibi_core::endpoint::with_endpoint_call_context(logical, run).await
244 } else {
245 run.await
246 }
247 }
248 async fn authorize_admission<S: AuthSchema>(
249 &self,
250 input: AuthorizationAdmission,
251 req: &impl SessionRequest,
252 ctx: &AuthContext<S>,
253 settings: AuthorizationSettings,
254 ) -> AuthResult<EndpointResponse> {
255 let AuthorizationAdmission {
256 mut query,
257 validation,
258 nonfinite_values,
259 reserved_error,
260 } = input;
261 let error_mode = authorization_error_mode(&query);
262 let text = |name: &str| {
263 query
264 .get(name)
265 .and_then(Value::as_str)
266 .unwrap_or_default()
267 .to_owned()
268 };
269 let client_id = text("client_id");
270 let redirect_uri = text("redirect_uri");
271 let response_type = text("response_type");
272 let state = text("state");
273 let server_error = format!(
274 "{}{}/error",
275 ctx.config.base_url.trim_end_matches('/'),
276 ctx.config.base_path
277 );
278 if reserved_error.is_none()
279 && response_type == "code"
280 && validation.as_ref().is_ok_and(|parameters| {
281 parameters
282 .prompts
283 .contains(&AuthorizationPrompt::SelectAccount)
284 })
285 && self.select_account_page().is_none_or(str::is_empty)
286 {
287 return browser_redirect(
288 req,
289 &error_url(
290 &server_error,
291 "unsupported_prompt_select_account",
292 "unsupported prompt type",
293 None,
294 None,
295 )?,
296 );
297 }
298 let error_only =
299 reserved_error.is_some() || validation.is_err() || response_type.is_empty();
300 let client = if error_only {
301 if client_id.is_empty() || redirect_uri.is_empty() {
302 None
303 } else {
304 super::discovery::resolve(ctx, &client_id)
305 .await
306 .unwrap_or(None)
307 }
308 } else {
309 super::discovery::resolve(ctx, &client_id).await?
310 };
311 let authorization_code_allowed = client.as_ref().is_some_and(|client| {
312 client
313 .metadata
314 .get("grant_types")
315 .and_then(Value::as_array)
316 .is_some_and(|grants| grants.contains(&json!("authorization_code")))
317 });
318 let trusted = if let Some(client) = client.as_ref().filter(|client| {
322 client.metadata.get("disabled") != Some(&json!(true))
323 && (error_only || authorization_code_allowed)
324 }) {
325 self.authorization_redirect_is_trusted(&client.metadata, &redirect_uri)
326 .await?
327 } else {
328 false
329 };
330 let issuer = self
331 .metadata(ctx)?
332 .get("issuer")
333 .and_then(Value::as_str)
334 .unwrap_or_default()
335 .to_owned();
336 let admission_error = |error: &str, description: &str| {
337 let target = if trusted {
338 redirect_uri.as_str()
339 } else {
340 server_error.as_str()
341 };
342 let url = error_url(
343 target,
344 error,
345 description,
346 if trusted { Some(state.as_str()) } else { None },
347 if trusted { Some(issuer.as_str()) } else { None },
348 )?;
349 browser_redirect(req, &url)
350 };
351 let request_error = |error: &str, description: &str| {
352 if trusted && error_mode == AuthorizationErrorMode::Fragment {
353 browser_redirect(
354 req,
355 &fragment_error_url(
356 &redirect_uri,
357 error,
358 description,
359 Some(&state),
360 Some(&issuer),
361 ),
362 )
363 } else {
364 admission_error(error, description)
365 }
366 };
367 if let Some((error, description)) = reserved_error {
368 return request_error(error, description);
369 }
370 let parameters = match validation {
371 Ok(parameters) => parameters,
372 Err((error, description)) => return request_error(error, &description),
373 };
374 if response_type.is_empty() {
375 return request_error("invalid_request", "response_type is required");
376 }
377 let prompts = parameters.prompts;
378 let max_age = parameters.max_age;
379 let Some(client) = client else {
380 return browser_redirect(
381 req,
382 &error_url(
383 &server_error,
384 "invalid_client",
385 "client not found",
386 None,
387 None,
388 )?,
389 );
390 };
391 if client.metadata.get("disabled") == Some(&json!(true)) {
392 return browser_redirect(
393 req,
394 &error_url(
395 &server_error,
396 "client_disabled",
397 "client is disabled",
398 None,
399 None,
400 )?,
401 );
402 }
403 if !authorization_code_allowed {
404 return browser_redirect(
405 req,
406 &error_url(
407 &server_error,
408 "unauthorized_client",
409 "client is not authorized to use the authorization_code grant",
410 None,
411 None,
412 )?,
413 );
414 }
415 if !trusted {
416 return browser_redirect(
417 req,
418 &error_url(
419 &server_error,
420 "invalid_redirect",
421 "invalid redirect uri",
422 None,
423 None,
424 )?,
425 );
426 }
427 let allowed = client
428 .metadata
429 .get("scope")
430 .and_then(Value::as_str)
431 .map_or_else(
432 || self.config.scopes.clone(),
433 |scope| scope.split(' ').map(str::to_owned).collect(),
434 );
435 let scope = query
436 .get("scope")
437 .and_then(Value::as_str)
438 .map_or_else(|| allowed.join(" "), str::to_owned);
439 let requested = scope
440 .split(' ')
441 .filter(|scope| !scope.is_empty())
442 .collect::<Vec<_>>();
443 let invalid = requested
444 .iter()
445 .filter(|scope| !allowed.iter().any(|allowed| allowed == **scope))
446 .copied()
447 .collect::<Vec<_>>();
448 if !invalid.is_empty() {
449 return admission_error(
450 "invalid_scope",
451 &format!("The following scopes are invalid: {}", invalid.join(", ")),
452 );
453 }
454 let requested_user_info_claims = if let Some(claims) = query.get("claims") {
455 if !requested.contains(&"openid") {
456 return admission_error(
457 "invalid_request",
458 "openid scope must be requested when using the claims parameter",
459 );
460 }
461 let Some(claims) = super::claims_request::parse(claims) else {
462 return admission_error(
463 "invalid_request",
464 "claims must be a valid Claims request object",
465 );
466 };
467 if !claims.acr_satisfied {
468 return admission_error("access_denied", "essential acr requirement cannot be met");
469 }
470 let supported = self.supported_claims();
471 claims
472 .user_info
473 .into_iter()
474 .filter(|name| supported.contains(name))
475 .collect::<Vec<_>>()
476 } else {
477 Vec::new()
478 };
479 if let Err(error) = self
480 .admit_resources(ctx, &client_id, &requested, query.get("resource"))
481 .await
482 {
483 if let Some(body) = error.body {
484 let body = body.to_json_value()?;
485 return admission_error(
486 body.get("error")
487 .and_then(Value::as_str)
488 .unwrap_or("server_error"),
489 body.get("error_description")
490 .and_then(Value::as_str)
491 .unwrap_or_default(),
492 );
493 }
494 return Err(error.error);
495 }
496 let challenge = text("code_challenge");
497 let method = text("code_challenge_method");
498 let public = client
499 .metadata
500 .get("token_endpoint_auth_method")
501 .and_then(Value::as_str)
502 == Some("none");
503 let nonce = text("nonce");
504 let pkce = if public {
505 Some("pkce is required for public clients")
506 } else if requested.contains(&"offline_access")
507 && (!requested.contains(&"openid") || nonce.is_empty())
508 {
509 Some("pkce or OIDC nonce is required when requesting offline_access scope")
510 } else if client.metadata.get("require_pkce") != Some(&json!(false)) {
511 Some("pkce is required for this client")
512 } else {
513 None
514 };
515 if let Some(reason) = pkce
516 && (challenge.is_empty() || method.is_empty())
517 {
518 return admission_error("invalid_request", reason);
519 }
520 if challenge.is_empty() != method.is_empty() {
521 return admission_error(
522 "invalid_request",
523 "code_challenge and code_challenge_method must both be provided",
524 );
525 }
526 let session = Box::pin(ctx.require_cached_session(req)).await.ok();
527
528 let within_max_age = if let (Some((_, session)), Some(age)) = (&session, max_age) {
529 let elapsed = json!(
530 chrono::Utc::now().timestamp_millis() - session.created_at.timestamp_millis()
531 )
532 .as_f64()
533 .ok_or_else(|| AuthError::internal("Invalid session authentication time"))?;
534 age > 0.0 && elapsed <= age * 1000.0
535 } else {
536 false
537 };
538 let force_login = session.is_none()
539 || max_age.is_some() && !within_max_age
540 || prompts.contains(&AuthorizationPrompt::Login)
541 || prompts.contains(&AuthorizationPrompt::Create);
542 if let Some(age) = max_age {
543 if !force_login && within_max_age {
544 _ = query.remove("max_age");
545 } else {
546 _ = query.insert("max_age".into(), json!(age));
547 }
548 }
549 _ = query.insert("scope".into(), json!(scope));
550 if !force_login
551 && settings.initial
552 && prompts.contains(&AuthorizationPrompt::SelectAccount)
553 && let Some(page) = self.select_account_page()
554 {
555 let params = self.sign_authorization_query(query, &nonfinite_values, None, ctx)?;
556 return browser_redirect(req, &format!("{page}?{params}"));
557 }
558 if !force_login
559 && settings.initial
560 && let Some(selection) = &self.config.select_account
561 && let Some((user, session)) = &session
562 {
563 let user = ctx.user_view(user);
564 let scopes = requested
565 .iter()
566 .map(|scope| (*scope).to_owned())
567 .collect::<Vec<_>>();
568 if selection
569 .should_redirect
570 .should_redirect(super::OAuthInteractionContext {
571 headers: &req
572 .physical_request()
573 .ok_or_else(|| AuthError::internal("request not found"))?
574 .headers,
575 user: &user,
576 session,
577 scopes: &scopes,
578 })
579 .await
580 .map_err(|error| {
581 if alibi_core::endpoint::is_endpoint_api_error(&error) {
582 error
583 } else {
584 AuthError::CallbackFailure(Box::new(error))
585 }
586 })?
587 {
588 if prompts.contains(&AuthorizationPrompt::None) {
589 return admission_error(
590 "account_selection_required",
591 "End-User account selection is required",
592 );
593 }
594 let page = self
595 .select_account_page()
596 .unwrap_or(&self.config.login_page);
597 let params = self.sign_authorization_query(query, &nonfinite_values, None, ctx)?;
598 return browser_redirect(req, &format!("{page}?{params}"));
599 }
600 }
601 if !force_login
602 && let Some(policy) = self
603 .config
604 .signup
605 .as_ref()
606 .and_then(|signup| signup.should_redirect.as_ref())
607 && let Some((user, session)) = &session
608 {
609 let user = ctx.user_view(user);
610 let scopes = requested
611 .iter()
612 .map(|scope| (*scope).to_owned())
613 .collect::<Vec<_>>();
614 let decision = policy
615 .should_redirect(super::OAuthSignupContext {
616 headers: &req
618 .physical_request()
619 .ok_or_else(|| AuthError::internal("request not found"))?
620 .headers,
621 user: &user,
622 session,
623 scopes: &scopes,
624 })
625 .await
626 .map_err(|error| {
627 if alibi_core::endpoint::is_endpoint_api_error(&error) {
628 error
629 } else {
630 AuthError::CallbackFailure(Box::new(error))
631 }
632 })?;
633 let page = match decision {
634 super::OAuthSignupRedirect::Continue => None,
635 super::OAuthSignupRedirect::Page(page) if page.is_empty() => None,
636 super::OAuthSignupRedirect::Page(page) => Some(page),
637 super::OAuthSignupRedirect::ConfiguredPage => Some(self.signup_page().to_owned()),
638 };
639 if let Some(page) = page {
640 if prompts.contains(&AuthorizationPrompt::None) {
641 return admission_error(
642 "interaction_required",
643 "End-User interaction is required",
644 );
645 }
646 let params = self.sign_authorization_query(query, &nonfinite_values, None, ctx)?;
647 return browser_redirect(req, &format!("{page}?{params}"));
648 }
649 }
650 if !force_login
651 && let Some(post_login) = &self.config.post_login
652 && let Some((user, session)) = &session
653 && settings.post_login_cleared_for_session.as_deref() != Some(session.id.as_str())
654 {
655 let user = ctx.user_view(user);
656 let scopes = requested
657 .iter()
658 .map(|scope| (*scope).to_owned())
659 .collect::<Vec<_>>();
660 if post_login
661 .should_redirect
662 .should_redirect(super::OAuthPostLoginContext {
663 headers: &req
664 .physical_request()
665 .ok_or_else(|| AuthError::internal("request not found"))?
666 .headers,
667 user: &user,
668 session,
669 scopes: &scopes,
670 is_continuation: settings.post_login_continuation,
671 })
672 .await
673 .map_err(|error| {
674 if alibi_core::endpoint::is_endpoint_api_error(&error) {
675 error
676 } else {
677 AuthError::CallbackFailure(Box::new(error))
678 }
679 })?
680 {
681 if prompts.contains(&AuthorizationPrompt::None) {
682 return admission_error(
683 "interaction_required",
684 "End-User interaction is required",
685 );
686 }
687 if post_login.page.is_empty() {
688 return Ok(EndpointResponse::error(AuthError::Api {
689 status: 500,
690 code: None,
691 message: String::new(),
692 })
693 .with_error_body(JsValue::from(
694 json!({"error_description":"postLogin should have been defined"}),
695 )));
696 }
697 let params = self.sign_authorization_query(query, &nonfinite_values, None, ctx)?;
698 return browser_redirect(req, &format!("{}?{params}", post_login.page));
699 }
700 }
701 let reference_id = if !force_login
702 && !prompts.contains(&AuthorizationPrompt::Consent)
703 && let Some((user, session)) = &session
704 {
705 let scopes = requested
706 .iter()
707 .map(|scope| (*scope).to_owned())
708 .collect::<Vec<_>>();
709 self.consent_reference_id(&ctx.user_view(user), session, &scopes)
710 .await?
711 } else {
712 None
713 };
714 let remembered = if !force_login
715 && !prompts.contains(&AuthorizationPrompt::Consent)
716 && client.metadata.get("skip_consent") != Some(&json!(true))
717 && let Some((user, _)) = &session
718 {
719 let requested_resources = super::resource_policy::resource_list(query.get("resource"));
720 ctx.database
721 .list_oauth_consents_by_user(user.id().as_ref())
722 .await?
723 .into_iter()
724 .find(|consent| {
725 consent.client_id == client_id
726 && super::post_login::consent_matches_reference(
727 &consent.metadata,
728 reference_id.as_deref(),
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}