feat(auth): GET /api/auth/check-invite with collapsed enumeration shield

This commit is contained in:
Jannis Braun
2026-04-28 20:42:46 +02:00
parent ade5c0d998
commit effb36c7f4
2 changed files with 255 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
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 fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
setWorkerId(2);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Module-level mutable state — see invites.test.ts for the rationale on why
// the `getDb` mock closes over a getter rather than the binding directly.
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
let app: FastifyInstance;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
async function buildApp(): Promise<FastifyInstance> {
const { authRoutes } = await import('./auth.js');
const f = Fastify();
await f.register(authRoutes);
return f;
}
const ADMIN_ID = 'admin-1';
beforeEach(async () => {
sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
applyMigrations(sqlite);
testDb = drizzle(sqlite, { schema });
// The invite_links rows we'll create reference users(id) via createdBy. Seed
// a single admin to act as creator across all tests.
testDb.insert(schema.users).values({
id: ADMIN_ID,
username: 'admin',
passwordHash: 'x',
isAdmin: 1,
createdAt: Date.now(),
}).run();
app = await buildApp();
});
describe('GET /api/auth/check-invite', () => {
it('returns valid: true with name for active token', async () => {
const token = 'a'.repeat(22);
testDb.insert(schema.inviteLinks).values({
id: 'inv-1',
token,
name: 'Friends batch 1',
createdBy: ADMIN_ID,
createdAt: Date.now(),
maxUses: 10,
usedCount: 0,
expiresAt: null,
revokedAt: null,
}).run();
const res = await app.inject({
method: 'GET',
url: `/api/auth/check-invite?token=${token}`,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.valid).toBe(true);
expect(body.name).toBe('Friends batch 1');
expect(body.reason).toBeUndefined();
});
it("returns valid: false, reason: 'expired' for past expiresAt", async () => {
const token = 'b'.repeat(22);
testDb.insert(schema.inviteLinks).values({
id: 'inv-2',
token,
name: 'Old link',
createdBy: ADMIN_ID,
createdAt: Date.now() - 10_000,
maxUses: 10,
usedCount: 0,
expiresAt: Date.now() - 1_000,
revokedAt: null,
}).run();
const res = await app.inject({
method: 'GET',
url: `/api/auth/check-invite?token=${token}`,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.valid).toBe(false);
expect(body.reason).toBe('expired');
expect(body.name).toBeUndefined();
});
it("returns valid: false, reason: 'exhausted' for used-up invite", async () => {
const token = 'c'.repeat(22);
testDb.insert(schema.inviteLinks).values({
id: 'inv-3',
token,
name: 'Burned',
createdBy: ADMIN_ID,
createdAt: Date.now(),
maxUses: 1,
usedCount: 1,
expiresAt: null,
revokedAt: null,
}).run();
const res = await app.inject({
method: 'GET',
url: `/api/auth/check-invite?token=${token}`,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.valid).toBe(false);
expect(body.reason).toBe('exhausted');
expect(body.name).toBeUndefined();
});
it("returns valid: false, reason: 'invalid' for revoked invite (collapsed shield)", async () => {
const token = 'd'.repeat(22);
testDb.insert(schema.inviteLinks).values({
id: 'inv-4',
token,
name: 'Revoked',
createdBy: ADMIN_ID,
createdAt: Date.now(),
maxUses: 10,
usedCount: 0,
expiresAt: null,
revokedAt: Date.now(),
}).run();
const res = await app.inject({
method: 'GET',
url: `/api/auth/check-invite?token=${token}`,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.valid).toBe(false);
expect(body.reason).toBe('invalid');
expect(body.name).toBeUndefined();
});
it("returns valid: false, reason: 'invalid' for unknown token", async () => {
// 22-char base64url string that is not in the DB.
const token = 'Z'.repeat(22);
const res = await app.inject({
method: 'GET',
url: `/api/auth/check-invite?token=${token}`,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.valid).toBe(false);
expect(body.reason).toBe('invalid');
expect(body.name).toBeUndefined();
});
it("returns valid: false, reason: 'invalid' for malformed token", async () => {
const res = await app.inject({
method: 'GET',
url: '/api/auth/check-invite?token=tooshort',
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.valid).toBe(false);
expect(body.reason).toBe('invalid');
expect(body.name).toBeUndefined();
});
it('does not include name field on invalid responses', async () => {
// Sweep across all invalid permutations to assert the absence-of-leak
// contract once for the whole endpoint, not just per-status.
const cases = [
{ url: '/api/auth/check-invite' },
{ url: '/api/auth/check-invite?token=' },
{ url: '/api/auth/check-invite?token=tooshort' },
{ url: `/api/auth/check-invite?token=${'Z'.repeat(22)}` },
];
for (const c of cases) {
const res = await app.inject({ method: 'GET', url: c.url });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.valid).toBe(false);
expect(body).not.toHaveProperty('name');
}
});
});
+43
View File
@@ -8,6 +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';
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: RegisterRequest }>('/api/auth/register', {
@@ -239,6 +240,48 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
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: {