auth flow - nself-org/cli GitHub Wiki

Auth Flow โ€” nSelf Unified Auth (D2)

This page documents the end-to-end authentication flows for api.nself.org powered by the auth plugin (D2-T01). Implementation is in web/backend/plugins/auth/ and web/backend/migrations/025_np_auth_plugin_schema.sql.

For plugin install instructions see plugin-auth.md.


Schema

Five Postgres tables back the auth system (migration 025_np_auth_plugin_schema.sql):

Table Purpose
np_users Primary identity records (email, hashed password, email_verified, source_account_id, tenant_id)
np_sessions Server-side session store with revocation support
np_mfa_devices TOTP enrollments and backup codes
np_password_reset_tokens PKCE-style single-use reset tokens (15-min TTL)
np_oauth_states CSRF state params for social login (10-min TTL)

Multi-Tenant Convention Wall (Decision #20):

  • source_account_id TEXT NOT NULL DEFAULT 'primary' โ€” isolates self-hosted app instances
  • tenant_id UUID nullable โ€” Cloud customer isolation only
  • Never swap these. Confusing them causes cross-customer data leaks.

Signup Flow

Status: Implemented in D2-T02 (stub: email send stubbed until D4/Postmark is live)

POST /auth/signup  { email, password }
  โ†’ validate email format + password strength
  โ†’ check duplicate email (409 if exists)
  โ†’ rate-limit: 3 signups per email per hour
  โ†’ captcha verification (hCaptcha / Cloudflare Turnstile)
  โ†’ Argon2id hash password (time=3, mem=64MB, p=4)
  โ†’ INSERT np_users (email_verified = FALSE)
  โ†’ generate email verification JWT (15-min TTL)
  โ†’ send verification email via D4/Postmark (template: signup-verify)
  โ† 201 { message: "Check your email to verify your account" }

Security invariants:

  • Password never stored in plaintext or logged
  • Duplicate email returns 409 with "email already registered" (no timing oracle)
  • Unverified users cannot sign in (403 "check your email")

Email Verification Flow

Status: Implemented in D2-T02

GET /auth/verify-email?token=<signed JWT>
  โ†’ verify JWT signature + TTL (15 min)
  โ†’ verify token is single-use (used_at check)
  โ†’ UPDATE np_users SET email_verified = TRUE
  โ†’ mark token used
  โ† 200 redirect to /account

Re-send:

POST /auth/resend-verify  { email }
  โ†’ rate-limit: 1 per 60s (dev: 1 per 5s)
  โ†’ generate new verification token
  โ†’ send email
  โ† 200 { message: "Verification email sent" }

Signin Flow

Status: Implemented in D2-T03 (stub: session cookie)

POST /auth/signin  { email, password }
  โ†’ check email_verified (403 if false)
  โ†’ check account lockout (403 if locked: 5 failed attempts โ†’ 15-min lockout)
  โ†’ Argon2id verify password
  โ†’ on success: INSERT np_sessions (expires_at = now + 7 days)
  โ†’ issue JWT (RS256, kid = AUTH_JWT_KEY_ID)
      payload: { sub: user_id, email, tenant_id?, source_account_id, exp, iat, session_id }
      Hasura claims namespace: https://hasura.io/jwt/claims
        X-Hasura-User-Id, X-Hasura-Default-Role, X-Hasura-Allowed-Roles
        X-Hasura-Source-Account-Id, X-Hasura-Tenant-Id (if Cloud)
  โ†’ set cookie: HttpOnly; SameSite=Lax; Secure; Path=/; Max-Age=604800; Domain=.nself.org
  โ† 200 { user: { id, email, email_verified } }

Password Reset Flow (PKCE โ€” Amendment 9 SEC-1)

Status: Implemented in D2-T04

POST /auth/forgot-password  { email }
  โ†’ always return 200 (no email enumeration)
  โ†’ generate code_verifier (32 random bytes, base64url)
  โ†’ code_challenge = BASE64URL(SHA256(code_verifier))
  โ†’ INSERT np_password_reset_tokens (code_challenge, expires_at = now + 15 min)
  โ†’ send reset email with link containing code_verifier
  โ† 200 { message: "If that email is registered, a reset link was sent" }

POST /auth/reset-password  { code_verifier, new_password }
  โ†’ recompute code_challenge = BASE64URL(SHA256(code_verifier))
  โ†’ look up token WHERE code_challenge = $1 AND used_at IS NULL AND expires_at > now
  โ†’ validate (400 if not found, expired, or already used)
  โ†’ Argon2id hash new_password
  โ†’ UPDATE np_users SET hashed_password = new_hash
  โ†’ UPDATE np_password_reset_tokens SET used_at = NOW()
  โ†’ REVOKE ALL SESSIONS: UPDATE np_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL
  โ† 200 { message: "Password reset. Please sign in with your new password." }

MFA (TOTP) Flow

Status: Implemented in D2-T05

POST /auth/mfa/enroll  (authenticated)
  โ†’ generate base32 TOTP secret
  โ†’ AES-256-GCM encrypt secret
  โ†’ INSERT np_mfa_devices (device_type='totp', totp_secret_enc, verified_at=NULL)
  โ†’ generate 8 backup codes; hash each; AES-256-GCM encrypt array
  โ†’ INSERT np_mfa_devices (device_type='backup_codes', backup_codes_enc)
  โ† 200 { qr_code_url, secret_b32, backup_codes_plaintext }

POST /auth/mfa/verify  (during signin challenge)
  โ†’ lookup active TOTP device for user (disabled_at IS NULL, verified_at IS NOT NULL)
  โ†’ verify submitted TOTP code against decrypted secret (RFC 6238, ยฑ30s window)
  โ†’ rate-limit: 5 failures โ†’ 5-min lockout
  โ†’ on first verify after enrollment: set verified_at = NOW()
  โ† 200 (session elevated to MFA-verified)

POST /auth/mfa/disable  (authenticated, re-auth required)
  โ†’ verify current password or valid TOTP
  โ†’ UPDATE np_mfa_devices SET disabled_at = NOW() WHERE user_id = $1
  โ† 200 { message: "MFA disabled" }

OAuth Social Login (CSRF Protected โ€” Amendment 9 SEC-2)

Status: Implemented in D2-T12

GET /auth/oauth/google   (or /auth/oauth/github)
  โ†’ generate state = 32 random bytes
  โ†’ state_hash = SHA256(state), hex-encoded
  โ†’ INSERT np_oauth_states (state_hash, provider, expires_at = now + 10 min)
  โ†’ redirect to provider with state param

GET /auth/callback/:provider?code=...&state=...
  โ†’ recompute state_hash = SHA256(state)
  โ†’ SELECT from np_oauth_states WHERE state_hash = $1 AND expires_at > now
  โ†’ 400 "CSRF validation failed" if not found or expired
  โ†’ exchange code for OAuth tokens
  โ†’ upsert np_users (email from provider, email_verified = TRUE for verified providers)
  โ†’ issue session + JWT (same flow as signin)
  โ† redirect to AUTH_MAGIC_LINK_BASE_URL (allowlisted paths only)

redirect_uri allowlist: only api.nself.org/auth/callback/* paths. No wildcards.


JWT Key Rotation

  1. Generate new RSA 2048-bit keypair: openssl genrsa -out new-key.pem 2048
  2. Extract public key: openssl rsa -in new-key.pem -pubout -out new-pub.pem
  3. Bump AUTH_JWT_KEY_ID in .env.prod (e.g., prod-key-YYYY-MM-DD)
  4. Update AUTH_JWT_PRIVATE_KEY + AUTH_JWT_PUBLIC_KEY in .env.secrets
  5. Add new public key to JWKS endpoint alongside the old key (24h grace period)
  6. After 24h: remove old key from JWKS
  7. Run nself build && nself deploy prod

The JWKS endpoint is at https://auth.nself.org/.well-known/jwks.json. Hasura is configured with {"type":"RS256","jwk_url":"https://auth.nself.org/.well-known/jwks.json"} so it auto-rotates when the JWKS updates.


Session Revocation (Amendment 9 SEC-1)

Sessions are stored in np_sessions with a revoked_at column. The auth middleware checks revoked_at IS NULL on every authenticated request โ€” this means even a valid unexpired JWT will return 401 if the session was server-side revoked.

Revocation triggers:

  • Password reset: all sessions for the user
  • Sign-out-all: all sessions for the user
  • Admin revocation: specific session by ID

Hasura JWT Claims

JWT payload includes Hasura claims in the namespace https://hasura.io/jwt/claims:

{
  "https://hasura.io/jwt/claims": {
    "x-hasura-user-id": "<np_users.id>",
    "x-hasura-default-role": "nself_user",
    "x-hasura-allowed-roles": ["nself_user", "nself_admin"],
    "x-hasura-source-account-id": "<source_account_id>",
    "x-hasura-tenant-id": "<tenant_id or null>"
  }
}

Hasura row filters use X-Hasura-User-Id for np_users and np_sessions. Cloud tables also filter by X-Hasura-Tenant-Id.


See Also

  • plugin-auth.md โ€” plugin install instructions
  • Config-Auth.md โ€” env var reference
  • web/backend/migrations/025_np_auth_plugin_schema.sql โ€” schema source
  • web/backend/plugins/auth/plugin.yaml โ€” plugin config
โš ๏ธ **GitHub.com Fallback** โš ๏ธ