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
+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: {