1use 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
22pub 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#[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
139fn 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
156fn 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
192async 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#[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#[must_use]
435pub fn auth() -> AuthLayer {
436 AuthLayer { mcp: false }
437}
438
439#[must_use]
461pub fn mcp() -> AuthLayer {
462 AuthLayer { mcp: true }
463}
464
465#[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#[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}