auth flow - nself-org/cli GitHub Wiki
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.
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 UUIDnullable โ Cloud customer isolation only - Never swap these. Confusing them causes cross-customer data leaks.
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")
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" }
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 } }
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." }
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" }
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.
- Generate new RSA 2048-bit keypair:
openssl genrsa -out new-key.pem 2048 - Extract public key:
openssl rsa -in new-key.pem -pubout -out new-pub.pem - Bump
AUTH_JWT_KEY_IDin.env.prod(e.g.,prod-key-YYYY-MM-DD) - Update
AUTH_JWT_PRIVATE_KEY+AUTH_JWT_PUBLIC_KEYin.env.secrets - Add new public key to JWKS endpoint alongside the old key (24h grace period)
- After 24h: remove old key from JWKS
- 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.
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
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.
- 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