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::{Digest as _, Sha256};
11impl OAuthProviderPlugin {
12    /// Authorize an OAuth request with its genuine physical request attached.
13    pub fn oauth2_authorize_endpoint(
14        query: &impl serde::Serialize,
15    ) -> AuthResult<ServerEndpoint<Value>> {
16        ServerEndpoint::new("oauth-provider", "oauth2Authorize").with_query(query)
17    }
18    pub(super) async fn authorize<S: AuthSchema>(
19        &self,
20        input: Option<JsValue>,
21        req: &impl SessionRequest,
22        ctx: &AuthContext<S>,
23    ) -> AuthResult<EndpointResponse> {
24        Box::pin(self.authorize_flow(input, req, ctx, true)).await
25    }
26    pub(super) async fn authorize_flow<S: AuthSchema>(
27        &self,
28        input: Option<JsValue>,
29        req: &impl SessionRequest,
30        ctx: &AuthContext<S>,
31        initial: bool,
32    ) -> AuthResult<EndpointResponse> {
33        if !self
34            .config
35            .grant_types
36            .iter()
37            .any(|grant| grant == "authorization_code")
38        {
39            return Ok(EndpointResponse::error(AuthError::Api {
40                status: 404,
41                code: None,
42                message: String::new(),
43            })
44            .without_error_body()
45            .with_header("Content-Type", "application/json"));
46        }
47        if req.physical_request().is_none() {
48            return super::clients::status_protocol_error(
49                401,
50                "invalid_request",
51                "request not found",
52            );
53        }
54        let mut query: serde_json::Map<String, Value> = input
55            .and_then(|value| alibi_core::utils::json::from_value(value).ok())
56            .unwrap_or_default();
57        let text = |name: &str| {
58            query
59                .get(name)
60                .and_then(Value::as_str)
61                .unwrap_or_default()
62                .to_owned()
63        };
64        let client_id = text("client_id");
65        let redirect_uri = text("redirect_uri");
66        let response_type = text("response_type");
67        let state = text("state");
68        let server_error = format!(
69            "{}{}/error",
70            ctx.config.base_url.trim_end_matches('/'),
71            ctx.config.base_path
72        );
73        if text("prompt")
74            .split(' ')
75            .any(|prompt| prompt == "select_account")
76            && self.config.select_account_page.is_none()
77        {
78            return browser_redirect(
79                req,
80                &error_url(
81                    &server_error,
82                    "unsupported_prompt_select_account",
83                    "unsupported prompt type",
84                    None,
85                    None,
86                )?,
87            );
88        }
89        let client = ctx.database.get_oauth_client(&client_id).await?;
90        let trusted = client.as_ref().is_some_and(|client| {
91            client.metadata.get("disabled") != Some(&json!(true))
92                && registered_redirect(&client.metadata, &redirect_uri)
93        });
94        let issuer = self
95            .metadata(ctx)?
96            .get("issuer")
97            .and_then(Value::as_str)
98            .unwrap_or_default()
99            .to_owned();
100        let admission_error = |error: &str, description: &str| {
101            let target = if trusted {
102                redirect_uri.as_str()
103            } else {
104                server_error.as_str()
105            };
106            let url = error_url(
107                target,
108                error,
109                description,
110                if trusted { Some(state.as_str()) } else { None },
111                if trusted { Some(issuer.as_str()) } else { None },
112            )?;
113            browser_redirect(req, &url)
114        };
115        if query.contains_key("request") && query.contains_key("request_uri") {
116            return admission_error(
117                "invalid_request",
118                "request and request_uri cannot be used together",
119            );
120        }
121        if query.contains_key("request") {
122            return admission_error("request_not_supported", "request object not supported");
123        }
124        if query.contains_key("request_uri") {
125            return admission_error("request_uri_not_supported", "request_uri not supported");
126        }
127        for name in [
128            "response_type",
129            "request",
130            "request_uri",
131            "redirect_uri",
132            "scope",
133            "state",
134            "client_id",
135            "prompt",
136            "display",
137            "ui_locales",
138            "acr_values",
139            "login_hint",
140            "id_token_hint",
141            "code_challenge",
142            "code_challenge_method",
143            "nonce",
144            "dpop_jkt",
145        ] {
146            if let Some(value) = query.get(name)
147                && !value.is_string()
148            {
149                let message = if value.is_array() {
150                    format!("{name} must not appear more than once")
151                } else {
152                    format!("{name} must be a string")
153                };
154                return admission_error("invalid_request", &message);
155            }
156        }
157        if client_id.is_empty() {
158            return admission_error("invalid_request", "client_id is required");
159        }
160        if response_type.is_empty() {
161            return admission_error("invalid_request", "response_type is required");
162        }
163        if response_type != "code" {
164            return admission_error(
165                "unsupported_response_type",
166                "response_type must be one of: code",
167            );
168        }
169        let Some(client) = client else {
170            return browser_redirect(
171                req,
172                &error_url(
173                    &server_error,
174                    "invalid_client",
175                    "client_id is required",
176                    None,
177                    None,
178                )?,
179            );
180        };
181        if client.metadata.get("disabled") == Some(&json!(true)) {
182            return browser_redirect(
183                req,
184                &error_url(
185                    &server_error,
186                    "client_disabled",
187                    "client is disabled",
188                    None,
189                    None,
190                )?,
191            );
192        }
193        if !client
194            .metadata
195            .get("grant_types")
196            .and_then(Value::as_array)
197            .is_some_and(|grants| grants.contains(&json!("authorization_code")))
198        {
199            return browser_redirect(
200                req,
201                &error_url(
202                    &server_error,
203                    "unauthorized_client",
204                    "client is not authorized to use the authorization_code grant",
205                    None,
206                    None,
207                )?,
208            );
209        }
210        if !trusted {
211            return browser_redirect(
212                req,
213                &error_url(
214                    &server_error,
215                    "invalid_redirect",
216                    "invalid redirect uri",
217                    None,
218                    None,
219                )?,
220            );
221        }
222        let allowed = client
223            .metadata
224            .get("scope")
225            .and_then(Value::as_str)
226            .map_or_else(
227                || self.config.scopes.clone(),
228                |scope| scope.split(' ').map(str::to_owned).collect(),
229            );
230        let scope = query
231            .get("scope")
232            .and_then(Value::as_str)
233            .map_or_else(|| allowed.join(" "), str::to_owned);
234        let requested = scope
235            .split(' ')
236            .filter(|scope| !scope.is_empty())
237            .collect::<Vec<_>>();
238        let invalid = requested
239            .iter()
240            .filter(|scope| !allowed.iter().any(|allowed| allowed == **scope))
241            .copied()
242            .collect::<Vec<_>>();
243        if !invalid.is_empty() {
244            return admission_error(
245                "invalid_scope",
246                &format!("The following scopes are invalid: {}", invalid.join(", ")),
247            );
248        }
249        if let Some(resource) = query.get("resource") {
250            let resources = if let Some(uri) = resource.as_str() {
251                vec![uri]
252            } else if let Some(values) = resource.as_array() {
253                values.iter().filter_map(Value::as_str).collect()
254            } else {
255                return admission_error("invalid_request", "resource: Invalid input");
256            };
257            let userinfo = format!(
258                "{}{}/oauth2/userinfo",
259                ctx.config.base_url.trim_end_matches('/'),
260                ctx.config.base_path
261            );
262            let mut resolved = Vec::new();
263            let mut seen = std::collections::HashSet::new();
264            for resource in resources {
265                if resource == userinfo || !seen.insert(resource) {
266                    continue;
267                }
268                let Some(row) = ctx.database.get_oauth_resource(resource).await? else {
269                    return admission_error(
270                        "invalid_target",
271                        &format!("requested resource {resource} is not configured"),
272                    );
273                };
274                if row.metadata.get("disabled") == Some(&json!(true)) {
275                    return admission_error(
276                        "invalid_target",
277                        &format!("requested resource {resource} is disabled"),
278                    );
279                }
280                resolved.push(row);
281            }
282            if self.config.enforce_per_client_resources && !resolved.is_empty() {
283                let links = ctx.database.list_oauth_client_resources(&client_id).await?;
284                let unlinked = resolved
285                    .iter()
286                    .filter(|resource| {
287                        !links
288                            .iter()
289                            .any(|link| link.resource_id == resource.identifier)
290                    })
291                    .map(|resource| resource.identifier.as_str())
292                    .collect::<Vec<_>>();
293                if !unlinked.is_empty() {
294                    return admission_error(
295                        "invalid_target",
296                        &format!(
297                            "client {client_id} is not linked to resource(s) {}",
298                            unlinked.join(", ")
299                        ),
300                    );
301                }
302            }
303            let mut effective = requested.clone();
304            let mut algorithms = std::collections::HashSet::new();
305            let mut keys = std::collections::HashSet::new();
306            for row in resolved {
307                if let Some(scopes) = row.metadata.get("allowedScopes").and_then(Value::as_array) {
308                    effective
309                        .retain(|scope| scopes.iter().any(|value| value.as_str() == Some(scope)));
310                    if effective.is_empty() {
311                        return admission_error(
312                            "invalid_scope",
313                            &format!(
314                                "none of the requested scopes are allowed for resource {}",
315                                row.identifier
316                            ),
317                        );
318                    }
319                }
320                if let Some(algorithm) =
321                    row.metadata.get("signingAlgorithm").and_then(Value::as_str)
322                {
323                    _ = algorithms.insert(algorithm.to_owned());
324                }
325                if let Some(key) = row.metadata.get("signingKeyId").and_then(Value::as_str) {
326                    _ = keys.insert(key.to_owned());
327                }
328            }
329            if algorithms.len() > 1 {
330                return admission_error(
331                    "invalid_request",
332                    "multi-resource request has conflicting signingAlgorithm pins; a single JWS signature cannot satisfy multiple algorithms",
333                );
334            }
335            if keys.len() > 1 {
336                return admission_error(
337                    "invalid_request",
338                    "multi-resource request has conflicting signingKeyId pins; a single JWS signature cannot satisfy multiple key ids",
339                );
340            }
341        }
342        let challenge = text("code_challenge");
343        let method = text("code_challenge_method");
344        let public = client
345            .metadata
346            .get("token_endpoint_auth_method")
347            .and_then(Value::as_str)
348            == Some("none");
349        let nonce = text("nonce");
350        let pkce = if public {
351            Some("pkce is required for public clients")
352        } else if requested.contains(&"offline_access")
353            && (!requested.contains(&"openid") || nonce.is_empty())
354        {
355            Some("pkce or OIDC nonce is required when requesting offline_access scope")
356        } else if client.metadata.get("require_pkce") != Some(&json!(false)) {
357            Some("pkce is required for this client")
358        } else {
359            None
360        };
361        if let Some(reason) = pkce
362            && (challenge.is_empty() || method.is_empty())
363        {
364            return admission_error("invalid_request", reason);
365        }
366        if challenge.is_empty() != method.is_empty() {
367            return admission_error(
368                "invalid_request",
369                "code_challenge and code_challenge_method must both be provided",
370            );
371        }
372        if !method.is_empty() && method != "S256" {
373            return admission_error(
374                "invalid_request",
375                "code_challenge_method must be one of: S256",
376            );
377        }
378        let prompt = text("prompt");
379        let prompts = prompt.split(' ').collect::<Vec<_>>();
380        let session = Box::pin(ctx.require_cached_session(req)).await.ok();
381
382        let force_login =
383            session.is_none() || prompts.contains(&"login") || prompts.contains(&"create");
384        _ = query.insert("scope".into(), json!(scope));
385        if !force_login
386            && initial
387            && prompts.contains(&"select_account")
388            && let Some(page) = &self.config.select_account_page
389        {
390            let params = self.sign_authorization_query(query, ctx)?;
391            return browser_redirect(req, &format!("{page}?{params}"));
392        }
393        let remembered = if !force_login
394            && !prompts.contains(&"consent")
395            && client.metadata.get("skip_consent") != Some(&json!(true))
396            && let Some((user, _)) = &session
397        {
398            ctx.database
399                .list_oauth_consents_by_user(user.id().as_ref())
400                .await?
401                .into_iter()
402                .any(|consent| {
403                    consent.client_id == client_id
404                        && scope.split(' ').all(|scope| {
405                            consent
406                                .metadata
407                                .get("scopes")
408                                .and_then(Value::as_array)
409                                .is_some_and(|scopes| {
410                                    scopes.iter().any(|value| value.as_str() == Some(scope))
411                                })
412                        })
413                })
414        } else {
415            false
416        };
417        if !force_login
418            && !prompts.contains(&"consent")
419            && (client.metadata.get("skip_consent") == Some(&json!(true)) || remembered)
420            && let Some((user, session)) = &session
421        {
422            let code = rand::RngExt::sample_iter(rand::rng(), rand::distr::Alphanumeric)
423                .map(char::from)
424                .take(32)
425                .collect::<String>();
426            let now = chrono::Utc::now().timestamp();
427            let created = chrono::DateTime::from_timestamp(now, 0)
428                .ok_or_else(|| AuthError::internal("Invalid authorization code issue time"))?;
429            let seconds = json!(now)
430                .as_f64()
431                .ok_or_else(|| AuthError::internal("Invalid authorization code issue time"))?;
432            let expires_ms = ((seconds + self.config.code_expires_in) * 1000.0)
433                .trunc()
434                .to_string()
435                .parse::<i64>()
436                .map_err(|_| AuthError::internal("Invalid authorization code expiration"))?;
437            let expires = chrono::DateTime::from_timestamp_millis(expires_ms)
438                .ok_or_else(|| AuthError::internal("Invalid authorization code expiration"))?;
439            let identifier = base64::engine::general_purpose::URL_SAFE_NO_PAD
440                .encode(Sha256::digest(code.as_bytes()));
441            let resources = query.get("resource").map_or_else(Vec::new, |value| {
442                value
443                    .as_array()
444                    .cloned()
445                    .unwrap_or_else(|| vec![value.clone()])
446            });
447            let value = serde_json::to_string(
448                &json!({"type":"authorization_code","query":query,"userId":user.id().as_ref(),"sessionId":session.id,"authTime":session.created_at.timestamp_millis(),"resource":resources}),
449            )?;
450            _ = ctx
451                .verifications()
452                .create(alibi_core::verification::VerificationCreation {
453                    id: None,
454                    identifier,
455                    value,
456                    expires_at: expires,
457                    created_at: created,
458                    updated_at: created,
459                })
460                .await?;
461            let mut target = url::Url::parse(&redirect_uri)
462                .map_err(|error| AuthError::internal(error.to_string()))?;
463            _ = target.query_pairs_mut().append_pair("code", &code);
464            if !state.is_empty() {
465                _ = target.query_pairs_mut().append_pair("state", &state);
466            }
467            _ = target.query_pairs_mut().append_pair("iss", &issuer);
468            return browser_redirect(req, target.as_str());
469        }
470        if prompts.contains(&"none") {
471            return admission_error(
472                if force_login {
473                    "login_required"
474                } else {
475                    "consent_required"
476                },
477                if force_login {
478                    "authentication required"
479                } else {
480                    "End-User consent is required"
481                },
482            );
483        }
484        let page = if force_login {
485            &self.config.login_page
486        } else {
487            &self.config.consent_page
488        };
489        let params = self.sign_authorization_query(query, ctx)?;
490        browser_redirect(req, &format!("{page}?{params}"))
491    }
492    fn sign_authorization_query<S: AuthSchema>(
493        &self,
494        query: serde_json::Map<String, Value>,
495        ctx: &AuthContext<S>,
496    ) -> AuthResult<String> {
497        let now = chrono::Utc::now().timestamp_millis();
498        let seconds = json!(now / 1000)
499            .as_f64()
500            .ok_or_else(|| AuthError::internal("Invalid authorization timestamp"))?;
501        let mut pairs = Vec::new();
502        for (name, value) in query {
503            if ["sig", "exp", "ba_iat", "ba_param", "ba_pl"].contains(&name.as_str()) {
504                continue;
505            }
506            match value {
507                Value::Null => {}
508                Value::Array(values) => {
509                    for value in values {
510                        pairs.push((name.clone(), parameter_text(&value)));
511                    }
512                }
513                value => pairs.push((name, parameter_text(&value))),
514            }
515        }
516        pairs.push((
517            "exp".into(),
518            (seconds + self.config.code_expires_in).to_string(),
519        ));
520        pairs.push(("ba_iat".into(), now.to_string()));
521        let mut names = pairs
522            .iter()
523            .map(|(name, _)| name.clone())
524            .collect::<Vec<_>>();
525        names.push("ba_param".into());
526        names.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
527        names.dedup();
528        for name in names {
529            pairs.push(("ba_param".into(), name));
530        }
531        let mut canonical = pairs.clone();
532        canonical.sort_by(|(a, av), (b, bv)| {
533            a.encode_utf16()
534                .cmp(b.encode_utf16())
535                .then_with(|| av.encode_utf16().cmp(bv.encode_utf16()))
536        });
537        let mut mac = Hmac::<Sha256>::new_from_slice(ctx.config.current_secret().as_bytes())
538            .map_err(|_| AuthError::internal("Invalid authorization signing key"))?;
539        mac.update(encode_pairs(&canonical).as_bytes());
540        let signature =
541            base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
542        pairs.push(("sig".into(), signature));
543        Ok(encode_pairs(&pairs))
544    }
545}
546fn parameter_text(value: &Value) -> String {
547    value
548        .as_str()
549        .map_or_else(|| value.to_string(), str::to_owned)
550}
551fn encode_pairs(pairs: &[(String, String)]) -> String {
552    let mut encoded = url::form_urlencoded::Serializer::new(String::new());
553    for (name, value) in pairs {
554        _ = encoded.append_pair(name, value);
555    }
556    encoded.finish()
557}
558fn registered_redirect(metadata: &Value, requested: &str) -> bool {
559    if requested.contains('#') {
560        return false;
561    }
562    metadata
563        .get("redirect_uris")
564        .and_then(Value::as_array)
565        .is_some_and(|uris| {
566            uris.iter().filter_map(Value::as_str).any(|uri| {
567                uri == requested || {
568                    let parsed = url::Url::parse(uri)
569                        .ok()
570                        .zip(url::Url::parse(requested).ok());
571                    parsed.is_some_and(|(mut registered, mut requested)| {
572                        let loopback = registered.host_str().is_some_and(|host| {
573                            ["localhost", "127.0.0.1", "[::1]"].contains(&host)
574                        });
575                        if registered.scheme() != "http"
576                            || requested.scheme() != "http"
577                            || !loopback
578                        {
579                            return false;
580                        }
581                        _ = registered.set_port(None);
582                        _ = requested.set_port(None);
583                        registered == requested
584                    })
585                }
586            })
587        })
588}
589fn error_url(
590    target: &str,
591    error: &str,
592    description: &str,
593    state: Option<&str>,
594    issuer: Option<&str>,
595) -> AuthResult<String> {
596    let mut url =
597        url::Url::parse(target).map_err(|error| AuthError::internal(error.to_string()))?;
598    {
599        let mut params = url.query_pairs_mut();
600        _ = params.append_pair("error", error);
601        _ = params.append_pair("error_description", description);
602        if let Some(state) = state.filter(|state| !state.is_empty()) {
603            _ = params.append_pair("state", state);
604        }
605        if let Some(issuer) = issuer {
606            _ = params.append_pair("iss", issuer);
607        }
608    }
609    Ok(url.into())
610}
611fn browser_redirect(req: &impl SessionRequest, target: &str) -> AuthResult<EndpointResponse> {
612    let json = req.session_headers().iter().any(|(name, value)| {
613        name.eq_ignore_ascii_case("accept") && value.contains("application/json")
614    });
615    let fetch = req.physical_request().is_some_and(|request| {
616        request
617            .header("sec-fetch-dest")
618            .is_some_and(|value| value == "empty")
619    });
620    if json || fetch {
621        EndpointResponse::json(&json!({"redirect":true,"url":target}))
622    } else {
623        Ok(EndpointResponse::redirect(target))
624    }
625}
626
627pub(super) fn verify_authorization_query(query: &str, secret: &str) -> AuthResult<bool> {
628    let pairs = url::form_urlencoded::parse(query.as_bytes())
629        .map(|(k, v)| (k.into_owned(), v.into_owned()))
630        .collect::<Vec<_>>();
631    let signatures = pairs.iter().filter(|(k, _)| k == "sig").collect::<Vec<_>>();
632    let [(_, signature_text)] = signatures.as_slice() else {
633        return Ok(false);
634    };
635    if signature_text.is_empty() {
636        return Ok(false);
637    }
638    let Some(exp) = pairs
639        .iter()
640        .find(|(k, _)| k == "exp")
641        .and_then(|(_, v)| v.parse::<f64>().ok())
642    else {
643        return Ok(false);
644    };
645    if !exp.is_finite()
646        || exp * 1000.0
647            < json!(chrono::Utc::now().timestamp_millis())
648                .as_f64()
649                .ok_or_else(|| AuthError::internal("Invalid authorization timestamp"))?
650    {
651        return Ok(false);
652    }
653    let mut canonical = pairs
654        .iter()
655        .filter(|(k, _)| k != "sig")
656        .cloned()
657        .collect::<Vec<_>>();
658    canonical.sort_by(|(a, av), (b, bv)| {
659        a.encode_utf16()
660            .cmp(b.encode_utf16())
661            .then_with(|| av.encode_utf16().cmp(bv.encode_utf16()))
662    });
663    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
664        .map_err(|_| AuthError::internal("Invalid authorization signing key"))?;
665    mac.update(encode_pairs(&canonical).as_bytes());
666    let Ok(signature) = base64::engine::general_purpose::STANDARD.decode(signature_text) else {
667        return Ok(false);
668    };
669    if base64::engine::general_purpose::STANDARD.encode(&signature) != *signature_text {
670        return Ok(false);
671    }
672    Ok(mac.verify_slice(&signature).is_ok())
673}