← Back to Plan

Auth Architecture

Steel Notes — Authentication Architecture

**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.

Design Principles

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.


Database Schema

```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()

);

```

Why separate auth_providers from users?

A user is a person with a vault. How they prove their identity is a separate concern. This lets us:

  • Add providers (GitHub, Google) without touching the users table
  • Let one user link multiple sign-in methods
  • Keep password hashes isolated from the core user model

  • Auth Flows

    1. CLI: Email/Password Registration

    ```

    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/

    ```

    2. CLI: Email/Password Login

    ```

    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 }

    ```

    3. iOS/macOS: Sign in with Apple

    ```

    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 }

    ```

    4. Token Refresh (all clients)

    ```

    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.

    5. Account Linking (authenticated)

    ```

    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.


    Unified Login Endpoint

    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"

    }

    ```


    JWT Structure

    ```json

    {

    "sub": "<user_id UUID>",

    "iat": 1711234567,

    "exp": 1711238167,

    "device_id": "<device_id UUID>"

    }

    ```

  • Signed with HS256 using server-side JWT_SECRET
  • 1-hour TTL — short enough to limit damage from a leaked token
  • No provider info in the JWT — downstream code only sees user_id

  • Token Storage by Platform

    PlatformAccess TokenRefresh Token
    **iOS/macOS**Keychain (kSecAttrAccessibleAfterFirstUnlock)Keychain
    **Android**EncryptedSharedPreferencesEncryptedSharedPreferences
    **CLI**~/.steelnotes/auth.json (mode 600)~/.steelnotes/auth.json

    CLI auth file format

    ```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.


    CLI Commands

    ```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

    ```


    Server Implementation (Rust / Axum)

    Route structure

    ```

    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

    ```

    Password hashing

    ```rust

    // Using the argon2 crate with default Argon2id params

    use argon2::{Argon2, PasswordHasher, PasswordVerifier};

    ```

  • Argon2id with default parameters (19 MiB memory, 2 iterations, 1 parallelism)
  • Each hash includes a random salt — no separate salt column needed
  • Adding a new OAuth provider

    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


    Security Considerations

  • **Rate limiting** on /auth/register and /auth/login — 5 attempts per email per 15 minutes (Tower rate-limit middleware)
  • **Refresh token rotation** — every refresh invalidates the old token
  • **No password in JWT** — JWT contains only sub (user_id) and device_id
  • **HTTPS only** — API Gateway terminates TLS; Lambda never sees plaintext HTTP
  • **Argon2id** — memory-hard, resistant to GPU/ASIC brute force
  • **CLI auth file** — created with 0600 permissions, user-only read/write