Skip to main content

alibi_plugins/oauth_provider/
authorization.rs

1//! Authorization admission and signed browser interaction requests.
2use 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
12// Replace only logical query input. Browser continuations can carry genuine
13// authenticated session state and response/header overrides independently of
14// their enclosing endpoint call; those still belong to the original input.
15struct 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    /// Authorize an OAuth request with its genuine physical request attached.
74    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        // Reserved request controls belong only to the original input. A
125        // resolver replaces the parameters once; its result is never resolved
126        // recursively or subjected to the original request-object dispatch.
127        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        // Clone the actual logical call, sharing its session/output state and
178        // retaining the distinct physical Request. Discovery and other native
179        // callbacks must observe the effective input, not the caller's query.
180        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        // Failed schemas and unsupported request objects redirect using their raw
217        // parameters. Only a fully parsed query publishes URL transformations.
218        if reserved_error.is_none()
219            && let Ok(parameters) = &validation
220            && let Some(redirect) = &parameters.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        // Early request/schema errors can trust an enabled client without grant
317        // admission. Valid requests reach the operator only after that admission.
318        // Keep one decision for both the error channel and eventual code binding.
319        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                    // authorize_flow has already required a physical request.
615                    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                // URLSearchParams.set replaces the first occurrence in place
826                // and removes later duplicates, retaining unrelated entries.
827                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        // SafeUrlSchema validates a JS-trimmed value, but the subsequent URL
902        // constructor and the operator still receive the original parameter.
903        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        // Only callback failures deny trust. Raw URL constructor errors above
927        // remain ordinary empty-500 failures, matching the reference boundary.
928        Ok(policy
929            .validate(requested, &registered, 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            // Grant JSON follows JSON.stringify's null projection. Browser
950            // interactions instead preserve String(Infinity/NaN), including arrays.
951            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
1028// String(object) is unaffected by JSON's non-finite number projection; scalar
1029// and array coercion is not. Traverse arrays without adding recursive stack use.
1030fn 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    // Schema declaration order determines the first published issue, regardless
1098    // of caller key order. Optional fields are validated before provider policy.
1099    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    // A type or absolute-URI failure aborts the union branch, even if another
1251    // array entry has a non-fatal fragment/scheme issue.
1252    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
1313/// Native HTTP callbacks may change their port, not any other raw URI byte.
1314fn 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}