diff --git a/packages/server/src/routes/auth.attachProof.test.ts b/packages/server/src/routes/auth.attachProof.test.ts new file mode 100644 index 00000000..2e8a1d0c --- /dev/null +++ b/packages/server/src/routes/auth.attachProof.test.ts @@ -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>; +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 { + 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); + }); +}); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 9f732a2e..dfb426ec 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -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 { 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 }); + }); }