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;
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 = super::discovery::resolve(ctx, &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 query
250            .get("resource")
251            .is_some_and(|value| !value.is_string() && !value.is_array())
252        {
253            return admission_error("invalid_request", "resource: Invalid input");
254        }
255        if let Err(error) = self
256            .admit_resources(ctx, &client_id, &requested, query.get("resource"))
257            .await
258        {
259            if let Some(body) = error.body {
260                let body = body.to_json_value()?;
261                return admission_error(
262                    body.get("error")
263                        .and_then(Value::as_str)
264                        .unwrap_or("server_error"),
265                    body.get("error_description")
266                        .and_then(Value::as_str)
267                        .unwrap_or_default(),
268                );
269            }
270            return Err(error.error);
271        }
272        let challenge = text("code_challenge");
273        let method = text("code_challenge_method");
274        let public = client
275            .metadata
276            .get("token_endpoint_auth_method")
277            .and_then(Value::as_str)
278            == Some("none");
279        let nonce = text("nonce");
280        let pkce = if public {
281            Some("pkce is required for public clients")
282        } else if requested.contains(&"offline_access")
283            && (!requested.contains(&"openid") || nonce.is_empty())
284        {
285            Some("pkce or OIDC nonce is required when requesting offline_access scope")
286        } else if client.metadata.get("require_pkce") != Some(&json!(false)) {
287            Some("pkce is required for this client")
288        } else {
289            None
290        };
291        if let Some(reason) = pkce
292            && (challenge.is_empty() || method.is_empty())
293        {
294            return admission_error("invalid_request", reason);
295        }
296        if challenge.is_empty() != method.is_empty() {
297            return admission_error(
298                "invalid_request",
299                "code_challenge and code_challenge_method must both be provided",
300            );
301        }
302        if !method.is_empty() && method != "S256" {
303            return admission_error(
304                "invalid_request",
305                "code_challenge_method must be one of: S256",
306            );
307        }
308        let prompt = text("prompt");
309        let prompts = prompt.split(' ').collect::<Vec<_>>();
310        let session = Box::pin(ctx.require_cached_session(req)).await.ok();
311
312        let force_login =
313            session.is_none() || prompts.contains(&"login") || prompts.contains(&"create");
314        _ = query.insert("scope".into(), json!(scope));
315        if !force_login
316            && initial
317            && prompts.contains(&"select_account")
318            && let Some(page) = &self.config.select_account_page
319        {
320            let params = self.sign_authorization_query(query, ctx)?;
321            return browser_redirect(req, &format!("{page}?{params}"));
322        }
323        let remembered = if !force_login
324            && !prompts.contains(&"consent")
325            && client.metadata.get("skip_consent") != Some(&json!(true))
326            && let Some((user, _)) = &session
327        {
328            ctx.database
329                .list_oauth_consents_by_user(user.id().as_ref())
330                .await?
331                .into_iter()
332                .any(|consent| {
333                    consent.client_id == client_id
334                        && scope.split(' ').all(|scope| {
335                            consent
336                                .metadata
337                                .get("scopes")
338                                .and_then(Value::as_array)
339                                .is_some_and(|scopes| {
340                                    scopes.iter().any(|value| value.as_str() == Some(scope))
341                                })
342                        })
343                })
344        } else {
345            false
346        };
347        if !force_login
348            && !prompts.contains(&"consent")
349            && (client.metadata.get("skip_consent") == Some(&json!(true)) || remembered)
350            && let Some((user, session)) = &session
351        {
352            let code = rand::RngExt::sample_iter(rand::rng(), rand::distr::Alphanumeric)
353                .map(char::from)
354                .take(32)
355                .collect::<String>();
356            let now = chrono::Utc::now().timestamp();
357            let created = chrono::DateTime::from_timestamp(now, 0)
358                .ok_or_else(|| AuthError::internal("Invalid authorization code issue time"))?;
359            let seconds = json!(now)
360                .as_f64()
361                .ok_or_else(|| AuthError::internal("Invalid authorization code issue time"))?;
362            let expires_ms = ((seconds + self.config.code_expires_in) * 1000.0)
363                .trunc()
364                .to_string()
365                .parse::<i64>()
366                .map_err(|_| AuthError::internal("Invalid authorization code expiration"))?;
367            let expires = chrono::DateTime::from_timestamp_millis(expires_ms)
368                .ok_or_else(|| AuthError::internal("Invalid authorization code expiration"))?;
369            let identifier = self.hash_token(&code, "authorization_code").await?;
370            let resources = query.get("resource").map_or_else(Vec::new, |value| {
371                value
372                    .as_array()
373                    .cloned()
374                    .unwrap_or_else(|| vec![value.clone()])
375            });
376            let value = serde_json::to_string(
377                &json!({"type":"authorization_code","query":query,"userId":user.id().as_ref(),"sessionId":session.id,"authTime":session.created_at.timestamp_millis(),"resource":resources}),
378            )?;
379            _ = ctx
380                .verifications()
381                .create(alibi_core::verification::VerificationCreation {
382                    id: None,
383                    identifier,
384                    value,
385                    expires_at: expires,
386                    created_at: created,
387                    updated_at: created,
388                })
389                .await?;
390            let mut target = url::Url::parse(&redirect_uri)
391                .map_err(|error| AuthError::internal(error.to_string()))?;
392            _ = target.query_pairs_mut().append_pair("code", &code);
393            if !state.is_empty() {
394                _ = target.query_pairs_mut().append_pair("state", &state);
395            }
396            _ = target.query_pairs_mut().append_pair("iss", &issuer);
397            return browser_redirect(req, target.as_str());
398        }
399        if prompts.contains(&"none") {
400            return admission_error(
401                if force_login {
402                    "login_required"
403                } else {
404                    "consent_required"
405                },
406                if force_login {
407                    "authentication required"
408                } else {
409                    "End-User consent is required"
410                },
411            );
412        }
413        let page = if force_login {
414            &self.config.login_page
415        } else {
416            &self.config.consent_page
417        };
418        let params = self.sign_authorization_query(query, ctx)?;
419        browser_redirect(req, &format!("{page}?{params}"))
420    }
421    fn sign_authorization_query<S: AuthSchema>(
422        &self,
423        query: serde_json::Map<String, Value>,
424        ctx: &AuthContext<S>,
425    ) -> AuthResult<String> {
426        let now = chrono::Utc::now().timestamp_millis();
427        let seconds = json!(now / 1000)
428            .as_f64()
429            .ok_or_else(|| AuthError::internal("Invalid authorization timestamp"))?;
430        let mut pairs = Vec::new();
431        for (name, value) in query {
432            if ["sig", "exp", "ba_iat", "ba_param", "ba_pl"].contains(&name.as_str()) {
433                continue;
434            }
435            match value {
436                Value::Null => {}
437                Value::Array(values) => {
438                    for value in values {
439                        pairs.push((name.clone(), parameter_text(&value)));
440                    }
441                }
442                value => pairs.push((name, parameter_text(&value))),
443            }
444        }
445        pairs.push((
446            "exp".into(),
447            (seconds + self.config.code_expires_in).to_string(),
448        ));
449        pairs.push(("ba_iat".into(), now.to_string()));
450        let mut names = pairs
451            .iter()
452            .map(|(name, _)| name.clone())
453            .collect::<Vec<_>>();
454        names.push("ba_param".into());
455        names.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
456        names.dedup();
457        for name in names {
458            pairs.push(("ba_param".into(), name));
459        }
460        let mut canonical = pairs.clone();
461        canonical.sort_by(|(a, av), (b, bv)| {
462            a.encode_utf16()
463                .cmp(b.encode_utf16())
464                .then_with(|| av.encode_utf16().cmp(bv.encode_utf16()))
465        });
466        let mut mac = Hmac::<Sha256>::new_from_slice(ctx.config.current_secret().as_bytes())
467            .map_err(|_| AuthError::internal("Invalid authorization signing key"))?;
468        mac.update(encode_pairs(&canonical).as_bytes());
469        let signature =
470            base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
471        pairs.push(("sig".into(), signature));
472        Ok(encode_pairs(&pairs))
473    }
474}
475fn parameter_text(value: &Value) -> String {
476    value
477        .as_str()
478        .map_or_else(|| value.to_string(), str::to_owned)
479}
480fn encode_pairs(pairs: &[(String, String)]) -> String {
481    let mut encoded = url::form_urlencoded::Serializer::new(String::new());
482    for (name, value) in pairs {
483        _ = encoded.append_pair(name, value);
484    }
485    encoded.finish()
486}
487fn registered_redirect(metadata: &Value, requested: &str) -> bool {
488    if requested.contains('#') {
489        return false;
490    }
491    metadata
492        .get("redirect_uris")
493        .and_then(Value::as_array)
494        .is_some_and(|uris| {
495            uris.iter().filter_map(Value::as_str).any(|uri| {
496                uri == requested || {
497                    let parsed = url::Url::parse(uri)
498                        .ok()
499                        .zip(url::Url::parse(requested).ok());
500                    parsed.is_some_and(|(mut registered, mut requested)| {
501                        let loopback = registered.host_str().is_some_and(|host| {
502                            ["localhost", "127.0.0.1", "[::1]"].contains(&host)
503                        });
504                        if registered.scheme() != "http"
505                            || requested.scheme() != "http"
506                            || !loopback
507                        {
508                            return false;
509                        }
510                        _ = registered.set_port(None);
511                        _ = requested.set_port(None);
512                        registered == requested
513                    })
514                }
515            })
516        })
517}
518fn error_url(
519    target: &str,
520    error: &str,
521    description: &str,
522    state: Option<&str>,
523    issuer: Option<&str>,
524) -> AuthResult<String> {
525    let mut url =
526        url::Url::parse(target).map_err(|error| AuthError::internal(error.to_string()))?;
527    {
528        let mut params = url.query_pairs_mut();
529        _ = params.append_pair("error", error);
530        _ = params.append_pair("error_description", description);
531        if let Some(state) = state.filter(|state| !state.is_empty()) {
532            _ = params.append_pair("state", state);
533        }
534        if let Some(issuer) = issuer {
535            _ = params.append_pair("iss", issuer);
536        }
537    }
538    Ok(url.into())
539}
540fn browser_redirect(req: &impl SessionRequest, target: &str) -> AuthResult<EndpointResponse> {
541    let json = req.session_headers().iter().any(|(name, value)| {
542        name.eq_ignore_ascii_case("accept") && value.contains("application/json")
543    });
544    let fetch = req.physical_request().is_some_and(|request| {
545        request
546            .header("sec-fetch-dest")
547            .is_some_and(|value| value == "empty")
548    });
549    if json || fetch {
550        EndpointResponse::json(&json!({"redirect":true,"url":target}))
551    } else {
552        Ok(EndpointResponse::redirect(target))
553    }
554}
555
556pub(super) fn verify_authorization_query(query: &str, secret: &str) -> AuthResult<bool> {
557    let pairs = url::form_urlencoded::parse(query.as_bytes())
558        .map(|(k, v)| (k.into_owned(), v.into_owned()))
559        .collect::<Vec<_>>();
560    let signatures = pairs.iter().filter(|(k, _)| k == "sig").collect::<Vec<_>>();
561    let [(_, signature_text)] = signatures.as_slice() else {
562        return Ok(false);
563    };
564    if signature_text.is_empty() {
565        return Ok(false);
566    }
567    let Some(exp) = pairs
568        .iter()
569        .find(|(k, _)| k == "exp")
570        .and_then(|(_, v)| v.parse::<f64>().ok())
571    else {
572        return Ok(false);
573    };
574    if !exp.is_finite()
575        || exp * 1000.0
576            < json!(chrono::Utc::now().timestamp_millis())
577                .as_f64()
578                .ok_or_else(|| AuthError::internal("Invalid authorization timestamp"))?
579    {
580        return Ok(false);
581    }
582    let mut canonical = pairs
583        .iter()
584        .filter(|(k, _)| k != "sig")
585        .cloned()
586        .collect::<Vec<_>>();
587    canonical.sort_by(|(a, av), (b, bv)| {
588        a.encode_utf16()
589            .cmp(b.encode_utf16())
590            .then_with(|| av.encode_utf16().cmp(bv.encode_utf16()))
591    });
592    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
593        .map_err(|_| AuthError::internal("Invalid authorization signing key"))?;
594    mac.update(encode_pairs(&canonical).as_bytes());
595    let Ok(signature) = base64::engine::general_purpose::STANDARD.decode(signature_text) else {
596        return Ok(false);
597    };
598    if base64::engine::general_purpose::STANDARD.encode(&signature) != *signature_text {
599        return Ok(false);
600    }
601    Ok(mac.verify_slice(&signature).is_ok())
602}