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..98c570d7 --- /dev/null +++ b/rust-backend/services/auth-service/src/db/migrations/000001_init/up.sql @@ -0,0 +1,74 @@ +-- 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 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, + + -- 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..5da1fd3c --- /dev/null +++ b/rust-backend/services/auth-service/src/db/models.rs @@ -0,0 +1,157 @@ +//! 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. 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 { + /// 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, + /// 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>, +} + +#[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..0de0a146 --- /dev/null +++ b/rust-backend/services/auth-service/src/db/schema.rs @@ -0,0 +1,42 @@ +//! 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, + metadata -> Nullable, + verified_at -> 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..ee72233b --- /dev/null +++ b/rust-backend/services/auth-service/src/handlers/account.rs @@ -0,0 +1,344 @@ +//! 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, + })) +} + +// ------------------------------------------------------- credential plumbing + +/// 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(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: AuthMethod, +} + +/// `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 resolved = resolve_method(&state, &req.method)?; + + let user = state + .repo + .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" => 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 + +#[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 + +/// `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 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, + resolved.kind, + &resolved.identifier, + resolved.secret_hash, + ) + .map_err(|_| { + ( + StatusCode::CONFLICT, + "that login method is already in use".to_string(), + ) + })?; + + info!(user_id = %user.id, method = resolved.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) +} + +#[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/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..9f67459c 100644 --- a/rust-backend/services/auth-service/src/lib.rs +++ b/rust-backend/services/auth-service/src/lib.rs @@ -1,21 +1,50 @@ //! 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. +//! +//! 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 +//! 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 +59,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 +75,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()