Files
archy/core/archipelago/src/appgate/mod.rs
T
archipelagoandClaude Fable 5 d2e4b00789 fix(security): gate classifies from the catalog overlay and releases withdrawn claims
Dev-box verification of the Tor/FIPS fixes caught a pre-existing split
brain: the orchestrator publishes containers from the signed catalog's
embedded manifests (origin-wins), but the gate classified ports from the
stale disk manifests — so it externally bound nbxplorer 32838, a port
the catalog declares auth: local and pins to loopback. Reachable behind
a login, but reachable where it deliberately was not.

- build_port_map now consults the catalog overlay first, via the same
  parse/validate/image-only filter the orchestrator uses (moved to
  app_catalog::catalog_manifest_overlay so the two cannot diverge again).
- GatedPort carries . The gated set still includes undeclared
  Session-default ports for challenge/audit, but every action that
  REDIRECTS traffic — the torrc 127.0.0.2 repoint, the FIPS relay
  stand-down, the Tor-upstream bind — now keys on the declaration.
- The sweep releases held claims whose port left the gated set, so a
  catalog refresh that withdraws a port (gated → local/none) takes
  effect without a daemon restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 08:50:46 -04:00

725 lines
27 KiB
Rust

//! The app gate — authentication in front of every app port.
//!
//! # Why this exists
//!
//! Reproduced on a live node 2026-08-03: with no session cookie at all, over
//! the Tailscale address, six app ports answered `HTTP 200` with their real
//! UIs. `ss -tlnp` showed them bound `0.0.0.0`, so the same pages were served
//! on the LAN address, the FIPS mesh address, and through each app's onion.
//! This is the same bug class as the `/lnd-connect-info` and `/bitcoin-rpc/`
//! leaks closed in v1.7.120, but across every app rather than two endpoints.
//!
//! # Why one gate covers four transports
//!
//! LAN, Tailscale, Tor and the FIPS mesh all converge on
//! `127.0.0.1:<app_port>` — the container publishes there, the mesh relay
//! forwards there, and `HiddenServicePort` points there. Authorising at that
//! convergence point is one gate rather than four, which is the only reason
//! this is tractable at all.
//!
//! # Why not umbrel's sidecar proxy
//!
//! umbrelOS gives every app an `app_proxy` container that owns the published
//! port. That works, but it costs a container per app and a second service to
//! hold the shared secret. Here the daemon already terminates HTTP, already
//! owns the session store, and already runs a relay loop for the mesh, so the
//! gate is assembly rather than new infrastructure.
//!
//! # What it does NOT do
//!
//! It does not invent authentication policy. Password verification, TOTP
//! decryption and step replay protection, session lifetime, and rate limiting
//! are the same primitives the JSON-RPC login path uses. Only the transport
//! differs — an HTML form instead of JSON-RPC — because a browser being
//! redirected to an app cannot speak JSON-RPC.
pub mod identity;
pub mod listener;
use crate::auth::AuthManager;
use crate::rate_limit::LoginRateLimiter;
use crate::session::SessionStore;
use hyper::{header, Body, HeaderMap, Method, Request, Response, StatusCode};
use identity::{GatedPort, PortMap};
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Paths the gate serves itself rather than proxying. Namespaced so an app
/// that happens to have its own `/login` is unaffected.
const GATE_PREFIX: &str = "/__archipelago-gate/";
/// Result of examining a request's credentials.
#[derive(Debug, PartialEq, Eq)]
pub enum Authorization {
/// Proxy it through.
Allow,
/// Serve the login page.
Challenge,
}
pub struct AppGate {
sessions: SessionStore,
auth: AuthManager,
limiter: LoginRateLimiter,
data_dir: PathBuf,
port_map: Arc<RwLock<PortMap>>,
}
impl AppGate {
pub fn new(
sessions: SessionStore,
auth: AuthManager,
limiter: LoginRateLimiter,
data_dir: PathBuf,
) -> Self {
Self {
sessions,
auth,
limiter,
data_dir,
port_map: Arc::new(RwLock::new(identity::build_port_map())),
}
}
/// Re-read the manifests. Called on catalog refresh so a newly installed
/// app is gated without a daemon restart.
pub async fn refresh(&self) {
*self.port_map.write().await = identity::build_port_map();
}
pub async fn port_map(&self) -> PortMap {
self.port_map.read().await.clone()
}
/// Does this request carry a credential good for `app_id`?
///
/// Two accepted forms, deliberately no others:
///
/// * the node session cookie — and because a session still pending its
/// TOTP step fails `validate()`, **2FA is honoured here for free**. The
/// gate never sees a TOTP code on a proxied request and never needs to.
/// * an app-scoped bearer token, for machine clients that speak HTTP but
/// cannot hold a cookie or complete an interactive login (Home
/// Assistant reaching an app's API is the motivating case).
pub async fn authorize(&self, headers: &HeaderMap, app_id: &str) -> Authorization {
if let Some(token) = crate::session::extract_session_cookie(headers) {
if self.sessions.validate(&token).await {
return Authorization::Allow;
}
}
if let Some(token) = bearer_token(headers) {
if crate::device_tokens::verify_for_app(&self.data_dir, &token, app_id)
.await
.is_some()
{
return Authorization::Allow;
}
}
Authorization::Challenge
}
/// Handle one inbound request on a gated port.
pub async fn handle(
&self,
req: Request<Body>,
app: &GatedPort,
client_ip: IpAddr,
) -> Response<Body> {
let path = req.uri().path().to_string();
if let Some(action) = path.strip_prefix(GATE_PREFIX) {
return self.handle_gate_action(req, app, action, client_ip).await;
}
match self.authorize(req.headers(), &app.app_id).await {
Authorization::Allow => proxy_to_app(req, app.port).await,
// 401 rather than a redirect: a redirect to a login page is
// indistinguishable from the app itself redirecting, and machine
// clients would follow it and parse HTML as if it were their API
// response. The status says "you are not authenticated" in a way
// every client understands, and browsers still render the body.
Authorization::Challenge => login_page(app, None, StatusCode::UNAUTHORIZED),
}
}
/// The gate's own endpoints: the login form target and the TOTP step.
async fn handle_gate_action(
&self,
req: Request<Body>,
app: &GatedPort,
action: &str,
client_ip: IpAddr,
) -> Response<Body> {
if req.method() != Method::POST {
return login_page(app, None, StatusCode::OK);
}
// Captured before the body is consumed. The pending-2FA session
// rides the cookie rather than a hidden form field so the token
// never appears in the HTML, in a `view-source`, or in a screenshot
// of the second-factor page.
let pending = crate::session::extract_session_cookie(req.headers());
// Same limiter instance as the JSON-RPC login path, so an attacker
// cannot get a fresh budget of guesses simply by moving to an app
// port.
if !self.limiter.check(client_ip).await {
return login_page(
app,
Some("Too many attempts. Wait a minute and try again."),
StatusCode::TOO_MANY_REQUESTS,
);
}
let form = match read_form(req).await {
Some(form) => form,
None => return login_page(app, Some("Malformed request."), StatusCode::BAD_REQUEST),
};
match action {
"login" => self.do_login(app, &form, client_ip).await,
"totp" => self.do_totp(app, &form, pending, client_ip).await,
_ => not_found(),
}
}
async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response<Body> {
let password = field(form, "password").unwrap_or_default();
match self.auth.verify_password(&password).await {
Ok(true) => {}
_ => {
self.limiter.record_failure(client_ip).await;
return login_page(app, Some("Incorrect password."), StatusCode::UNAUTHORIZED);
}
}
// 2FA, if configured. The secret is encrypted with the password, so
// this is the only moment it can be decrypted — exactly as in the
// JSON-RPC path. A pending session cannot pass `authorize`, so a
// half-finished login grants nothing.
if self.auth.is_totp_enabled().await.unwrap_or(false) {
if let Ok(Some(totp_data)) = self.auth.get_totp_data().await {
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, &password) {
let pending = self.sessions.create_pending(secret).await;
let mut resp = totp_page(app, None, StatusCode::OK);
set_session_cookie(&mut resp, &pending);
return resp;
}
}
// TOTP is on but its data is unreadable. Refuse: falling through
// to a full session would silently downgrade the node's second
// factor to nothing.
return login_page(
app,
Some("Two-factor data could not be read. Sign in from the dashboard."),
StatusCode::INTERNAL_SERVER_ERROR,
);
}
let token = self.sessions.create().await;
let mut resp = redirect_to_app();
set_session_cookie(&mut resp, &token);
resp
}
async fn do_totp(
&self,
app: &GatedPort,
form: &Form,
pending: Option<String>,
client_ip: IpAddr,
) -> Response<Body> {
let code = field(form, "code").unwrap_or_default();
let Some(pending) = pending.filter(|s| !s.is_empty()) else {
return login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED);
};
let Some(secret) = self.sessions.get_pending_secret(&pending).await else {
return login_page(
app,
Some("Session expired. Start again."),
StatusCode::UNAUTHORIZED,
);
};
let totp_data = self.auth.get_totp_data().await.ok().flatten();
let used_steps = totp_data
.as_ref()
.map(|d| d.used_steps.clone())
.unwrap_or_default();
match crate::totp::verify_code(&secret, &code, &used_steps) {
Ok(Some(step)) => {
// Record the step so the same code cannot be replayed — the
// JSON-RPC path does this and skipping it here would make the
// gate the weaker of the two doors.
if let Some(mut data) = totp_data {
data.used_steps.push(step);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let cutoff = (now / 30) - 10;
data.used_steps.retain(|s| *s > cutoff);
let _ = self.auth.update_totp(data).await;
}
match self.sessions.upgrade_to_full(&pending).await {
Some(full) => {
let mut resp = redirect_to_app();
set_session_cookie(&mut resp, &full);
resp
}
None => login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED),
}
}
_ => {
self.limiter.record_failure(client_ip).await;
let mut resp = totp_page(app, Some("Incorrect code."), StatusCode::UNAUTHORIZED);
set_session_cookie(&mut resp, &pending);
resp
}
}
}
}
// ---------------------------------------------------------------------------
// Request helpers
// ---------------------------------------------------------------------------
fn bearer_token(headers: &HeaderMap) -> Option<String> {
let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
let token = value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))?;
let token = token.trim();
(!token.is_empty()).then(|| token.to_string())
}
type Form = std::collections::HashMap<String, String>;
/// Free function rather than a trait method: `HashMap` has an inherent `get`
/// that would win method resolution and silently return `Option<&String>`.
fn field(form: &Form, key: &str) -> Option<String> {
form.get(key).cloned()
}
/// Read an `application/x-www-form-urlencoded` body.
///
/// Capped: an unauthenticated caller must not be able to make the daemon
/// buffer arbitrary bytes, and no legitimate login form approaches this.
const MAX_FORM_BYTES: usize = 8 * 1024;
async fn read_form(req: Request<Body>) -> Option<Form> {
let bytes = hyper::body::to_bytes(req.into_body()).await.ok()?;
if bytes.len() > MAX_FORM_BYTES {
return None;
}
let text = std::str::from_utf8(&bytes).ok()?;
let mut form = Form::new();
for pair in text.split('&') {
let Some((k, v)) = pair.split_once('=') else {
continue;
};
form.insert(percent_decode(k), percent_decode(v));
}
Some(form)
}
fn percent_decode(input: &str) -> String {
let bytes = input.replace('+', " ").into_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Forward an authorised request to the app on loopback.
async fn proxy_to_app(req: Request<Body>, port: u16) -> Response<Body> {
let path_and_query = req
.uri()
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/")
.to_string();
let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::<hyper::Uri>() {
Ok(uri) => uri,
Err(_) => return bad_gateway(),
};
let (mut parts, body) = req.into_parts();
parts.uri = uri;
// Strip the gate's own credential before it reaches the app: the app has
// no use for the node session and should never be in a position to log,
// echo, or forward it.
parts.headers.remove(header::COOKIE);
parts.headers.remove(header::AUTHORIZATION);
let client = hyper::Client::new();
match client.request(Request::from_parts(parts, body)).await {
Ok(resp) => resp,
Err(_) => bad_gateway(),
}
}
fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
// No Domain attribute, so the cookie is host-only. Cookies ignore port,
// which is what makes one sign-in cover the dashboard and every app port
// on the same host — and equally why an app on a *different* host (its
// own onion) is a separate sign-in.
if let Ok(value) =
header::HeaderValue::from_str(&format!("session={token}; HttpOnly; SameSite=Lax; Path=/"))
{
resp.headers_mut().append(header::SET_COOKIE, value);
}
}
fn redirect_to_app() -> Response<Body> {
Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/")
.body(Body::empty())
.expect("static response builds")
}
fn bad_gateway() -> Response<Body> {
Response::builder()
.status(StatusCode::BAD_GATEWAY)
.body(Body::from("app is not responding"))
.expect("static response builds")
}
fn not_found() -> Response<Body> {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.expect("static response builds")
}
// ---------------------------------------------------------------------------
// Pages
// ---------------------------------------------------------------------------
/// Minimal HTML escape for values interpolated into the pages below.
fn esc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
/// The app's icon as an `<img>`, or a lettermark when the manifest declares
/// none. Inlined as a data URI rather than linked: the gate is answering on
/// the app's own port, so any asset URL would either hit the unauthenticated
/// app behind it or a different origin the browser may not reach.
fn icon_markup(app: &GatedPort) -> String {
if let Some(path) = &app.icon {
if let Some(data_uri) = read_icon_data_uri(path) {
return format!(r#"<img class="icon" src="{}" alt="">"#, esc(&data_uri));
}
}
let letter = app
.app_name
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
format!(r#"<div class="icon lettermark">{}</div>"#, esc(&letter))
}
/// Icons live with the web UI. Only files under the icon directory are read,
/// and only known image extensions — the path comes from a manifest, which is
/// signed, but treating it as untrusted costs nothing.
fn read_icon_data_uri(icon_path: &str) -> Option<String> {
let name = std::path::Path::new(icon_path).file_name()?.to_str()?;
let mime = match name.rsplit_once('.')?.1.to_ascii_lowercase().as_str() {
"svg" => "image/svg+xml",
"png" => "image/png",
"webp" => "image/webp",
"jpg" | "jpeg" => "image/jpeg",
_ => return None,
};
for root in [
"/opt/archipelago/web-ui/assets/img/app-icons",
"web/dist/neode-ui/assets/img/app-icons",
] {
let candidate = std::path::Path::new(root).join(name);
if let Ok(bytes) = std::fs::read(&candidate) {
if bytes.len() > 512 * 1024 {
return None;
}
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
}
}
None
}
fn base64_encode(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Response<Body> {
let html = format!(
r#"<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>{title} — {app_name}</title>
<style>
:root {{ color-scheme: dark; }}
* {{ box-sizing: border-box; }}
body {{ margin:0; min-height:100vh; display:grid; place-items:center;
background:#0b0f14; color:#e6edf3; font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif; }}
.card {{ width:min(92vw,380px); padding:2rem; background:#121820;
border:1px solid #223; border-radius:14px; text-align:center; }}
.icon {{ width:64px; height:64px; border-radius:14px; margin:0 auto 1rem; display:block; object-fit:cover; }}
.lettermark {{ display:grid; place-items:center; background:#1d2733; font-size:28px; font-weight:600; }}
h1 {{ font-size:1.15rem; margin:0 0 .25rem; }}
p.sub {{ margin:0 0 1.5rem; color:#8b98a5; font-size:.9rem; }}
input {{ width:100%; padding:.7rem .8rem; margin-bottom:.75rem; border-radius:9px;
border:1px solid #2b3947; background:#0d131a; color:#e6edf3; font-size:1rem; }}
input:focus {{ outline:2px solid #3b82f6; outline-offset:1px; }}
button {{ width:100%; padding:.7rem; border:0; border-radius:9px; background:#3b82f6;
color:#fff; font-size:1rem; font-weight:600; cursor:pointer; }}
button:hover {{ background:#2f6fd6; }}
.err {{ background:#3b1519; border:1px solid #7f1d1d; color:#fca5a5;
padding:.6rem .8rem; border-radius:9px; margin-bottom:1rem; font-size:.9rem; }}
</style></head>
<body><main class="card">{body}</main></body></html>"#,
title = esc(title),
app_name = esc(&app.app_name),
body = body,
);
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
// The gate answers on the app's own port for an unauthenticated
// caller; nothing here should be cached or framed.
.header(header::CACHE_CONTROL, "no-store")
.header("X-Frame-Options", "DENY")
.header(
"Content-Security-Policy",
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'",
)
.body(Body::from(html))
.expect("static response builds")
}
/// The challenge. Names and pictures the app being opened, so the visitor can
/// confirm what they are authenticating to rather than being asked for a
/// password by an unexplained page.
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
let body = format!(
r#"{icon}
<h1>Sign in to open {name}</h1>
<p class="sub">This app is protected by your node password.</p>
{err}
<form method="post" action="{prefix}login">
<input type="password" name="password" placeholder="Node password" autocomplete="current-password" autofocus required>
<button type="submit">Sign in</button>
</form>"#,
icon = icon_markup(app),
name = esc(&app.app_name),
err = error
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
.unwrap_or_default(),
prefix = GATE_PREFIX,
);
page("Sign in", app, &body, status)
}
/// Second factor. Reached only after the password verified, and the session
/// backing it cannot authorise anything until this completes.
fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
let body = format!(
r#"{icon}
<h1>Two-factor code</h1>
<p class="sub">Enter the 6-digit code to open {name}.</p>
{err}
<form method="post" action="{prefix}totp">
<input type="text" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="000000" autofocus required>
<button type="submit">Verify</button>
</form>"#,
icon = icon_markup(app),
name = esc(&app.app_name),
err = error
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
.unwrap_or_default(),
prefix = GATE_PREFIX,
);
page("Two-factor", app, &body, status)
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> GatedPort {
GatedPort {
port: 8090,
app_id: "strfry".to_string(),
app_name: "Strfry Relay".to_string(),
icon: None,
declared: true,
}
}
#[test]
fn bearer_token_is_parsed_case_insensitively() {
let mut headers = HeaderMap::new();
headers.insert(header::AUTHORIZATION, "Bearer abc123".parse().unwrap());
assert_eq!(bearer_token(&headers), Some("abc123".to_string()));
headers.insert(header::AUTHORIZATION, "bearer abc123".parse().unwrap());
assert_eq!(bearer_token(&headers), Some("abc123".to_string()));
}
#[test]
fn non_bearer_authorization_is_ignored() {
let mut headers = HeaderMap::new();
// An app's own Basic credential must never be mistaken for ours.
headers.insert(header::AUTHORIZATION, "Basic dXNlcjpwYXNz".parse().unwrap());
assert_eq!(bearer_token(&headers), None);
headers.insert(header::AUTHORIZATION, "Bearer ".parse().unwrap());
assert_eq!(bearer_token(&headers), None);
}
#[tokio::test]
async fn login_page_names_the_app() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Sign in to open Strfry Relay"));
// A lettermark stands in when the manifest declares no icon.
assert!(html.contains("lettermark"));
}
#[tokio::test]
async fn page_escapes_app_names() {
let mut app = app();
app.app_name = r#"<script>alert(1)</script>"#.to_string();
let resp = login_page(&app, None, StatusCode::UNAUTHORIZED);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(!html.contains("<script>alert"));
assert!(html.contains("&lt;script&gt;"));
}
#[tokio::test]
async fn error_messages_are_escaped() {
let resp = login_page(
&app(),
Some("<img src=x onerror=1>"),
StatusCode::UNAUTHORIZED,
);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(!html.contains("<img src=x"));
}
#[test]
fn challenge_pages_are_not_cacheable_or_framable() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
assert_eq!(resp.headers()["X-Frame-Options"], "DENY");
}
#[tokio::test]
async fn form_parsing_decodes_percent_and_plus() {
let req = Request::builder()
.body(Body::from("password=a%40b+c&code=123456"))
.unwrap();
let form = read_form(req).await.unwrap();
assert_eq!(field(&form, "password"), Some("a@b c".to_string()));
assert_eq!(field(&form, "code"), Some("123456".to_string()));
}
#[tokio::test]
async fn oversized_form_bodies_are_refused() {
let req = Request::builder()
.body(Body::from("x=".to_string() + &"a".repeat(MAX_FORM_BYTES)))
.unwrap();
assert!(read_form(req).await.is_none());
}
#[tokio::test]
async fn no_credential_is_challenged() {
let gate = test_gate().await;
assert_eq!(
gate.authorize(&HeaderMap::new(), "strfry").await,
Authorization::Challenge
);
}
#[tokio::test]
async fn a_valid_session_cookie_is_allowed() {
let gate = test_gate().await;
let token = gate.sessions.create().await;
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, format!("session={token}").parse().unwrap());
assert_eq!(
gate.authorize(&headers, "strfry").await,
Authorization::Allow
);
}
/// The load-bearing 2FA property: a session still awaiting its TOTP code
/// fails `validate()`, so the gate rejects it without knowing anything
/// about second factors.
#[tokio::test]
async fn a_pending_2fa_session_is_challenged() {
let gate = test_gate().await;
let pending = gate.sessions.create_pending(vec![1, 2, 3]).await;
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
format!("session={pending}").parse().unwrap(),
);
assert_eq!(
gate.authorize(&headers, "strfry").await,
Authorization::Challenge
);
}
#[tokio::test]
async fn a_garbage_cookie_is_challenged() {
let gate = test_gate().await;
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, "session=deadbeef".parse().unwrap());
assert_eq!(
gate.authorize(&headers, "strfry").await,
Authorization::Challenge
);
}
async fn test_gate() -> AppGate {
let dir = std::env::temp_dir().join(format!("appgate-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
AppGate::new(
SessionStore::new().await,
AuthManager::new(dir.clone()),
LoginRateLimiter::new(),
dir,
)
}
}