feat(auth): attach-proof mint endpoint for detached-account re-attach (re-attach spec §3.1)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user