Skip to content

[SO-341] Rework auth-service into a multi-method identity service - #387

Open
ewitulsk wants to merge 2 commits into
stagingfrom
ewitulsk/auth/multi-method-identity
Open

[SO-341] Rework auth-service into a multi-method identity service#387
ewitulsk wants to merge 2 commits into
stagingfrom
ewitulsk/auth/multi-method-identity

Conversation

@ewitulsk

@ewitulsk ewitulsk commented Aug 2, 2026

Copy link
Copy Markdown
Owner

SO-341

auth-service was Sui-wallet-only: sign a challenge, get an admin JWT, and membership of a config allowlist was the entire authorization model. That works for operators and for nobody else.

One account is now reachable by several login methods. An account holds a role and an opaque scope; it's reached through one or more identities — 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 later is a new kind value plus a branch in two matches; nothing else moves.

Split out of #382 so the identity change can be reviewed on its own.

Deliberately no email

The store holds usernames, Sui addresses, roles and opaque scope ids. No email column exists — usernames are opaque handles, not addresses.

The consequence is real and worth agreeing to: there is no password reset. Recovery is an admin minting a fresh invite. That's the right trade here — this fronts a system whose whole design premise is that we never hold personally identifying data, and an email table would be the first crack in it.

Accounts only exist by invitation

There's no self-serve signup. An invite carries the role and scope, and nothing about them is read from the registration body — so a redeemer can't promote themselves. Invites are single-use and time-boxed, claimed with a conditional UPDATE so two simultaneous redemptions of the same link can't both win.

One exception, and it's the bootstrap: a wallet on admin_addresses is auto-provisioned as an admin on first login. That list is now a root-of-trust, and it's commented as such.

A privilege escalation this closes

token-info's mutate routes were gated on require_auth, which only proves a token is valid. Once auth-service started issuing tokens to business and individual accounts, any newly-created customer account could have mutated the token catalog.

crates/auth-client gains require_admin; token-info uses it. Anything else gating a privileged operation on require_auth wants the same treatment.

VerifiedClaims also gains user_id, role and scope. role defaults to the least-privileged value if absent, so a version skew fails closed.

Claims changed shape

sub is now 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. The address moves to its own optional address claim.

The admin frontend read sub as the address, so frontend/src/api/authClient.ts moves to address and gains jwtRole. Wallet login still returns address in the response body, so nothing else there changes.

Blocking: auth_prod before the next prod deploy

auth-service now has a hard Postgres dependency and will not boot without it. It ships to prod, is health-gated, and deploy.sh rolls back the whole planned set on the first failed gate — so a prod deploy without this database reverts everything deployed alongside it.

The embedded migrations run themselves. The database and role do not.

CREATE ROLE auth_prod    LOGIN PASSWORD '<the shared DB_PASSWORD>';
CREATE DATABASE auth_prod    OWNER auth_prod;
-- staging too
CREATE ROLE auth_staging LOGIN PASSWORD '<the shared DB_PASSWORD>';
CREATE DATABASE auth_staging OWNER auth_staging;

This is called out in config.prod.toml at the line that needs it.

Smaller decisions worth a look

  • A wrong password and an unknown username return the same 401, and the unknown-username path hashes anyway so the timing matches. Enumerating which usernames exist is free for an attacker and costs us nothing to withhold.
  • The last identity can't be removed. With no email on file, an account with zero identities is unreachable forever.
  • /refresh re-reads role and scope from the database rather than copying them from the old token, so a role change or a disable lands at the next refresh instead of lingering for the rest of the window.
  • The internal port gains POST /invites, unauthenticated. Keeping that port off the nginx proxy is the access control — anything that can reach it can mint an admin invite. Said plainly in the router's module doc.
  • Username rules are ASCII alphanumerics plus .-_, lowercased for a case-insensitive unique index, so Evan can't be registered alongside evan and impersonate it. Password rule is a length floor, not composition — composition rules push people toward Passw0rd! and buy nothing.

Verification

Workspace build + clippy clean
Unit tests 19 passed
DB integration tests 12 passed
Frontend typecheck clean

The integration tests cover the behaviour a regression would actually break: wallet-then-password and password-then-wallet both resolving to one user_id, an identifier being unclaimable twice, the last identity being unremovable, one account being unable to remove another's identity, invites carrying role and scope, single-use and expiry, and a failed registration leaving the invite open rather than burning it on a typo.

They need a real Postgres and follow the existing #[ignore] convention:

AUTH_TEST_DATABASE_URL=postgres://…/auth_test cargo test -p auth-service -- --ignored

🤖 Generated with Claude Code

https://claude.ai/code/session_0152Pn9P1PWogKrSdsS1jmrr

One account, reachable by username+password or Sui wallet, linkable in either
direction. Adds a Postgres-backed users/identities/invites store, role+scope
JWT claims, and require_admin in auth-client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0152Pn9P1PWogKrSdsS1jmrr
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sui-options Ready Ready Preview Aug 6, 2026 11:55am

Request Review

Follow-ups on the identity model, all cheap now and expensive later.

- `RegisterMethod` and `AddIdentityReq` were byte-identical untagged enums.
  One `AuthMethod` tagged on `method` replaces both, and both handlers go
  through a shared `resolve_method`. Untagged picks the first variant that
  deserializes and drops unknown fields, so a future variant overlapping an
  existing shape would bind to the wrong branch silently. Tests cover the
  dispatch, the untagged body now being rejected rather than guessed, and an
  unknown method naming itself in the error.
- Wire shape changes to `{invite, method, ...}`. Free right now: nothing in
  the repo or the frontend calls `/register` or `/identities` yet.
- `identities` gains `metadata JSONB` and `verified_at`, added while the
  table is still empty. Read-only — absent from `NewIdentity` and from `/me`,
  since nothing writes them and `metadata` will hold provider-internal state.
- Migration 000001 is edited in place rather than superseded; it has never
  been applied outside dev machines. Drop any local `auth_test` database.
- Docs claimed a new method was "a new kind value and nothing else" / "a
  branch in the two matches". It is four places, and sign-in is not free.
  Corrected in lib.rs, models.rs and the migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant