Files
backspace/packages/server/src/routes/auth.ts
T
Jannis Braun 87301bd4d2 test(auth): polish register handler — comment, test isolation, +1 coverage
Quality-review polish on Task 11:

1. One-line comment near the federatedRegistrationOpen default behavior
   noting that the missing-row case is unreachable post-migration but
   falls federation-closed defensively (asymmetric with registrationOpen
   which falls back to env config — by design).

2. The "federated gate blocks token registration" test now sets ONLY
   federatedRegistrationOpen=0, isolating the federated-gate-alone
   effect rather than a both-gates-closed compound.

3. New test: open registration + revoked token → 201 (silently ignored).
   Locks the spec §5.7 invariant "no validation when registration is open"
   against future "let's just validate it for safety" regressions.

4. auth.md prose explicitly notes that federated stub upgrade and new-
   account paths do NOT enter redeemInvite — surfacing the structural
   enforcement of spec §1.3 "tokens never unlock federated creation".
2026-04-28 20:58:16 +02:00

445 lines
20 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { config } from '../config.js';
import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared';
import { AVATAR_COLORS } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
import { findFederatedUser } from './federation.js';
import { getInviteByToken, inviteStatus, redeemInvite, InviteUnavailableError } from '../utils/inviteService.js';
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: RegisterRequest }>('/api/auth/register', {
config: {
rateLimit: {
max: 10,
timeWindow: '2 minutes',
keyGenerator: (request: any) => request.ip,
},
},
}, async (request, reply) => {
const { username, password, displayName, avatarColor: requestedAvatarColor, homeInstance, homeUserId } = request.body;
if (!username || typeof username !== 'string') {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
}
if (!password || typeof password !== 'string') {
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
}
const trimmedUsername = username.trim().toLowerCase();
// Replicated registrations (homeInstance provided) may use username@domain format
// for collision fallback. Local registrations use strict alphanumeric+underscore.
if (homeInstance) {
// Validate homeInstance is a reasonable domain string
if (typeof homeInstance !== 'string' || homeInstance.length > 253 || !/^[a-zA-Z0-9._-]+$/.test(homeInstance)) {
return reply.code(400).send({ error: 'Invalid homeInstance domain', statusCode: 400 });
}
if (trimmedUsername.includes('@')) {
// username@domain format: validate local part + domain part
const atIndex = trimmedUsername.indexOf('@');
const localPart = trimmedUsername.slice(0, atIndex);
const domainPart = trimmedUsername.slice(atIndex + 1);
if (localPart.length < 3 || localPart.length > 32 || !/^[a-z0-9_]+$/.test(localPart)) {
return reply.code(400).send({ error: 'Username local part must be 3-32 lowercase alphanumeric/underscore characters', statusCode: 400 });
}
if (domainPart.length === 0 || domainPart.length > 253 || !/^[a-zA-Z0-9._-]+$/.test(domainPart)) {
return reply.code(400).send({ error: 'Username domain part is invalid', statusCode: 400 });
}
if (trimmedUsername.length > 100) {
return reply.code(400).send({ error: 'Username must be 100 characters or less', statusCode: 400 });
}
} else {
// Replicated users MUST use username@domain format — plain usernames
// are reserved exclusively for native users of this instance
return reply.code(400).send({ error: 'Replicated users must use username@domain format', statusCode: 400 });
}
} else {
// Local registration — strict validation
if (trimmedUsername.length < 3 || trimmedUsername.length > 32) {
return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 });
}
if (!/^[a-z0-9_]+$/.test(trimmedUsername)) {
return reply.code(400).send({ error: 'Username can only contain lowercase letters, numbers, and underscores', statusCode: 400 });
}
}
if (password.length < 8) {
return reply.code(400).send({ error: 'Password must be at least 8 characters', statusCode: 400 });
}
const db = getDb();
// 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;
// instanceRow is guaranteed by ensureDefaults() (migrate.ts) to have id=1
// post-boot, with federatedRegistrationOpen NOT NULL DEFAULT 1. The optional
// chain is defensive against the impossible-in-production case of a missing
// row (e.g., a hand-cleared DB) — falls open-closed rather than open-open
// for federation, which is the safer default.
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);
// --- Federated stub upgrade path (BEFORE username uniqueness check) ---
// If this is a federated registration, check if a relay-created stub already
// exists for this person. If so, upgrade it (add credentials, update username)
// instead of creating a duplicate record. The user gets their full DM history.
// This must run BEFORE the username check because the stub may have a different
// username (e.g., "291255103060533248@nova.ddns.net") that wouldn't collide.
if (homeInstance && homeUserId) {
const usernameBase = trimmedUsername.includes('@') ? trimmedUsername.split('@')[0]! : trimmedUsername;
const existingStub = findFederatedUser(homeUserId, homeInstance, db, { username: usernameBase });
if (existingStub) {
// If the found user already has real credentials, they already registered.
// Return 409 so the client falls back to login.
if (existingStub.passwordHash !== '!federation-replicated') {
return reply.code(409).send({ error: 'Username already taken', statusCode: 409 });
}
// Check the NEW username isn't taken by someone else (not the stub itself)
const usernameCollision = db.select().from(schema.users)
.where(eq(schema.users.username, trimmedUsername)).get();
if (usernameCollision && usernameCollision.id !== existingStub.id) {
return reply.code(409).send({ error: 'Username already taken', statusCode: 409 });
}
// Upgrade the stub: add credentials, update username and profile
const updates: Record<string, string | number | null> = {
passwordHash,
username: trimmedUsername,
homeUserId,
};
if (displayName?.trim() && !existingStub.displayName) {
updates.displayName = displayName.trim();
}
const avatarColor = (requestedAvatarColor && (AVATAR_COLORS as readonly string[]).includes(requestedAvatarColor))
? requestedAvatarColor
: AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)]!;
if (!existingStub.avatarColor) {
updates.avatarColor = avatarColor;
}
db.update(schema.users)
.set(updates)
.where(eq(schema.users.id, existingStub.id))
.run();
const upgraded = db.select().from(schema.users).where(eq(schema.users.id, existingStub.id)).get();
if (!upgraded) {
return reply.code(500).send({ error: 'Failed to upgrade user stub', statusCode: 500 });
}
console.log(`[auth] Upgraded federation stub ${existingStub.id} (${existingStub.username}${trimmedUsername}) to full account`);
const token = signJwt({ userId: upgraded.id, username: upgraded.username });
const response: AuthResponse = {
token,
user: sanitizeUser(upgraded, true),
};
return reply.code(200).send(response);
}
}
// --- Normal registration path (no existing stub found) ---
// Username uniqueness check (for non-federated registrations, or federated
// registrations where no stub was found to upgrade)
const existing = db.select().from(schema.users).where(eq(schema.users.username, trimmedUsername)).get();
if (existing) {
return reply.code(409).send({ error: 'Username already taken', statusCode: 409 });
}
const userId = generateSnowflake();
const now = Date.now();
// First registered user becomes instance admin (replicated users are never admins)
const userCount = db.select().from(schema.users).all().length;
const isFirstUser = userCount === 0 && !homeInstance;
const avatarColor = (requestedAvatarColor && (AVATAR_COLORS as readonly string[]).includes(requestedAvatarColor))
? requestedAvatarColor
: AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];
// Note: status is left at the schema default ('offline') and is set to
// 'online' exclusively by the WebSocket auth path (ws/handler.ts). A
// successful REST /register does not by itself imply a live connection —
// the client may never establish a WS (transient network failure, mobile
// background, error path between this 201 response and /ws connect),
// 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.
const userRow = {
id: userId,
username: trimmedUsername,
displayName: displayName?.trim() || null,
passwordHash,
isAdmin: isFirstUser ? 1 : 0,
homeInstance: homeInstance || null,
homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null,
avatarColor,
createdAt: now,
};
// 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) {
return reply.code(500).send({ error: 'Failed to create user', statusCode: 500 });
}
const token = signJwt({ userId: user.id, username: user.username });
const response: AuthResponse = {
token,
user: sanitizeUser(user, true),
};
return reply.code(201).send(response);
});
app.get<{ Querystring: { username?: string } }>('/api/auth/check-username', {
config: {
rateLimit: {
max: 30,
timeWindow: '1 minute',
keyGenerator: (request: any) => request.ip,
},
},
}, async (request, reply) => {
const raw = request.query.username;
if (!raw || typeof raw !== 'string') {
return reply.code(400).send({ available: false, reason: 'Username is required' });
}
const trimmed = raw.trim().toLowerCase();
// Format validation (same rules as registration)
if (trimmed.length < 3 || trimmed.length > 32) {
return reply.code(200).send({ available: false, reason: 'Username must be between 3 and 32 characters' });
}
if (!/^[a-z0-9_]+$/.test(trimmed)) {
return reply.code(200).send({ available: false, reason: 'Username can only contain lowercase letters, numbers, and underscores' });
}
// Check registration is open
const db = getDb();
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({ available: false, reason: 'Registration is currently closed' });
}
const existing = db.select().from(schema.users).where(eq(schema.users.username, trimmed)).get();
return reply.code(200).send({ available: !existing });
});
// Public — used by RegisterPage to debounce-validate invite tokens during
// typing. The status -> response mapping enforces a "collapsed enumeration
// shield": revoked, not-found, and malformed tokens all collapse to
// `'invalid'` so this endpoint can't be used to distinguish them. Only
// `expired` and `exhausted` surface as themselves because those are
// legitimate UX hints ("ask the admin to extend it") rather than
// existence/state leaks. The `name` field is returned ONLY in the valid
// case — invalid responses must not leak any invite metadata.
// Status code is always 200; the response body discriminates.
app.get<{ Querystring: { token?: string } }>('/api/auth/check-invite', {
config: {
rateLimit: {
max: 30,
timeWindow: '1 minute',
keyGenerator: (request: any) => request.ip,
},
},
}, async (request, reply) => {
const token = request.query.token;
if (!token || typeof token !== 'string') {
return reply.code(200).send({ valid: false, reason: 'invalid' });
}
// getInviteByToken pre-validates the 22-char base64url shape before
// hitting the DB; malformed inputs return null here, so the same branch
// covers both "wrong shape" and "shape ok, not in DB".
const row = getInviteByToken(token);
if (!row) {
return reply.code(200).send({ valid: false, reason: 'invalid' });
}
const status = inviteStatus(row);
if (status === 'active') {
return reply.code(200).send({ valid: true, name: row.name });
}
if (status === 'expired' || status === 'exhausted') {
return reply.code(200).send({ valid: false, reason: status });
}
// status === 'revoked' — collapsed to 'invalid' (no enumeration leak)
return reply.code(200).send({ valid: false, reason: 'invalid' });
});
app.post<{ Body: LoginRequest }>('/api/auth/login', {
config: {
rateLimit: {
max: 15,
timeWindow: '2 minutes',
keyGenerator: (request: any) => request.ip,
},
},
}, async (request, reply) => {
const { username, password } = request.body;
if (!username || typeof username !== 'string') {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
}
if (!password || typeof password !== 'string') {
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
}
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.username, username.trim().toLowerCase())).get();
if (!user) {
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}
if (user.isDeleted) {
return reply.code(401).send({ error: 'This account has been deleted', statusCode: 401 });
}
const validPassword = await verifyPassword(password, user.passwordHash);
if (!validPassword) {
// For federated users, try verifying against the home instance.
// If the password is valid there but stale here, self-heal the local hash.
if (user.homeInstance) {
try {
const homeUsername = user.username.includes('@')
? user.username.split('@')[0]!
: user.username;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
const homeResponse = await fetch(`https://${user.homeInstance}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: homeUsername, password }),
signal: controller.signal,
});
clearTimeout(timeout);
if (homeResponse.ok) {
// Home instance accepted the password — update our stale hash.
// Do NOT set passwordChangedAt: this is a state correction, not a
// password change. Setting it would invalidate existing valid JWTs.
const newHash = await hashPassword(password);
db.update(schema.users)
.set({ passwordHash: newHash })
.where(eq(schema.users.id, user.id))
.run();
app.log.info(`Self-healed password hash for federated user ${user.username} via ${user.homeInstance}`);
} else {
// Home instance also rejected — password is genuinely wrong
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}
} catch {
// Home instance unreachable — fall back to local-only rejection
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}
} else {
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}
}
// Note: status='online' is set exclusively by the WebSocket auth path
// (ws/handler.ts). A successful REST /login does not by itself imply a
// live connection — the client may never establish a WS (transient
// network failure, mobile background, error path), which would otherwise
// produce a permanently stuck-online row that no disconnect timer can
// clean up. The user's reported status remains whatever it was; the WS
// handshake will flip it to 'online' once a real socket attaches.
const token = signJwt({ userId: user.id, username: user.username });
const response: AuthResponse = {
token,
user: sanitizeUser(user, true),
};
return reply.code(200).send(response);
});
}