Skip to main content

rws_server/sso/
oidc_auth.rs

1//! `OidcAuth` middleware — full OIDC / OAuth 2.0 authorization code flow with
2//! PKCE, session management, and claim injection.
3//!
4//! # Usage
5//!
6//! ```rust,no_run
7//! # extern crate rws_server as rust_web_server;
8//! use std::sync::Arc;
9//! use rust_web_server::app::App;
10//! use rust_web_server::core::New;
11//! use rust_web_server::session::SessionStore;
12//! use rust_web_server::sso::{OidcAuth, OidcConfig};
13//!
14//! let sessions = Arc::new(SessionStore::new(86400));
15//! let config   = OidcConfig::google("client-id", "client-secret", "https://example.com/auth/callback");
16//! let app      = App::new().wrap(OidcAuth::new(config, sessions));
17//! ```
18//!
19//! The middleware intercepts three paths:
20//! - `GET /auth/login`    — generates PKCE + state + nonce, stores them in a
21//!   pre-auth session, and redirects the user to the identity provider.
22//! - `GET /auth/callback` — validates state, exchanges the code, verifies the
23//!   id_token (or fetches UserInfo for GitHub), stores claims in the session,
24//!   and redirects to the app.
25//! - `GET /auth/logout`   — destroys the session and redirects to `/`.
26//!
27//! On all other paths the middleware checks the session for `_oidc_claims`.
28//! If found, the claims are injected into the request as the
29//! `X-Rws-Oidc-Claims` header (JSON) and the request is passed to the next
30//! layer.  If not found, the user is redirected to `/auth/login`.
31//!
32//! Use [`OidcAuth::claims`] inside a handler to read the injected claims.
33//!
34//! Since 18.21.0 (#73):
35//! - [`OidcAuth::signed_cookies`] keeps the session in an HMAC-signed cookie
36//!   instead of the [`SessionStore`], so sessions survive a restart and work
37//!   across replicas;
38//! - [`OidcAuth::allow_emails`] / [`allow_domains`](OidcAuth::allow_domains) /
39//!   [`allow_groups`](OidcAuth::allow_groups) admit only some users (a
40//!   verified email, its domain, or a group in the ID token);
41//! - [`OidcAuth::forward_headers`] sends `X-Auth-Request-User`, `-Email` and
42//!   `-Groups` upstream (the names oauth2-proxy uses);
43//! - an empty `redirect_uri` means `<scheme>://<Host><callback_path>` of the
44//!   request;
45//! - claim headers a client sends itself are always removed.
46
47use 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; // 24 h
71/// Request header name used to pass OIDC claims into downstream handlers.
72pub 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/// A signed-in user ([`OidcAuth::signed_in`]).
85#[derive(Debug, Clone, PartialEq)]
86pub struct SignedIn {
87    pub sub: String,
88    /// Only when the provider says it's verified.
89    pub email: Option<String>,
90    pub name: Option<String>,
91    pub groups: Vec<String>,
92}
93
94/// Who signed in: the claims kept for a session.
95#[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            // Providers that don't say (GitHub's verified primary email) count as verified.
114            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    /// The `X-Rws-Oidc-Claims` value: the [`OidcClaims`] fields known.
126    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
135/// The groups claim of an (already verified) ID token: an array of strings,
136/// or one string.
137fn 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
154/// OIDC / OAuth 2.0 authentication middleware.
155///
156/// See the [module documentation](self) for a usage example.
157pub 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    /// Create a new `OidcAuth` middleware.
177    ///
178    /// `sessions` is the session store shared with the application.
179    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    /// Exclude a path prefix from authentication checks.
207    ///
208    /// Requests whose path starts with `prefix` bypass the session check and
209    /// are passed directly to the next layer.  Use this for public paths like
210    /// `/healthz` or `/public/`.
211    pub fn exclude(mut self, prefix: &str) -> Self {
212        self.excluded.push(prefix.into());
213        self
214    }
215
216    /// Override the login redirect path (default: `"/auth/login"`).
217    pub fn login_path(mut self, path: &str) -> Self {
218        self.login_path = path.into();
219        self
220    }
221
222    /// Override the callback path (default: `"/auth/callback"`).
223    pub fn callback_path(mut self, path: &str) -> Self {
224        self.callback_path = path.into();
225        self
226    }
227
228    /// Override the logout path (default: `"/auth/logout"`).
229    pub fn logout_path(mut self, path: &str) -> Self {
230        self.logout_path = path.into();
231        self
232    }
233
234    /// Keep sessions in an HMAC-SHA256-signed cookie (`_rws_oidc`) instead
235    /// of the [`SessionStore`]: nothing is held in memory, so sessions
236    /// survive a restart and every replica sharing `secret` accepts them.
237    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    /// How long a sign-in lasts (default 24 h).
243    pub fn session_ttl(mut self, secs: u64) -> Self {
244        self.ttl = secs.max(60);
245        self
246    }
247
248    /// Admit users with one of these (verified) email addresses.
249    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    /// Admit users whose (verified) email is in one of these domains.
255    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    /// Admit users with one of these groups in the ID token's `claim`
261    /// (`groups` by default; Keycloak, Okta, Azure AD and Authentik send one).
262    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    /// Send `X-Auth-Request-User` (sub), `X-Auth-Request-Email` and
271    /// `X-Auth-Request-Groups` to the next layer.
272    pub fn forward_headers(mut self, on: bool) -> Self {
273        self.forward = on;
274        self
275    }
276
277    /// Who is signed in on `request` (a valid session cookie), without
278    /// redirecting: for callers that check identity themselves, like the
279    /// admin API (18.22.0). Allow-lists are not applied.
280    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    /// Read the OIDC claims injected by `OidcAuth` from a request.
285    ///
286    /// Returns `None` if the request did not pass through an authenticated
287    /// `OidcAuth` layer.
288    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    /// Shortcut: return the `sub` (subject / user ID) from the injected claims.
296    pub fn sub(req: &Request) -> Option<String> {
297        Self::claims(req).map(|c| c.sub)
298    }
299
300    /// Shortcut: return the `email` from the injected claims.
301    pub fn email(req: &Request) -> Option<String> {
302        Self::claims(req).and_then(|c| c.email)
303    }
304
305    // ── private helpers ───────────────────────────────────────────────────────
306
307    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    /// Whether the allow-lists admit `identity` (no lists: everyone).
322    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    /// The redirect URI for this request: the configured one, else
341    /// `<scheme>://<Host><callback_path>`.
342    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        // Behind a TLS-terminating load balancer the connection is plain
348        // HTTP: the scheme from a trusted proxy's X-Forwarded-Proto.
349        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        // Secure when the client is on HTTPS, a TLS-terminating proxy's too.
363        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    /// The payload of a cookie [`sign`](Self::sign) made, if the MAC and its
375    /// `exp` hold.
376    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    /// The signed-in identity of a request, from the cookie or the session.
390    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    // ── route handlers ────────────────────────────────────────────────────────
420
421    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        // The pre-auth values: from the signed cookie, or the session.
462        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        // Validate state
488        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        // Check for provider error
495        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        // Exchange code for tokens
509        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        // Get claims — prefer id_token (OIDC), fall back to UserInfo for GitHub
515        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            // Signed cookies: the identity (groups kept to the allowed ones,
545            // so the cookie stays small) and an expiry.
546            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        // Promote session: clear pre-auth keys, store claims
559        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        // Handle built-in auth routes first
605        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        // Identity headers only ever come from this layer: drop a client's.
616        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        // Excluded paths bypass auth
621        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        // No valid session — redirect to login
647        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
653// ── helpers ───────────────────────────────────────────────────────────────────
654
655fn 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
670/// A `return_to` that stays on this site: a path starting with one `/`, no
671/// backslash and no control or whitespace characters (browsers drop tabs
672/// and newlines from URLs, so `/\t/evil.example` would become
673/// `//evil.example`, 18.24.0).
674fn 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}