diff --git a/docs/systems/auth.md b/docs/systems/auth.md index 79dad638..f3642804 100644 --- a/docs/systems/auth.md +++ b/docs/systems/auth.md @@ -118,11 +118,54 @@ There is **no token blocklist**. The only revocation mechanism is the `passwordC ### Registration Gate -Registration open/closed is determined by: +The `/api/auth/register` route splits its gate by request shape (spec §1.2). There are **two independent toggles** plus an **invite-token bypass** for the local path. + +**Local anonymous signup** (no `homeInstance` in body): + 1. `instanceSettings.registrationOpen` (DB, id=1) -- if not null, this takes priority 2. `config.registrationOpen` (env `REGISTRATION_OPEN`, default `true`) -- fallback -Both are checked. DB value overrides env when explicitly set by admin. +DB value overrides env when explicitly set by admin. When closed, a valid `inviteToken` bypasses the gate and is **atomically consumed** alongside the user insert (see "Invite Tokens" below). When open, an `inviteToken` field is **silently ignored** (no validation, no consumption). + +**Federated identity replication** (request body has `homeInstance`): + +- Gated solely by `instanceSettings.federatedRegistrationOpen` (NOT NULL DEFAULT 1). +- `inviteToken` is **ignored entirely** on this path -- tokens never unlock federated creation, even when supplied. +- Closed → 403 `"Federated registration is closed on this instance"`. + +**Invariants** (spec §1.3): + +- **Login is never gated** by either toggle. Both gates affect *creation only*. Existing accounts remain loginable regardless of policy. +- The federated stub upgrade flow (below) is gated by `federatedRegistrationOpen`, never by an invite token. + +**Toggle matrix** (spec §5.6): + +| `registrationOpen` | `federatedRegistrationOpen` | Local register | Federated register | +|---|---|---|---| +| true | true | open | allowed | +| true | false | open | 403 | +| false | true | invite-required | allowed | +| false | false | invite-required | 403 | + +### Invite Tokens + +When `registrationOpen` is false, the local-signup path accepts an `inviteToken` field on the register body. Token format: 22-char base64url (16 random bytes). Lifecycle and admin CRUD live in `inviteService.ts` and `routes/invites.ts` -- see `docs/systems/admin.md` for the panel UX and the full status state machine. + +Atomic redemption (spec §2.4): + +``` +db.transaction(() => { + // 1. Re-fetch invite by token under txn (closes TOCTOU vs /check-invite) + // 2. Reject if status !== 'active' → throw InviteUnavailableError → 403 + // 3. INSERT user row + // 4. UPDATE invite_links SET usedCount = usedCount + 1 + // 5. INSERT invite_redemptions row (forensic audit, snapshots username) +}) +``` + +If any step throws (concurrent revoke, last-slot race, username collision against the unique index), the entire transaction rolls back -- `usedCount` is never incremented on a failed registration. The route catches `InviteUnavailableError` from `redeemInvite()` and surfaces it as 403 `"Invalid or expired invite"`. + +The `/api/auth/check-invite` debounced UX endpoint pre-validates a token from the register page; the in-txn re-derive inside `redeemInvite()` is the authoritative enforcement point. ### First-User Admin Promotion @@ -144,18 +187,21 @@ If `requestedAvatarColor` is provided and is in `AVATAR_COLORS`, use it. Otherwi ### Registration Steps 1. Validate inputs (username format, password length) -2. Check registration is open -3. **Federated stub upgrade check** (if `homeInstance` is set): call `findFederatedUser` to look for an existing relay-created stub. If found and upgradeable, upgrade it instead of creating a new record (see below). -4. Check username uniqueness (exact match on lowercased username) -5. Hash password (bcrypt, 12 rounds) -6. Generate Snowflake ID -7. Insert user row (status defaults to `'offline'` at the schema level; it is set to `'online'` only when the client establishes a WebSocket via the WS auth path in `ws/handler.ts`). Admin flag set if first user. -8. Sign JWT with `{ userId, username }` -9. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`) +2. Read both registration gates from `instance_settings` +3. **Branch by request shape** (spec §1.2): + - If `homeInstance` set → reject with 403 unless `federatedRegistrationOpen === true`. `inviteToken` ignored on this path. + - Else (local) → if `registrationOpen` is false, require a valid `inviteToken`; otherwise reject with 403. Pre-flight token check rejects obvious-invalid tokens before bcrypt. +4. **Federated stub upgrade check** (if `homeInstance` is set): call `findFederatedUser` to look for an existing relay-created stub. If found and upgradeable, upgrade it instead of creating a new record (see below). +5. Check username uniqueness (exact match on lowercased username) +6. Hash password (bcrypt, 12 rounds) +7. Generate Snowflake ID +8. Insert user row (status defaults to `'offline'` at the schema level; it is set to `'online'` only when the client establishes a WebSocket via the WS auth path in `ws/handler.ts`). Admin flag set if first user. **When the local-closed-with-token path is in play**, the insert runs inside `redeemInvite()`'s transaction so the user row, the `usedCount` bump, and the `invite_redemptions` row commit atomically (or all roll back). +9. Sign JWT with `{ userId, username }` +10. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`) ### Federated Stub Upgrade -When a user registers with `homeInstance` set (federated registration via friend-connect), the registration path checks for an existing relay-created stub using `findFederatedUser`. If found and the stub has `passwordHash = '!federation-replicated'` (not a real account), the stub is upgraded: +When a user registers with `homeInstance` set (federated registration via friend-connect), the registration path checks for an existing relay-created stub using `findFederatedUser`. The stub-upgrade flow is **always gated by `federatedRegistrationOpen`**, never by an invite token (spec §1.3). If found and the stub has `passwordHash = '!federation-replicated'` (not a real account), the stub is upgraded: - `passwordHash` is set to the new bcrypt hash (enables login) - `username` is updated to the registration's chosen username (replaces placeholder like `291255103060533248@nova.ddns.net` with `nova@nova.ddns.net`) diff --git a/packages/server/src/routes/auth.test.ts b/packages/server/src/routes/auth.test.ts index 99adbdab..57f41432 100644 --- a/packages/server/src/routes/auth.test.ts +++ b/packages/server/src/routes/auth.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -203,3 +204,254 @@ describe('GET /api/auth/check-invite', () => { } }); }); + +describe('POST /api/auth/register — federation gate split', () => { + beforeEach(() => { + // Ensure a fresh instance_settings singleton row with both gates default-true. + // The test harness's applyMigrations creates the table but does not seed the + // id=1 row (production does so via migrate.ts:ensureDefaults on first boot). + // Each test then mutates the toggles it cares about. + testDb.delete(schema.instanceSettings).run(); + testDb.insert(schema.instanceSettings).values({ + id: 1, + registrationOpen: 1, + federatedRegistrationOpen: 1, + updatedAt: Date.now(), + }).run(); + }); + + it('open registration: register without token succeeds; token field ignored if present', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'alice', password: 'password123' }, + }); + expect(res.statusCode).toBe(201); + + // Try with bogus token — still succeeds, token ignored + const res2 = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'bob', password: 'password123', inviteToken: 'fakefakefakefakefakeXX' }, + }); + expect(res2.statusCode).toBe(201); + }); + + it('closed registration without token: 403 "An invite is required"', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123' }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toContain('invite is required'); + }); + + it('closed registration with valid token: succeeds, usedCount incremented, redemption written', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-redeem-1', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 5, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123', inviteToken: token }, + }); + expect(res.statusCode).toBe(201); + + const inv = testDb.select().from(schema.inviteLinks) + .where(eq(schema.inviteLinks.id, 'inv-redeem-1')).get(); + expect(inv?.usedCount).toBe(1); + const redemptions = testDb.select().from(schema.inviteRedemptions) + .where(eq(schema.inviteRedemptions.inviteId, 'inv-redeem-1')).all(); + expect(redemptions).toHaveLength(1); + expect(redemptions[0]?.registrantUsername).toBe('newalice'); + }); + + it('closed registration with invalid token: 403 "Invalid or expired invite"', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123', inviteToken: 'fakefakefakefakefakeXX' }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toContain('Invalid or expired'); + }); + + it('open registration with token: usedCount NOT incremented', async () => { + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-ignore-1', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 5, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123', inviteToken: token }, + }); + expect(res.statusCode).toBe(201); + + const inv = testDb.select().from(schema.inviteLinks) + .where(eq(schema.inviteLinks.id, 'inv-ignore-1')).get(); + expect(inv?.usedCount).toBe(0); + + const redemptions = testDb.select().from(schema.inviteRedemptions) + .where(eq(schema.inviteRedemptions.inviteId, 'inv-ignore-1')).all(); + expect(redemptions).toHaveLength(0); + }); + + it('federated registration: blocked when federatedRegistrationOpen=false', async () => { + testDb.update(schema.instanceSettings) + .set({ federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + username: 'alice@otherhost', + password: 'password123', + homeInstance: 'otherhost', + homeUserId: 'remote-id', + }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toContain('Federated registration'); + }); + + it('federated registration: blocked even with valid token when federatedRegistrationOpen=false', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0, federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-fed-blocked', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 5, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + username: 'alice@otherhost', + password: 'password123', + homeInstance: 'otherhost', + homeUserId: 'remote-id', + inviteToken: token, + }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toContain('Federated registration'); + + // Token MUST NOT be consumed + const inv = testDb.select().from(schema.inviteLinks) + .where(eq(schema.inviteLinks.id, 'inv-fed-blocked')).get(); + expect(inv?.usedCount).toBe(0); + }); + + it('federated registration: token IGNORED even if provided (no usedCount increment)', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0, federatedRegistrationOpen: 1 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-fed-ignore', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 5, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + username: 'alice@otherhost', + password: 'password123', + homeInstance: 'otherhost', + homeUserId: 'remote-id', + inviteToken: token, + }, + }); + expect(res.statusCode).toBe(201); + + const inv = testDb.select().from(schema.inviteLinks) + .where(eq(schema.inviteLinks.id, 'inv-fed-ignore')).get(); + expect(inv?.usedCount).toBe(0); + + const redemptions = testDb.select().from(schema.inviteRedemptions) + .where(eq(schema.inviteRedemptions.inviteId, 'inv-fed-ignore')).all(); + expect(redemptions).toHaveLength(0); + }); + + it('closed registration: token last-slot race → 403 (in-txn re-derive)', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-exhausted', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 1, + usedCount: 1, // already at the cap + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123', inviteToken: token }, + }); + expect(res.statusCode).toBe(403); + }); +}); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 9f42517d..157bbb4e 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -8,7 +8,7 @@ import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/sha import { AVATAR_COLORS } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { findFederatedUser } from './federation.js'; -import { getInviteByToken, inviteStatus } from '../utils/inviteService.js'; +import { getInviteByToken, inviteStatus, redeemInvite, InviteUnavailableError } from '../utils/inviteService.js'; export async function authRoutes(app: FastifyInstance): Promise { app.post<{ Body: RegisterRequest }>('/api/auth/register', { @@ -76,13 +76,47 @@ export async function authRoutes(app: FastifyInstance): Promise { const db = getDb(); - // Check registration: DB setting overrides env var if explicitly set by admin + // Read both gates from instance_settings. + // - registrationOpen: nullable column; null falls back to env var (config.registrationOpen). + // Admin-explicit 0/1 overrides env. Gates LOCAL anonymous signup. + // - federatedRegistrationOpen: NOT NULL DEFAULT 1 column. Gates FEDERATED identity + // replication (homeInstance set). Independent of registrationOpen by spec §1.2. const instanceRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); const registrationOpen = instanceRow?.registrationOpen !== null && instanceRow?.registrationOpen !== undefined ? instanceRow.registrationOpen === 1 : config.registrationOpen; - if (!registrationOpen) { - return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 }); + const federatedRegistrationOpen = instanceRow?.federatedRegistrationOpen === 1; + + // Optional invite token. Only meaningful for the local-closed path; ignored + // entirely on the federated path (spec §1.3, §5.6) and on the local-open path + // (spec §5.7). + const inviteToken = typeof request.body.inviteToken === 'string' + ? request.body.inviteToken + : undefined; + + if (homeInstance) { + // Federated path: token IGNORED entirely. Gate is federatedRegistrationOpen. + if (!federatedRegistrationOpen) { + return reply.code(403).send({ error: 'Federated registration is closed on this instance', statusCode: 403 }); + } + // Fall through to existing federated stub upgrade / new federated user logic below. + } else { + // Local path: registrationOpen is the primary gate. A valid invite token + // bypasses it when closed. When open, the token is silently ignored. + if (!registrationOpen) { + if (!inviteToken) { + return reply.code(403).send({ error: 'Registration is closed. An invite is required.', statusCode: 403 }); + } + // Pre-flight check: reject obviously-invalid tokens before any expensive + // work (bcrypt). The final enforcement still happens inside the redemption + // transaction below — this only short-circuits the easy reject path. + const inviteRow = getInviteByToken(inviteToken); + if (!inviteRow || inviteStatus(inviteRow) !== 'active') { + return reply.code(403).send({ error: 'Invalid or expired invite', statusCode: 403 }); + } + } + // If registrationOpen is true: inviteToken is silently ignored — no validation, + // no consumption (spec §5.7). } const passwordHash = await hashPassword(password); @@ -175,7 +209,7 @@ export async function authRoutes(app: FastifyInstance): Promise { // which would otherwise produce a permanently stuck-online row that no // disconnect timer can clean up. The WS handshake will flip it to // 'online' once a real socket attaches. - db.insert(schema.users).values({ + const userRow = { id: userId, username: trimmedUsername, displayName: displayName?.trim() || null, @@ -185,7 +219,35 @@ export async function authRoutes(app: FastifyInstance): Promise { homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null, avatarColor, createdAt: now, - }).run(); + }; + + // Only the LOCAL-CLOSED-WITH-VALID-TOKEN path consumes an invite. The federated + // paths (handled above and in the stub-upgrade block) and the local-open path + // never touch the invite_links table. + const consumesInvite = !homeInstance && !registrationOpen && !!inviteToken; + + if (consumesInvite) { + // Atomic redemption: the user INSERT, the usedCount bump, and the + // invite_redemptions row all run inside one SQLite transaction. If any + // step throws (token consumed by a concurrent request, username collision + // bumping into the unique index, etc.) the entire transaction rolls back — + // we never burn a redemption on a failed registration. + try { + redeemInvite(inviteToken!, () => { + db.insert(schema.users).values(userRow).run(); + return { id: userId, username: trimmedUsername }; + }); + } catch (err) { + if (err instanceof InviteUnavailableError) { + // Concurrent revoke / last-slot race / expiry-while-typing all surface here. + return reply.code(403).send({ error: 'Invalid or expired invite', statusCode: 403 }); + } + throw err; + } + } else { + // Standard local-open or federated-new-user path: plain user insert. + db.insert(schema.users).values(userRow).run(); + } const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); if (!user) {