1use super::OAuthProviderPlugin;
3use alibi_core::endpoint::{EndpointResponse, ServerEndpoint};
4use alibi_core::session::SessionRequest;
5use alibi_core::utils::json::JsValue;
6use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, AuthUser};
7use base64::Engine;
8use hmac::{Hmac, KeyInit, Mac};
9use serde_json::{Value, json};
10use sha2::{Digest as _, Sha256};
11impl OAuthProviderPlugin {
12 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}