1use std::sync::Arc;
48
49use rand_core::{OsRng, RngCore};
50use serde_json;
51
52use crate::application::Application;
53use crate::core::New;
54use crate::error::{AppError, IntoResponse};
55use crate::header::Header;
56use crate::middleware::Middleware;
57use crate::request::Request;
58use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
59use crate::server::ConnectionInfo;
60use crate::session::{destroy_cookie, session_cookie, session_id_from_request, SessionStore};
61
62use super::{
63 client::OidcClient,
64 config::OidcConfig,
65 jwks::{JwksCache, OidcClaims, VerifyOptions},
66 pkce::PkceVerifier,
67};
68
69const SESSION_COOKIE: &str = "_rws_sid";
70const SESSION_TTL: u64 = 86_400; pub const CLAIMS_HEADER: &str = "X-Rws-Oidc-Claims";
73const LOGIN_PATH: &str = "/auth/login";
74const CALLBACK_PATH: &str = "/auth/callback";
75const LOGOUT_PATH: &str = "/auth/logout";
76const SIGNED_COOKIE: &str = "_rws_oidc";
77const PRE_AUTH_COOKIE: &str = "_rws_oidc_pre";
78const PRE_AUTH_TTL: u64 = 600;
79
80fn unix_now() -> u64 {
81 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
82}
83
84#[derive(Debug, Clone, PartialEq)]
86pub struct SignedIn {
87 pub sub: String,
88 pub email: Option<String>,
90 pub name: Option<String>,
91 pub groups: Vec<String>,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
96struct Identity {
97 sub: String,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 email: Option<String>,
100 #[serde(default)]
101 email_verified: bool,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 name: Option<String>,
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 groups: Vec<String>,
106}
107
108impl Identity {
109 fn from_claims(claims: &OidcClaims, groups: Vec<String>) -> Identity {
110 Identity {
111 sub: claims.sub.clone(),
112 email: claims.email.clone(),
113 email_verified: claims.email_verified.unwrap_or(true),
115 name: claims.name.clone(),
116 groups,
117 }
118 }
119
120 fn from_claims_json(json: &str) -> Option<Identity> {
121 let claims: OidcClaims = serde_json::from_str(json).ok()?;
122 Some(Identity::from_claims(&claims, Vec::new()))
123 }
124
125 fn claims_json(&self) -> String {
127 serde_json::json!({
128 "sub": self.sub, "iss": "", "aud": [], "exp": 0, "iat": 0,
129 "email": self.email, "email_verified": self.email_verified, "name": self.name,
130 })
131 .to_string()
132 }
133}
134
135fn groups_claim(id_token: &str, claim: &str) -> Vec<String> {
138 let Some(payload) = id_token.split('.').nth(1).and_then(|p| super::pkce::base64url_decode(p).ok()) else { return Vec::new() };
139 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&payload) else { return Vec::new() };
140 match value.get(claim) {
141 Some(serde_json::Value::Array(items)) => items.iter().filter_map(|g| g.as_str().map(str::to_string)).collect(),
142 Some(serde_json::Value::String(g)) => vec![g.clone()],
143 _ => Vec::new(),
144 }
145}
146
147fn cookie_value(request: &Request, name: &str) -> Option<String> {
148 request.headers.iter().filter(|h| h.name.eq_ignore_ascii_case("cookie")).flat_map(|h| h.value.split(';').map(str::trim).map(str::to_string).collect::<Vec<_>>()).find_map(|pair| {
149 let (k, v) = pair.split_once('=')?;
150 (k.trim() == name && !v.is_empty()).then(|| v.trim().to_string())
151 })
152}
153
154pub struct OidcAuth {
158 config: Arc<OidcConfig>,
159 signed: Option<Arc<Vec<u8>>>,
160 ttl: u64,
161 allow_emails: Vec<String>,
162 allow_domains: Vec<String>,
163 allow_groups: Vec<String>,
164 groups_claim: String,
165 forward: bool,
166 jwks: Option<Arc<JwksCache>>,
167 client: OidcClient,
168 sessions: Arc<SessionStore>,
169 excluded: Vec<String>,
170 login_path: String,
171 callback_path: String,
172 logout_path: String,
173}
174
175impl OidcAuth {
176 pub fn new(config: OidcConfig, sessions: Arc<SessionStore>) -> Self {
180 let jwks = if !config.provider.jwks_uri.is_empty() {
181 Some(Arc::new(JwksCache::new(&config.provider.jwks_uri)))
182 } else {
183 None
184 };
185 let config = Arc::new(config);
186 let client = OidcClient::new((*config).clone());
187 OidcAuth {
188 config,
189 signed: None,
190 ttl: SESSION_TTL,
191 allow_emails: Vec::new(),
192 allow_domains: Vec::new(),
193 allow_groups: Vec::new(),
194 groups_claim: "groups".into(),
195 forward: false,
196 jwks,
197 client,
198 sessions,
199 excluded: Vec::new(),
200 login_path: LOGIN_PATH.into(),
201 callback_path: CALLBACK_PATH.into(),
202 logout_path: LOGOUT_PATH.into(),
203 }
204 }
205
206 pub fn exclude(mut self, prefix: &str) -> Self {
212 self.excluded.push(prefix.into());
213 self
214 }
215
216 pub fn login_path(mut self, path: &str) -> Self {
218 self.login_path = path.into();
219 self
220 }
221
222 pub fn callback_path(mut self, path: &str) -> Self {
224 self.callback_path = path.into();
225 self
226 }
227
228 pub fn logout_path(mut self, path: &str) -> Self {
230 self.logout_path = path.into();
231 self
232 }
233
234 pub fn signed_cookies(mut self, secret: impl Into<Vec<u8>>) -> Self {
238 self.signed = Some(Arc::new(secret.into()));
239 self
240 }
241
242 pub fn session_ttl(mut self, secs: u64) -> Self {
244 self.ttl = secs.max(60);
245 self
246 }
247
248 pub fn allow_emails(mut self, emails: impl IntoIterator<Item = impl Into<String>>) -> Self {
250 self.allow_emails.extend(emails.into_iter().map(|e| e.into().trim().to_ascii_lowercase()).filter(|e| !e.is_empty()));
251 self
252 }
253
254 pub fn allow_domains(mut self, domains: impl IntoIterator<Item = impl Into<String>>) -> Self {
256 self.allow_domains.extend(domains.into_iter().map(|d| d.into().trim().trim_start_matches('@').to_ascii_lowercase()).filter(|d| !d.is_empty()));
257 self
258 }
259
260 pub fn allow_groups(mut self, claim: &str, groups: impl IntoIterator<Item = impl Into<String>>) -> Self {
263 if !claim.is_empty() {
264 self.groups_claim = claim.to_string();
265 }
266 self.allow_groups.extend(groups.into_iter().map(Into::into).filter(|g: &String| !g.is_empty()));
267 self
268 }
269
270 pub fn forward_headers(mut self, on: bool) -> Self {
273 self.forward = on;
274 self
275 }
276
277 pub fn signed_in(&self, request: &Request) -> Option<SignedIn> {
281 self.identity(request).map(|i| SignedIn { sub: i.sub, email: i.email.filter(|_| i.email_verified), name: i.name, groups: i.groups })
282 }
283
284 pub fn claims(req: &Request) -> Option<OidcClaims> {
289 req.headers
290 .iter()
291 .find(|h| h.name.eq_ignore_ascii_case(CLAIMS_HEADER))
292 .and_then(|h| serde_json::from_str(&h.value).ok())
293 }
294
295 pub fn sub(req: &Request) -> Option<String> {
297 Self::claims(req).map(|c| c.sub)
298 }
299
300 pub fn email(req: &Request) -> Option<String> {
302 Self::claims(req).and_then(|c| c.email)
303 }
304
305 fn random_token() -> String {
308 let mut bytes = [0u8; 16];
309 OsRng.fill_bytes(&mut bytes);
310 bytes.iter().map(|b| format!("{:02x}", b)).collect()
311 }
312
313 fn redirect(url: &str) -> Response {
314 let mut r = Response::new();
315 r.status_code = *STATUS_CODE_REASON_PHRASE.n302_found.status_code;
316 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n302_found.reason_phrase.to_string();
317 r.headers.push(Header { name: "Location".into(), value: url.to_string().into() });
318 r
319 }
320
321 fn allowed(&self, identity: &Identity) -> bool {
323 if self.allow_emails.is_empty() && self.allow_domains.is_empty() && self.allow_groups.is_empty() {
324 return true;
325 }
326 let email = identity.email.as_deref().filter(|_| identity.email_verified).map(str::to_ascii_lowercase);
327 if let Some(email) = &email {
328 if self.allow_emails.iter().any(|e| e == email) {
329 return true;
330 }
331 if let Some((_, domain)) = email.rsplit_once('@') {
332 if self.allow_domains.iter().any(|d| d == domain) {
333 return true;
334 }
335 }
336 }
337 identity.groups.iter().any(|g| self.allow_groups.contains(g))
338 }
339
340 fn redirect_uri(&self, request: &Request, connection: &ConnectionInfo) -> String {
343 if !self.config.redirect_uri.is_empty() {
344 return self.config.redirect_uri.clone();
345 }
346 let host = request.header("Host").map(|h| h.value.to_string()).unwrap_or_else(|| "localhost".into());
347 format!("{}://{}{}", crate::ip_filter::request_scheme(request, &connection.client.ip, connection.tls), host, self.callback_path)
350 }
351
352 fn client_for(&self, redirect_uri: &str) -> OidcClient {
353 if redirect_uri == self.config.redirect_uri {
354 return self.client.clone();
355 }
356 let mut config = (*self.config).clone();
357 config.redirect_uri = redirect_uri.to_string();
358 OidcClient::new(config)
359 }
360
361 fn cookie(&self, name: &str, value: &str, max_age: u64, request: &Request, connection: &ConnectionInfo) -> Header {
362 let secure = if crate::ip_filter::request_scheme(request, &connection.client.ip, connection.tls) == "https" { "; Secure" } else { "" };
364 Header { name: "Set-Cookie".into(), value: format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}").into() }
365 }
366
367 fn sign(&self, payload: &str) -> String {
368 let secret = self.signed.as_deref().map(Vec::as_slice).unwrap_or_default();
369 let body = super::pkce::base64url_encode(payload.as_bytes());
370 let mac = crate::core::sha256::hmac_sha256(secret, body.as_bytes());
371 format!("{body}.{}", super::pkce::base64url_encode(&mac))
372 }
373
374 fn verify_signed(&self, value: &str) -> Option<serde_json::Value> {
377 let secret = self.signed.as_deref()?;
378 let (body, mac) = value.split_once('.')?;
379 let expected = crate::core::sha256::hmac_sha256(secret, body.as_bytes());
380 let mac = super::pkce::base64url_decode(mac).ok()?;
381 if !crate::core::constant_time_eq(&mac, &expected) {
382 return None;
383 }
384 let payload: serde_json::Value = serde_json::from_slice(&super::pkce::base64url_decode(body).ok()?).ok()?;
385 let exp = payload.get("exp")?.as_u64()?;
386 (exp > unix_now()).then_some(payload)
387 }
388
389 fn identity(&self, request: &Request) -> Option<Identity> {
391 if self.signed.is_some() {
392 let value = cookie_value(request, SIGNED_COOKIE)?;
393 let payload = self.verify_signed(&value)?;
394 return serde_json::from_value(payload.get("id")?.clone()).ok();
395 }
396 let sid = session_id_from_request(request, SESSION_COOKIE)?;
397 let session = self.sessions.load(&sid)?;
398 let claims = session.get("_oidc_claims")?;
399 let mut identity = Identity::from_claims_json(claims)?;
400 identity.groups = session.get("_oidc_groups").and_then(|g| serde_json::from_str(g).ok()).unwrap_or_default();
401 Some(identity)
402 }
403
404 fn forbidden(identity: &Identity, logout: &str) -> Response {
405 let who = identity.email.clone().unwrap_or_else(|| identity.sub.clone());
406 let mut r = crate::error::problem::problem_with_text(
407 STATUS_CODE_REASON_PHRASE.n403_forbidden,
408 &format!("{who} is signed in but not allowed here. Sign out at {logout}."),
409 "signed in, but not allowed",
410 );
411 r.headers.push(Header { name: "Cache-Control".into(), value: "no-store".into() });
412 r
413 }
414
415 fn is_excluded(&self, path: &str) -> bool {
416 self.excluded.iter().any(|p| path.starts_with(p.as_str()))
417 }
418
419 fn handle_login(&self, request: &Request, connection: &ConnectionInfo) -> Response {
422 let verifier = PkceVerifier::new();
423 let state = Self::random_token();
424 let nonce = Self::random_token();
425 let redirect_uri = self.redirect_uri(request, connection);
426 let return_to = query_param(request, "return_to")
427 .filter(|r| local_path(r))
428 .unwrap_or_else(|| self.config.post_login_redirect.clone());
429
430 if self.signed.is_some() {
431 let pre = serde_json::json!({
432 "state": state, "nonce": nonce, "pkce": verifier.as_str(),
433 "return_to": return_to, "redirect_uri": redirect_uri, "exp": unix_now() + PRE_AUTH_TTL,
434 });
435 let url = self.client_for(&redirect_uri).authorization_url(&verifier, &state, &nonce);
436 let mut response = Self::redirect(&url);
437 response.headers.push(self.cookie(PRE_AUTH_COOKIE, &self.sign(&pre.to_string()), PRE_AUTH_TTL, request, connection));
438 response.headers.push(Header { name: "Cache-Control".into(), value: "no-store".into() });
439 return response;
440 }
441
442 let mut session = self.sessions.create();
443 session.set("_oidc_state", &state);
444 session.set("_oidc_nonce", &nonce);
445 session.set("_oidc_pkce", verifier.as_str());
446
447 session.set("_oidc_return_to", &return_to);
448 session.set("_oidc_redirect_uri", &redirect_uri);
449 self.sessions.save(&session);
450
451 let url = self.client_for(&redirect_uri).authorization_url(&verifier, &state, &nonce);
452 let mut response = Self::redirect(&url);
453 response.headers.push(Header {
454 name: "Set-Cookie".into(),
455 value: session_cookie(&session.id, SESSION_COOKIE, SESSION_TTL).into(),
456 });
457 response
458 }
459
460 fn handle_callback(&self, request: &Request, connection: &ConnectionInfo) -> Response {
461 let mut session = None;
463 let pre: serde_json::Value = if self.signed.is_some() {
464 match cookie_value(request, PRE_AUTH_COOKIE).and_then(|v| self.verify_signed(&v)) {
465 Some(p) => p,
466 None => return AppError::Forbidden.into_response(),
467 }
468 } else {
469 let sid = match session_id_from_request(request, SESSION_COOKIE) {
470 Some(id) => id,
471 None => return AppError::Forbidden.into_response(),
472 };
473 let s = match self.sessions.load(&sid) {
474 Some(s) => s,
475 None => return AppError::Forbidden.into_response(),
476 };
477 let get = |k: &str| serde_json::Value::String(s.get(k).unwrap_or("").to_string());
478 let pre = serde_json::json!({
479 "state": get("_oidc_state"), "nonce": get("_oidc_nonce"), "pkce": get("_oidc_pkce"),
480 "return_to": s.get("_oidc_return_to").unwrap_or("/"), "redirect_uri": get("_oidc_redirect_uri"),
481 });
482 session = Some(s);
483 pre
484 };
485 let field = |k: &str| pre.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
486
487 let stored_state = field("state");
489 let received_state = query_param(request, "state").unwrap_or_default();
490 if stored_state.is_empty() || stored_state != received_state {
491 return AppError::Forbidden.into_response();
492 }
493
494 let code = match query_param(request, "code") {
496 Some(c) => c,
497 None => {
498 let error = query_param(request, "error").unwrap_or_else(|| "unknown".into());
499 return error_response(&format!("OAuth2 error: {error}"));
500 }
501 };
502
503 let pkce_verifier = field("pkce");
504 let stored_nonce = field("nonce");
505 let return_to = Some(field("return_to")).filter(|r| !r.is_empty()).unwrap_or_else(|| "/".into());
506 let redirect_uri = Some(field("redirect_uri")).filter(|r| !r.is_empty()).unwrap_or_else(|| self.config.redirect_uri.clone());
507
508 let tokens = match self.client_for(&redirect_uri).exchange_code(&code, &pkce_verifier) {
510 Ok(t) => t,
511 Err(e) => return error_response(&e.0),
512 };
513
514 let claims = if let (Some(id_token), Some(jwks)) = (&tokens.id_token, &self.jwks) {
516 let opts = VerifyOptions {
517 audience: &self.config.client_id,
518 issuer: &self.config.provider.issuer,
519 leeway_secs: 60,
520 };
521 match jwks.verify_jwt(id_token, &opts) {
522 Ok(c) => {
523 if !stored_nonce.is_empty() && c.nonce.as_deref() != Some(&stored_nonce) {
524 return AppError::Forbidden.into_response();
525 }
526 c
527 }
528 Err(e) => return error_response(&e.0),
529 }
530 } else {
531 match self.client.fetch_user_info(&tokens.access_token) {
532 Ok(c) => c,
533 Err(e) => return error_response(&e.0),
534 }
535 };
536
537 let groups = tokens.id_token.as_deref().map(|t| groups_claim(t, &self.groups_claim)).unwrap_or_default();
538 let identity = Identity::from_claims(&claims, groups);
539 if !self.allowed(&identity) {
540 return Self::forbidden(&identity, &self.logout_path);
541 }
542
543 let Some(mut session) = session else {
544 let mut stored = identity.clone();
547 if !self.allow_groups.is_empty() {
548 stored.groups.retain(|g| self.allow_groups.contains(g));
549 }
550 stored.groups.truncate(32);
551 let payload = serde_json::json!({ "id": stored, "exp": unix_now() + self.ttl });
552 let mut response = Self::redirect(&return_to);
553 response.headers.push(self.cookie(SIGNED_COOKIE, &self.sign(&payload.to_string()), self.ttl, request, connection));
554 response.headers.push(self.cookie(PRE_AUTH_COOKIE, "", 0, request, connection));
555 return response;
556 };
557
558 session.remove("_oidc_state");
560 session.remove("_oidc_nonce");
561 session.remove("_oidc_pkce");
562 session.remove("_oidc_return_to");
563 session.remove("_oidc_redirect_uri");
564 let claims_json = serde_json::to_string(&claims).unwrap_or_default();
565 session.set("_oidc_claims", &claims_json);
566 session.set("_oidc_groups", &serde_json::to_string(&identity.groups).unwrap_or_default());
567 self.sessions.save(&session);
568
569 let mut response = Self::redirect(&return_to);
570 response.headers.push(Header {
571 name: "Set-Cookie".into(),
572 value: session_cookie(&session.id, SESSION_COOKIE, self.ttl).into(),
573 });
574 response
575 }
576
577 fn handle_logout(&self, request: &Request, connection: &ConnectionInfo) -> Response {
578 if self.signed.is_some() {
579 let mut response = Self::redirect(&self.config.post_login_redirect);
580 response.headers.push(self.cookie(SIGNED_COOKIE, "", 0, request, connection));
581 return response;
582 }
583 if let Some(sid) = session_id_from_request(request, SESSION_COOKIE) {
584 self.sessions.destroy(&sid);
585 }
586 let mut response = Self::redirect("/");
587 response.headers.push(Header {
588 name: "Set-Cookie".into(),
589 value: destroy_cookie(SESSION_COOKIE).into(),
590 });
591 response
592 }
593}
594
595impl Middleware for OidcAuth {
596 fn handle(
597 &self,
598 request: &Request,
599 connection: &ConnectionInfo,
600 next: &dyn Application,
601 ) -> Result<Response, String> {
602 let path = request.request_uri.split('?').next().unwrap_or("");
603
604 if path == self.login_path {
606 return Ok(self.handle_login(request, connection));
607 }
608 if path == self.callback_path {
609 return Ok(self.handle_callback(request, connection));
610 }
611 if path == self.logout_path {
612 return Ok(self.handle_logout(request, connection));
613 }
614
615 let spoofed = |name: &str| name.eq_ignore_ascii_case(CLAIMS_HEADER) || name.len() > 15 && name[..15].eq_ignore_ascii_case("x-auth-request-");
617 let mut req = request.clone();
618 req.headers.retain(|h| !spoofed(&h.name));
619
620 if self.is_excluded(path) {
622 return next.execute(&req, connection);
623 }
624
625 if let Some(identity) = self.identity(request) {
626 if !self.allowed(&identity) {
627 return Ok(Self::forbidden(&identity, &self.logout_path));
628 }
629 let claims_json = identity.claims_json();
630 req.headers.push(Header { name: CLAIMS_HEADER.to_string().into(), value: claims_json.clone().into() });
631 if self.forward {
632 req.headers.push(Header { name: "X-Auth-Request-User".into(), value: identity.sub.clone().into() });
633 if let Some(email) = &identity.email {
634 req.headers.push(Header { name: "X-Auth-Request-Email".into(), value: email.clone().into() });
635 }
636 if !identity.groups.is_empty() {
637 req.headers.push(Header { name: "X-Auth-Request-Groups".into(), value: identity.groups.join(",").into() });
638 }
639 }
640 return match crate::authz::Identity::from_claims(&claims_json, "sub", "oidc") {
641 Some(authz) => crate::authz::with_identity(authz, || next.execute(&req, connection)),
642 None => next.execute(&req, connection),
643 };
644 }
645
646 let return_to = super::client::url_encode(&request.request_uri);
648 let login_url = format!("{}?return_to={}", self.login_path, return_to);
649 Ok(Self::redirect(&login_url))
650 }
651}
652
653fn query_param(request: &Request, name: &str) -> Option<String> {
656 let uri = &request.request_uri;
657 let qs = uri.splitn(2, '?').nth(1)?;
658 for pair in qs.split('&') {
659 let mut parts = pair.splitn(2, '=');
660 let k = parts.next()?.trim();
661 if k == name {
662 let v = parts.next().unwrap_or("").trim();
663 return Some(crate::url::URL::form_decode(v));
664 }
665 }
666 None
667}
668
669
670fn local_path(r: &str) -> bool {
675 r.starts_with('/') && !r.starts_with("//") && !r.contains('\\') && !r.chars().any(|c| c.is_control() || c.is_whitespace())
676}
677
678fn error_response(msg: &str) -> Response {
679 crate::error::problem::problem_with_text(STATUS_CODE_REASON_PHRASE.n500_internal_server_error, msg, msg)
680}