Skip to main content

stackure/
middleware.rs

1//! Session verification and the tower authentication middleware.
2
3use std::future::Future;
4use std::pin::Pin;
5use std::task::{Context, Poll};
6
7use bytes::Bytes;
8use http::request::Parts;
9use http::{HeaderValue, Request, Response, StatusCode};
10use http_body::Body;
11use http_body_util::{BodyExt, Limited};
12use ring::digest::{SHA256, digest};
13use ring::rand::{SecureRandom, SystemRandom};
14use tower::{Layer, Service};
15
16use crate::client::{
17    CODE_COOKIE, CODE_PARAM, DevSignIn, SESSION_COOKIE, TOKEN_PARAM, VERIFIER_COOKIE, base_url,
18    cookie, exchange_code, form_value, origin, sign_out, validate_mcp, validate_token,
19};
20use crate::types::{User, VerifyError, VerifyResult};
21
22/// Verify a request without returning an error.
23///
24/// Callers inspect `authenticated` and decide how to respond. Transport and
25/// API failures come back as a 500 result.
26///
27/// # Example
28///
29/// ```no_run
30/// # async fn example(parts: &http::request::Parts) {
31/// let result = stackure::verify(parts).await;
32/// if result.authenticated {
33///     println!("{}", result.user.unwrap().user_email);
34/// }
35/// # }
36/// ```
37pub async fn verify(parts: &Parts) -> VerifyResult {
38    check(parts, None).await
39}
40
41async fn check(parts: &Parts, dev: Option<&DevSignIn>) -> VerifyResult {
42    let session = match validate_token(&cookie(parts, SESSION_COOKIE), parts, dev).await {
43        Ok(session) => session,
44        Err(e) => {
45            return VerifyResult {
46                error: Some(verify_failed(e)),
47                ..VerifyResult::default()
48            };
49        }
50    };
51
52    let Some(user) = session.user.filter(|_| session.authenticated) else {
53        return VerifyResult {
54            error: Some(VerifyError {
55                code: 401,
56                message: "Valid authentication required".into(),
57                sign_in_url: session.sign_in_url,
58            }),
59            ..VerifyResult::default()
60        };
61    };
62
63    VerifyResult {
64        authenticated: true,
65        user: Some(user),
66        error: None,
67    }
68}
69
70fn verify_failed(e: impl std::fmt::Display) -> VerifyError {
71    eprintln!("stackure: verification error: {e}");
72    VerifyError {
73        code: 500,
74        message: "Authentication verification failed".into(),
75        sign_in_url: String::new(),
76    }
77}
78
79/// The user attached by [`auth`] or [`mcp`], or `None` if the request was not
80/// authenticated. In axum you can also take an `Extension<User>` directly.
81#[must_use]
82pub fn user_from_request(parts: &Parts) -> Option<&User> {
83    parts.extensions.get::<User>()
84}
85
86fn is_https(parts: &Parts) -> bool {
87    parts.uri.scheme_str() == Some("https")
88        || parts
89            .headers
90            .get("x-forwarded-proto")
91            .and_then(|v| v.to_str().ok())
92            .is_some_and(|v| v.eq_ignore_ascii_case("https"))
93}
94
95fn host(parts: &Parts) -> &str {
96    parts
97        .headers
98        .get("host")
99        .and_then(|v| v.to_str().ok())
100        .or_else(|| parts.uri.authority().map(http::uri::Authority::as_str))
101        .unwrap_or_default()
102}
103
104fn is_loopback(parts: &Parts) -> bool {
105    let host = host(parts).to_ascii_lowercase();
106    let name = host
107        .rsplit_once(':')
108        .filter(|(_, port)| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()))
109        .map_or(host.as_str(), |(name, _)| name);
110    name == "localhost" || name == "127.0.0.1"
111}
112
113fn cookie_header(name: &str, value: &str, secure: bool, max_age: Option<i32>) -> String {
114    let mut parts = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
115    if secure {
116        parts.push_str("; Secure");
117    }
118    if let Some(age) = max_age {
119        use std::fmt::Write as _;
120        let _ = write!(parts, "; Max-Age={age}");
121    }
122    parts
123}
124
125const MAX_HANDOFF_BODY: usize = 4096;
126const SESSION_MAX_AGE: i32 = 604_800;
127const CODE_MAX_AGE: i32 = 60;
128const VERIFIER_MAX_AGE: i32 = 900;
129
130fn self_url(parts: &Parts) -> &str {
131    parts
132        .uri
133        .path_and_query()
134        .map(http::uri::PathAndQuery::as_str)
135        .filter(|p| p.starts_with('/') && !p.starts_with("//") && !p.starts_with("/\\"))
136        .unwrap_or("/")
137}
138
139/// The `Origin` of a form POST, or `None` for any other request.
140fn form_origin(parts: &Parts) -> Option<&str> {
141    let form = parts.method == http::Method::POST
142        && parts
143            .headers
144            .get("content-type")
145            .and_then(|v| v.to_str().ok())
146            .is_some_and(|v| v.starts_with("application/x-www-form-urlencoded"));
147    form.then(|| {
148        parts
149            .headers
150            .get("origin")
151            .and_then(|v| v.to_str().ok())
152            .unwrap_or_default()
153    })
154}
155
156/// Base64url without padding.
157fn base64url(bytes: &[u8]) -> String {
158    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
159    bytes.chunks(3).fold(String::new(), |mut out, chunk| {
160        let n = chunk
161            .iter()
162            .enumerate()
163            .fold(0, |n, (i, &b)| n | (usize::from(b) << (16 - 8 * i)));
164        out.extend((0..=chunk.len()).map(|i| char::from(ALPHABET[(n >> (18 - 6 * i)) & 63])));
165        out
166    })
167}
168
169fn verifier() -> Option<String> {
170    let mut bytes = [0u8; 32];
171    SystemRandom::new().fill(&mut bytes).ok()?;
172    Some(base64url(&bytes))
173}
174
175fn dev_sign_in(parts: &Parts, verifier: &str) -> DevSignIn {
176    let scheme = if is_https(parts) { "https" } else { "http" };
177    DevSignIn {
178        url: format!("{scheme}://{}{}", host(parts), self_url(parts)),
179        challenge: base64url(digest(&SHA256, verifier.as_bytes()).as_ref()),
180    }
181}
182
183fn see_other<B: From<Bytes>>(parts: &Parts, cookie: String) -> Response<B> {
184    Response::builder()
185        .status(StatusCode::SEE_OTHER)
186        .header("location", self_url(parts))
187        .header("set-cookie", cookie)
188        .body(B::from(Bytes::new()))
189        .expect("handoff response is always valid")
190}
191
192/// Stash the one-time code Stackure posted to a localhost page, or swap a
193/// stashed one for a session; `None` when there is neither.
194async fn local_sign_in<B: From<Bytes>>(parts: &Parts, posted: &str) -> Option<Response<B>> {
195    let secure = is_https(parts);
196    if posted.len() == 64
197        && posted
198            .bytes()
199            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
200    {
201        let stash = cookie_header(CODE_COOKIE, posted, secure, Some(CODE_MAX_AGE));
202        return Some(see_other(parts, stash));
203    }
204
205    let code = cookie(parts, CODE_COOKIE);
206    if code.is_empty() {
207        return None;
208    }
209    let response = Response::builder()
210        .header(
211            "set-cookie",
212            cookie_header(CODE_COOKIE, "", secure, Some(0)),
213        )
214        .header(
215            "set-cookie",
216            cookie_header(VERIFIER_COOKIE, "", secure, Some(0)),
217        );
218
219    let response = match exchange_code(&code, &cookie(parts, VERIFIER_COOKIE), parts).await {
220        Ok(token) => response
221            .status(StatusCode::SEE_OTHER)
222            .header("location", self_url(parts))
223            .header(
224                "set-cookie",
225                cookie_header(SESSION_COOKIE, &token, secure, Some(SESSION_MAX_AGE)),
226            )
227            .body(B::from(Bytes::new())),
228        Err(e) => {
229            eprintln!("stackure: localhost sign-in failed: {e}");
230            if accepts_html(parts) {
231                response
232                    .status(StatusCode::SEE_OTHER)
233                    .header("location", failure_page("localhostFailed"))
234                    .body(B::from(Bytes::new()))
235            } else {
236                response
237                    .status(StatusCode::UNAUTHORIZED)
238                    .header("content-type", "application/json")
239                    .body(B::from(Bytes::from(
240                        serde_json::json!({ "error": "localhost sign-in failed, reload to try again" })
241                            .to_string(),
242                    )))
243            }
244        }
245    };
246    Some(response.expect("sign-in response is always valid"))
247}
248
249fn accepts_html(parts: &Parts) -> bool {
250    let accept = parts
251        .headers
252        .get("accept")
253        .and_then(|v| v.to_str().ok())
254        .unwrap_or_default();
255    accept.contains("text/html") && !accept.contains("application/json")
256}
257
258fn failure_page(message: &str) -> String {
259    format!(
260        "{}/sign-in/magic-link?state=error&message={message}",
261        base_url()
262    )
263}
264
265fn refuse<B: From<Bytes>>(parts: &Parts, error: &VerifyError) -> Response<B> {
266    if error.code == 500 && accepts_html(parts) {
267        return Response::builder()
268            .status(StatusCode::SEE_OTHER)
269            .header("location", failure_page("appUnavailable"))
270            .body(B::from(Bytes::new()))
271            .expect("redirect response is always valid");
272    }
273    Response::builder()
274        .status(StatusCode::from_u16(error.code).unwrap_or(StatusCode::UNAUTHORIZED))
275        .header("content-type", "application/json")
276        .body(B::from(error_body(error)))
277        .expect("error response is always valid")
278}
279
280fn error_body(error: &VerifyError) -> Bytes {
281    let label = if error.code == 401 {
282        "Unauthorized"
283    } else {
284        "Error"
285    };
286    Bytes::from(
287        serde_json::json!({
288            "error": label,
289            "message": error.message,
290            "sign_in_url": error.sign_in_url,
291        })
292        .to_string(),
293    )
294}
295
296fn mcp_url(parts: &Parts) -> String {
297    let host = host(parts);
298    let scheme = if is_https(parts) { "https" } else { "http" };
299    #[cfg(feature = "axum")]
300    let path = parts
301        .extensions
302        .get::<axum::extract::OriginalUri>()
303        .map_or(parts.uri.path(), |u| u.0.path());
304    #[cfg(not(feature = "axum"))]
305    let path = parts.uri.path();
306    format!("{scheme}://{host}{path}")
307}
308
309fn mcp_error<B: From<Bytes>>(
310    status: StatusCode,
311    error: &str,
312    challenge: Option<&str>,
313) -> Response<B> {
314    let mut response = Response::builder()
315        .status(status)
316        .header("content-type", "application/json");
317    if let Some(challenge) = challenge {
318        response = response.header(
319            "www-authenticate",
320            HeaderValue::from_str(challenge)
321                .ok()
322                .filter(|v| !v.is_empty())
323                .unwrap_or(HeaderValue::from_static("Bearer")),
324        );
325    }
326    response
327        .body(B::from(Bytes::from(
328            serde_json::json!({ "error": error }).to_string(),
329        )))
330        .expect("error response is always valid")
331}
332
333fn same_origin_post(parts: &Parts) -> bool {
334    let header = |name: &str| {
335        parts
336            .headers
337            .get(name)
338            .map(|v| v.to_str().unwrap_or_default())
339    };
340    let repeated = |name: &str| parts.headers.get_all(name).iter().nth(1).is_some();
341    if parts.method != http::Method::POST
342        || ["sec-fetch-site", "origin", "host"]
343            .into_iter()
344            .any(repeated)
345    {
346        return false;
347    }
348    if let Some(site) = header("sec-fetch-site") {
349        return site == "same-origin";
350    }
351    let host = header("host").or_else(|| parts.uri.authority().map(http::uri::Authority::as_str));
352    header("origin")
353        .and_then(|origin| origin.split_once("://"))
354        .zip(host)
355        .is_some_and(|((scheme, origin), host)| {
356            (scheme == "https" || !is_https(parts))
357                && !host.is_empty()
358                && origin.eq_ignore_ascii_case(host)
359        })
360}
361
362/// End the user's access everywhere with a server-side call to Stackure,
363/// clear the app's session cookie and redirect to Stackure.
364///
365/// Mount it for every method on the sign-out path. Trigger it with a form or
366/// button that POSTs from the app's own page; a link or any other request is
367/// sent to Stackure's sign-out page, where the user confirms, with no call
368/// made and the cookie left in place.
369///
370/// A request counts as coming from the app's own page when it is a POST with
371/// `Sec-Fetch-Site: same-origin` or, when that header is absent, with an
372/// `Origin` whose host and port match `Host` and whose scheme is `https` if
373/// the request arrived over HTTPS. A request that repeats `Sec-Fetch-Site`,
374/// `Origin` or `Host` never counts.
375///
376/// If the call fails, the redirect goes to Stackure's sign-out page instead,
377/// where the user can finish signing out.
378///
379/// Asynchronous: awaiting it yields the 303 [`Response`], never an error.
380///
381/// # Example
382///
383/// ```no_run
384/// # use axum::{Router, routing::any};
385/// async fn logout(parts: http::request::Parts) -> axum::response::Response {
386///     stackure::logout(&parts).await
387/// }
388///
389/// let app: Router = Router::new().route("/logout", any(logout));
390/// ```
391#[must_use]
392pub async fn logout<B: Default>(parts: &Parts) -> Response<B> {
393    let mut response = Response::builder().status(StatusCode::SEE_OTHER);
394    let mut path = "/signout";
395    if same_origin_post(parts) {
396        match sign_out(parts).await {
397            Ok(()) => path = "/",
398            Err(e) => eprintln!("stackure: sign-out error: {e}"),
399        }
400        response = response.header(
401            "set-cookie",
402            cookie_header(SESSION_COOKIE, "", is_https(parts), Some(0)),
403        );
404    }
405    response
406        .header("location", format!("{}{path}", base_url()))
407        .body(B::default())
408        .expect("logout response is always valid")
409}
410
411/// Middleware that enforces authentication, for any tower stack: axum,
412/// tonic or hyper.
413///
414/// Completes Stackure's sign-in handoff by validating the posted
415/// `session_token` and storing it as a cookie on your domain. On success the
416/// user is inserted into the request extensions (read it back with
417/// [`user_from_request`]). Browser requests get redirected to sign-in on 401,
418/// and to a Stackure page that says sign-in couldn't be confirmed when the
419/// check fails; API requests get JSON.
420///
421/// On `localhost` or `127.0.0.1` with the app's development secret, sign-in
422/// returns to the page that asked for it: Stackure posts a one-time code
423/// there, and the layer swaps it for a session using the secret and a check
424/// value it keeps in a cookie. Only app admins of the organization that added
425/// the app can sign in this way.
426///
427/// # Example
428///
429/// ```no_run
430/// # use axum::{Router, routing::get};
431/// # let app: Router = Router::new().route("/admin", get(|| async {}));
432/// let app = app.layer(stackure::auth());
433/// ```
434#[must_use]
435pub fn auth() -> AuthLayer {
436    AuthLayer { mcp: false }
437}
438
439/// Middleware that protects an MCP route, for any tower stack: axum, tonic
440/// or hyper.
441///
442/// AI clients sign users in through Stackure and send the credential it issues
443/// as `Authorization: Bearer`. The layer checks that credential against
444/// Stackure on every request, with the same app secret as [`auth`]; cookies
445/// are ignored. On success the user is inserted into the request extensions,
446/// exactly as [`auth`] does. Every other answer is JSON, never a redirect and
447/// never a cookie: 401 with the `WWW-Authenticate` challenge from Stackure
448/// when not signed in, 503 when the check itself fails.
449///
450/// The MCP endpoint must be served from the same site as the app's registered
451/// URL unless an MCP URL is set for the app in Stackure.
452///
453/// # Example
454///
455/// ```no_run
456/// # use axum::{Router, routing::any};
457/// # let app: Router = Router::new().route("/mcp", any(|| async {}));
458/// let app = app.layer(stackure::mcp());
459/// ```
460#[must_use]
461pub fn mcp() -> AuthLayer {
462    AuthLayer { mcp: true }
463}
464
465/// The tower [`Layer`] returned by [`auth`] and [`mcp`].
466#[derive(Clone, Debug)]
467pub struct AuthLayer {
468    mcp: bool,
469}
470
471impl<S> Layer<S> for AuthLayer {
472    type Service = Auth<S>;
473
474    fn layer(&self, inner: S) -> Auth<S> {
475        Auth {
476            inner,
477            mcp: self.mcp,
478        }
479    }
480}
481
482/// The tower [`Service`] produced by [`AuthLayer`].
483#[derive(Clone, Debug)]
484pub struct Auth<S> {
485    inner: S,
486    mcp: bool,
487}
488
489type BoxFuture<T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send>>;
490
491impl<S, ReqB, ResB> Service<Request<ReqB>> for Auth<S>
492where
493    S: Service<Request<ReqB>, Response = Response<ResB>> + Clone + Send + 'static,
494    S::Future: Send + 'static,
495    ReqB: Body<Data = Bytes> + From<Bytes> + Send + 'static,
496    ReqB::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
497    ResB: From<Bytes> + Send + 'static,
498{
499    type Response = Response<ResB>;
500    type Error = S::Error;
501    type Future = BoxFuture<Self::Response, Self::Error>;
502
503    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
504        self.inner.poll_ready(cx)
505    }
506
507    fn call(&mut self, req: Request<ReqB>) -> Self::Future {
508        let ready = self.inner.clone();
509        let mut inner = std::mem::replace(&mut self.inner, ready);
510        let mcp = self.mcp;
511
512        Box::pin(async move {
513            let (mut parts, body) = req.into_parts();
514
515            if mcp {
516                let session = match validate_mcp(&mcp_url(&parts), &parts).await {
517                    Ok(session) => session,
518                    Err(e) => {
519                        eprintln!("stackure: verification error: {e}");
520                        return Ok(mcp_error(
521                            StatusCode::SERVICE_UNAVAILABLE,
522                            "unavailable",
523                            None,
524                        ));
525                    }
526                };
527                let Some(user) = session.user.filter(|_| session.authenticated) else {
528                    return Ok(mcp_error(
529                        StatusCode::UNAUTHORIZED,
530                        "unauthorized",
531                        Some(&session.www_authenticate),
532                    ));
533                };
534                parts.extensions.insert(user);
535                return inner.call(Request::from_parts(parts, body)).await;
536            }
537
538            let secure = is_https(&parts);
539            let local = is_loopback(&parts);
540            let from = form_origin(&parts);
541            let handoff = from == Some(origin().as_str());
542            let stash = local && (handoff || from == Some("null"));
543
544            let (mut token, mut code) = (String::new(), String::new());
545            let body = if handoff || stash {
546                let bytes = Limited::new(body, MAX_HANDOFF_BODY)
547                    .collect()
548                    .await
549                    .map(http_body_util::Collected::to_bytes)
550                    .unwrap_or_default();
551                let form = String::from_utf8_lossy(&bytes);
552                if handoff {
553                    token = form_value(&form, TOKEN_PARAM);
554                }
555                if stash {
556                    code = form_value(&form, CODE_PARAM);
557                }
558                ReqB::from(bytes)
559            } else {
560                body
561            };
562
563            if !token.is_empty() {
564                match validate_token(&token, &parts, None).await {
565                    Ok(s) if s.authenticated => {
566                        let session =
567                            cookie_header(SESSION_COOKIE, &token, secure, Some(SESSION_MAX_AGE));
568                        return Ok(see_other(&parts, session));
569                    }
570                    Ok(_) => {}
571                    Err(e) => return Ok(refuse(&parts, &verify_failed(e))),
572                }
573            }
574
575            if local {
576                if let Some(response) = local_sign_in(&parts, &code).await {
577                    return Ok(response);
578                }
579            }
580
581            let verifier = if local { verifier() } else { None };
582            let dev = verifier.as_deref().map(|v| dev_sign_in(&parts, v));
583            let result = check(&parts, dev.as_ref()).await;
584
585            if let Some(error) = result.error.filter(|_| !result.authenticated) {
586                if error.code == 401 && accepts_html(&parts) && !error.sign_in_url.is_empty() {
587                    let mut response = Response::builder()
588                        .status(StatusCode::FOUND)
589                        .header("location", error.sign_in_url);
590                    if let Some(verifier) = verifier {
591                        response = response.header(
592                            "set-cookie",
593                            cookie_header(
594                                VERIFIER_COOKIE,
595                                &verifier,
596                                secure,
597                                Some(VERIFIER_MAX_AGE),
598                            ),
599                        );
600                    }
601                    return Ok(response
602                        .body(ResB::from(Bytes::new()))
603                        .expect("redirect response is always valid"));
604                }
605                return Ok(refuse(&parts, &error));
606            }
607
608            if let Some(user) = result.user {
609                parts.extensions.insert(user);
610            }
611            inner.call(Request::from_parts(parts, body)).await
612        })
613    }
614}