auth: make dev sign-in a feature, require both headers
A `dev` line in a deployed config would have let anyone in as the first user; as a cargo feature the bypass is absent from release builds, so the Fly demos now build their own image with it instead of running the release. Signing in without a slug renamed the user to their login prefix, so a missing X-User-Slug is now a 401 like a missing Remote-User.
Assisted-by: Claude Opus 5.5 via Claude Code
diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml
index 73514f8..10f7def 100644
--- a/.cargo/mutants.toml
+++ b/.cargo/mutants.toml
@@ -1,3 +1,5 @@
# Skip ignored files when copying the tree: ramekin bind-mounts a
# character device over the direnv file, which aborts the copy.
gitignore = true
+# Mutate the dev-only code too.
+all_features = true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7553235..9b8853f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -53,7 +53,6 @@ jobs:
packages: write # push container image to GHCR
outputs:
version: ${{ steps.meta.outputs.version }}
- image: ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -94,7 +93,7 @@ jobs:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
- contents: read # checkout, for fly.toml
+ contents: read # checkout, for fly.toml and the build context
environment:
name: demo
url: https://docket.demo.kejadlen.dev
@@ -119,9 +118,8 @@ jobs:
- name: Create the app if it is not there yet
run: flyctl status --app "$FLY_APP" >/dev/null 2>&1 || flyctl apps create "$FLY_APP" --org "$FLY_ORG"
- # The image the build job just pushed, rather than a second build
- # of the same commit: what the demo runs is then the release.
- - name: Deploy the released image
- env:
- NEEDS_BUILD_OUTPUTS_IMAGE: ${{ needs.build.outputs.image }}
- run: flyctl deploy --app "$FLY_APP" --config fly.toml --image "$NEEDS_BUILD_OUTPUTS_IMAGE"
+ # A build of its own from fly.toml, with the `dev` feature the
+ # release image leaves out. It still waits on the release, so the
+ # demo only runs commits that shipped.
+ - name: Deploy a demo build
+ run: flyctl deploy --app "$FLY_APP" --config fly.toml --remote-only
diff --git a/Cargo.toml b/Cargo.toml
index cc59094..261e3c9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,6 +7,12 @@ edition = "2024"
name = "docket"
path = "src/bin/docket/main.rs"
+[features]
+# Stands in for caddy-tailscale so the app runs locally against fixtures:
+# seeds an empty database and signs requests in as a user picked from the
+# menu. Never enable it in a deployed build.
+dev = []
+
[dependencies]
axum = "*"
camino = "*"
diff --git a/Dockerfile b/Dockerfile
index cf9c05d..67588cf 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,12 +19,15 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/build/target \
cargo chef cook --release --recipe-path recipe.json
COPY . .
+# Cargo features to build with. Empty for the release image; the Fly demos
+# set `dev` (see fly.toml).
+ARG FEATURES=""
# Copy the binary out of the cache mount so it survives into the runtime
# stage.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/build/target \
- cargo build --release --bin docket && \
+ cargo build --release --bin docket --features "$FEATURES" && \
cp target/release/docket /build/docket
# Runtime stage.
diff --git a/bin/coverage b/bin/coverage
index 631a322..c3b947c 100755
--- a/bin/coverage
+++ b/bin/coverage
@@ -22,7 +22,7 @@ main() {
export LLVM_PROFILE_FILE="$TARGET_DIR/profraw/%p-%m.profraw"
rm -rf "$TARGET_DIR"
- cargo test --workspace --quiet
+ cargo test --workspace --all-features --quiet
local report
report=$(grcov "$TARGET_DIR/profraw" \
diff --git a/docket.kdl b/docket.kdl
index ec141c9..4d2d0d5 100644
--- a/docket.kdl
+++ b/docket.kdl
@@ -1,7 +1,6 @@
// Docket's settings, read from this file (see `--help` for pointing
-// DOCKET_CONFIG elsewhere). This copy is the local-dev one: `dev` serves
-// fixture data without Tailscale, picking a user from the sidebar, and
-// seeds the database with it the first time. Delete the file to start over.
+// DOCKET_CONFIG elsewhere). This copy is the local-dev one; `just dev`
+// builds with the `dev` feature, which seeds the database with fixtures the
+// first time and stands in for Tailscale. Delete docket.db to start over.
bind "127.0.0.1:3000"
database "docket.db"
-dev
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 93d3d65..e63263e 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -20,8 +20,9 @@ A small self-hosted webapp for two people to jointly manage household email acco
- Two app users.
- Access is via Tailscale, which is the whole of authentication: anyone the tailnet lets reach Docket is a user, added on their first request. Docket keeps no list of people.
-- The app binds to localhost behind Caddy with [caddy-tailscale](https://github.com/tailscale/caddy-tailscale), which sets two headers from the tailnet identity: `Remote-User` (the full login, `{http.auth.user.id}`) identifies the user, and `X-User-Slug` carries a short name the Caddyfile maps from the login. Requests without a login are rejected.
-- A user's slug is what Docket shows for them. It follows the latest `X-User-Slug`, falls back to the part of the login before the `@`, and need not be unique.
+- The app binds to localhost behind Caddy with [caddy-tailscale](https://github.com/tailscale/caddy-tailscale), which sets two headers from the tailnet identity: `Remote-User` (the full login, `{http.auth.user.id}`) identifies the user, and `X-User-Slug` carries a short name the Caddyfile maps from the login. Requests missing either header are rejected.
+- A user's slug is what Docket shows for them. It follows the latest `X-User-Slug` and need not be unique.
+- For local work, the `dev` cargo feature stands in for the proxy: a middleware sets both headers for a user picked from a menu. Release builds leave it out, so no setting can turn it on in production.
## Credentials and accounts
diff --git a/fly.kdl b/fly.kdl
index 090bcc5..86a9d3b 100644
--- a/fly.kdl
+++ b/fly.kdl
@@ -1,11 +1,10 @@
// Config for the two Fly demo apps, baked into the image at
// /etc/docket.kdl (fly.toml points DOCKET_CONFIG at it). Binds wide
-// because Fly's edge routes to the machine, not to localhost. `dev` is
-// the whole of what makes them demos: fixture data instead of JMAP, and
-// a stand-in identity in place of the identity headers that Caddy
-// (caddy-tailscale) supplies in front of the real deployment. The database
-// sits on the machine's own disk, which a deploy replaces, so each deploy
-// starts from the fixtures again.
+// because Fly's edge routes to the machine, not to localhost. What makes
+// them demos is the `dev` feature fly.toml builds with: fixture data
+// instead of JMAP, and a stand-in identity in place of the identity
+// headers that Caddy (caddy-tailscale) supplies in front of the real
+// deployment. The database sits on the machine's own disk, which a deploy
+// replaces, so each deploy starts from the fixtures again.
bind "0.0.0.0:3000"
database "/tmp/docket.db"
-dev
diff --git a/fly.toml b/fly.toml
index ded0730..4293ce5 100644
--- a/fly.toml
+++ b/fly.toml
@@ -1,11 +1,12 @@
# The one Fly config, for the two demo apps: the app main deploys (the
# deploy job in .github/workflows/ci.yml) and the per-PR previews (the
# review-app action in .github/workflows/preview.yml, which overrides
-# the app name with the PR's). Both read the config baked into the image
-# from fly.kdl: fixture data instead of JMAP, and a stand-in identity in
-# place of the identity headers that Caddy (caddy-tailscale) supplies in
-# front of the real deployment. No volume, so every deploy starts
-# from the fixtures again.
+# the app name with the PR's). Both build the image here with the `dev`
+# feature: fixture data instead of JMAP, and a stand-in identity in place
+# of the identity headers that Caddy (caddy-tailscale) supplies in front
+# of the real deployment. The release image leaves the feature out, so
+# the demos can't run it. No volume, so every deploy starts from the
+# fixtures again.
#
# The app name is Fly's, not GitHub's, so it has to be unique across all
# of Fly and a taken one fails the deploy. ci.yml spells it twice more,
@@ -14,7 +15,8 @@
app = 'docket-kejadlen'
primary_region = 'sjc'
-[build]
+[build.args]
+FEATURES = 'dev'
[env]
DOCKET_CONFIG = '/etc/docket.kdl'
diff --git a/justfile b/justfile
index 0085625..b7c7736 100644
--- a/justfile
+++ b/justfile
@@ -4,10 +4,10 @@ fmt:
cargo fmt --all
check:
- cargo check --workspace
+ cargo check --workspace --all-features
clippy:
- cargo clippy --workspace -- -D warnings
+ cargo clippy --workspace --all-features -- -D warnings
coverage:
./bin/coverage
@@ -28,7 +28,7 @@ all: fmt clippy coverage
# Serve the fixture-backed UI without Tailscale on http://127.0.0.1:3000,
# restarting on changes.
dev:
- { fd -t f . src assets; echo Cargo.toml docket.kdl; } | entr -r cargo run
+ { fd -t f . src assets; echo Cargo.toml docket.kdl; } | entr -r cargo run --features dev
install:
cargo install --locked --path .
diff --git a/src/bin/docket/config.rs b/src/bin/docket/config.rs
index 9934def..809c5ca 100644
--- a/src/bin/docket/config.rs
+++ b/src/bin/docket/config.rs
@@ -22,10 +22,6 @@ pub struct Config {
/// fronts it and supplies the user identity headers.
pub bind: SocketAddr,
- /// Serve without Tailscale: requests with no identity header act as a
- /// user picked from the sidebar. For local work against fixtures only.
- pub dev: bool,
-
/// SQLite database file, created if missing. A relative path is
/// relative to the working directory.
pub database: Utf8PathBuf,
@@ -56,7 +52,6 @@ pub struct Args {
fn parse(source: &str) -> Result<Config, Error> {
let document = KdlDocument::parse(source)?;
let mut bind = None;
- let mut dev = None;
let mut database = None;
for node in document.nodes() {
@@ -70,15 +65,6 @@ fn parse(source: &str) -> Result<Config, Error> {
}
bind = Some(parse_bind(node)?);
}
- "dev" => {
- if dev.is_some() {
- return Err(Error::Duplicate {
- name: "dev".to_owned(),
- span: node.name().span(),
- });
- }
- dev = Some(parse_dev(node)?);
- }
"database" => {
if database.is_some() {
return Err(Error::Duplicate {
@@ -99,7 +85,6 @@ fn parse(source: &str) -> Result<Config, Error> {
Ok(Config {
bind: bind.unwrap_or(DEFAULT_BIND),
- dev: dev.unwrap_or(false),
database: database.unwrap_or_else(|| Utf8PathBuf::from(DEFAULT_DATABASE)),
})
}
@@ -122,13 +107,6 @@ fn parse_database(node: &KdlNode) -> Result<Utf8PathBuf, Error> {
.ok_or(Error::DatabaseValue { span: node.span() })
}
-fn parse_dev(node: &KdlNode) -> Result<bool, Error> {
- match single_value(node, "dev")? {
- None => Ok(true),
- Some(value) => value.as_bool().ok_or(Error::DevValue { span: node.span() }),
- }
-}
-
/// Returns the node's single positional value, or `None` when the node
/// is bare. Everything else a KDL node can carry — properties, type
/// annotations, child blocks, extra values — is rejected rather than
@@ -157,7 +135,7 @@ enum Error {
#[error("unknown setting `{name}`")]
#[diagnostic(
code(docket::config::unknown),
- help("known settings: `bind`, `database`, `dev`")
+ help("known settings: `bind`, `database`")
)]
Unknown {
name: String,
@@ -204,13 +182,6 @@ enum Error {
#[label]
span: SourceSpan,
},
-
- #[error("dev takes #true or #false, or nothing at all (which means true)")]
- #[diagnostic(code(docket::config::dev_value))]
- DevValue {
- #[label]
- span: SourceSpan,
- },
}
#[cfg(test)]
@@ -222,7 +193,6 @@ mod tests {
fn empty_document_uses_defaults() {
let config = parse("").unwrap();
assert_eq!(config.bind.to_string(), "127.0.0.1:3000");
- assert!(!config.dev);
assert_eq!(config.database, "docket.db");
}
@@ -230,7 +200,6 @@ mod tests {
fn comments_only_uses_defaults() {
let config = parse("// nothing configured\n").unwrap();
assert_eq!(config.bind.to_string(), "127.0.0.1:3000");
- assert!(!config.dev);
}
#[test]
@@ -278,45 +247,22 @@ mod tests {
));
}
- #[test]
- fn bare_dev_means_true() {
- let config = parse("dev").unwrap();
- assert!(config.dev);
- }
-
- #[test]
- fn dev_accepts_explicit_booleans() {
- assert!(parse("dev #true").unwrap().dev);
- assert!(!parse("dev #false").unwrap().dev);
- }
-
- #[test]
- fn dev_rejects_non_boolean_values() {
- assert!(matches!(parse(r#"dev "yes""#), Err(Error::DevValue { .. })));
- assert!(matches!(parse("dev 1"), Err(Error::DevValue { .. })));
- assert!(matches!(parse("dev #null"), Err(Error::DevValue { .. })));
- // Bare `true` never reaches us: KDL v2 rejects it at parse time.
- assert!(matches!(parse("dev true"), Err(Error::Kdl(_))));
- }
-
#[test]
fn settings_combine() {
- let config = parse("bind \"0.0.0.0:3000\"\ndev\n").unwrap();
+ let config = parse("bind \"0.0.0.0:3000\"\ndatabase \"d.db\"\n").unwrap();
assert_eq!(config.bind.to_string(), "0.0.0.0:3000");
- assert!(config.dev);
+ assert_eq!(config.database, "d.db");
}
#[test]
fn unknown_setting_is_rejected() {
assert!(matches!(parse("bnd \"x\""), Err(Error::Unknown { .. })));
+ // Dev mode is a build feature now, not a setting.
+ assert!(matches!(parse("dev"), Err(Error::Unknown { .. })));
}
#[test]
fn duplicate_setting_is_rejected() {
- assert!(matches!(
- parse("dev\ndev #false"),
- Err(Error::Duplicate { .. })
- ));
let source = "bind \"127.0.0.1:1\"\nbind \"127.0.0.1:2\"";
assert!(matches!(parse(source), Err(Error::Duplicate { .. })));
}
@@ -324,11 +270,17 @@ mod tests {
#[test]
fn extra_syntax_is_rejected() {
assert!(matches!(
- parse("dev #true #false"),
+ parse(r#"database "a.db" "b.db""#),
+ Err(Error::Shape { .. })
+ ));
+ assert!(matches!(
+ parse(r#"database path="a.db""#),
+ Err(Error::Shape { .. })
+ ));
+ assert!(matches!(
+ parse(r#"database "a.db" { inner }"#),
Err(Error::Shape { .. })
));
- assert!(matches!(parse("dev on=#false"), Err(Error::Shape { .. })));
- assert!(matches!(parse("dev { inner }"), Err(Error::Shape { .. })));
assert!(matches!(
parse(r#"bind address="127.0.0.1:3000""#),
Err(Error::Shape { .. })
diff --git a/src/bin/docket/main.rs b/src/bin/docket/main.rs
index 057e2a8..6d625fe 100644
--- a/src/bin/docket/main.rs
+++ b/src/bin/docket/main.rs
@@ -21,11 +21,15 @@ async fn main() -> miette::Result<()> {
// The fixtures' clock keeps their ages stable until JMAP sync brings
// real mail.
let store = docket::store::Store::open(&config.database, docket::fixtures::now())?;
- if config.dev && store.is_empty()? {
+ #[cfg(feature = "dev")]
+ if store.is_empty()? {
docket::fixtures::seed(&store)?;
tracing::info!(database = %config.database, "seeded fixtures");
}
- let state = docket::routes::AppState::new(store, config.dev);
+ let state = docket::routes::AppState::new(store);
+ #[cfg(feature = "dev")]
+ let app = docket::dev::router(state);
+ #[cfg(not(feature = "dev"))]
let app = docket::routes::router(state);
let listener = TcpListener::bind(config.bind).await.into_diagnostic()?;
diff --git a/src/dev.rs b/src/dev.rs
new file mode 100644
index 0000000..d03852a
--- /dev/null
+++ b/src/dev.rs
@@ -0,0 +1,85 @@
+//! Local development without Tailscale, compiled in only with the `dev`
+//! feature. A middleware plays caddy-tailscale, setting the identity headers
+//! for a user picked from the menu, so requests take the same path they do
+//! in production.
+
+use axum::extract::{Request, State};
+use axum::http::header::{COOKIE, SET_COOKIE};
+use axum::http::{HeaderMap, HeaderValue};
+use axum::middleware::{self, Next};
+use axum::response::{IntoResponse, Redirect, Response};
+use axum::routing::post;
+use axum::{Form, Router};
+
+use crate::Error;
+use crate::routes::{
+ self, AppState, IDENTITY_HEADER, SLUG_HEADER, UserForm, header, safe_back, same_origin,
+};
+
+const COOKIE_NAME: &str = "docket_dev_user";
+
+/// The app's routes plus the user switch, with requests signed in as the
+/// user the switch chose, or the first user before it's been used.
+pub fn router(state: AppState) -> Router {
+ routes::router(state.clone())
+ .merge(
+ Router::new()
+ .route("/dev/user", post(switch_user))
+ .with_state(state.clone()),
+ )
+ .layer(middleware::from_fn_with_state(state, identify))
+}
+
+async fn identify(
+ State(state): State<AppState>,
+ mut req: Request,
+ next: Next,
+) -> Result<Response, Error> {
+ let store = &state.store;
+ let chosen = match cookie(req.headers(), COOKIE_NAME) {
+ Some(login) => store.user(login)?,
+ None => None,
+ };
+ let user = match chosen {
+ Some(user) => Some(user),
+ None => store.users()?.into_iter().next(),
+ };
+ // Like the proxy, never trust what the client sent.
+ let headers = req.headers_mut();
+ headers.remove(IDENTITY_HEADER);
+ headers.remove(SLUG_HEADER);
+ // The slug goes along too: signing in without one would rename the user.
+ if let Some(user) = user
+ && let (Ok(login), Ok(slug)) = (
+ HeaderValue::from_str(&user.login),
+ HeaderValue::from_str(&user.slug),
+ )
+ {
+ headers.insert(IDENTITY_HEADER, login);
+ headers.insert(SLUG_HEADER, slug);
+ }
+ Ok(next.run(req).await)
+}
+
+async fn switch_user(
+ State(state): State<AppState>,
+ headers: HeaderMap,
+ Form(form): Form<UserForm>,
+) -> Result<Response, Error> {
+ if !same_origin(&headers) {
+ return Err(Error::Forbidden("cross-site request"));
+ }
+ if state.store.user(&form.user)?.is_none() {
+ return Err(Error::NotFound("user"));
+ }
+ let cookie = format!("{COOKIE_NAME}={}; Path=/; SameSite=Lax", form.user);
+ Ok(([(SET_COOKIE, cookie)], Redirect::to(safe_back(&form.back))).into_response())
+}
+
+fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
+ header(headers, COOKIE.as_str())?
+ .split(';')
+ .filter_map(|pair| pair.trim().split_once('='))
+ .find(|(k, _)| *k == name)
+ .map(|(_, v)| v)
+}
diff --git a/src/fixtures.rs b/src/fixtures.rs
index c0a2170..f5534b4 100644
--- a/src/fixtures.rs
+++ b/src/fixtures.rs
@@ -130,7 +130,7 @@ pub fn store() -> Result<Store, Error> {
/// Writes the fixtures into an empty database.
pub fn seed(store: &Store) -> Result<(), Error> {
- let users = [User::new(ALEX, Some("Alex")), User::new(SAM, Some("Sam"))];
+ let users = [User::new(ALEX, "Alex"), User::new(SAM, "Sam")];
let accounts = [
Account {
slug: "household".into(),
diff --git a/src/lib.rs b/src/lib.rs
index 645588a..1868bc8 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,6 @@
pub mod dates;
+#[cfg(feature = "dev")]
+pub mod dev;
pub mod error;
pub mod fixtures;
pub mod lists;
diff --git a/src/model.rs b/src/model.rs
index 3c6e93a..833dcd7 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -74,16 +74,10 @@ pub struct User {
}
impl User {
- /// The slug the proxy sent, or the part of the login before the `@`.
- pub fn new(login: &str, slug: Option<&str>) -> Self {
- let fallback = login.split_once('@').map_or(login, |(name, _)| name);
- let slug = slug
- .map(str::trim)
- .filter(|s| !s.is_empty())
- .unwrap_or(fallback);
+ pub fn new(login: &str, slug: &str) -> Self {
Self {
login: login.to_owned(),
- slug: slug.to_owned(),
+ slug: slug.trim().to_owned(),
}
}
}
@@ -171,13 +165,10 @@ mod tests {
use super::*;
#[test]
- fn slugs_fall_back_to_the_login() {
- let named = User::new("alex@example.com", Some(" Al "));
+ fn slugs_are_trimmed() {
+ let named = User::new("alex@example.com", " Al ");
assert_eq!(named.login, "alex@example.com");
assert_eq!(named.slug, "Al");
- assert_eq!(User::new("alex@example.com", None).slug, "alex");
- assert_eq!(User::new("alex@example.com", Some(" ")).slug, "alex");
- assert_eq!(User::new("tagged-device", None).slug, "tagged-device");
}
#[test]
diff --git a/src/routes.rs b/src/routes.rs
index e10ddc6..bb89cf0 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -1,10 +1,10 @@
use std::sync::Arc;
use axum::extract::{FromRequestParts, OriginalUri, Path, Query, State as Extract};
-use axum::http::header::{CONTENT_TYPE, COOKIE, HOST, ORIGIN, SET_COOKIE};
+use axum::http::header::{CONTENT_TYPE, HOST, ORIGIN};
use axum::http::request::Parts;
use axum::http::{HeaderMap, Method};
-use axum::response::{IntoResponse, Redirect, Response};
+use axum::response::{IntoResponse, Redirect};
use axum::routing::{get, post};
use axum::{Form, Router};
use maud::Markup;
@@ -19,28 +19,24 @@ use crate::views::{self, Page, ThreadView};
// caddy-tailscale sets both from the tailnet identity, overwriting whatever
// the client sent.
-const IDENTITY_HEADER: &str = "Remote-User";
-const SLUG_HEADER: &str = "X-User-Slug";
-const DEV_COOKIE: &str = "docket_dev_user";
+pub(crate) const IDENTITY_HEADER: &str = "Remote-User";
+pub(crate) const SLUG_HEADER: &str = "X-User-Slug";
#[derive(Clone)]
pub struct AppState {
pub store: Arc<Store>,
- /// Without Tailscale in front, fall back to a cookie-chosen user.
- pub dev: bool,
}
impl AppState {
- pub fn new(store: Store, dev: bool) -> Self {
+ pub fn new(store: Store) -> Self {
Self {
store: Arc::new(store),
- dev,
}
}
}
pub fn router(state: AppState) -> Router {
- let mut router = Router::new()
+ Router::new()
.route("/health", get(health))
.route("/", get(for_me))
.route("/{lane}", get(lane))
@@ -52,11 +48,9 @@ pub fn router(state: AppState) -> Router {
.route("/undo", post(undo))
.route("/assets/gloss.css", get(gloss_css))
.route("/assets/docket.css", get(docket_css))
- .route("/assets/alpine.js", get(alpine_js));
- if state.dev {
- router = router.route("/dev/user", post(dev_user));
- }
- router.layer(TraceLayer::new_for_http()).with_state(state)
+ .route("/assets/alpine.js", get(alpine_js))
+ .layer(TraceLayer::new_for_http())
+ .with_state(state)
}
async fn health() -> &'static str {
@@ -64,7 +58,8 @@ async fn health() -> &'static str {
}
/// The requesting user, from the identity headers caddy-tailscale adds. Anyone
-/// Tailscale lets through is a user; the first request adds them.
+/// Tailscale lets through is a user; the first request adds them. Missing
+/// either header means the proxy isn't doing its job, so nobody gets in.
struct Me(User);
impl FromRequestParts<AppState> for Me {
@@ -76,31 +71,19 @@ impl FromRequestParts<AppState> for Me {
if parts.method == Method::POST && !same_origin(&parts.headers) {
return Err(Error::Forbidden("cross-site request"));
}
- let store = &state.store;
- if let Some(login) = header(&parts.headers, IDENTITY_HEADER).filter(|l| !l.is_empty()) {
- let slug = header(&parts.headers, SLUG_HEADER);
- return Ok(Me(store.sign_in(login, slug)?));
- }
- if !state.dev {
+ let present = |name| header(&parts.headers, name).filter(|v| !v.trim().is_empty());
+ let (Some(login), Some(slug)) = (present(IDENTITY_HEADER), present(SLUG_HEADER)) else {
return Err(Error::Unauthenticated);
- }
- let chosen = match cookie(&parts.headers, DEV_COOKIE) {
- Some(login) => store.user(login)?,
- None => None,
- };
- let user = match chosen {
- Some(user) => Some(user),
- None => store.users()?.into_iter().next(),
};
- user.map(Me).ok_or(Error::Unauthenticated)
+ Ok(Me(state.store.sign_in(login, slug)?))
}
}
-fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
+pub(crate) fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name).and_then(|v| v.to_str().ok())
}
-fn same_origin(headers: &HeaderMap) -> bool {
+pub(crate) fn same_origin(headers: &HeaderMap) -> bool {
let Some(origin) = header(headers, ORIGIN.as_str()) else {
return true;
};
@@ -108,14 +91,6 @@ fn same_origin(headers: &HeaderMap) -> bool {
origin_host.is_some() && origin_host == header(headers, HOST.as_str())
}
-fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
- header(headers, COOKIE.as_str())?
- .split(';')
- .filter_map(|pair| pair.trim().split_once('='))
- .find(|(k, _)| *k == name)
- .map(|(_, v)| v)
-}
-
#[derive(Deserialize)]
struct Selection {
m: Option<MessageId>,
@@ -180,7 +155,6 @@ fn render(
thread,
flash: store.take_flash(&me.login),
here,
- dev: state.dev,
}))
}
@@ -205,7 +179,7 @@ fn open_thread(store: &Store, me: &User, id: MessageId) -> Result<ThreadView, Er
}
/// Only same-site paths, so a form can't bounce the browser elsewhere.
-fn safe_back(back: &str) -> &str {
+pub(crate) fn safe_back(back: &str) -> &str {
if back.starts_with('/') && !back.starts_with("//") && !back.contains('\\') {
back
} else {
@@ -246,9 +220,9 @@ async fn set_folder(
}
#[derive(Deserialize)]
-struct UserForm {
- user: String,
- back: String,
+pub(crate) struct UserForm {
+ pub(crate) user: String,
+ pub(crate) back: String,
}
async fn toggle_assignee(
@@ -308,21 +282,6 @@ async fn undo(
Ok(Redirect::to(safe_back(&form.back)))
}
-async fn dev_user(
- Extract(state): Extract<AppState>,
- headers: HeaderMap,
- Form(form): Form<UserForm>,
-) -> Result<Response, Error> {
- if !same_origin(&headers) {
- return Err(Error::Forbidden("cross-site request"));
- }
- if state.store.user(&form.user)?.is_none() {
- return Err(Error::NotFound("user"));
- }
- let cookie = format!("{DEV_COOKIE}={}; Path=/; SameSite=Lax", form.user);
- Ok(([(SET_COOKIE, cookie)], Redirect::to(safe_back(&form.back))).into_response())
-}
-
async fn gloss_css() -> impl IntoResponse {
(
[(CONTENT_TYPE, "text/css; charset=utf-8")],
diff --git a/src/store.rs b/src/store.rs
index 7e14034..dea0cf8 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -135,7 +135,7 @@ impl Store {
/// The user behind a request Tailscale identified, added on their first
/// request. Their slug follows whatever the proxy sends.
- pub fn sign_in(&self, login: &str, slug: Option<&str>) -> Result<User, Error> {
+ pub fn sign_in(&self, login: &str, slug: &str) -> Result<User, Error> {
let user = User::new(login, slug);
self.lock().conn.execute(
"INSERT INTO users (login, slug) VALUES (?1, ?2)
@@ -910,16 +910,16 @@ mod tests {
fn signing_in_adds_users_and_follows_their_slug() {
let store = Store::open_in_memory(fixtures::now()).unwrap();
assert!(store.is_empty().unwrap());
- let pat = store.sign_in("pat@example.com", None).unwrap();
- assert_eq!(pat, User::new("pat@example.com", None));
+ let pat = store.sign_in("pat@example.com", "pat").unwrap();
+ assert_eq!(pat, User::new("pat@example.com", "pat"));
assert!(!store.is_empty().unwrap());
assert_eq!(store.user("pat@example.com").unwrap(), Some(pat));
- store.sign_in("pat@example.com", Some("Pat")).unwrap();
- store.sign_in("pat@example.com", Some("Pat")).unwrap();
+ store.sign_in("pat@example.com", "Pat").unwrap();
+ store.sign_in("pat@example.com", "Pat").unwrap();
assert_eq!(store.user("pat@example.com").unwrap().unwrap().slug, "Pat");
// Slugs are only for show, so two logins may share one.
- store.sign_in("pat@work.example", Some("Pat")).unwrap();
+ store.sign_in("pat@work.example", "Pat").unwrap();
assert_eq!(store.users().unwrap().len(), 2);
}
@@ -1042,7 +1042,7 @@ mod tests {
let db_err = |r: Result<(), Error>| assert!(matches!(r, Err(Error::Db(_))), "{r:?}");
db_err(store.edit(SAM, 4, Change::State(State::Do)));
db_err(store.mark_read(SAM, 4));
- db_err(store.sign_in("pat@example.com", None).map(|_| ()));
+ db_err(store.sign_in("pat@example.com", "pat").map(|_| ()));
db_err(store.add_comment(SAM, 1, "hi"));
db_err(store.import(|tx| tx.folder("Travel")));
db_err(store.import(|tx| tx.message(&msg)));
diff --git a/src/views.rs b/src/views.rs
index 2f7f534..877a9fe 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -30,8 +30,6 @@ pub struct Page<'a> {
pub flash: Option<Flash>,
/// This page's URL, for forms to return to.
pub here: &'a str,
- /// Lets the viewer switch users without Tailscale, for fixtures.
- pub dev: bool,
}
pub struct ThreadView {
@@ -174,7 +172,7 @@ fn whoami(p: &Page<'_>) -> Markup {
span.type-label.who-login { (p.me.login) }
};
html! {
- @if p.dev {
+ @if cfg!(feature = "dev") {
div.who.ctrl x-data="{ open: false }" "@click.outside"="open = false" "@keydown.escape"="open = false" {
button.who-btn type="button" "@click"="open = !open" { (who) }
form.menu.up method="post" action="/dev/user" x-show="open" x-cloak {
diff --git a/tests/api.rs b/tests/api.rs
index 603ed07..0a90e38 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -1,14 +1,15 @@
use std::net::SocketAddr;
+use axum::Router;
use docket::routes::{AppState, router};
use reqwest::{Client, StatusCode, redirect};
use tokio::net::TcpListener;
const SAM: &str = "sam@example.com";
const ALEX: &str = "alex@example.com";
+const EVE: &str = "eve@example.com";
-async fn spawn_with(dev: bool) -> SocketAddr {
- let app = router(AppState::new(docket::fixtures::store().unwrap(), dev));
+async fn serve(app: Router) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
@@ -18,7 +19,7 @@ async fn spawn_with(dev: bool) -> SocketAddr {
}
async fn spawn() -> SocketAddr {
- spawn_with(false).await
+ serve(router(AppState::new(docket::fixtures::store().unwrap()))).await
}
fn client() -> Client {
@@ -28,10 +29,21 @@ fn client() -> Client {
.unwrap()
}
+/// What the proxy would send as `X-User-Slug`: "sam@example.com" is "Sam".
+fn slug(login: &str) -> String {
+ let name = login.split('@').next().unwrap_or(login);
+ let mut chars = name.chars();
+ chars
+ .next()
+ .map(|c| c.to_uppercase().chain(chars).collect())
+ .unwrap_or_default()
+}
+
async fn get(addr: SocketAddr, login: &str, path: &str) -> (StatusCode, String) {
let res = client()
.get(format!("http://{addr}{path}"))
.header("Remote-User", login)
+ .header("X-User-Slug", slug(login))
.send()
.await
.unwrap();
@@ -47,6 +59,7 @@ async fn post(
client()
.post(format!("http://{addr}{path}"))
.header("Remote-User", login)
+ .header("X-User-Slug", slug(login))
.form(form)
.send()
.await
@@ -66,12 +79,29 @@ async fn health_returns_ok() {
}
#[tokio::test]
-async fn requires_an_identity() {
+async fn requires_both_identity_headers() {
let addr = spawn().await;
let res = reqwest::get(format!("http://{addr}/")).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
- let (status, _) = get(addr, "", "/").await;
- assert_eq!(status, StatusCode::UNAUTHORIZED);
+ for (login, slug) in [
+ (Some(EVE), None),
+ (None, Some("Eve")),
+ (Some(""), Some("Eve")),
+ (Some(EVE), Some(" ")),
+ ] {
+ let mut req = client().get(format!("http://{addr}/"));
+ if let Some(login) = login {
+ req = req.header("Remote-User", login);
+ }
+ if let Some(slug) = slug {
+ req = req.header("X-User-Slug", slug);
+ }
+ let res = req.send().await.unwrap();
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "{login:?} {slug:?}");
+ }
+ // Nobody got added along the way.
+ let (_, body) = get(addr, ALEX, "/?m=4").await;
+ assert!(!body.contains(EVE));
}
#[tokio::test]
@@ -79,9 +109,9 @@ async fn anyone_tailscale_lets_in_is_a_user() {
let addr = spawn().await;
let (status, body) = get(addr, "eve@example.com", "/").await;
assert_eq!(status, StatusCode::OK);
- assert!(body.contains(r#"<span class="who-name">eve</span>"#));
+ assert!(body.contains(r#"<span class="who-name">Eve</span>"#));
- // The proxy names them with X-User-Slug, and the name follows it.
+ // The name follows the latest X-User-Slug.
let body = client()
.get(format!("http://{addr}/?m=4"))
.header("Remote-User", "eve@example.com")
@@ -118,7 +148,7 @@ async fn lanes_and_search() {
("/do", "Exemption renewal"),
("/wait", "Claim 4471"),
("/watch", "Shipped: furnace filters"),
- ("/search?q=downspout", "sam → Northwind Roofing"),
+ ("/search?q=downspout", "Sam → Northwind Roofing"),
] {
let (status, body) = get(addr, SAM, path).await;
assert_eq!(status, StatusCode::OK, "{path}");
@@ -316,88 +346,108 @@ async fn comments_join_the_thread() {
#[tokio::test]
async fn cross_site_posts_are_refused() {
- let addr = spawn_with(true).await;
- let send = |origin: &'static str, path: &'static str| {
+ let addr = spawn().await;
+ let send = |origin: &'static str| {
client()
- .post(format!("http://{addr}{path}"))
+ .post(format!("http://{addr}/messages/4/state"))
.header("Remote-User", SAM)
+ .header("X-User-Slug", "Sam")
.header("Origin", origin)
- .form(&[("state", "do"), ("user", ALEX), ("back", "/")])
+ .form(&[("state", "do"), ("back", "/")])
.send()
};
- let res = send("https://evil.example", "/messages/4/state")
- .await
- .unwrap();
- assert_eq!(res.status(), StatusCode::FORBIDDEN);
- let res = send("null", "/messages/4/state").await.unwrap();
+ let res = send("https://evil.example").await.unwrap();
assert_eq!(res.status(), StatusCode::FORBIDDEN);
- let res = send("https://evil.example", "/dev/user").await.unwrap();
+ let res = send("null").await.unwrap();
assert_eq!(res.status(), StatusCode::FORBIDDEN);
let same: &'static str = Box::leak(format!("http://{addr}").into_boxed_str());
- let res = send(same, "/messages/4/state").await.unwrap();
+ let res = send(same).await.unwrap();
assert_eq!(res.status(), StatusCode::SEE_OTHER);
}
#[tokio::test]
-async fn dev_mode_picks_a_user_from_a_cookie() {
- let addr = spawn_with(true).await;
- let anon = client();
- let body = anon
- .get(format!("http://{addr}/"))
- .send()
- .await
- .unwrap()
- .text()
- .await
- .unwrap();
- assert!(body.contains(r#"<span class="who-name">Alex</span>"#));
- assert!(body.contains(r#"action="/dev/user""#));
+async fn without_the_dev_feature_there_is_no_user_switch() {
+ let addr = spawn().await;
+ let res = post(addr, SAM, "/dev/user", &[("user", ALEX), ("back", "/")]).await;
+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
+}
- let res = anon
- .post(format!("http://{addr}/dev/user"))
- .form(&[("user", SAM), ("back", "/do")])
- .send()
- .await
- .unwrap();
- assert_eq!(res.status(), StatusCode::SEE_OTHER);
- assert_eq!(location(&res), "/do");
- let cookie = res.headers()["set-cookie"].to_str().unwrap();
- assert!(cookie.starts_with("docket_dev_user=sam@example.com;"));
+#[cfg(feature = "dev")]
+mod dev {
+ use super::*;
- let body = anon
- .get(format!("http://{addr}/"))
- .header("Cookie", "theme=dark; docket_dev_user=sam@example.com")
- .send()
- .await
- .unwrap()
- .text()
- .await
- .unwrap();
- assert!(body.contains(r#"<span class="who-name">Sam</span>"#));
+ async fn spawn_dev(store: docket::store::Store) -> SocketAddr {
+ serve(docket::dev::router(AppState::new(store))).await
+ }
- let res = anon
- .post(format!("http://{addr}/dev/user"))
- .form(&[("user", "eve"), ("back", "/")])
- .send()
- .await
- .unwrap();
- assert_eq!(res.status(), StatusCode::NOT_FOUND);
+ async fn page(addr: SocketAddr, cookie: Option<&str>) -> String {
+ let mut req = client()
+ .get(format!("http://{addr}/"))
+ // Whatever the client claims, the middleware decides.
+ .header("Remote-User", "mallory@example.com")
+ .header("X-User-Slug", "Mallory");
+ if let Some(cookie) = cookie {
+ req = req.header("Cookie", cookie);
+ }
+ req.send().await.unwrap().text().await.unwrap()
+ }
- // Outside dev mode, the switch doesn't exist.
- let prod = spawn().await;
- let res = post(prod, SAM, "/dev/user", &[("user", ALEX), ("back", "/")]).await;
- assert_eq!(res.status(), StatusCode::NOT_FOUND);
-}
+ #[tokio::test]
+ async fn picks_a_user_from_a_cookie() {
+ let addr = spawn_dev(docket::fixtures::store().unwrap()).await;
+ let body = page(addr, None).await;
+ assert!(body.contains(r#"<span class="who-name">Alex</span>"#));
+ assert!(body.contains(r#"action="/dev/user""#));
+ assert!(!body.contains("Mallory"));
+
+ let res = client()
+ .post(format!("http://{addr}/dev/user"))
+ .form(&[("user", SAM), ("back", "/do")])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
+ assert_eq!(location(&res), "/do");
+ let cookie = res.headers()["set-cookie"].to_str().unwrap();
+ assert!(cookie.starts_with("docket_dev_user=sam@example.com;"));
+
+ let body = page(addr, Some("theme=dark; docket_dev_user=sam@example.com")).await;
+ assert!(body.contains(r#"<span class="who-name">Sam</span>"#));
+ // A cookie for someone unknown falls back to the first user.
+ let body = page(addr, Some("docket_dev_user=eve@example.com")).await;
+ assert!(body.contains(r#"<span class="who-name">Alex</span>"#));
+
+ let res = client()
+ .post(format!("http://{addr}/dev/user"))
+ .form(&[("user", "eve"), ("back", "/")])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
+
+ let res = client()
+ .post(format!("http://{addr}/dev/user"))
+ .header("Origin", "https://evil.example")
+ .form(&[("user", SAM), ("back", "/")])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::FORBIDDEN);
+ }
-#[tokio::test]
-async fn dev_mode_with_no_users_is_unauthenticated() {
- let store = docket::store::Store::open_in_memory(docket::fixtures::now()).unwrap();
- let app = router(AppState::new(store, true));
- let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
- let addr = listener.local_addr().unwrap();
- tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
- let res = reqwest::get(format!("http://{addr}/")).await.unwrap();
- assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
+ #[tokio::test]
+ async fn with_no_users_is_unauthenticated() {
+ let store = docket::store::Store::open_in_memory(docket::fixtures::now()).unwrap();
+ let addr = spawn_dev(store).await;
+ let res = client()
+ .get(format!("http://{addr}/"))
+ .header("Remote-User", SAM)
+ .header("X-User-Slug", "Sam")
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
+ }
}
#[tokio::test]