feat(auth): GET /api/auth/check-invite with collapsed enumeration shield
This commit is contained in:
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/sha
|
|||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
import { findFederatedUser } from './federation.js';
|
import { findFederatedUser } from './federation.js';
|
||||||
|
import { getInviteByToken, inviteStatus } from '../utils/inviteService.js';
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.post<{ Body: RegisterRequest }>('/api/auth/register', {
|
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 });
|
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', {
|
app.post<{ Body: LoginRequest }>('/api/auth/login', {
|
||||||
config: {
|
config: {
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
|
|||||||
Reference in New Issue
Block a user