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    /// The user returned from the post-login page through `/oauth2/continue`.
30    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    /// Authorize an OAuth request with its genuine physical request attached.
76    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        // Reserved request controls belong only to the original input. A
127        // resolver replaces the parameters once; its result is never resolved
128        // recursively or subjected to the original request-object dispatch.
129        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        // Clone the actual logical call, sharing its session/output state and
180        // retaining the distinct physical Request. Discovery and other native
181        // callbacks must observe the effective input, not the caller's query.
182        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        // Failed schemas and unsupported request objects redirect using their raw
219        // parameters. Only a fully parsed query publishes URL transformations.
220        if reserved_error.is_none()
221            && let Ok(parameters) = &validation
222            && let Some(redirect) = &parameters.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        // Early request/schema errors can trust an enabled client without grant
319        // admission. Valid requests reach the operator only after that admission.
320        // Keep one decision for both the error channel and eventual code binding.
321        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                    // authorize_flow has already required a physical request.
617                    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                // 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}