**Multi-provider auth** supporting CLI (email/password) and frontend (Sign in with Apple, future OAuth providers), with account linking so one user can have multiple sign-in methods.
1. CLI is a first-class citizen. You can sign up, sign in, and use every feature from the terminal — no browser redirect required.
2. Provider-agnostic core. The server issues its own JWTs regardless of how the user authenticated. All protected endpoints see a user_id, never a provider-specific token.
3. One user, many providers. A user who signs up via CLI with email/password can later link their Apple ID from the iOS app (and vice versa). All providers attach to the same user record.
4. Passwords never leave the server. Argon2id hashing, no plaintext storage, no password in JWTs.
```sql
-- Core user identity (provider-independent)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE, -- nullable: Apple "hide my email" users may not share it
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
vault_size BIGINT NOT NULL DEFAULT 0 -- total bytes in S3
);
-- One row per auth method a user has linked
CREATE TABLE auth_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL, -- 'email' | 'apple' | 'google' | 'github'
provider_id TEXT NOT NULL, -- email (for 'email'), sub claim (for OAuth)
password_hash TEXT, -- only for provider='email', Argon2id
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(provider, provider_id)
);
CREATE INDEX idx_auth_providers_user ON auth_providers(user_id);
-- Refresh tokens (one per device session)
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the opaque token
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Devices (unchanged from SYNC_ARCHITECTURE.md)
CREATE TABLE devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_name TEXT NOT NULL,
platform TEXT NOT NULL, -- 'ios' | 'macos' | 'android' | 'cli'
push_token TEXT, -- APNs/FCM token (null for CLI)
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
auth_providers from users?A user is a person with a vault. How they prove their identity is a separate concern. This lets us:
```
Client Server
| POST /auth/register | |
| { email, password, device_name } | |
| --------------------------------> | |
| validate email format | |
| check email not taken | |
| hash password (Argon2id) | |
| INSERT users + auth_providers + devices | |
| generate access_token + refresh_token | |
| <-------------------------------- | |
| { access_token, refresh_token, | |
| user_id, device_id } | |
| Store tokens in ~/.steelnotes/ |
```
```
Client Server
| POST /auth/login | |
| { grant_type: "email", | |
| email, password, device_name } | |
| --------------------------------> | |
| lookup auth_providers WHERE provider='email', provider_id=email | |
| verify password against Argon2id hash | |
| upsert device record | |
| generate access_token + refresh_token | |
| <-------------------------------- | |
| { access_token, refresh_token, | |
| user_id, device_id } |
```
```
Client Server
| [User taps "Sign in with Apple"] | |
| ASAuthorizationController gives | |
| identity_token (JWT from Apple) | |
| POST /auth/login | |
| { grant_type: "apple", | |
| identity_token, device_name } | |
| --------------------------------> | |
| fetch Apple JWKS from appleid.apple.com/auth/keys | |
| validate identity_token signature + claims | |
| extract sub (provider_id) + email | |
| find or create user + auth_providers row | |
| upsert device record | |
| generate access_token + refresh_token | |
| <-------------------------------- | |
| { access_token, refresh_token, | |
| user_id, device_id } |
```
```
Client Server
| POST /auth/refresh | |
| { refresh_token } | |
| --------------------------------> | |
| hash token, lookup in refresh_tokens table | |
| check not expired | |
| delete old refresh_token (rotation) | |
| generate new access_token + refresh_token | |
| <-------------------------------- | |
| { access_token, refresh_token } |
```
Refresh token rotation: every use of a refresh token invalidates it and issues a new one. If a stolen token is used after the legitimate client has already refreshed, the stolen token is dead.
```
Client Server
| POST /auth/link | |
| Authorization: Bearer {token} | |
| { provider: "apple", | |
| identity_token: "..." } | |
| --------------------------------> | |
| validate identity_token | |
| check provider_id not already linked to another user | |
| INSERT auth_providers (user_id from JWT, provider, provider_id) | |
| <-------------------------------- | |
| { linked: true, provider: "apple" } |
```
This lets a CLI user later open the iOS app and link their Apple ID, or vice versa. Both sign-in methods now resolve to the same user and vault.
POST /auth/login uses a grant_type discriminator:
```json
// Email/password
{
"grant_type": "email",
"email": "user@example.com",
"password": "...",
"device_name": "Ethan's Terminal"
}
// Sign in with Apple
{
"grant_type": "apple",
"identity_token": "<JWT from Apple>",
"device_name": "Ethan's iPhone"
}
// Google (future)
{
"grant_type": "google",
"identity_token": "<JWT from Google>",
"device_name": "Ethan's Pixel"
}
```
Response (all grant types):
```json
{
"access_token": "<JWT, 1hr TTL>",
"refresh_token": "<opaque, 30-day TTL>",
"user_id": "uuid",
"device_id": "uuid"
}
```
```json
{
"sub": "<user_id UUID>",
"iat": 1711234567,
"exp": 1711238167,
"device_id": "<device_id UUID>"
}
```
JWT_SECRETuser_id| Platform | Access Token | Refresh Token |
|---|---|---|
| **iOS/macOS** | Keychain (kSecAttrAccessibleAfterFirstUnlock) | Keychain |
| **Android** | EncryptedSharedPreferences | EncryptedSharedPreferences |
| **CLI** | ~/.steelnotes/auth.json (mode 600) | ~/.steelnotes/auth.json |
```json
{
"api_url": "https://api.steelnotes.app",
"access_token": "eyJ...",
"refresh_token": "opaque-token-here",
"user_id": "uuid",
"device_id": "uuid"
}
```
The CLI reads this file on every command. If the access token is expired, it auto-refreshes before proceeding. If refresh fails, it prompts the user to steel auth login again.
```bash
steel auth register # interactive: prompts for email + password
steel auth register --email e@x.com --password '...' # non-interactive
steel auth login # interactive: prompts for email + password
steel auth login --email e@x.com --password '...' # non-interactive
steel auth status # shows current user, email, linked providers, device
steel auth logout # deletes local tokens + calls server to revoke refresh token
```
```
src/
routes/
auth.rs # POST /auth/register, /auth/login, /auth/refresh, /auth/link
auth/
password.rs # Argon2id hash + verify (argon2 crate)
apple.rs # Fetch JWKS, validate Apple identity tokens (jsonwebtoken crate)
jwt.rs # Sign + verify our JWTs
middleware.rs # Tower layer: extract Bearer token → inject UserId into request
```
```rust
// Using the argon2 crate with default Argon2id params
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
```
To add a new provider (e.g., Google, GitHub):
1. Add a validation function in src/auth/{provider}.rs that takes an identity token and returns (provider_id, email)
2. Add the grant_type variant to the login endpoint match
3. No schema changes — auth_providers.provider is a text field, not an enum
/auth/register and /auth/login — 5 attempts per email per 15 minutes (Tower rate-limit middleware)sub (user_id) and device_id0600 permissions, user-only read/write