feat(auth): attach-proof mint endpoint for detached-account re-attach (re-attach spec §3.1)

This commit is contained in:
Jannis Braun
2026-07-03 01:41:08 +02:00
parent 669d80d7c7
commit 1c962ded12
2 changed files with 181 additions and 2 deletions
@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, afterEach, 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';
import { signJwt } from '../utils/auth.js';
setWorkerId(13);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
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,
}));
// authRoutes → ./federation.js → ../ws/handler.js; stub the connection manager.
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToUser: vi.fn(),
sendToSpace: vi.fn(),
sendToDmMembers: vi.fn(),
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
evictFederatedCallsForHost: vi.fn(),
federatedCalls: new Map(),
isUserOnline: vi.fn(),
lateBindFederatedCall: vi.fn(),
},
}));
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;
}
beforeEach(async () => {
sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
applyMigrations(sqlite);
testDb = drizzle(sqlite, { schema });
testDb.insert(schema.users).values([
{ id: 'native-1', username: 'youruser', passwordHash: 'x', homeInstance: null, createdAt: 1 },
{ id: 'fed-1', username: 'guest@orbit.test', passwordHash: 'x', homeInstance: 'orbit.test', homeUserId: 'g-home', createdAt: 1 },
]).run();
app = await buildApp();
});
afterEach(async () => {
await app.close();
});
describe('POST /api/auth/attach-proof', () => {
it('mints a one-time token bound to the target domain', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/attach-proof',
headers: { authorization: `Bearer ${signJwt({ userId: 'native-1', username: 'youruser' })}` },
payload: { targetDomain: 'nova.ddns.net' },
});
expect(res.statusCode).toBe(200);
const { token } = JSON.parse(res.body);
expect(token).toMatch(/^[0-9a-f]{64}$/);
const row = testDb.select().from(schema.federationAttachProofs).all()[0]!;
expect(row.homeUserId).toBe('native-1');
expect(row.targetDomain).toBe('nova.ddns.net');
expect(row.usedAt).toBeNull();
expect(row.expiresAt - row.createdAt).toBe(60_000);
});
it('rejects non-native (federated) accounts', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/attach-proof',
headers: { authorization: `Bearer ${signJwt({ userId: 'fed-1', username: 'guest@orbit.test' })}` },
payload: { targetDomain: 'nova.ddns.net' },
});
expect(res.statusCode).toBe(403);
});
it('rejects a missing/invalid targetDomain', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/attach-proof',
headers: { authorization: `Bearer ${signJwt({ userId: 'native-1', username: 'youruser' })}` },
payload: {},
});
expect(res.statusCode).toBe(400);
});
it('rejects unauthenticated requests', async () => {
const res = await app.inject({ method: 'POST', url: '/api/auth/attach-proof', payload: { targetDomain: 'nova.ddns.net' } });
expect(res.statusCode).toBe(401);
});
it('deletes expired rows opportunistically on mint', async () => {
testDb.insert(schema.federationAttachProofs).values({
token: 'e'.repeat(64), homeUserId: 'native-1', targetDomain: 'x.test',
createdAt: 1, expiresAt: 2, usedAt: null,
}).run();
await app.inject({
method: 'POST',
url: '/api/auth/attach-proof',
headers: { authorization: `Bearer ${signJwt({ userId: 'native-1', username: 'youruser' })}` },
payload: { targetDomain: 'nova.ddns.net' },
});
const tokens = testDb.select().from(schema.federationAttachProofs).all().map(r => r.token);
expect(tokens).not.toContain('e'.repeat(64));
expect(tokens).toHaveLength(1);
});
});
+46 -2
View File
@@ -1,7 +1,8 @@
import type { FastifyInstance } from 'fastify';
import { eq, or } from 'drizzle-orm';
import { eq, or, lt } from 'drizzle-orm';
import { randomBytes } from 'node:crypto';
import { getDb, schema } from '../db/index.js';
import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js';
import { hashPassword, verifyPassword, signJwt, authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { config } from '../config.js';
import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared';
@@ -485,4 +486,47 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send(response);
});
// ─── POST /api/auth/attach-proof ──────────────────────────────────────────
// Mint a one-time proof token for detached-account re-attach on a peer
// (re-attach spec §3.1). Native accounts only — the token proves control of
// THIS home identity. 60s TTL, single-use, bound to the target peer domain
// (the verifying peer's domain is checked server-side on D, not trusted from
// the token bearer).
app.post<{ Body: { targetDomain?: unknown } }>('/api/auth/attach-proof', {
preHandler: authenticate,
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
}, async (request, reply) => {
const db = getDb();
const rawTarget = request.body?.targetDomain;
if (typeof rawTarget !== 'string' || rawTarget.trim().length === 0 || rawTarget.length > 255) {
return reply.code(400).send({ error: 'targetDomain is required (string)', statusCode: 400 });
}
const targetDomain = rawTarget.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
// Native accounts only — a federated/replicated account has no authority
// to mint proofs for this domain's identities.
if (request.homeInstance) {
return reply.code(403).send({ error: 'Only native accounts can mint attach proofs', statusCode: 403 });
}
const now = Date.now();
// Opportunistic janitor: expired rows have no residual value.
db.delete(schema.federationAttachProofs)
.where(lt(schema.federationAttachProofs.expiresAt, now))
.run();
const token = randomBytes(32).toString('hex');
db.insert(schema.federationAttachProofs).values({
token,
homeUserId: request.userId,
targetDomain,
createdAt: now,
expiresAt: now + 60_000,
usedAt: null,
}).run();
return reply.code(200).send({ token });
});
}