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::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 = 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}