Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions frontend/src/api/authClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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;
}
Expand Down
30 changes: 30 additions & 0 deletions rust-backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions rust-backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -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"] }
77 changes: 69 additions & 8 deletions rust-backend/crates/auth-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,40 @@ 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<String>,
/// 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 {
valid: bool,
#[serde(default)]
address: Option<String>,
#[serde(default)]
user_id: Option<String>,
#[serde(default)]
role: Option<String>,
#[serde(default)]
scope: Option<String>,
#[serde(default)]
exp: Option<u64>,
}

Expand Down Expand Up @@ -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 {
Expand All @@ -98,26 +124,61 @@ fn bearer(req: &Request) -> Option<String> {
.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<Arc<AuthClient>>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
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<Arc<AuthClient>>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
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<String>,
) -> Result<VerifiedClaims, StatusCode> {
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");
Expand Down
8 changes: 8 additions & 0 deletions rust-backend/services/auth-service/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
23 changes: 21 additions & 2 deletions rust-backend/services/auth-service/config/config.prod.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<the shared DB_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
13 changes: 10 additions & 3 deletions rust-backend/services/auth-service/config/config.staging.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 18 additions & 8 deletions rust-backend/services/auth-service/config/config.toml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading