diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts index 0f6d1e325..0ab14b7c7 100644 --- a/services/mana-auth/src/index.ts +++ b/services/mana-auth/src/index.ts @@ -3,15 +3,6 @@ * * Hono + Bun runtime. Replaces NestJS-based mana-core-auth. * Uses Better Auth natively (fetch-based handler, no Express conversion). - * - * Better Auth handles: - * - Email/password auth with verification - * - JWT tokens (EdDSA via JWKS) - * - Sessions with cross-domain SSO - * - Organizations (B2B multi-tenant) - * - OIDC Provider (Matrix/Synapse SSO) - * - Two-factor authentication (TOTP) - * - Magic links (passwordless) */ import { Hono } from 'hono'; @@ -23,6 +14,12 @@ import { errorHandler } from './middleware/error-handler'; import { jwtAuth } from './middleware/jwt-auth'; import { serviceAuth } from './middleware/service-auth'; import { initializeEmail } from './email/send'; +import { SecurityEventsService, AccountLockoutService } from './services/security'; +import { ApiKeysService } from './services/api-keys'; +import { createAuthRoutes } from './routes/auth'; +import { createGuildRoutes } from './routes/guilds'; +import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys'; +import { createMeRoutes } from './routes/me'; // ─── Bootstrap ────────────────────────────────────────────── @@ -30,16 +27,17 @@ const config = loadConfig(); const db = getDb(config.databaseUrl); const auth = createBetterAuth(config.databaseUrl); -// Initialize email transport +// Initialize services initializeEmail(config.smtp); +const security = new SecurityEventsService(db); +const lockout = new AccountLockoutService(db); +const apiKeysService = new ApiKeysService(db); // ─── App ──────────────────────────────────────────────────── const app = new Hono(); app.onError(errorHandler); - -// CORS — must match Better Auth trustedOrigins app.use( '*', cors({ @@ -57,150 +55,64 @@ app.get('/health', (c) => ); // ─── Better Auth Native Handler ───────────────────────────── -// Better Auth's handler is fetch-based — Hono is fetch-based. -// No Express↔Fetch conversion needed! Just forward the request. -app.all('/api/auth/*', async (c) => { - const response = await auth.handler(c.req.raw); - return response; -}); - -// OIDC Discovery (must be at root) -app.get('/.well-known/openid-configuration', async (c) => { - const response = await auth.handler(c.req.raw); - return response; -}); +app.all('/api/auth/*', async (c) => auth.handler(c.req.raw)); +app.get('/.well-known/openid-configuration', async (c) => auth.handler(c.req.raw)); // ─── Custom Auth Endpoints ────────────────────────────────── -// Wrapper routes that add business logic around Better Auth -app.post('/api/v1/auth/register', async (c) => { - const body = await c.req.json(); +app.route('/api/v1/auth', createAuthRoutes(auth, config, security, lockout)); - // Store source app URL for email verification redirect - if (body.sourceAppUrl && body.email) { - const { sourceAppStore } = await import('./auth/stores'); - sourceAppStore.set(body.email, body.sourceAppUrl); - } +// ─── Guilds ───────────────────────────────────────────────── - // Forward to Better Auth sign-up - const signUpUrl = new URL('/api/auth/sign-up/email', config.baseUrl); - const response = await auth.handler( - new Request(signUpUrl, { - method: 'POST', - headers: c.req.raw.headers, - body: JSON.stringify({ - email: body.email, - password: body.password, - name: body.name || body.email.split('@')[0], - }), - }) - ); +app.use('/api/v1/gilden/*', jwtAuth(config.baseUrl)); +app.route('/api/v1/gilden', createGuildRoutes(auth, config)); - if (response.ok) { - // Initialize credit balance via mana-credits (fire-and-forget) - const result = await response.json(); - if (result?.user?.id) { - fetch(`${config.manaCreditsUrl}/api/v1/internal/credits/init`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Service-Key': config.serviceKey }, - body: JSON.stringify({ userId: result.user.id }), - }).catch(() => {}); - } - return c.json(result); - } +// ─── API Keys ─────────────────────────────────────────────── - // Forward error response - const errorBody = await response.text(); - return c.text(errorBody, response.status as any); -}); +app.use('/api/v1/api-keys/*', jwtAuth(config.baseUrl)); +app.route('/api/v1/api-keys', createApiKeyRoutes(apiKeysService)); +app.route('/api/v1/api-keys', createApiKeyValidationRoute(apiKeysService)); -app.post('/api/v1/auth/login', async (c) => { - const body = await c.req.json(); +// ─── Me (GDPR) ────────────────────────────────────────────── - const signInUrl = new URL('/api/auth/sign-in/email', config.baseUrl); - const response = await auth.handler( - new Request(signInUrl, { - method: 'POST', - headers: c.req.raw.headers, - body: JSON.stringify({ email: body.email, password: body.password }), - }) - ); +app.use('/api/v1/me/*', jwtAuth(config.baseUrl)); +app.route('/api/v1/me', createMeRoutes(db)); - // Copy Set-Cookie headers for SSO - const newResponse = new Response(response.body, { - status: response.status, - headers: response.headers, - }); - return newResponse; -}); +// ─── Internal API ─────────────────────────────────────────── -app.post('/api/v1/auth/validate', jwtAuth(config.baseUrl), async (c) => { - const user = c.get('user'); - return c.json({ valid: true, payload: user }); -}); +app.use('/api/v1/internal/*', serviceAuth(config.serviceKey)); -app.post('/api/v1/auth/logout', async (c) => { - const signOutUrl = new URL('/api/auth/sign-out', config.baseUrl); - return auth.handler( - new Request(signOutUrl, { - method: 'POST', - headers: c.req.raw.headers, - }) - ); -}); - -app.get('/api/v1/auth/session', async (c) => { - const sessionUrl = new URL('/api/auth/get-session', config.baseUrl); - return auth.handler( - new Request(sessionUrl, { - method: 'GET', - headers: c.req.raw.headers, - }) - ); -}); - -// ─── Internal API (service-to-service) ────────────────────── - -app.get('/api/v1/internal/org/:orgId/member/:userId', serviceAuth(config.serviceKey), async (c) => { +app.get('/api/v1/internal/org/:orgId/member/:userId', async (c) => { const { orgId, userId } = c.req.param(); - // Query members table directly - const { eq, and } = await import('drizzle-orm'); const { members } = await import('./db/schema/organizations'); + const { eq, and } = await import('drizzle-orm'); const [member] = await db .select() .from(members) .where(and(eq(members.organizationId, orgId), eq(members.userId, userId))) .limit(1); - - return c.json({ - isMember: !!member, - role: member?.role || '', - }); + return c.json({ isMember: !!member, role: member?.role || '' }); }); -// ─── Login Page (for OIDC) ────────────────────────────────── +// ─── Login Page (OIDC) ───────────────────────────────────── app.get('/login', (c) => { - const query = c.req.query(); + const q = c.req.query(); return c.html(`