From ee9982bf125377339c2162482a062a9639902c09 Mon Sep 17 00:00:00 2001 From: Evan Witulski Date: Sun, 2 Aug 2026 19:37:22 -0400 Subject: [PATCH 1/2] [SO-341] Rework auth-service into a multi-method identity service One account, reachable by username+password or Sui wallet, linkable in either direction. Adds a Postgres-backed users/identities/invites store, role+scope JWT claims, and require_admin in auth-client. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0152Pn9P1PWogKrSdsS1jmrr --- frontend/src/api/authClient.ts | 27 +- rust-backend/Cargo.lock | 30 ++ rust-backend/Cargo.toml | 10 +- rust-backend/crates/auth-client/src/lib.rs | 77 ++++- rust-backend/services/auth-service/Cargo.toml | 8 + .../auth-service/config/config.prod.toml | 23 +- .../auth-service/config/config.staging.toml | 13 +- .../services/auth-service/config/config.toml | 26 +- .../services/auth-service/src/config.rs | 26 +- .../src/db/migrations/000001_init/down.sql | 3 + .../src/db/migrations/000001_init/up.sql | 62 ++++ .../services/auth-service/src/db/mod.rs | 33 ++ .../services/auth-service/src/db/models.rs | 149 +++++++++ .../services/auth-service/src/db/repo.rs | 299 ++++++++++++++++++ .../services/auth-service/src/db/schema.rs | 40 +++ .../services/auth-service/src/db/tests.rs | 268 ++++++++++++++++ .../services/auth-service/src/handlers.rs | 219 ------------- .../auth-service/src/handlers/account.rs | 241 ++++++++++++++ .../auth-service/src/handlers/internal.rs | 134 ++++++++ .../services/auth-service/src/handlers/mod.rs | 127 ++++++++ .../auth-service/src/handlers/session.rs | 270 ++++++++++++++++ rust-backend/services/auth-service/src/jwt.rs | 40 ++- rust-backend/services/auth-service/src/lib.rs | 52 ++- .../services/auth-service/src/main.rs | 13 +- .../services/auth-service/src/password.rs | 130 ++++++++ .../services/auth-service/src/router.rs | 32 +- .../services/auth-service/src/state.rs | 14 +- .../services/token-info/src/router.rs | 5 +- 28 files changed, 2092 insertions(+), 279 deletions(-) create mode 100644 rust-backend/services/auth-service/src/db/migrations/000001_init/down.sql create mode 100644 rust-backend/services/auth-service/src/db/migrations/000001_init/up.sql create mode 100644 rust-backend/services/auth-service/src/db/mod.rs create mode 100644 rust-backend/services/auth-service/src/db/models.rs create mode 100644 rust-backend/services/auth-service/src/db/repo.rs create mode 100644 rust-backend/services/auth-service/src/db/schema.rs create mode 100644 rust-backend/services/auth-service/src/db/tests.rs delete mode 100644 rust-backend/services/auth-service/src/handlers.rs create mode 100644 rust-backend/services/auth-service/src/handlers/account.rs create mode 100644 rust-backend/services/auth-service/src/handlers/internal.rs create mode 100644 rust-backend/services/auth-service/src/handlers/mod.rs create mode 100644 rust-backend/services/auth-service/src/handlers/session.rs create mode 100644 rust-backend/services/auth-service/src/password.rs diff --git a/frontend/src/api/authClient.ts b/frontend/src/api/authClient.ts index 12286c0d..642e214b 100644 --- a/frontend/src/api/authClient.ts +++ b/frontend/src/api/authClient.ts @@ -11,7 +11,12 @@ const base = AUTH_URL.replace(/\/$/, ""); export type TokenResp = { token: string; - address: string; + /** Only wallet-opened sessions carry an address, which is all this client + * opens. Password sessions (the Dakota dashboard) leave it unset. */ + address?: string; + user_id: string; + role: string; + scope?: string; expires_in: number; }; @@ -88,12 +93,26 @@ export function jwtExp(token: string): number { } } -/** `0x…` subject (admin address) from a JWT, or null. */ +/** `0x…` wallet address from a JWT, or null. + * + * Reads the `address` claim. `sub` used to hold the address but now holds the + * account uuid — an account can be reached by wallet OR password, so the + * address is one identity among several rather than the subject itself. */ export function jwtSubject(token: string): string | null { + return jwtClaims(token)?.address ?? null; +} + +/** Role from a JWT (`admin` | `business` | `individual`), or null. */ +export function jwtRole(token: string): string | null { + return jwtClaims(token)?.role ?? null; +} + +type Claims = { sub?: string; role?: string; scope?: string; address?: string; exp?: number }; + +function jwtClaims(token: string): Claims | null { try { const payload = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"); - const json = JSON.parse(atob(payload)) as { sub?: string }; - return json.sub ?? null; + return JSON.parse(atob(payload)) as Claims; } catch { return null; } diff --git a/rust-backend/Cargo.lock b/rust-backend/Cargo.lock index b03fba43..35da4b10 100644 --- a/rust-backend/Cargo.lock +++ b/rust-backend/Cargo.lock @@ -363,6 +363,18 @@ dependencies = [ "object", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "ark-bn254" version = "0.4.0" @@ -828,6 +840,7 @@ name = "auth-service" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "axum 0.7.9", "base64 0.22.1", "blake2", @@ -836,12 +849,15 @@ dependencies = [ "cli-spec", "config", "dashmap 6.1.0", + "diesel", + "diesel_migrations", "ed25519-dalek", "hex", "hmac", "metrics", "observability", "protocol-types", + "r2d2", "rand 0.8.6", "runtime-config", "serde", @@ -852,6 +868,7 @@ dependencies = [ "tower-http 0.6.10", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -2719,6 +2736,7 @@ dependencies = [ "pq-sys", "r2d2", "serde_json", + "uuid", ] [[package]] @@ -7328,6 +7346,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pasta_curves" version = "0.5.1" @@ -12449,6 +12478,7 @@ dependencies = [ "getrandom 0.4.2", "js-sys", "rand 0.10.1", + "serde_core", "wasm-bindgen", ] diff --git a/rust-backend/Cargo.toml b/rust-backend/Cargo.toml index f1b5f972..d473a344 100644 --- a/rust-backend/Cargo.toml +++ b/rust-backend/Cargo.toml @@ -100,6 +100,10 @@ sha2 = "0.10" hmac = "0.12" blake2 = "0.10" base64 = "0.22" +# auth-service: password-identity hashing. Argon2id at the crate defaults — +# the PHC string it emits embeds its own params, so a later parameter bump +# still verifies hashes written today. +argon2 = "0.5" thiserror = "1" anyhow = "1" @@ -120,7 +124,9 @@ opentelemetry-http = "0.30" tracing-opentelemetry = "0.31" dashmap = "6" -uuid = { version = "1", features = ["v4"] } +# `serde` so uuids can be request/response fields directly (auth-service +# invites) without a String round-trip. +uuid = { version = "1", features = ["v4", "serde"] } url = "2" parking_lot = "0.12" @@ -159,7 +165,7 @@ async-graphql = { version = "7" } async-graphql-axum = { version = "7" } # Postgres persistence for the indexer. -diesel = { version = "2.2", features = ["postgres", "r2d2", "chrono", "numeric", "serde_json"] } +diesel = { version = "2.2", features = ["postgres", "r2d2", "chrono", "numeric", "serde_json", "uuid"] } diesel_migrations = { version = "2", features = ["postgres"] } r2d2 = "0.8" bigdecimal = { version = "0.4", features = ["serde"] } diff --git a/rust-backend/crates/auth-client/src/lib.rs b/rust-backend/crates/auth-client/src/lib.rs index 51f041c6..265860a3 100644 --- a/rust-backend/crates/auth-client/src/lib.rs +++ b/rust-backend/crates/auth-client/src/lib.rs @@ -32,12 +32,27 @@ use tracing::{debug, warn}; /// The claims auth-service confirms for a valid token. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VerifiedClaims { - /// Sui address the token was issued to (the admin), `0x`-prefixed. + /// Sui address the session was opened with, `0x`-prefixed. Empty for + /// password sessions, which have no address — gate on [`Self::role`], not + /// on this field. pub address: String, + /// Account uuid. Stable across login methods, unlike `address`. + pub user_id: String, + /// `admin` | `business` | `individual`. + pub role: String, + /// Opaque authorization scope — dakota-service reads it as a Dakota + /// customer id. `None` for admins, who are unscoped. + pub scope: Option, /// Expiry, unix seconds. pub exp: u64, } +impl VerifiedClaims { + pub fn is_admin(&self) -> bool { + self.role == "admin" + } +} + /// Wire shape of auth-service's `POST /verify` response. #[derive(Debug, Deserialize)] struct VerifyResp { @@ -45,6 +60,12 @@ struct VerifyResp { #[serde(default)] address: Option, #[serde(default)] + user_id: Option, + #[serde(default)] + role: Option, + #[serde(default)] + scope: Option, + #[serde(default)] exp: Option, } @@ -82,6 +103,11 @@ impl AuthClient { if resp.valid { Ok(Some(VerifiedClaims { address: resp.address.unwrap_or_default(), + user_id: resp.user_id.unwrap_or_default(), + // Absent only if auth-service predates roles; treating that as + // the least-privileged role fails closed. + role: resp.role.unwrap_or_else(|| "individual".to_string()), + scope: resp.scope, exp: resp.exp.unwrap_or_default(), })) } else { @@ -98,26 +124,61 @@ fn bearer(req: &Request) -> Option { .map(|s| s.trim().to_string()) } -/// axum middleware: require a valid admin JWT, verified by auth-service. +/// axum middleware: require any valid JWT, verified by auth-service. /// /// Wire with `from_fn_with_state(Arc::new(AuthClient::new(url)), require_auth)`. /// 401 if the header is missing/invalid or the token doesn't verify; 502 if /// auth-service is unreachable (fail closed — never let a request through when /// we can't confirm it). +/// +/// This authenticates but does NOT authorize. Since auth-service began issuing +/// tokens to non-admin roles, "valid token" no longer implies "operator" — +/// anything gating a privileged operation wants [`require_admin`]. pub async fn require_auth( State(auth): State>, mut req: Request, next: Next, ) -> Result { - let Some(token) = bearer(&req) else { + let claims = authenticate(&auth, bearer(&req)).await?; + debug!(user_id = %claims.user_id, role = %claims.role, "auth ok"); + req.extensions_mut().insert(claims); + Ok(next.run(req).await) +} + +/// axum middleware: require a valid JWT belonging to an **admin**. +/// +/// Same failure modes as [`require_auth`], plus 403 for an authenticated +/// non-admin. +pub async fn require_admin( + State(auth): State>, + mut req: Request, + next: Next, +) -> Result { + let claims = authenticate(&auth, bearer(&req)).await?; + if !claims.is_admin() { + warn!(user_id = %claims.user_id, role = %claims.role, "rejected: admin required"); + return Err(StatusCode::FORBIDDEN); + } + debug!(user_id = %claims.user_id, "admin auth ok"); + req.extensions_mut().insert(claims); + Ok(next.run(req).await) +} + +/// Shared verification for the middlewares above. +/// +/// Takes the already-extracted token rather than the request: holding a +/// `&Request` across the `.await` would make the future non-`Send` (its `Body` +/// is not `Sync`), and axum silently rejects such a middleware with an +/// unsatisfied `Service` bound at the call site. +async fn authenticate( + auth: &AuthClient, + token: Option, +) -> Result { + let Some(token) = token else { return Err(StatusCode::UNAUTHORIZED); }; match auth.verify(&token).await { - Ok(Some(claims)) => { - debug!(address = %claims.address, "auth ok"); - req.extensions_mut().insert(claims); - Ok(next.run(req).await) - } + Ok(Some(claims)) => Ok(claims), Ok(None) => Err(StatusCode::UNAUTHORIZED), Err(e) => { warn!(error = %e, "auth-service verify failed; rejecting"); diff --git a/rust-backend/services/auth-service/Cargo.toml b/rust-backend/services/auth-service/Cargo.toml index 167e34f7..e8c41478 100644 --- a/rust-backend/services/auth-service/Cargo.toml +++ b/rust-backend/services/auth-service/Cargo.toml @@ -40,6 +40,14 @@ metrics = { workspace = true } dashmap = { workspace = true } rand = { workspace = true } +# Identity store: users / identities / invites. Same diesel + r2d2 + +# embedded-migration shape as the indexer and cctp-relay `db` modules. +diesel = { workspace = true } +diesel_migrations = { workspace = true } +r2d2 = { workspace = true } +uuid = { workspace = true } +argon2 = { workspace = true } + # Sui personal-message digest + signature decoding, HS256 JWT signing. blake2 = { workspace = true } base64 = { workspace = true } diff --git a/rust-backend/services/auth-service/config/config.prod.toml b/rust-backend/services/auth-service/config/config.prod.toml index c67cc3c9..c3b97431 100644 --- a/rust-backend/services/auth-service/config/config.prod.toml +++ b/rust-backend/services/auth-service/config/config.prod.toml @@ -7,10 +7,29 @@ internal_bind_addr = "0.0.0.0:9008" allowed_origins = ["*"] -# Wallets allowed to obtain an admin JWT. Case/padding-insensitive. Replace -# with the real mainnet admin wallet address(es) before going live. +# !! PROVISION BEFORE THE NEXT PROD DEPLOY !! +# +# auth-service gained a hard Postgres dependency when it became a multi-method +# identity service: it will not boot without this database, and it is +# health-gated, so deploy.sh rolls back the WHOLE planned set if it fails. +# `auth_prod` does not create itself — the embedded migrations run on boot, the +# database and role do not. Create both on the prod RDS first: +# +# CREATE ROLE auth_prod LOGIN PASSWORD ''; +# CREATE DATABASE auth_prod OWNER auth_prod; +# +# The Dakota work this was built for is staging-only, but auth-service itself +# still ships to prod, so prod carries the dependency regardless. +database_url = "postgresql://auth_prod:${DB_PASSWORD}@${DB_HOST}:5432/auth_prod" +db_pool_size = 4 + +# Wallets auto-provisioned with the `admin` role on first login — the only +# account-creation path that skips an invite, so this is the root-of-trust +# list. Case/padding-insensitive. Replace with the real mainnet admin wallet +# address(es) before going live. admin_addresses = ["0xab8d1b5a5311c9400e3eaf5c3b641f10fb48b43cc30d365fa8a98a6ca6bd4865"] token_ttl_secs = 3600 refresh_max_secs = 86400 challenge_ttl_secs = 300 +invite_ttl_secs = 604800 diff --git a/rust-backend/services/auth-service/config/config.staging.toml b/rust-backend/services/auth-service/config/config.staging.toml index 761164de..b199a148 100644 --- a/rust-backend/services/auth-service/config/config.staging.toml +++ b/rust-backend/services/auth-service/config/config.staging.toml @@ -7,11 +7,18 @@ internal_bind_addr = "0.0.0.0:9008" allowed_origins = ["*"] -# Wallets allowed to obtain an admin JWT. Case/padding-insensitive. The seed -# entry is the protocol deployer (AdminCap holder) from deployments.json — -# replace / extend with the real admin wallet addresses. +# Identity store on the shared staging RDS. The role and database must exist +# before first boot — migrations run themselves, the database does not. +database_url = "postgresql://auth_staging:${DB_PASSWORD}@${DB_HOST}:5432/auth_staging" +db_pool_size = 4 + +# Wallets auto-provisioned with the `admin` role on first login. This is the +# ONLY path that creates an account without an invite — treat it as the +# root-of-trust list. Case/padding-insensitive. The seed entry is the protocol +# deployer (AdminCap holder) from deployments.json. admin_addresses = ["0xab8d1b5a5311c9400e3eaf5c3b641f10fb48b43cc30d365fa8a98a6ca6bd4865"] token_ttl_secs = 3600 refresh_max_secs = 86400 challenge_ttl_secs = 300 +invite_ttl_secs = 604800 diff --git a/rust-backend/services/auth-service/config/config.toml b/rust-backend/services/auth-service/config/config.toml index ed7f7028..4a841eae 100644 --- a/rust-backend/services/auth-service/config/config.toml +++ b/rust-backend/services/auth-service/config/config.toml @@ -1,22 +1,32 @@ # auth-service config (local dev). # -# Issues admin JWTs after a Sui signature challenge-response and exposes an -# internal verify route. The JWT secret comes from the secrets TOML -# (`--secrets`, default services/auth-service/config/secrets.toml); copy -# secrets.example.toml to secrets.toml and fill in `auth.jwt_secret`. +# Password or Sui-wallet login, linkable per account; issues JWTs carrying a +# role and scope, and exposes an internal verify + invite-minting route. The +# JWT secret comes from the secrets TOML (`--secrets`, default +# services/auth-service/config/secrets.toml); copy secrets.example.toml to +# secrets.toml and fill in `auth.jwt_secret`. +# +# Local Postgres is required — the service will not boot without it: +# createdb auth_dev environment = "dev" public_bind_addr = "127.0.0.1:9007" internal_bind_addr = "127.0.0.1:9008" -allowed_origins = ["http://localhost:5173", "http://127.0.0.1:5173"] +# 5173 is the protocol frontend; 5174 is where the Dakota dashboard's vite +# lands when 5173 is already taken. +allowed_origins = ["http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:5174", "http://127.0.0.1:5174"] + +database_url = "postgresql://postgres:postgres@127.0.0.1:5432/auth_dev" +db_pool_size = 4 -# Wallets allowed to obtain an admin JWT. Case/padding-insensitive. The seed -# entry is the protocol deployer (AdminCap holder) from deployments.json — -# replace / extend with the real admin wallet addresses. +# Wallets auto-provisioned with the `admin` role on first login — the only +# account-creation path that skips an invite. Case/padding-insensitive. The +# seed entry is the protocol deployer (AdminCap holder) from deployments.json. admin_addresses = ["0xab8d1b5a5311c9400e3eaf5c3b641f10fb48b43cc30d365fa8a98a6ca6bd4865"] token_ttl_secs = 3600 refresh_max_secs = 86400 challenge_ttl_secs = 300 +invite_ttl_secs = 604800 diff --git a/rust-backend/services/auth-service/src/config.rs b/rust-backend/services/auth-service/src/config.rs index 426f6758..6095ccd1 100644 --- a/rust-backend/services/auth-service/src/config.rs +++ b/rust-backend/services/auth-service/src/config.rs @@ -22,12 +22,26 @@ pub struct Config { #[serde(default = "default_cors")] pub allowed_origins: Vec, - /// Admin allowlist: Sui addresses permitted to obtain a JWT. `0x`-prefixed; - /// case/padding-insensitive (normalized before comparison). Membership is - /// the only authorization check. + /// Postgres connection string for the identity store, assembled from + /// `${DB_HOST}` / `${DB_PASSWORD}` at load time. + pub database_url: String, + #[serde(default = "default_db_pool_size")] + pub db_pool_size: u32, + + /// Admin bootstrap: Sui addresses auto-provisioned with the `admin` role on + /// first wallet login. `0x`-prefixed; case/padding-insensitive (normalized + /// before comparison). + /// + /// This is the only account-creation path that does not require an invite, + /// and it grants `admin` — treat it as the root-of-trust list. #[serde(default)] pub admin_addresses: Vec, + /// Default lifetime of a minted invite, seconds. Default 7 days — long + /// enough to send a link and have a human act on it. + #[serde(default = "default_invite_ttl")] + pub invite_ttl_secs: i64, + /// Issued-JWT lifetime, seconds. Default 1h. #[serde(default = "default_token_ttl")] pub token_ttl_secs: u64, @@ -55,6 +69,12 @@ fn default_refresh_max() -> u64 { fn default_challenge_ttl() -> u64 { 300 } +fn default_db_pool_size() -> u32 { + 4 +} +fn default_invite_ttl() -> i64 { + 7 * 86_400 +} impl Config { pub fn load>(path: P) -> Result { diff --git a/rust-backend/services/auth-service/src/db/migrations/000001_init/down.sql b/rust-backend/services/auth-service/src/db/migrations/000001_init/down.sql new file mode 100644 index 00000000..cdfae411 --- /dev/null +++ b/rust-backend/services/auth-service/src/db/migrations/000001_init/down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS invites; +DROP TABLE IF EXISTS identities; +DROP TABLE IF EXISTS users; diff --git a/rust-backend/services/auth-service/src/db/migrations/000001_init/up.sql b/rust-backend/services/auth-service/src/db/migrations/000001_init/up.sql new file mode 100644 index 00000000..4a8c8a6a --- /dev/null +++ b/rust-backend/services/auth-service/src/db/migrations/000001_init/up.sql @@ -0,0 +1,62 @@ +-- auth-service identity store. +-- +-- Deliberately holds NO personally identifying information. `identities. +-- identifier` is either a username (an opaque handle the user picks) or a Sui +-- address — never an email, legal name, or anything sourced from KYC. Any +-- future method must keep that property. + +CREATE TABLE users ( + id UUID PRIMARY KEY, + -- 'admin' | 'business' | 'individual'. Authorization is role + scope_id; + -- there is nothing finer-grained. + role TEXT NOT NULL, + -- Opaque to this service. dakota-service reads it as a Dakota customer + -- KSUID; auth-service never interprets it. NULL for admins, who are + -- unscoped. + scope_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + disabled_at TIMESTAMPTZ +); + +CREATE INDEX users_scope_id_idx ON users (scope_id) WHERE scope_id IS NOT NULL; + +-- One row per login method. A user may hold several, which is how "set a +-- password on my wallet account" and "add a wallet to my password account" +-- both work: they insert a second row against the same user_id. +CREATE TABLE identities ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + -- 'password' | 'sui_wallet'. The extension point: a future 'passkey' or + -- 'oauth:google' is a new value here and nothing else. + kind TEXT NOT NULL, + -- Username for 'password', normalized 0x-address for 'sui_wallet'. + identifier TEXT NOT NULL, + -- Argon2id PHC string for 'password'; NULL for signature-proved methods. + secret_hash TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_at TIMESTAMPTZ, + + -- One account per (method, identifier): a username or wallet cannot be + -- claimed twice. + UNIQUE (kind, identifier) +); + +CREATE INDEX identities_user_id_idx ON identities (user_id); + +-- Signup grants. dakota-service mints one when an admin creates a partner +-- business, or when a business invites one of its own customers; the invitee +-- redeems it at POST /register. Single-use and time-boxed. +CREATE TABLE invites ( + id UUID PRIMARY KEY, + role TEXT NOT NULL, + scope_id TEXT, + -- NULL when minted by an internal service rather than a logged-in user. + created_by UUID REFERENCES users (id) ON DELETE SET NULL, + label TEXT, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + consumed_by UUID REFERENCES users (id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX invites_open_idx ON invites (expires_at) WHERE consumed_at IS NULL; diff --git a/rust-backend/services/auth-service/src/db/mod.rs b/rust-backend/services/auth-service/src/db/mod.rs new file mode 100644 index 00000000..57069684 --- /dev/null +++ b/rust-backend/services/auth-service/src/db/mod.rs @@ -0,0 +1,33 @@ +//! Postgres persistence for the identity store. Same diesel + r2d2 + +//! embedded-migration shape as the indexer / cctp-relay `db` modules. + +pub mod models; +pub mod repo; +pub mod schema; + +#[cfg(test)] +mod tests; + +use anyhow::{Context, Result}; +use diesel::pg::PgConnection; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; + +pub type DbPool = Pool>; + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("src/db/migrations"); + +pub fn establish_pool(database_url: &str, max_size: u32) -> Result { + let manager = ConnectionManager::::new(database_url); + Pool::builder() + .max_size(max_size) + .build(manager) + .context("building r2d2 pool for the auth-service DB") +} + +pub fn run_migrations(pool: &DbPool) -> Result<()> { + let mut conn = pool.get().context("checking out connection for migrations")?; + conn.run_pending_migrations(MIGRATIONS) + .map_err(|e| anyhow::anyhow!("running migrations: {e}"))?; + Ok(()) +} diff --git a/rust-backend/services/auth-service/src/db/models.rs b/rust-backend/services/auth-service/src/db/models.rs new file mode 100644 index 00000000..0605203c --- /dev/null +++ b/rust-backend/services/auth-service/src/db/models.rs @@ -0,0 +1,149 @@ +//! Row structs for the identity store. + +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::Serialize; +use uuid::Uuid; + +use super::schema::{identities, invites, users}; + +// ---------------------------------------------------------------------- role + +/// Authorization role. Kept as a plain string in the database — this enum is +/// only the parse boundary, so an unknown value from a future version is a +/// clean error rather than a panic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Role { + /// Unscoped. Full control plane. + Admin, + /// Scoped to a Dakota sub-client; may act for customers beneath it. + Business, + /// Scoped to a single Dakota customer. + Individual, +} + +impl Role { + pub fn as_str(self) -> &'static str { + match self { + Role::Admin => "admin", + Role::Business => "business", + Role::Individual => "individual", + } + } + + pub fn parse(s: &str) -> anyhow::Result { + Ok(match s { + "admin" => Role::Admin, + "business" => Role::Business, + "individual" => Role::Individual, + other => anyhow::bail!("unknown role {other:?}"), + }) + } + + /// Whether this role requires a `scope_id`. Admins are unscoped; everyone + /// else is meaningless without something to be scoped to. + pub fn requires_scope(self) -> bool { + !matches!(self, Role::Admin) + } +} + +// ------------------------------------------------------------------ identity + +/// Login method discriminator. New methods are added here and in +/// [`IdentityKind::parse`]; nothing else in the service needs to change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentityKind { + /// Username + Argon2id password. + Password, + /// Sui address proved by a personal-message signature. + SuiWallet, +} + +impl IdentityKind { + pub fn as_str(self) -> &'static str { + match self { + IdentityKind::Password => "password", + IdentityKind::SuiWallet => "sui_wallet", + } + } + + pub fn parse(s: &str) -> anyhow::Result { + Ok(match s { + "password" => IdentityKind::Password, + "sui_wallet" => IdentityKind::SuiWallet, + other => anyhow::bail!("unknown identity kind {other:?}"), + }) + } +} + +// ------------------------------------------------------------------- queries + +#[derive(Debug, Clone, Queryable, Selectable)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct User { + pub id: Uuid, + pub role: String, + pub scope_id: Option, + pub created_at: DateTime, + pub disabled_at: Option>, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = users)] +pub struct NewUser { + pub id: Uuid, + pub role: String, + pub scope_id: Option, +} + +#[derive(Debug, Clone, Queryable, Selectable)] +#[diesel(table_name = identities)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Identity { + pub id: Uuid, + pub user_id: Uuid, + pub kind: String, + pub identifier: String, + pub secret_hash: Option, + pub created_at: DateTime, + pub last_used_at: Option>, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = identities)] +pub struct NewIdentity { + pub id: Uuid, + pub user_id: Uuid, + pub kind: String, + pub identifier: String, + pub secret_hash: Option, +} + +#[derive(Debug, Clone, Queryable, Selectable)] +#[diesel(table_name = invites)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Invite { + pub id: Uuid, + pub role: String, + pub scope_id: Option, + pub created_by: Option, + pub label: Option, + pub expires_at: DateTime, + pub consumed_at: Option>, + pub consumed_by: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = invites)] +pub struct NewInvite { + pub id: Uuid, + pub role: String, + pub scope_id: Option, + pub created_by: Option, + pub label: Option, + pub expires_at: DateTime, +} diff --git a/rust-backend/services/auth-service/src/db/repo.rs b/rust-backend/services/auth-service/src/db/repo.rs new file mode 100644 index 00000000..1e3bad55 --- /dev/null +++ b/rust-backend/services/auth-service/src/db/repo.rs @@ -0,0 +1,299 @@ +//! Identity-store queries. +//! +//! Everything that mutates more than one row runs inside a transaction — +//! registration in particular must consume the invite and create the user in +//! one atomic step, or a crash mid-way would burn an invite with no account to +//! show for it. + +use std::sync::Arc; + +use anyhow::{bail, Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use diesel::prelude::*; +use uuid::Uuid; + +use super::models::{Identity, IdentityKind, Invite, NewIdentity, NewInvite, NewUser, Role, User}; +use super::schema::{identities, invites, users}; +use super::DbPool; + +#[derive(Clone)] +pub struct Repo { + pool: Arc, +} + +/// An identity paired with the account it belongs to — what every login path +/// actually needs. +pub struct ResolvedIdentity { + pub identity: Identity, + pub user: User, +} + +impl Repo { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + fn conn(&self) -> Result>> { + self.pool.get().context("checking out a db connection") + } + + // ------------------------------------------------------------- lookups + + /// Resolve a login method to its account. Returns `None` when the + /// identifier is unknown; callers must not distinguish that from a bad + /// password in what they return to the client. + pub fn find_identity( + &self, + kind: IdentityKind, + identifier: &str, + ) -> Result> { + let mut conn = self.conn()?; + let found = identities::table + .inner_join(users::table) + .filter(identities::kind.eq(kind.as_str())) + .filter(identities::identifier.eq(identifier)) + .select((Identity::as_select(), User::as_select())) + .first::<(Identity, User)>(&mut conn) + .optional() + .context("looking up identity")?; + Ok(found.map(|(identity, user)| ResolvedIdentity { identity, user })) + } + + pub fn get_user(&self, user_id: Uuid) -> Result> { + let mut conn = self.conn()?; + users::table + .find(user_id) + .select(User::as_select()) + .first(&mut conn) + .optional() + .context("loading user") + } + + pub fn list_identities(&self, user_id: Uuid) -> Result> { + let mut conn = self.conn()?; + identities::table + .filter(identities::user_id.eq(user_id)) + .order(identities::created_at.asc()) + .select(Identity::as_select()) + .load(&mut conn) + .context("listing identities") + } + + pub fn touch_identity(&self, identity_id: Uuid) -> Result<()> { + let mut conn = self.conn()?; + diesel::update(identities::table.find(identity_id)) + .set(identities::last_used_at.eq(Utc::now())) + .execute(&mut conn) + .context("stamping identity last_used_at")?; + Ok(()) + } + + // -------------------------------------------------------- registration + + /// Redeem an invite into a brand-new account with its first identity. + /// + /// Atomic: the invite is claimed with a conditional UPDATE, so two + /// simultaneous redemptions of the same link cannot both win. + pub fn register_with_invite( + &self, + invite_id: Uuid, + kind: IdentityKind, + identifier: &str, + secret_hash: Option, + ) -> Result { + let mut conn = self.conn()?; + conn.transaction(|conn| { + let invite: Invite = invites::table + .find(invite_id) + .select(Invite::as_select()) + .first(conn) + .optional() + .context("loading invite")? + .ok_or_else(|| anyhow::anyhow!("unknown invite"))?; + + if invite.consumed_at.is_some() { + bail!("invite already used"); + } + if invite.expires_at <= Utc::now() { + bail!("invite expired"); + } + + let user_id = Uuid::new_v4(); + diesel::insert_into(users::table) + .values(NewUser { + id: user_id, + role: invite.role.clone(), + scope_id: invite.scope_id.clone(), + }) + .execute(conn) + .context("inserting user")?; + + diesel::insert_into(identities::table) + .values(NewIdentity { + id: Uuid::new_v4(), + user_id, + kind: kind.as_str().to_string(), + identifier: identifier.to_string(), + secret_hash, + }) + .execute(conn) + .context("inserting first identity")?; + + // Conditional claim: `consumed_at IS NULL` in the predicate means a + // concurrent redemption updates 0 rows and loses. + let claimed = diesel::update( + invites::table + .find(invite_id) + .filter(invites::consumed_at.is_null()), + ) + .set(( + invites::consumed_at.eq(Utc::now()), + invites::consumed_by.eq(user_id), + )) + .execute(conn) + .context("claiming invite")?; + if claimed != 1 { + bail!("invite already used"); + } + + users::table + .find(user_id) + .select(User::as_select()) + .first(conn) + .context("reloading new user") + }) + } + + /// Create an account directly, bypassing invites. Used only to bootstrap an + /// allowlisted admin wallet on first login — there is no other unsolicited + /// account-creation path. + pub fn create_user_with_identity( + &self, + role: Role, + scope_id: Option, + kind: IdentityKind, + identifier: &str, + secret_hash: Option, + ) -> Result { + let mut conn = self.conn()?; + conn.transaction(|conn| { + let user_id = Uuid::new_v4(); + diesel::insert_into(users::table) + .values(NewUser { + id: user_id, + role: role.as_str().to_string(), + scope_id, + }) + .execute(conn) + .context("inserting user")?; + diesel::insert_into(identities::table) + .values(NewIdentity { + id: Uuid::new_v4(), + user_id, + kind: kind.as_str().to_string(), + identifier: identifier.to_string(), + secret_hash, + }) + .execute(conn) + .context("inserting identity")?; + users::table + .find(user_id) + .select(User::as_select()) + .first(conn) + .context("reloading new user") + }) + } + + // ---------------------------------------------------- identity linking + + /// Attach an additional login method to an existing account. + pub fn add_identity( + &self, + user_id: Uuid, + kind: IdentityKind, + identifier: &str, + secret_hash: Option, + ) -> Result { + let mut conn = self.conn()?; + diesel::insert_into(identities::table) + .values(NewIdentity { + id: Uuid::new_v4(), + user_id, + kind: kind.as_str().to_string(), + identifier: identifier.to_string(), + secret_hash, + }) + .returning(Identity::as_returning()) + .get_result(&mut conn) + .context("adding identity") + } + + /// Remove a login method. Refuses to remove the last one — an account with + /// no identities is unreachable forever, with no recovery path since we + /// store no email. + pub fn remove_identity(&self, user_id: Uuid, identity_id: Uuid) -> Result<()> { + let mut conn = self.conn()?; + conn.transaction(|conn| { + let remaining: i64 = identities::table + .filter(identities::user_id.eq(user_id)) + .count() + .get_result(conn) + .context("counting identities")?; + if remaining <= 1 { + bail!("cannot remove the only login method on this account"); + } + let deleted = diesel::delete( + identities::table + .find(identity_id) + .filter(identities::user_id.eq(user_id)), + ) + .execute(conn) + .context("deleting identity")?; + if deleted == 0 { + bail!("identity not found on this account"); + } + Ok(()) + }) + } + + // ------------------------------------------------------------- invites + + pub fn create_invite( + &self, + role: Role, + scope_id: Option, + created_by: Option, + label: Option, + ttl_secs: i64, + ) -> Result { + if role.requires_scope() && scope_id.is_none() { + bail!("role {} requires a scope_id", role.as_str()); + } + let mut conn = self.conn()?; + let expires_at: DateTime = Utc::now() + Duration::seconds(ttl_secs); + diesel::insert_into(invites::table) + .values(NewInvite { + id: Uuid::new_v4(), + role: role.as_str().to_string(), + scope_id, + created_by, + label, + expires_at, + }) + .returning(Invite::as_returning()) + .get_result(&mut conn) + .context("creating invite") + } + + /// Read an invite without consuming it, so the signup page can show what it + /// is for before the user commits. + pub fn peek_invite(&self, invite_id: Uuid) -> Result> { + let mut conn = self.conn()?; + invites::table + .find(invite_id) + .select(Invite::as_select()) + .first(&mut conn) + .optional() + .context("peeking invite") + } +} diff --git a/rust-backend/services/auth-service/src/db/schema.rs b/rust-backend/services/auth-service/src/db/schema.rs new file mode 100644 index 00000000..48799d2f --- /dev/null +++ b/rust-backend/services/auth-service/src/db/schema.rs @@ -0,0 +1,40 @@ +//! Diesel table definitions. Hand-written to match `migrations/000001_init`. + +diesel::table! { + users (id) { + id -> Uuid, + role -> Text, + scope_id -> Nullable, + created_at -> Timestamptz, + disabled_at -> Nullable, + } +} + +diesel::table! { + identities (id) { + id -> Uuid, + user_id -> Uuid, + kind -> Text, + identifier -> Text, + secret_hash -> Nullable, + created_at -> Timestamptz, + last_used_at -> Nullable, + } +} + +diesel::table! { + invites (id) { + id -> Uuid, + role -> Text, + scope_id -> Nullable, + created_by -> Nullable, + label -> Nullable, + expires_at -> Timestamptz, + consumed_at -> Nullable, + consumed_by -> Nullable, + created_at -> Timestamptz, + } +} + +diesel::joinable!(identities -> users (user_id)); +diesel::allow_tables_to_appear_in_same_query!(users, identities, invites); diff --git a/rust-backend/services/auth-service/src/db/tests.rs b/rust-backend/services/auth-service/src/db/tests.rs new file mode 100644 index 00000000..0d12aea1 --- /dev/null +++ b/rust-backend/services/auth-service/src/db/tests.rs @@ -0,0 +1,268 @@ +//! Identity-store behaviour tests. +//! +//! These need a real Postgres — set `AUTH_TEST_DATABASE_URL` and run with +//! `cargo test -p auth-service -- --ignored`. Same convention as +//! option-scheduler's `SCHEDULER_TEST_DATABASE_URL`. + +use std::sync::Arc; + +use chrono::{Duration, Utc}; +use diesel::prelude::*; +use uuid::Uuid; + +use super::models::{IdentityKind, Role}; +use super::repo::Repo; +use super::{establish_pool, run_migrations, DbPool}; + +fn test_pool() -> DbPool { + let url = std::env::var("AUTH_TEST_DATABASE_URL") + .expect("set AUTH_TEST_DATABASE_URL to run DB tests"); + let pool = establish_pool(&url, 2).expect("pool"); + run_migrations(&pool).expect("migrations"); + let mut conn = pool.get().unwrap(); + // `users` cascades into `identities`; `invites` references users, so it + // has to go in the same statement. + diesel::sql_query("TRUNCATE users, identities, invites RESTART IDENTITY CASCADE") + .execute(&mut conn) + .expect("truncate"); + pool +} + +fn repo() -> Repo { + Repo::new(Arc::new(test_pool())) +} + +/// Mint an open invite for `role`, scoped when the role demands it. +fn open_invite(repo: &Repo, role: Role) -> Uuid { + let scope = role.requires_scope().then(|| "cus_probe".to_string()); + repo.create_invite(role, scope, None, Some("test".into()), 3600) + .expect("create invite") + .id +} + +// ------------------------------------------------------------------ linking + +#[test] +#[ignore] // requires AUTH_TEST_DATABASE_URL +fn wallet_account_can_add_a_password_and_both_resolve_to_one_user() { + let repo = repo(); + let user = repo + .create_user_with_identity(Role::Admin, None, IdentityKind::SuiWallet, "0xabc", None) + .unwrap(); + + repo.add_identity( + user.id, + IdentityKind::Password, + "evan", + Some(crate::password::hash("correct horse battery").unwrap()), + ) + .unwrap(); + + // Both doors open onto the same account — the whole point of the model. + let via_wallet = repo + .find_identity(IdentityKind::SuiWallet, "0xabc") + .unwrap() + .expect("wallet identity"); + let via_password = repo + .find_identity(IdentityKind::Password, "evan") + .unwrap() + .expect("password identity"); + assert_eq!(via_wallet.user.id, user.id); + assert_eq!(via_password.user.id, user.id); + assert_eq!(repo.list_identities(user.id).unwrap().len(), 2); +} + +#[test] +#[ignore] +fn password_account_can_add_a_wallet() { + let repo = repo(); + let user = repo + .create_user_with_identity( + Role::Individual, + Some("cus_1".into()), + IdentityKind::Password, + "jane", + Some(crate::password::hash("correct horse battery").unwrap()), + ) + .unwrap(); + + repo.add_identity(user.id, IdentityKind::SuiWallet, "0xdef", None) + .unwrap(); + + let via_wallet = repo + .find_identity(IdentityKind::SuiWallet, "0xdef") + .unwrap() + .expect("wallet identity"); + assert_eq!(via_wallet.user.id, user.id); + // Scope rides on the account, so it applies however you signed in. + assert_eq!(via_wallet.user.scope_id.as_deref(), Some("cus_1")); +} + +#[test] +#[ignore] +fn an_identifier_cannot_be_claimed_twice() { + let repo = repo(); + let a = repo + .create_user_with_identity(Role::Admin, None, IdentityKind::SuiWallet, "0xabc", None) + .unwrap(); + let b = repo + .create_user_with_identity(Role::Admin, None, IdentityKind::SuiWallet, "0xbbb", None) + .unwrap(); + assert_ne!(a.id, b.id); + + // b tries to graft a's wallet onto itself — the UNIQUE index is what stops + // an account takeover here. + assert!(repo + .add_identity(b.id, IdentityKind::SuiWallet, "0xabc", None) + .is_err()); +} + +#[test] +#[ignore] +fn the_last_identity_cannot_be_removed() { + let repo = repo(); + let user = repo + .create_user_with_identity(Role::Admin, None, IdentityKind::SuiWallet, "0xabc", None) + .unwrap(); + let only = repo.list_identities(user.id).unwrap().remove(0); + + // With no email on file there is no recovery path, so this must not be + // allowed to succeed. + assert!(repo.remove_identity(user.id, only.id).is_err()); + + let second = repo + .add_identity( + user.id, + IdentityKind::Password, + "evan", + Some(crate::password::hash("correct horse battery").unwrap()), + ) + .unwrap(); + repo.remove_identity(user.id, second.id).expect("second is removable"); + assert_eq!(repo.list_identities(user.id).unwrap().len(), 1); +} + +#[test] +#[ignore] +fn cannot_remove_an_identity_belonging_to_someone_else() { + let repo = repo(); + let victim = repo + .create_user_with_identity(Role::Admin, None, IdentityKind::SuiWallet, "0xabc", None) + .unwrap(); + repo.add_identity(victim.id, IdentityKind::Password, "victim", Some("x".into())) + .unwrap(); + let attacker = repo + .create_user_with_identity(Role::Admin, None, IdentityKind::SuiWallet, "0xbbb", None) + .unwrap(); + repo.add_identity(attacker.id, IdentityKind::Password, "attacker", Some("x".into())) + .unwrap(); + + let victim_identity = repo.list_identities(victim.id).unwrap().remove(0); + assert!(repo.remove_identity(attacker.id, victim_identity.id).is_err()); + assert_eq!(repo.list_identities(victim.id).unwrap().len(), 2); +} + +// ------------------------------------------------------------------ invites + +#[test] +#[ignore] +fn invite_carries_role_and_scope_onto_the_new_account() { + let repo = repo(); + let invite = open_invite(&repo, Role::Business); + let user = repo + .register_with_invite(invite, IdentityKind::Password, "acme", Some("hash".into())) + .unwrap(); + + // Authority comes from the invite, never from the registration body. + assert_eq!(user.role, "business"); + assert_eq!(user.scope_id.as_deref(), Some("cus_probe")); +} + +#[test] +#[ignore] +fn an_invite_is_single_use() { + let repo = repo(); + let invite = open_invite(&repo, Role::Individual); + repo.register_with_invite(invite, IdentityKind::Password, "first", Some("hash".into())) + .expect("first redemption wins"); + + let second = + repo.register_with_invite(invite, IdentityKind::Password, "second", Some("hash".into())); + assert!(second.is_err(), "a spent invite must not mint a second account"); +} + +#[test] +#[ignore] +fn an_expired_invite_is_refused() { + let repo = repo(); + let invite = repo + .create_invite(Role::Individual, Some("cus_1".into()), None, None, -60) + .unwrap(); + assert!(repo + .register_with_invite(invite.id, IdentityKind::Password, "late", Some("hash".into())) + .is_err()); +} + +#[test] +#[ignore] +fn a_failed_registration_leaves_the_invite_open() { + let repo = repo(); + repo.create_user_with_identity( + Role::Individual, + Some("cus_1".into()), + IdentityKind::Password, + "taken", + Some("hash".into()), + ) + .unwrap(); + + let invite = open_invite(&repo, Role::Individual); + // Username collision aborts the transaction mid-way. + assert!(repo + .register_with_invite(invite, IdentityKind::Password, "taken", Some("hash".into())) + .is_err()); + + // The invite must roll back with it, or a typo would burn the link. + assert!(repo.peek_invite(invite).unwrap().unwrap().consumed_at.is_none()); + repo.register_with_invite(invite, IdentityKind::Password, "free", Some("hash".into())) + .expect("invite still redeemable"); +} + +#[test] +#[ignore] +fn a_scoped_role_requires_a_scope() { + let repo = repo(); + // A business invite with nothing to be scoped to would mint an account + // that can see everything or nothing, depending on the caller's care. + assert!(repo.create_invite(Role::Business, None, None, None, 3600).is_err()); + assert!(repo.create_invite(Role::Individual, None, None, None, 3600).is_err()); + // Admins are unscoped by design. + assert!(repo.create_invite(Role::Admin, None, None, None, 3600).is_ok()); +} + +#[test] +#[ignore] +fn peek_reports_expiry_without_consuming() { + let repo = repo(); + let invite = repo + .create_invite(Role::Individual, Some("cus_1".into()), None, None, 3600) + .unwrap(); + let seen = repo.peek_invite(invite.id).unwrap().expect("invite"); + assert!(seen.consumed_at.is_none()); + assert!(seen.expires_at > Utc::now() + Duration::seconds(3000)); + // Still redeemable after peeking. + assert!(repo + .register_with_invite(invite.id, IdentityKind::Password, "peeked", Some("h".into())) + .is_ok()); +} + +#[test] +#[ignore] +fn unknown_identifier_resolves_to_none() { + let repo = repo(); + assert!(repo + .find_identity(IdentityKind::Password, "nobody") + .unwrap() + .is_none()); + assert!(repo.peek_invite(Uuid::new_v4()).unwrap().is_none()); +} diff --git a/rust-backend/services/auth-service/src/handlers.rs b/rust-backend/services/auth-service/src/handlers.rs deleted file mode 100644 index 2a4ce742..00000000 --- a/rust-backend/services/auth-service/src/handlers.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! HTTP handlers. -//! -//! Public: [`challenge`], [`login`], [`refresh`]. -//! Internal: [`verify`] (delegated to by `auth-client`). - -use std::net::SocketAddr; -use std::sync::Arc; - -use axum::extract::{ConnectInfo, Json, State}; -use axum::http::{HeaderMap, StatusCode}; -use serde::{Deserialize, Serialize}; -use tracing::{info, warn}; - -use crate::allowlist; -use crate::jwt::{self, Claims}; -use crate::state::AppState; -use crate::sui_sig; - -type ApiError = (StatusCode, String); - -pub async fn health() -> &'static str { - "ok" -} - -// ----------------------------------------------------------------- challenge - -#[derive(Serialize)] -pub struct ChallengeResp { - /// Exact message the wallet must sign (UTF-8). - pub message: String, -} - -/// `GET /challenge` — mint a single-use message to sign. -pub async fn challenge(State(state): State>) -> Json { - metrics::counter!("auth_challenges_issued_total").increment(1); - Json(ChallengeResp { - message: state.challenges.issue(), - }) -} - -// --------------------------------------------------------------------- login - -#[derive(Deserialize)] -pub struct LoginReq { - /// Base64 serialized Sui signature (`signPersonalMessage().signature`). - pub signature: String, - /// Base64 of the signed message (`signPersonalMessage().bytes`). - pub bytes: String, -} - -#[derive(Serialize)] -pub struct TokenResp { - pub token: String, - pub address: String, - /// Seconds until the token expires. - pub expires_in: u64, -} - -/// `POST /login` — verify a signed challenge against the allowlist, issue a JWT -/// bound to the caller's IP. -pub async fn login( - State(state): State>, - ConnectInfo(peer): ConnectInfo, - headers: HeaderMap, - Json(req): Json, -) -> Result, ApiError> { - let res = login_inner(state, peer, headers, req).await; - let outcome = match &res { - Ok(_) => "ok", - Err((code, _)) if code.is_server_error() => "error", - Err(_) => "rejected", - }; - metrics::counter!("auth_logins_total", "outcome" => outcome).increment(1); - res -} - -async fn login_inner( - state: Arc, - peer: SocketAddr, - headers: HeaderMap, - req: LoginReq, -) -> Result, ApiError> { - use base64::Engine; - - let message = base64::engine::general_purpose::STANDARD - .decode(req.bytes.trim()) - .map_err(|_| (StatusCode::BAD_REQUEST, "bytes is not base64".into()))?; - let message_str = String::from_utf8(message.clone()) - .map_err(|_| (StatusCode::BAD_REQUEST, "message is not utf-8".into()))?; - - // Single-use challenge: must match one we issued and not yet consumed. - if !state.challenges.consume(&message_str) { - return Err((StatusCode::BAD_REQUEST, "unknown or expired challenge".into())); - } - - let address = sui_sig::recover_and_verify(&req.signature, &message) - .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?; - - if !allowlist::is_allowed(&state.admin_addresses, &address) { - warn!(%address, "login rejected: not on allowlist"); - return Err((StatusCode::FORBIDDEN, "address not on admin allowlist".into())); - } - - let now = jwt::now_secs(); - let claims = Claims { - sub: allowlist::normalize(&address), - ip: client_ip(&headers, peer), - iat: now, - exp: now + state.token_ttl_secs, - }; - let token = jwt::sign(&claims, &state.jwt_secret) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - info!(address = %claims.sub, "admin login"); - Ok(Json(TokenResp { - token, - address: claims.sub, - expires_in: state.token_ttl_secs, - })) -} - -// ------------------------------------------------------------------- refresh - -/// `POST /refresh` — issue a fresh token from a still-in-window one, provided -/// the request comes from the same IP. No re-signing required. -pub async fn refresh( - State(state): State>, - ConnectInfo(peer): ConnectInfo, - headers: HeaderMap, -) -> Result, ApiError> { - let token = bearer(&headers) - .ok_or((StatusCode::UNAUTHORIZED, "missing bearer token".into()))?; - - // Accept an expired-but-otherwise-valid token (exp not enforced here); the - // refresh window is bounded by the original iat below. - let claims = jwt::verify(&token, &state.jwt_secret, false) - .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?; - - let now = jwt::now_secs(); - if now >= claims.iat + state.refresh_max_secs { - return Err((StatusCode::UNAUTHORIZED, "refresh window elapsed; sign in again".into())); - } - if client_ip(&headers, peer) != claims.ip { - return Err((StatusCode::UNAUTHORIZED, "refresh must come from the same IP".into())); - } - - // Preserve the original iat so the total session stays bounded by - // refresh_max_secs; only the expiry slides forward. - let next = Claims { - sub: claims.sub.clone(), - ip: claims.ip, - iat: claims.iat, - exp: now + state.token_ttl_secs, - }; - let token = jwt::sign(&next, &state.jwt_secret) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(TokenResp { - token, - address: next.sub, - expires_in: state.token_ttl_secs, - })) -} - -// -------------------------------------------------------------------- verify - -#[derive(Deserialize)] -pub struct VerifyReq { - pub token: String, -} - -#[derive(Serialize)] -pub struct VerifyResp { - pub valid: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub address: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub exp: Option, -} - -/// `POST /verify` (internal) — the yes/no answer other services delegate to. -pub async fn verify( - State(state): State>, - Json(req): Json, -) -> Json { - match jwt::verify(&req.token, &state.jwt_secret, true) { - Ok(claims) => { - metrics::counter!("auth_verifies_total", "outcome" => "ok").increment(1); - Json(VerifyResp { - valid: true, - address: Some(claims.sub), - exp: Some(claims.exp), - }) - } - Err(_) => { - metrics::counter!("auth_verifies_total", "outcome" => "invalid").increment(1); - Json(VerifyResp { valid: false, address: None, exp: None }) - } - } -} - -// --------------------------------------------------------------------- utils - -fn bearer(headers: &HeaderMap) -> Option { - let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?; - raw.strip_prefix("Bearer ") - .or_else(|| raw.strip_prefix("bearer ")) - .map(|s| s.trim().to_string()) -} - -/// Client IP, preferring the left-most `X-Forwarded-For` entry (set by nginx) -/// and falling back to the direct peer for local/dev calls. -fn client_ip(headers: &HeaderMap, peer: SocketAddr) -> String { - headers - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.split(',').next()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| peer.ip().to_string()) -} diff --git a/rust-backend/services/auth-service/src/handlers/account.rs b/rust-backend/services/auth-service/src/handlers/account.rs new file mode 100644 index 00000000..50aebbce --- /dev/null +++ b/rust-backend/services/auth-service/src/handlers/account.rs @@ -0,0 +1,241 @@ +//! Account lifecycle: redeeming an invite, whoami, and linking login methods. + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::extract::{ConnectInfo, Json, Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use serde::{Deserialize, Serialize}; +use tracing::info; +use uuid::Uuid; + +use super::session::verify_challenge_signature; +use super::{bad_request, client_ip, internal, issue_token, load_user, ApiError, TokenResp}; +use crate::db::models::IdentityKind; +use crate::password; +use crate::state::AppState; + +// -------------------------------------------------------------- invite peek + +#[derive(Deserialize)] +pub struct InviteQuery { + pub invite: Uuid, +} + +#[derive(Serialize)] +pub struct InvitePreview { + pub role: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + pub valid: bool, + pub reason: Option, +} + +/// `GET /invites/preview?invite=` — what an invite link is for, before +/// the visitor commits to it. Deliberately leaks nothing but the role and the +/// label the minter chose; the scope id stays server-side. +pub async fn preview_invite( + State(state): State>, + Query(q): Query, +) -> Result, ApiError> { + let invite = state + .repo + .peek_invite(q.invite) + .map_err(internal)? + .ok_or((StatusCode::NOT_FOUND, "unknown invite".to_string()))?; + + let reason = if invite.consumed_at.is_some() { + Some("already used".to_string()) + } else if invite.expires_at <= chrono::Utc::now() { + Some("expired".to_string()) + } else { + None + }; + + Ok(Json(InvitePreview { + role: invite.role, + label: invite.label, + valid: reason.is_none(), + reason, + })) +} + +// ------------------------------------------------------------------ register + +/// How the new account will log in. Untagged so the body reads naturally: +/// either `{username, password}` or `{signature, bytes}`. +#[derive(Deserialize)] +#[serde(untagged)] +pub enum RegisterMethod { + Password { username: String, password: String }, + SuiWallet { signature: String, bytes: String }, +} + +#[derive(Deserialize)] +pub struct RegisterReq { + pub invite: Uuid, + #[serde(flatten)] + pub method: RegisterMethod, +} + +/// `POST /register` — redeem an invite into a new account and open its session. +/// +/// The invite carries the role and scope; nothing about them is taken from the +/// request body, so a redeemer cannot promote themselves. +pub async fn register( + State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result, ApiError> { + let (kind, identifier, secret_hash, address) = match &req.method { + RegisterMethod::Password { username, password } => { + let username = password::normalize_username(username); + password::validate_username(&username).map_err(bad_request)?; + password::validate_password(password).map_err(bad_request)?; + let hash = password::hash(password).map_err(internal)?; + (IdentityKind::Password, username, Some(hash), None) + } + RegisterMethod::SuiWallet { signature, bytes } => { + let address = verify_challenge_signature(&state, signature, bytes)?; + (IdentityKind::SuiWallet, address.clone(), None, Some(address)) + } + }; + + let user = state + .repo + .register_with_invite(req.invite, kind, &identifier, secret_hash) + .map_err(|e| { + // Every failure here is the caller's: a spent, expired or unknown + // invite, or an identifier someone already claimed. + (StatusCode::BAD_REQUEST, e.to_string()) + })?; + + metrics::counter!("auth_registrations_total", "method" => kind.as_str()).increment(1); + info!(user_id = %user.id, role = %user.role, method = kind.as_str(), "account registered"); + Ok(Json(issue_token(&state, &user, address, client_ip(&headers, peer))?)) +} + +// --------------------------------------------------------------------- me + +#[derive(Serialize)] +pub struct IdentityView { + pub id: String, + pub kind: String, + /// Username or `0x` address. Never PII by construction — see the migration. + pub identifier: String, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_used_at: Option, +} + +#[derive(Serialize)] +pub struct MeResp { + pub user_id: String, + pub role: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + pub identities: Vec, +} + +/// `GET /me` — the account behind the current token, with its login methods. +pub async fn me( + State(state): State>, + headers: HeaderMap, +) -> Result, ApiError> { + let claims = super::require_session(&state, &headers)?; + let user = load_user(&state, &claims)?; + let identities = state.repo.list_identities(user.id).map_err(internal)?; + + Ok(Json(MeResp { + user_id: user.id.to_string(), + role: user.role, + scope: user.scope_id, + identities: identities + .into_iter() + .map(|i| IdentityView { + id: i.id.to_string(), + kind: i.kind, + identifier: i.identifier, + created_at: i.created_at.to_rfc3339(), + last_used_at: i.last_used_at.map(|t| t.to_rfc3339()), + }) + .collect(), + })) +} + +// ---------------------------------------------------------- identity linking + +#[derive(Deserialize)] +#[serde(untagged)] +pub enum AddIdentityReq { + Password { username: String, password: String }, + SuiWallet { signature: String, bytes: String }, +} + +/// `POST /identities` — attach a second login method to the current account. +/// +/// This is both directions of what the account owner asked for: a wallet +/// account setting a password, and a password account adding a wallet. A wallet +/// still has to prove itself with a fresh signed challenge; asserting an +/// address would let anyone graft their session onto someone else's wallet. +pub async fn add_identity( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, ApiError> { + let claims = super::require_session(&state, &headers)?; + let user = load_user(&state, &claims)?; + + let (kind, identifier, secret_hash) = match &req { + AddIdentityReq::Password { username, password } => { + let username = password::normalize_username(username); + password::validate_username(&username).map_err(bad_request)?; + password::validate_password(password).map_err(bad_request)?; + let hash = password::hash(password).map_err(internal)?; + (IdentityKind::Password, username, Some(hash)) + } + AddIdentityReq::SuiWallet { signature, bytes } => { + let address = verify_challenge_signature(&state, signature, bytes)?; + (IdentityKind::SuiWallet, address, None) + } + }; + + // The UNIQUE (kind, identifier) index is what actually stops a takeover: + // a wallet already bound elsewhere cannot be bound here. + let identity = state + .repo + .add_identity(user.id, kind, &identifier, secret_hash) + .map_err(|_| { + ( + StatusCode::CONFLICT, + "that login method is already in use".to_string(), + ) + })?; + + info!(user_id = %user.id, method = kind.as_str(), "identity linked"); + Ok(Json(IdentityView { + id: identity.id.to_string(), + kind: identity.kind, + identifier: identity.identifier, + created_at: identity.created_at.to_rfc3339(), + last_used_at: None, + })) +} + +/// `DELETE /identities/:id` — drop a login method. The repo refuses the last +/// one; with no email on file there would be no way back into the account. +pub async fn remove_identity( + State(state): State>, + headers: HeaderMap, + Path(identity_id): Path, +) -> Result { + let claims = super::require_session(&state, &headers)?; + let user = load_user(&state, &claims)?; + state + .repo + .remove_identity(user.id, identity_id) + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + info!(user_id = %user.id, %identity_id, "identity removed"); + Ok(StatusCode::NO_CONTENT) +} diff --git a/rust-backend/services/auth-service/src/handlers/internal.rs b/rust-backend/services/auth-service/src/handlers/internal.rs new file mode 100644 index 00000000..2fc0b77a --- /dev/null +++ b/rust-backend/services/auth-service/src/handlers/internal.rs @@ -0,0 +1,134 @@ +//! Internal-port handlers. Bound on a port nginx never proxies and reachable +//! only container-to-container or over the VPN — there is no caller +//! authentication here, so the network boundary is the whole control. + +use std::sync::Arc; + +use axum::extract::{Json, State}; +use serde::{Deserialize, Serialize}; +use tracing::info; +use uuid::Uuid; + +use super::{internal, parse_role, ApiError}; +use crate::jwt; +use crate::state::AppState; + +// -------------------------------------------------------------------- verify + +#[derive(Deserialize)] +pub struct VerifyReq { + pub token: String, +} + +#[derive(Serialize)] +pub struct VerifyResp { + pub valid: bool, + /// Sui address, when the session was opened by wallet. Kept under this name + /// for `auth-client`'s existing `VerifiedClaims`. + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option, +} + +/// `POST /verify` — the yes/no answer other services delegate to, now carrying +/// the role and scope so callers can authorize as well as authenticate. +/// +/// Claims are read from the token rather than the database: this is on the hot +/// path of every gated request in the fleet, and a role change lands at the +/// next refresh (at most `token_ttl_secs` away). +pub async fn verify( + State(state): State>, + Json(req): Json, +) -> Json { + match jwt::verify(&req.token, &state.jwt_secret, true) { + Ok(claims) => { + metrics::counter!("auth_verifies_total", "outcome" => "ok").increment(1); + Json(VerifyResp { + valid: true, + address: claims.address, + user_id: Some(claims.sub), + role: Some(claims.role), + scope: claims.scope, + exp: Some(claims.exp), + }) + } + Err(_) => { + metrics::counter!("auth_verifies_total", "outcome" => "invalid").increment(1); + Json(VerifyResp { + valid: false, + address: None, + user_id: None, + role: None, + scope: None, + exp: None, + }) + } + } +} + +// ------------------------------------------------------------------- invites + +#[derive(Deserialize)] +pub struct CreateInviteReq { + /// `admin` | `business` | `individual`. + pub role: String, + /// Required for every role but `admin`. + #[serde(default)] + pub scope_id: Option, + /// Shown on the signup page so the invitee knows what they are joining. + /// Keep it non-identifying — it is the one free-text field here. + #[serde(default)] + pub label: Option, + #[serde(default)] + pub created_by: Option, + /// Override the configured default lifetime. + #[serde(default)] + pub ttl_secs: Option, +} + +#[derive(Serialize)] +pub struct CreateInviteResp { + pub invite_id: String, + pub role: String, + pub expires_at: String, +} + +/// `POST /invites` — mint a signup grant. dakota-service calls this when an +/// admin creates a partner business, or when a business invites one of its own +/// customers. +pub async fn create_invite( + State(state): State>, + Json(req): Json, +) -> Result, ApiError> { + let role = parse_role(&req.role)?; + let ttl = req.ttl_secs.unwrap_or(state.invite_ttl_secs); + + let invite = state + .repo + .create_invite(role, req.scope_id, req.created_by, req.label, ttl) + .map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()))?; + + info!(invite_id = %invite.id, role = %invite.role, "invite minted"); + Ok(Json(CreateInviteResp { + invite_id: invite.id.to_string(), + role: invite.role, + expires_at: invite.expires_at.to_rfc3339(), + })) +} + +/// `GET /health` on the internal port doubles as the readiness probe, so it +/// touches the database rather than answering blind. +pub async fn ready(State(state): State>) -> Result<&'static str, ApiError> { + state + .repo + .peek_invite(Uuid::nil()) + .map(|_| "ok") + .map_err(internal) +} diff --git a/rust-backend/services/auth-service/src/handlers/mod.rs b/rust-backend/services/auth-service/src/handlers/mod.rs new file mode 100644 index 00000000..36341764 --- /dev/null +++ b/rust-backend/services/auth-service/src/handlers/mod.rs @@ -0,0 +1,127 @@ +//! HTTP handlers. +//! +//! - [`session`] — public: challenge, wallet login, password login, refresh. +//! - [`account`] — public, authenticated: register, whoami, identity linking. +//! - [`internal`] — internal port: token verification and invite minting. + +pub mod account; +pub mod internal; +pub mod session; + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::http::{HeaderMap, StatusCode}; +use serde::Serialize; + +use crate::db::models::{Role, User}; +use crate::jwt::{self, Claims}; +use crate::state::AppState; + +pub type ApiError = (StatusCode, String); + +pub async fn health() -> &'static str { + "ok" +} + +/// What every successful authentication returns. +#[derive(Serialize)] +pub struct TokenResp { + pub token: String, + pub user_id: String, + pub role: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + /// Present only for wallet-opened sessions. Retained under this name + /// because the existing admin frontend reads it. + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + /// Seconds until the token expires. + pub expires_in: u64, +} + +/// Mint a session token for `user`. +pub fn issue_token( + state: &AppState, + user: &User, + address: Option, + ip: String, +) -> Result { + let now = jwt::now_secs(); + let claims = Claims { + sub: user.id.to_string(), + role: user.role.clone(), + scope: user.scope_id.clone(), + address, + ip, + iat: now, + exp: now + state.token_ttl_secs, + }; + let token = jwt::sign(&claims, &state.jwt_secret).map_err(internal)?; + Ok(TokenResp { + token, + user_id: claims.sub, + role: claims.role, + scope: claims.scope, + address: claims.address, + expires_in: state.token_ttl_secs, + }) +} + +/// Require a valid, unexpired session on a public route, returning its claims. +pub fn require_session(state: &AppState, headers: &HeaderMap) -> Result { + let token = bearer(headers).ok_or((StatusCode::UNAUTHORIZED, "missing bearer token".into()))?; + jwt::verify(&token, &state.jwt_secret, true) + .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string())) +} + +/// Load the account behind a set of claims, rejecting disabled ones. +pub fn load_user(state: &Arc, claims: &Claims) -> Result { + let user_id = claims + .sub + .parse() + .map_err(|_| (StatusCode::UNAUTHORIZED, "malformed subject".to_string()))?; + let user = state + .repo + .get_user(user_id) + .map_err(internal)? + .ok_or((StatusCode::UNAUTHORIZED, "account no longer exists".to_string()))?; + if user.disabled_at.is_some() { + return Err((StatusCode::FORBIDDEN, "account disabled".into())); + } + Ok(user) +} + +/// Parse a stored role, treating an unknown value as a server fault rather +/// than silently downgrading the caller's authority. +pub fn parse_role(raw: &str) -> Result { + Role::parse(raw).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) +} + +pub fn internal(e: E) -> ApiError { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} + +pub fn bad_request(e: E) -> ApiError { + (StatusCode::BAD_REQUEST, e.to_string()) +} + +/// Pull the bearer token out of the `Authorization` header. +pub fn bearer(headers: &HeaderMap) -> Option { + let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?; + raw.strip_prefix("Bearer ") + .or_else(|| raw.strip_prefix("bearer ")) + .map(|s| s.trim().to_string()) +} + +/// Client IP, preferring the left-most `X-Forwarded-For` entry (set by nginx) +/// and falling back to the direct peer for local/dev calls. +pub fn client_ip(headers: &HeaderMap, peer: SocketAddr) -> String { + headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| peer.ip().to_string()) +} diff --git a/rust-backend/services/auth-service/src/handlers/session.rs b/rust-backend/services/auth-service/src/handlers/session.rs new file mode 100644 index 00000000..86dec9bb --- /dev/null +++ b/rust-backend/services/auth-service/src/handlers/session.rs @@ -0,0 +1,270 @@ +//! Opening a session: challenge, wallet login, password login, refresh. + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::extract::{ConnectInfo, Json, State}; +use axum::http::{HeaderMap, StatusCode}; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; + +use super::{client_ip, internal, issue_token, ApiError, TokenResp}; +use crate::allowlist; +use crate::db::models::{IdentityKind, Role}; +use crate::jwt::{self, Claims}; +use crate::password; +use crate::state::AppState; +use crate::sui_sig; + +// ----------------------------------------------------------------- challenge + +#[derive(Serialize)] +pub struct ChallengeResp { + /// Exact message the wallet must sign (UTF-8). + pub message: String, +} + +/// `GET /challenge` — mint a single-use message to sign. Used by both wallet +/// login and by attaching a wallet to an existing account. +pub async fn challenge(State(state): State>) -> Json { + metrics::counter!("auth_challenges_issued_total").increment(1); + Json(ChallengeResp { + message: state.challenges.issue(), + }) +} + +// --------------------------------------------------------------- wallet login + +#[derive(Deserialize)] +pub struct WalletLoginReq { + /// Base64 serialized Sui signature (`signPersonalMessage().signature`). + pub signature: String, + /// Base64 of the signed message (`signPersonalMessage().bytes`). + pub bytes: String, +} + +/// `POST /login` — prove control of a Sui address, then open its session. +/// +/// Two ways through: the address already has a `sui_wallet` identity, or it is +/// on the config allowlist and gets auto-provisioned as an admin. Anything else +/// is rejected — a proved signature alone is not an account. +pub async fn login( + State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result, ApiError> { + let res = wallet_login_inner(state, peer, headers, req).await; + record_login("sui_wallet", &res); + res +} + +async fn wallet_login_inner( + state: Arc, + peer: SocketAddr, + headers: HeaderMap, + req: WalletLoginReq, +) -> Result, ApiError> { + let address = verify_challenge_signature(&state, &req.signature, &req.bytes)?; + + let existing = state + .repo + .find_identity(IdentityKind::SuiWallet, &address) + .map_err(internal)?; + + let user = match existing { + Some(resolved) => { + state.repo.touch_identity(resolved.identity.id).map_err(internal)?; + resolved.user + } + None => { + // No identity yet. Only the allowlist can conjure an account. + if !allowlist::is_allowed(&state.admin_addresses, &address) { + warn!(%address, "wallet login rejected: no identity and not on the admin allowlist"); + return Err(( + StatusCode::FORBIDDEN, + "this wallet has no account; ask an admin for an invite".into(), + )); + } + info!(%address, "bootstrapping allowlisted admin account"); + state + .repo + .create_user_with_identity(Role::Admin, None, IdentityKind::SuiWallet, &address, None) + .map_err(internal)? + } + }; + + if user.disabled_at.is_some() { + return Err((StatusCode::FORBIDDEN, "account disabled".into())); + } + + info!(user_id = %user.id, role = %user.role, %address, "wallet login"); + Ok(Json(issue_token( + &state, + &user, + Some(address), + client_ip(&headers, peer), + )?)) +} + +// ------------------------------------------------------------ password login + +#[derive(Deserialize)] +pub struct PasswordLoginReq { + pub username: String, + pub password: String, +} + +/// `POST /login/password` — username + password. +/// +/// An unknown username and a wrong password return the same 401. Enumerating +/// which usernames exist is a free gift to an attacker and costs us nothing to +/// withhold. +pub async fn login_password( + State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result, ApiError> { + let res = password_login_inner(state, peer, headers, req).await; + record_login("password", &res); + res +} + +async fn password_login_inner( + state: Arc, + peer: SocketAddr, + headers: HeaderMap, + req: PasswordLoginReq, +) -> Result, ApiError> { + const REJECT: &str = "invalid username or password"; + + let username = password::normalize_username(&req.username); + let resolved = state + .repo + .find_identity(IdentityKind::Password, &username) + .map_err(internal)?; + + let Some(resolved) = resolved else { + // Hash anyway so a missing account and a wrong password take + // comparable time — otherwise response latency enumerates usernames. + let _ = password::hash(&req.password); + return Err((StatusCode::UNAUTHORIZED, REJECT.into())); + }; + + let stored = resolved.identity.secret_hash.as_deref().unwrap_or_default(); + if !password::verify(&req.password, stored) { + return Err((StatusCode::UNAUTHORIZED, REJECT.into())); + } + if resolved.user.disabled_at.is_some() { + return Err((StatusCode::FORBIDDEN, "account disabled".into())); + } + + state.repo.touch_identity(resolved.identity.id).map_err(internal)?; + info!(user_id = %resolved.user.id, role = %resolved.user.role, "password login"); + Ok(Json(issue_token( + &state, + &resolved.user, + None, + client_ip(&headers, peer), + )?)) +} + +// ------------------------------------------------------------------- refresh + +/// `POST /refresh` — slide the expiry on a still-in-window token, provided the +/// request comes from the same IP. No re-signing required. +/// +/// Role and scope are re-read from the database rather than copied from the old +/// token, so a role change or a disable takes effect on the next refresh +/// instead of lingering for the rest of the refresh window. +pub async fn refresh( + State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, +) -> Result, ApiError> { + let token = super::bearer(&headers) + .ok_or((StatusCode::UNAUTHORIZED, "missing bearer token".to_string()))?; + + // Accept an expired-but-otherwise-valid token; the window is bounded by the + // original iat below. + let claims = jwt::verify(&token, &state.jwt_secret, false) + .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?; + + let now = jwt::now_secs(); + if now >= claims.iat + state.refresh_max_secs { + return Err(( + StatusCode::UNAUTHORIZED, + "refresh window elapsed; sign in again".into(), + )); + } + if client_ip(&headers, peer) != claims.ip { + return Err(( + StatusCode::UNAUTHORIZED, + "refresh must come from the same IP".into(), + )); + } + + let user = super::load_user(&state, &claims)?; + + // Preserve the original iat so the session stays bounded by + // refresh_max_secs; only the expiry slides forward. + let next = Claims { + sub: user.id.to_string(), + role: user.role.clone(), + scope: user.scope_id.clone(), + address: claims.address.clone(), + ip: claims.ip, + iat: claims.iat, + exp: now + state.token_ttl_secs, + }; + let token = jwt::sign(&next, &state.jwt_secret).map_err(internal)?; + Ok(Json(TokenResp { + token, + user_id: next.sub, + role: next.role, + scope: next.scope, + address: next.address, + expires_in: state.token_ttl_secs, + })) +} + +// --------------------------------------------------------------------- utils + +/// Consume a live challenge and recover the Sui address that signed it. +/// +/// Shared by wallet login and wallet linking so both burn the nonce exactly +/// once — a challenge that survived verification could be replayed. +pub fn verify_challenge_signature( + state: &AppState, + signature: &str, + bytes_b64: &str, +) -> Result { + use base64::Engine; + + let message = base64::engine::general_purpose::STANDARD + .decode(bytes_b64.trim()) + .map_err(|_| (StatusCode::BAD_REQUEST, "bytes is not base64".to_string()))?; + let message_str = String::from_utf8(message.clone()) + .map_err(|_| (StatusCode::BAD_REQUEST, "message is not utf-8".to_string()))?; + + if !state.challenges.consume(&message_str) { + return Err(( + StatusCode::BAD_REQUEST, + "unknown or expired challenge".into(), + )); + } + + let address = sui_sig::recover_and_verify(signature, &message) + .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?; + Ok(allowlist::normalize(&address)) +} + +fn record_login(method: &'static str, res: &Result, ApiError>) { + let outcome = match res { + Ok(_) => "ok", + Err((code, _)) if code.is_server_error() => "error", + Err(_) => "rejected", + }; + metrics::counter!("auth_logins_total", "outcome" => outcome, "method" => method).increment(1); +} diff --git a/rust-backend/services/auth-service/src/jwt.rs b/rust-backend/services/auth-service/src/jwt.rs index 73c66023..c9dd021d 100644 --- a/rust-backend/services/auth-service/src/jwt.rs +++ b/rust-backend/services/auth-service/src/jwt.rs @@ -17,11 +17,25 @@ type HmacSha256 = Hmac; const B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD; -/// Admin token claims. +/// Token claims. +/// +/// `sub` is the account uuid, not a wallet address. An account may hold several +/// login methods, so an address is one identity among many rather than the +/// identity itself. `address` still carries the Sui address when the session +/// was opened by wallet signature, because callers display it. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Claims { - /// Subject: the admin Sui address, `0x`-prefixed. + /// Subject: the account uuid, hyphenated. pub sub: String, + /// `admin` | `business` | `individual`. + pub role: String, + /// Opaque authorization scope — dakota-service reads it as a Dakota + /// customer id. Absent for admins, who are unscoped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, + /// Sui address, present only for wallet-opened sessions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub address: Option, /// Client IP the token was issued to. Refresh is gated on this matching. pub ip: String, /// Issued-at, unix seconds. @@ -96,7 +110,10 @@ mod tests { fn claims(exp: u64) -> Claims { Claims { - sub: "0xabc".into(), + sub: "3f1a…".into(), + role: "admin".into(), + scope: None, + address: Some("0xabc".into()), ip: "1.2.3.4".into(), iat: now_secs(), exp, @@ -107,8 +124,23 @@ mod tests { fn sign_then_verify() { let t = sign(&claims(now_secs() + 100), "secret").unwrap(); let c = verify(&t, "secret", true).unwrap(); - assert_eq!(c.sub, "0xabc"); + assert_eq!(c.sub, "3f1a…"); assert_eq!(c.ip, "1.2.3.4"); + assert_eq!(c.role, "admin"); + assert_eq!(c.address.as_deref(), Some("0xabc")); + } + + #[test] + fn scoped_claims_round_trip() { + let mut c = claims(now_secs() + 100); + c.role = "business".into(); + c.scope = Some("3HNCB4vp2zWMwdfoY33qKK11iOJ".into()); + c.address = None; + let decoded = verify(&sign(&c, "secret").unwrap(), "secret", true).unwrap(); + assert_eq!(decoded.role, "business"); + assert_eq!(decoded.scope.as_deref(), Some("3HNCB4vp2zWMwdfoY33qKK11iOJ")); + // Omitted rather than serialized as null, so the token stays compact. + assert!(decoded.address.is_none()); } #[test] diff --git a/rust-backend/services/auth-service/src/lib.rs b/rust-backend/services/auth-service/src/lib.rs index 1b4cd1c9..16c6dc64 100644 --- a/rust-backend/services/auth-service/src/lib.rs +++ b/rust-backend/services/auth-service/src/lib.rs @@ -1,21 +1,44 @@ //! auth-service. //! -//! Issues short-lived admin JWTs to wallets on a hardcoded allowlist, after a -//! Sui signature challenge-response. It is the single holder of the JWT -//! signing secret: other services gate endpoints by calling the internal -//! `/verify` route (via `auth-client`) rather than verifying tokens -//! themselves. -//! -//! Two routers on two ports: -//! - public (proxied by nginx): `GET /challenge`, `POST /login`, -//! `POST /refresh`. -//! - internal (network-isolated): `POST /verify`. +//! Issues short-lived JWTs and is the single holder of the JWT signing secret: +//! other services gate endpoints by calling the internal `/verify` route (via +//! `auth-client`) rather than verifying tokens themselves. +//! +//! ## Identity model +//! +//! An **account** (`users`) holds a role and an opaque scope. It is reached +//! through one or more **identities** (`identities`), each a different way of +//! proving you are that account — today `password` (username + Argon2id) and +//! `sui_wallet` (address + signed challenge). Because they hang off a shared +//! account, a wallet user can add a password and a password user can add a +//! wallet, and either then signs them in. A new method is a new `kind` value +//! plus a branch in the two `match`es in [`handlers::account`]; nothing else +//! moves. +//! +//! Accounts are created by redeeming an **invite**, minted over the internal +//! port by whichever service knows the caller ought to exist. The lone +//! exception is a wallet listed in `admin_addresses`, auto-provisioned as an +//! admin on first login so the first operator can get in at all. +//! +//! ## No PII +//! +//! The store holds usernames, Sui addresses, roles and opaque scope ids — +//! deliberately no email, no legal name, nothing sourced from KYC. Password +//! recovery is consequently an admin re-invite, not a reset link. +//! +//! ## Two routers on two ports +//! +//! - public (proxied by nginx): `/challenge`, `/login`, `/login/password`, +//! `/register`, `/refresh`, `/me`, `/identities`, `/invites/preview`. +//! - internal (network-isolated): `/verify`, `/invites`. pub mod allowlist; pub mod challenge; pub mod config; +pub mod db; pub mod handlers; pub mod jwt; +pub mod password; pub mod router; pub mod state; pub mod sui_sig; @@ -30,7 +53,8 @@ use clap::Parser; #[derive(Parser, Debug)] #[command( name = "auth-service", - about = "Sui-wallet admin auth. Issues + verifies short-lived JWTs for an allowlist of addresses." + about = "Multi-method identity service. Password or Sui-wallet login, linkable per account; \ + issues + verifies short-lived JWTs carrying a role and scope." )] pub struct Cli { #[arg(short, long, default_value = "services/auth-service/config/config.toml")] @@ -45,8 +69,8 @@ cli_spec::define_program! { id = "auth-service", cargo_pkg = "auth-service", working_dir = ".", - description = "Sui-wallet admin auth-service. Challenge-response login against a hardcoded \ - address allowlist, issues HS256 JWTs, and exposes an internal verify route \ - other services delegate to.", + description = "Identity service. Username+password or Sui-wallet login (linkable to one \ + account), invite-gated registration, issues HS256 JWTs carrying role and \ + scope, and exposes an internal verify route other services delegate to.", cli = crate::Cli, } diff --git a/rust-backend/services/auth-service/src/main.rs b/rust-backend/services/auth-service/src/main.rs index 497b8952..0f11b670 100644 --- a/rust-backend/services/auth-service/src/main.rs +++ b/rust-backend/services/auth-service/src/main.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result}; use clap::Parser; use tracing::{error, info}; +use auth_service::db::{establish_pool, repo::Repo, run_migrations}; use auth_service::{router, AppState, Cli, Config}; #[tokio::main] @@ -15,6 +16,11 @@ async fn main() -> Result<()> { info!(cfg_path, "loading config"); let cfg = Config::load(&cfg_path).with_context(|| format!("loading config from {cfg_path}"))?; + let pool = Arc::new(establish_pool(&cfg.database_url, cfg.db_pool_size)?); + run_migrations(&pool).context("running auth-service DB migrations")?; + let repo = Repo::new(Arc::clone(&pool)); + info!(pool_size = cfg.db_pool_size, "identity store ready (migrations applied)"); + let secrets = runtime_config::Secrets::load(&cli.secrets) .with_context(|| format!("loading secrets {}", cli.secrets.display()))?; let jwt_secret = secrets.jwt_secret().context("auth.jwt_secret missing")?.to_string(); @@ -24,18 +30,23 @@ async fn main() -> Result<()> { admin_addresses = cfg.admin_addresses.len(), token_ttl_secs = cfg.token_ttl_secs, refresh_max_secs = cfg.refresh_max_secs, + invite_ttl_secs = cfg.invite_ttl_secs, "auth-service starting" ); if cfg.admin_addresses.is_empty() { - tracing::warn!("admin_addresses is empty — no wallet will be able to log in"); + // Existing accounts still log in; this only blocks bootstrapping a new + // admin, which is what bites on a fresh database. + tracing::warn!("admin_addresses is empty — no new admin can bootstrap"); } let state = Arc::new(AppState::new( jwt_secret, cfg.admin_addresses.clone(), + repo, cfg.challenge_ttl_secs, cfg.token_ttl_secs, cfg.refresh_max_secs, + cfg.invite_ttl_secs, )); let public_state = Arc::clone(&state); diff --git a/rust-backend/services/auth-service/src/password.rs b/rust-backend/services/auth-service/src/password.rs new file mode 100644 index 00000000..d0f2d6e6 --- /dev/null +++ b/rust-backend/services/auth-service/src/password.rs @@ -0,0 +1,130 @@ +//! Password identities: username rules and Argon2id hashing. +//! +//! We store no email, so there is no "reset via link" path — a forgotten +//! password means an admin mints a fresh invite. That makes the username rules +//! below load-bearing for support: they have to be unambiguous to read back +//! over a channel a human is using. + +use anyhow::{bail, Result}; +use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; + +/// Shortest password we accept. Deliberately a length floor rather than a +/// character-class rule: composition rules push users toward `Passw0rd!` and +/// buy nothing. +pub const MIN_PASSWORD_LEN: usize = 12; +/// Argon2 hashes the input, so length is otherwise unbounded — this only stops +/// a megabyte body burning CPU. +pub const MAX_PASSWORD_LEN: usize = 1024; + +const MIN_USERNAME_LEN: usize = 3; +const MAX_USERNAME_LEN: usize = 64; + +/// Normalize a username to its stored form: trimmed and lowercased. +/// +/// Lowercasing is what makes the `UNIQUE (kind, identifier)` index +/// case-insensitive, so `Evan` cannot be registered alongside `evan` and +/// impersonate it. +pub fn normalize_username(raw: &str) -> String { + raw.trim().to_lowercase() +} + +/// Validate an already-normalized username. +/// +/// ASCII alphanumerics plus `.`, `-` and `_`, and it must start with a letter +/// or digit. Rejecting everything else keeps usernames free of the whitespace +/// and lookalike Unicode that make two accounts indistinguishable on screen. +pub fn validate_username(name: &str) -> Result<()> { + if name.len() < MIN_USERNAME_LEN || name.len() > MAX_USERNAME_LEN { + bail!("username must be {MIN_USERNAME_LEN}-{MAX_USERNAME_LEN} characters"); + } + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) + { + bail!("username may contain only letters, digits, '.', '-' and '_'"); + } + if !name.chars().next().is_some_and(|c| c.is_ascii_alphanumeric()) { + bail!("username must start with a letter or digit"); + } + Ok(()) +} + +pub fn validate_password(password: &str) -> Result<()> { + if password.len() < MIN_PASSWORD_LEN { + bail!("password must be at least {MIN_PASSWORD_LEN} characters"); + } + if password.len() > MAX_PASSWORD_LEN { + bail!("password must be at most {MAX_PASSWORD_LEN} characters"); + } + Ok(()) +} + +/// Hash to a PHC string (`$argon2id$v=19$m=...`). The parameters travel inside +/// the string, so raising them later still verifies today's hashes. +pub fn hash(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| anyhow::anyhow!("hashing password: {e}")) +} + +/// Constant-time verify. A malformed stored hash verifies as `false` rather +/// than erroring, so a corrupt row cannot be told apart from a wrong password. +pub fn verify(password: &str, phc: &str) -> bool { + let Ok(parsed) = PasswordHash::new(phc) else { + return false; + }; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_then_verify() { + let h = hash("correct horse battery").unwrap(); + assert!(verify("correct horse battery", &h)); + assert!(!verify("wrong horse battery", &h)); + } + + #[test] + fn hashes_are_salted() { + // Same password, different salt each time — identical hashes would leak + // which accounts share a password. + assert_ne!(hash("correct horse battery").unwrap(), hash("correct horse battery").unwrap()); + } + + #[test] + fn malformed_hash_verifies_false() { + assert!(!verify("anything", "not-a-phc-string")); + assert!(!verify("anything", "")); + } + + #[test] + fn username_normalization_is_case_folding() { + assert_eq!(normalize_username(" EvanW "), "evanw"); + } + + #[test] + fn username_rules() { + assert!(validate_username("evan").is_ok()); + assert!(validate_username("evan.w-1_2").is_ok()); + assert!(validate_username("ev").is_err(), "too short"); + assert!(validate_username("_evan").is_err(), "must start alphanumeric"); + assert!(validate_username("evan w").is_err(), "no whitespace"); + assert!(validate_username("evan@example.com").is_err(), "no '@' — not an email store"); + assert!(validate_username(&"a".repeat(65)).is_err(), "too long"); + } + + #[test] + fn password_length_bounds() { + assert!(validate_password(&"a".repeat(MIN_PASSWORD_LEN)).is_ok()); + assert!(validate_password(&"a".repeat(MIN_PASSWORD_LEN - 1)).is_err()); + assert!(validate_password(&"a".repeat(MAX_PASSWORD_LEN + 1)).is_err()); + } +} diff --git a/rust-backend/services/auth-service/src/router.rs b/rust-backend/services/auth-service/src/router.rs index 94ba3e35..745d98fa 100644 --- a/rust-backend/services/auth-service/src/router.rs +++ b/rust-backend/services/auth-service/src/router.rs @@ -1,14 +1,17 @@ //! Two axum routers on two ports. //! -//! - [`serve_public`] — `/challenge`, `/login`, `/refresh`, proxied by nginx. -//! - [`serve_internal`] — `/verify`, bound on a separate port nginx never -//! proxies. Other services reach it container-to-container via `auth-client`. +//! - [`serve_public`] — login, registration and self-service, proxied by nginx. +//! - [`serve_internal`] — `/verify` and `/invites`, bound on a separate port +//! nginx never proxies. Other services reach it container-to-container via +//! `auth-client`. Minting an invite is unauthenticated, so keeping that port +//! off the proxy IS the access control: anything that can reach it can mint +//! an admin invite. use std::net::SocketAddr; use std::sync::Arc; use anyhow::Result; -use axum::routing::{get, post}; +use axum::routing::{delete, get, post}; use axum::Router; use tower_http::cors::{Any, CorsLayer}; use tracing::info; @@ -20,9 +23,19 @@ pub fn public_router(state: Arc, allowed_origins: &[String]) -> Result let cors = build_cors(allowed_origins)?; Ok(Router::new() .route("/health", get(handlers::health)) - .route("/challenge", get(handlers::challenge)) - .route("/login", post(handlers::login)) - .route("/refresh", post(handlers::refresh)) + // Session. + .route("/challenge", get(handlers::session::challenge)) + .route("/login", post(handlers::session::login)) + .route("/login/password", post(handlers::session::login_password)) + .route("/refresh", post(handlers::session::refresh)) + // Account lifecycle. `/me` and `/identities` authenticate inside the + // handler rather than behind a route layer, since they need the account + // row anyway and a layer would just fetch it twice. + .route("/register", post(handlers::account::register)) + .route("/invites/preview", get(handlers::account::preview_invite)) + .route("/me", get(handlers::account::me)) + .route("/identities", post(handlers::account::add_identity)) + .route("/identities/:id", delete(handlers::account::remove_identity)) .with_state(state) .layer(axum::middleware::from_fn( observability::middleware::http_obs, @@ -32,8 +45,9 @@ pub fn public_router(state: Arc, allowed_origins: &[String]) -> Result pub fn internal_router(state: Arc) -> Router { Router::new() - .route("/health", get(handlers::health)) - .route("/verify", post(handlers::verify)) + .route("/health", get(handlers::internal::ready)) + .route("/verify", post(handlers::internal::verify)) + .route("/invites", post(handlers::internal::create_invite)) .with_state(state) .merge(observability::middleware::metrics_route()) .layer(axum::middleware::from_fn( diff --git a/rust-backend/services/auth-service/src/state.rs b/rust-backend/services/auth-service/src/state.rs index fddee882..b32fab40 100644 --- a/rust-backend/services/auth-service/src/state.rs +++ b/rust-backend/services/auth-service/src/state.rs @@ -1,34 +1,46 @@ //! Shared application state for both routers. use crate::challenge::ChallengeStore; +use crate::db::repo::Repo; pub struct AppState { /// HMAC secret for signing/verifying JWTs (from the secrets file). pub jwt_secret: String, - /// Sui addresses permitted to obtain a JWT (from config). + /// Sui addresses auto-provisioned as admins on first wallet login (from + /// config). This is the only path that creates an account without an + /// invite — it exists so the first admin can get in at all. pub admin_addresses: Vec, /// Live login challenges. pub challenges: ChallengeStore, + /// Identity store. + pub repo: Repo, /// Issued-token lifetime, seconds. pub token_ttl_secs: u64, /// Max age from issue a token may still be refreshed at, seconds. pub refresh_max_secs: u64, + /// Default lifetime of a minted invite, seconds. + pub invite_ttl_secs: i64, } impl AppState { + #[allow(clippy::too_many_arguments)] pub fn new( jwt_secret: String, admin_addresses: Vec, + repo: Repo, challenge_ttl_secs: u64, token_ttl_secs: u64, refresh_max_secs: u64, + invite_ttl_secs: i64, ) -> Self { Self { jwt_secret, admin_addresses, challenges: ChallengeStore::new(challenge_ttl_secs), + repo, token_ttl_secs, refresh_max_secs, + invite_ttl_secs, } } } diff --git a/rust-backend/services/token-info/src/router.rs b/rust-backend/services/token-info/src/router.rs index b1ea00d5..c9934cf7 100644 --- a/rust-backend/services/token-info/src/router.rs +++ b/rust-backend/services/token-info/src/router.rs @@ -40,9 +40,12 @@ pub fn public_router( "/tokens/:coin_type", put(tokens::update_token).delete(tokens::delete_token), ) + // `require_admin`, not `require_auth`: auth-service now issues tokens + // to business and individual accounts too, so a merely-valid token + // stopped being proof of an operator. .route_layer(axum::middleware::from_fn_with_state( auth, - auth_client::require_auth, + auth_client::require_admin, )); let reads = Router::new() From ade9a94f382362226baed11b4b045d19b1cef66e Mon Sep 17 00:00:00 2001 From: Evan Witulski Date: Thu, 6 Aug 2026 07:54:51 -0400 Subject: [PATCH 2/2] [SO-341] Tag the credential enum, reserve the identity extension columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the identity model, all cheap now and expensive later. - `RegisterMethod` and `AddIdentityReq` were byte-identical untagged enums. One `AuthMethod` tagged on `method` replaces both, and both handlers go through a shared `resolve_method`. Untagged picks the first variant that deserializes and drops unknown fields, so a future variant overlapping an existing shape would bind to the wrong branch silently. Tests cover the dispatch, the untagged body now being rejected rather than guessed, and an unknown method naming itself in the error. - Wire shape changes to `{invite, method, ...}`. Free right now: nothing in the repo or the frontend calls `/register` or `/identities` yet. - `identities` gains `metadata JSONB` and `verified_at`, added while the table is still empty. Read-only — absent from `NewIdentity` and from `/me`, since nothing writes them and `metadata` will hold provider-internal state. - Migration 000001 is edited in place rather than superseded; it has never been applied outside dev machines. Drop any local `auth_test` database. - Docs claimed a new method was "a new kind value and nothing else" / "a branch in the two matches". It is four places, and sign-in is not free. Corrected in lib.rs, models.rs and the migration. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/db/migrations/000001_init/up.sql | 14 +- .../services/auth-service/src/db/models.rs | 12 +- .../services/auth-service/src/db/schema.rs | 2 + .../auth-service/src/handlers/account.rs | 195 +++++++++++++----- rust-backend/services/auth-service/src/lib.rs | 12 +- 5 files changed, 183 insertions(+), 52 deletions(-) diff --git a/rust-backend/services/auth-service/src/db/migrations/000001_init/up.sql b/rust-backend/services/auth-service/src/db/migrations/000001_init/up.sql index 4a8c8a6a..98c570d7 100644 --- a/rust-backend/services/auth-service/src/db/migrations/000001_init/up.sql +++ b/rust-backend/services/auth-service/src/db/migrations/000001_init/up.sql @@ -27,12 +27,24 @@ CREATE TABLE identities ( id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, -- 'password' | 'sui_wallet'. The extension point: a future 'passkey' or - -- 'oauth:google' is a new value here and nothing else. + -- 'oauth:google' is a new value here, and on the Rust side an IdentityKind + -- variant, an AuthMethod variant, and a login route. kind TEXT NOT NULL, -- Username for 'password', normalized 0x-address for 'sui_wallet'. identifier TEXT NOT NULL, -- Argon2id PHC string for 'password'; NULL for signature-proved methods. secret_hash TEXT, + -- Per-method state that does not fit one hash column: a passkey's + -- credential id and signature counter, an OAuth issuer and subject. + -- Unused by the two methods that exist today, and reserved for the next + -- one — adding it now is free while this table is empty. Same rule as + -- `identifier`: no PII in here either. + metadata JSONB, + -- When the identifier was proved to belong to the account holder. NULL + -- means unproved. Both current methods prove themselves at registration + -- (a signature, or a username that asserts nothing), so nothing sets this + -- yet; a method with an out-of-band confirmation step would. + verified_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_used_at TIMESTAMPTZ, diff --git a/rust-backend/services/auth-service/src/db/models.rs b/rust-backend/services/auth-service/src/db/models.rs index 0605203c..5da1fd3c 100644 --- a/rust-backend/services/auth-service/src/db/models.rs +++ b/rust-backend/services/auth-service/src/db/models.rs @@ -50,8 +50,11 @@ impl Role { // ------------------------------------------------------------------ identity -/// Login method discriminator. New methods are added here and in -/// [`IdentityKind::parse`]; nothing else in the service needs to change. +/// Login method discriminator. Adding one means a variant here (plus its +/// [`IdentityKind::as_str`] / [`IdentityKind::parse`] arms), a matching +/// `AuthMethod` variant and `resolve_method` arm in `handlers::account`, and +/// a login route of its own in `handlers::session` — registration and linking +/// come for free, sign-in does not. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum IdentityKind { @@ -108,6 +111,11 @@ pub struct Identity { pub kind: String, pub identifier: String, pub secret_hash: Option, + /// Per-method state a single hash column can't hold. Read-only for now: + /// nothing writes it, and it is deliberately absent from `NewIdentity` and + /// from the `/me` response — the first method that needs it adds both. + pub metadata: Option, + pub verified_at: Option>, pub created_at: DateTime, pub last_used_at: Option>, } diff --git a/rust-backend/services/auth-service/src/db/schema.rs b/rust-backend/services/auth-service/src/db/schema.rs index 48799d2f..0de0a146 100644 --- a/rust-backend/services/auth-service/src/db/schema.rs +++ b/rust-backend/services/auth-service/src/db/schema.rs @@ -17,6 +17,8 @@ diesel::table! { kind -> Text, identifier -> Text, secret_hash -> Nullable, + metadata -> Nullable, + verified_at -> Nullable, created_at -> Timestamptz, last_used_at -> Nullable, } diff --git a/rust-backend/services/auth-service/src/handlers/account.rs b/rust-backend/services/auth-service/src/handlers/account.rs index 50aebbce..ee72233b 100644 --- a/rust-backend/services/auth-service/src/handlers/account.rs +++ b/rust-backend/services/auth-service/src/handlers/account.rs @@ -60,22 +60,74 @@ pub async fn preview_invite( })) } -// ------------------------------------------------------------------ register +// ------------------------------------------------------- credential plumbing -/// How the new account will log in. Untagged so the body reads naturally: -/// either `{username, password}` or `{signature, bytes}`. +/// A credential the caller is presenting, either to open a new account or to +/// attach to one they already hold. Both routes take the same shape. +/// +/// Tagged on `method` rather than inferred from which fields are present: an +/// untagged enum picks the first variant that deserializes and ignores unknown +/// fields, so a future variant whose body is a superset of an existing one +/// would bind to the wrong branch and silently drop the extra field. The tag +/// also turns a malformed body into a usable error instead of "data did not +/// match any variant". #[derive(Deserialize)] -#[serde(untagged)] -pub enum RegisterMethod { +#[serde(tag = "method", rename_all = "snake_case")] +pub enum AuthMethod { Password { username: String, password: String }, SuiWallet { signature: String, bytes: String }, } +/// A credential validated and reduced to what the store holds. Adding a login +/// method means a variant above and an arm below; both handlers pick it up. +pub struct ResolvedMethod { + pub kind: IdentityKind, + /// What goes in `identities.identifier` — unique per `kind`. + pub identifier: String, + /// Argon2id PHC string for secret-bearing methods; `None` for methods + /// proved by signature. + pub secret_hash: Option, + /// Set only by methods that prove a Sui address, which then travels in the + /// token. Ignored when linking, where the session already has one. + pub address: Option, +} + +/// Validate a presented credential and hash it if it carries a secret. +/// +/// Wallet methods consume the challenge nonce here, so this must be called +/// exactly once per request. +fn resolve_method(state: &AppState, method: &AuthMethod) -> Result { + Ok(match method { + AuthMethod::Password { username, password } => { + let username = password::normalize_username(username); + password::validate_username(&username).map_err(bad_request)?; + password::validate_password(password).map_err(bad_request)?; + ResolvedMethod { + kind: IdentityKind::Password, + identifier: username, + secret_hash: Some(password::hash(password).map_err(internal)?), + address: None, + } + } + AuthMethod::SuiWallet { signature, bytes } => { + let address = verify_challenge_signature(state, signature, bytes)?; + ResolvedMethod { + kind: IdentityKind::SuiWallet, + identifier: address.clone(), + secret_hash: None, + address: Some(address), + } + } + }) +} + +// ------------------------------------------------------------------ register + #[derive(Deserialize)] pub struct RegisterReq { pub invite: Uuid, #[serde(flatten)] - pub method: RegisterMethod, + pub method: AuthMethod, } /// `POST /register` — redeem an invite into a new account and open its session. @@ -88,32 +140,30 @@ pub async fn register( headers: HeaderMap, Json(req): Json, ) -> Result, ApiError> { - let (kind, identifier, secret_hash, address) = match &req.method { - RegisterMethod::Password { username, password } => { - let username = password::normalize_username(username); - password::validate_username(&username).map_err(bad_request)?; - password::validate_password(password).map_err(bad_request)?; - let hash = password::hash(password).map_err(internal)?; - (IdentityKind::Password, username, Some(hash), None) - } - RegisterMethod::SuiWallet { signature, bytes } => { - let address = verify_challenge_signature(&state, signature, bytes)?; - (IdentityKind::SuiWallet, address.clone(), None, Some(address)) - } - }; + let resolved = resolve_method(&state, &req.method)?; let user = state .repo - .register_with_invite(req.invite, kind, &identifier, secret_hash) + .register_with_invite( + req.invite, + resolved.kind, + &resolved.identifier, + resolved.secret_hash, + ) .map_err(|e| { // Every failure here is the caller's: a spent, expired or unknown // invite, or an identifier someone already claimed. (StatusCode::BAD_REQUEST, e.to_string()) })?; - metrics::counter!("auth_registrations_total", "method" => kind.as_str()).increment(1); - info!(user_id = %user.id, role = %user.role, method = kind.as_str(), "account registered"); - Ok(Json(issue_token(&state, &user, address, client_ip(&headers, peer))?)) + metrics::counter!("auth_registrations_total", "method" => resolved.kind.as_str()).increment(1); + info!(user_id = %user.id, role = %user.role, method = resolved.kind.as_str(), "account registered"); + Ok(Json(issue_token( + &state, + &user, + resolved.address, + client_ip(&headers, peer), + )?)) } // --------------------------------------------------------------------- me @@ -166,13 +216,6 @@ pub async fn me( // ---------------------------------------------------------- identity linking -#[derive(Deserialize)] -#[serde(untagged)] -pub enum AddIdentityReq { - Password { username: String, password: String }, - SuiWallet { signature: String, bytes: String }, -} - /// `POST /identities` — attach a second login method to the current account. /// /// This is both directions of what the account owner asked for: a wallet @@ -182,30 +225,23 @@ pub enum AddIdentityReq { pub async fn add_identity( State(state): State>, headers: HeaderMap, - Json(req): Json, + Json(req): Json, ) -> Result, ApiError> { let claims = super::require_session(&state, &headers)?; let user = load_user(&state, &claims)?; - let (kind, identifier, secret_hash) = match &req { - AddIdentityReq::Password { username, password } => { - let username = password::normalize_username(username); - password::validate_username(&username).map_err(bad_request)?; - password::validate_password(password).map_err(bad_request)?; - let hash = password::hash(password).map_err(internal)?; - (IdentityKind::Password, username, Some(hash)) - } - AddIdentityReq::SuiWallet { signature, bytes } => { - let address = verify_challenge_signature(&state, signature, bytes)?; - (IdentityKind::SuiWallet, address, None) - } - }; + let resolved = resolve_method(&state, &req)?; // The UNIQUE (kind, identifier) index is what actually stops a takeover: // a wallet already bound elsewhere cannot be bound here. let identity = state .repo - .add_identity(user.id, kind, &identifier, secret_hash) + .add_identity( + user.id, + resolved.kind, + &resolved.identifier, + resolved.secret_hash, + ) .map_err(|_| { ( StatusCode::CONFLICT, @@ -213,7 +249,7 @@ pub async fn add_identity( ) })?; - info!(user_id = %user.id, method = kind.as_str(), "identity linked"); + info!(user_id = %user.id, method = resolved.kind.as_str(), "identity linked"); Ok(Json(IdentityView { id: identity.id.to_string(), kind: identity.kind, @@ -239,3 +275,70 @@ pub async fn remove_identity( info!(user_id = %user.id, %identity_id, "identity removed"); Ok(StatusCode::NO_CONTENT) } + +#[cfg(test)] +mod tests { + use super::*; + + const INVITE: &str = "8f1c4a2e-0b3d-4f5a-9c6e-1d2b3a4c5d6e"; + + #[test] + fn register_body_dispatches_on_the_tag() { + let body = serde_json::json!({ + "invite": INVITE, + "method": "password", + "username": "evan", + "password": "correct horse battery", + }); + let req: RegisterReq = serde_json::from_value(body).unwrap(); + assert_eq!(req.invite.to_string(), INVITE); + assert!(matches!(req.method, AuthMethod::Password { .. })); + + let body = serde_json::json!({ + "invite": INVITE, + "method": "sui_wallet", + "signature": "sig", + "bytes": "bytes", + }); + let req: RegisterReq = serde_json::from_value(body).unwrap(); + assert!(matches!(req.method, AuthMethod::SuiWallet { .. })); + } + + #[test] + fn link_body_is_the_same_shape_minus_the_invite() { + let body = serde_json::json!({ + "method": "sui_wallet", + "signature": "sig", + "bytes": "bytes", + }); + assert!(matches!( + serde_json::from_value::(body).unwrap(), + AuthMethod::SuiWallet { .. } + )); + } + + #[test] + fn a_body_with_no_tag_is_rejected_rather_than_guessed() { + // The reason this enum is tagged. Untagged, serde picks the first + // variant that happens to deserialize and drops unknown fields, so a + // future variant overlapping this shape would silently win. + let body = serde_json::json!({ + "invite": INVITE, + "username": "evan", + "password": "correct horse battery", + }); + assert!(serde_json::from_value::(body).is_err()); + } + + #[test] + fn an_unknown_method_names_itself_in_the_error() { + let body = serde_json::json!({ "method": "passkey", "credential": "…" }); + // Matched rather than `unwrap_err`'d: that would need `Debug` on + // AuthMethod, which holds a plaintext password. + let err = match serde_json::from_value::(body) { + Ok(_) => panic!("an unknown method was accepted"), + Err(e) => e.to_string(), + }; + assert!(err.contains("passkey"), "unhelpful error: {err}"); + } +} diff --git a/rust-backend/services/auth-service/src/lib.rs b/rust-backend/services/auth-service/src/lib.rs index 16c6dc64..9f67459c 100644 --- a/rust-backend/services/auth-service/src/lib.rs +++ b/rust-backend/services/auth-service/src/lib.rs @@ -11,9 +11,15 @@ //! proving you are that account — today `password` (username + Argon2id) and //! `sui_wallet` (address + signed challenge). Because they hang off a shared //! account, a wallet user can add a password and a password user can add a -//! wallet, and either then signs them in. A new method is a new `kind` value -//! plus a branch in the two `match`es in [`handlers::account`]; nothing else -//! moves. +//! wallet, and either then signs them in. +//! +//! Adding a method touches four things: an [`db::models::IdentityKind`] +//! variant, an [`handlers::account::AuthMethod`] variant, an arm in +//! `resolve_method`, and a login route of its own in [`handlers::session`]. +//! Registration and identity-linking both go through `resolve_method`, so they +//! need no further changes; sign-in does, because each method is proved +//! differently. Nothing outside this service moves — tokens carry the account +//! uuid, so callers never learn how the session was opened. //! //! Accounts are created by redeeming an **invite**, minted over the internal //! port by whichever service knows the caller ought to exist. The lone