From 61ee1ae26940da1a767b8a7fc7539cbc04d4b88d Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 28 Mar 2026 02:43:44 +0100 Subject: [PATCH] =?UTF-8?q?feat(services):=20create=20mana-auth=20(Hono=20?= =?UTF-8?q?+=20Bun)=20=E2=80=94=20Phase=205=20auth=20rewrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the central authentication service from NestJS to Hono + Bun. Uses Better Auth's native fetch-based handler — no Express conversion. Key architecture changes: - Better Auth handler mounted directly on Hono (app.all('/api/auth/*')) - No NestJS DI, modules, guards, decorators — plain TypeScript - JWT validation via jose (same as extracted services) - Email via nodemailer (simplified, German templates) - ~1,400 LOC vs ~11,500 LOC in NestJS (88% reduction) Service structure: - auth/better-auth.config.ts — copied from mana-core-auth (framework-agnostic) - auth/stores.ts — in-memory stores for email redirect URLs - email/send.ts — nodemailer email functions - middleware/ — JWT auth, service auth, error handler (shared pattern) - db/schema/ — copied from mana-core-auth (Drizzle, framework-agnostic) Port: 3001 (same as mana-core-auth — drop-in replacement) Database: mana_auth (same DB, same schemas) Better Auth plugins: Organization, JWT (EdDSA), OIDC Provider, Two-Factor (TOTP), Magic Link Note: This is the initial version. Guilds, API keys, Me (GDPR), security (lockout/audit), and admin endpoints will be added incrementally. The old mana-core-auth remains until fully replaced. Co-Authored-By: Claude Opus 4.6 (1M context) --- services/mana-auth/CLAUDE.md | 74 +++ services/mana-auth/Dockerfile | 16 + services/mana-auth/drizzle.config.ts | 11 + services/mana-auth/package.json | 29 ++ .../mana-auth/src/auth/better-auth.config.ts | 449 ++++++++++++++++++ services/mana-auth/src/auth/stores.ts | 34 ++ services/mana-auth/src/config.ts | 40 ++ services/mana-auth/src/db/connection.ts | 15 + services/mana-auth/src/db/schema/api-keys.ts | 32 ++ services/mana-auth/src/db/schema/auth.ts | 261 ++++++++++ services/mana-auth/src/db/schema/index.ts | 4 + .../mana-auth/src/db/schema/login-attempts.ts | 22 + .../mana-auth/src/db/schema/organizations.ts | 72 +++ services/mana-auth/src/email/send.ts | 85 ++++ services/mana-auth/src/index.ts | 206 ++++++++ services/mana-auth/src/lib/errors.ts | 43 ++ .../mana-auth/src/middleware/error-handler.ts | 29 ++ services/mana-auth/src/middleware/jwt-auth.ts | 57 +++ .../mana-auth/src/middleware/service-auth.ts | 26 + services/mana-auth/tsconfig.json | 13 + 20 files changed, 1518 insertions(+) create mode 100644 services/mana-auth/CLAUDE.md create mode 100644 services/mana-auth/Dockerfile create mode 100644 services/mana-auth/drizzle.config.ts create mode 100644 services/mana-auth/package.json create mode 100644 services/mana-auth/src/auth/better-auth.config.ts create mode 100644 services/mana-auth/src/auth/stores.ts create mode 100644 services/mana-auth/src/config.ts create mode 100644 services/mana-auth/src/db/connection.ts create mode 100644 services/mana-auth/src/db/schema/api-keys.ts create mode 100644 services/mana-auth/src/db/schema/auth.ts create mode 100644 services/mana-auth/src/db/schema/index.ts create mode 100644 services/mana-auth/src/db/schema/login-attempts.ts create mode 100644 services/mana-auth/src/db/schema/organizations.ts create mode 100644 services/mana-auth/src/email/send.ts create mode 100644 services/mana-auth/src/index.ts create mode 100644 services/mana-auth/src/lib/errors.ts create mode 100644 services/mana-auth/src/middleware/error-handler.ts create mode 100644 services/mana-auth/src/middleware/jwt-auth.ts create mode 100644 services/mana-auth/src/middleware/service-auth.ts create mode 100644 services/mana-auth/tsconfig.json diff --git a/services/mana-auth/CLAUDE.md b/services/mana-auth/CLAUDE.md new file mode 100644 index 000000000..2ae770d98 --- /dev/null +++ b/services/mana-auth/CLAUDE.md @@ -0,0 +1,74 @@ +# mana-auth + +Central authentication service for the ManaCore ecosystem. Rewritten from NestJS (mana-core-auth) to Hono + Bun. + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| **Runtime** | Bun | +| **Framework** | Hono | +| **Auth** | Better Auth (native Hono handler) | +| **Database** | PostgreSQL + Drizzle ORM | +| **JWT** | EdDSA via Better Auth JWT plugin | +| **Email** | Nodemailer + Brevo SMTP | + +## Port: 3001 (same as mana-core-auth — drop-in replacement) + +## Better Auth Plugins + +1. **Organization** — B2B multi-tenant with RBAC +2. **JWT** — EdDSA tokens with minimal claims (sub, email, role, sid) +3. **OIDC Provider** — Matrix/Synapse SSO +4. **Two-Factor** — TOTP with backup codes +5. **Magic Link** — Passwordless email login + +## Key Endpoints + +### Better Auth Native (`/api/auth/*`) +Handled directly by Better Auth — includes sign-in, sign-up, session, 2FA, magic links, org management. + +### Custom Auth (`/api/v1/auth/*`) +| Method | Path | Description | +|--------|------|-------------| +| POST | `/register` | Register + init credits | +| POST | `/login` | Login (returns JWT + sets SSO cookie) | +| POST | `/logout` | Logout | +| POST | `/validate` | Validate JWT token | +| GET | `/session` | Get current session | + +### OIDC (`/.well-known/*`, `/api/auth/oauth2/*`) +OpenID Connect provider for Matrix/Synapse SSO. + +### Internal (`/api/v1/internal/*`) +| Method | Path | Description | +|--------|------|-------------| +| GET | `/org/:orgId/member/:userId` | Check membership (for mana-credits) | + +## Cross-Domain SSO + +Session cookies shared across `*.mana.how` via `COOKIE_DOMAIN=.mana.how`. + +## Environment Variables + +```env +PORT=3001 +DATABASE_URL=postgresql://... +BASE_URL=https://auth.mana.how +COOKIE_DOMAIN=.mana.how +NODE_ENV=production +MANA_CORE_SERVICE_KEY=... +MANA_CREDITS_URL=http://mana-credits:3061 +SMTP_HOST=smtp-relay.brevo.com +SMTP_PORT=587 +SMTP_USER=... +SMTP_PASS=... +SYNAPSE_OIDC_CLIENT_SECRET=... +``` + +## Critical Rules + +- **ALWAYS use Better Auth** — no custom auth implementation +- **EdDSA algorithm only** for JWT (Better Auth manages JWKS) +- **Minimal JWT claims** — sub, email, role, sid only +- **jose library** for JWT validation (NOT jsonwebtoken) diff --git a/services/mana-auth/Dockerfile b/services/mana-auth/Dockerfile new file mode 100644 index 000000000..73b24b546 --- /dev/null +++ b/services/mana-auth/Dockerfile @@ -0,0 +1,16 @@ +FROM oven/bun:1 AS production + +WORKDIR /app + +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile 2>/dev/null || bun install + +COPY src ./src +COPY tsconfig.json drizzle.config.ts ./ + +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD bun -e "fetch('http://localhost:3001/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + +CMD ["bun", "run", "src/index.ts"] diff --git a/services/mana-auth/drizzle.config.ts b/services/mana-auth/drizzle.config.ts new file mode 100644 index 000000000..28afcb6e5 --- /dev/null +++ b/services/mana-auth/drizzle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema/*.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/mana_auth', + }, + schemaFilter: ['auth'], +}); diff --git a/services/mana-auth/package.json b/services/mana-auth/package.json new file mode 100644 index 000000000..1b6553621 --- /dev/null +++ b/services/mana-auth/package.json @@ -0,0 +1,29 @@ +{ + "name": "@mana/auth", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "hono": "^4.7.0", + "better-auth": "^1.4.3", + "drizzle-orm": "^0.38.3", + "postgres": "^3.4.5", + "jose": "^6.1.2", + "nodemailer": "^7.0.12", + "bcryptjs": "^3.0.2", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/nodemailer": "^6.4.17", + "@types/bcryptjs": "^2.4.6", + "drizzle-kit": "^0.30.4", + "typescript": "^5.9.3" + } +} diff --git a/services/mana-auth/src/auth/better-auth.config.ts b/services/mana-auth/src/auth/better-auth.config.ts new file mode 100644 index 000000000..c49fa9a62 --- /dev/null +++ b/services/mana-auth/src/auth/better-auth.config.ts @@ -0,0 +1,449 @@ +/** + * Better Auth Configuration + * + * This file configures Better Auth with: + * - Email/password authentication + * - Organization plugin for B2B (multi-tenant) + * - JWT plugin with minimal claims + * - Drizzle adapter for PostgreSQL + * + * ARCHITECTURE DECISION (2024-12): + * We use MINIMAL JWT claims. Organization and credit data should be fetched + * via API calls, not embedded in JWTs. See docs/AUTHENTICATION_ARCHITECTURE.md + * + * @see https://www.better-auth.com/docs + */ + +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { jwt } from 'better-auth/plugins/jwt'; +import { organization } from 'better-auth/plugins/organization'; +import { oidcProvider } from 'better-auth/plugins/oidc-provider'; +import { twoFactor } from 'better-auth/plugins/two-factor'; +import { magicLink } from 'better-auth/plugins/magic-link'; +import { getDb } from '../db/connection'; +import { organizations, members, invitations } from '../db/schema/organizations'; +import { + users, + sessions, + accounts, + verificationTokens, + jwks, + oauthApplications, + oauthAccessTokens, + oauthAuthorizationCodes, + oauthConsents, + twoFactorAuth, +} from '../db/schema/auth'; +import { + sendPasswordResetEmail, + sendInvitationEmail, + sendVerificationEmail, + sendMagicLinkEmail, +} from '../email/send'; +import { sourceAppStore, passwordResetRedirectStore } from './stores'; + +/** + * JWT Custom Payload Interface + * + * MINIMAL claims only. Organization context and credits are available via: + * - GET /organization/get-active-member - org membership & role + * - GET /api/v1/credits/balance - credit balance + * + * Why minimal claims? + * 1. Credit balance changes frequently - JWT would be stale + * 2. Organization context available via Better Auth org plugin APIs + * 3. Smaller tokens = better performance + * 4. Follows Better Auth's session-based design + */ +export interface JWTCustomPayload { + /** User ID (standard JWT claim) */ + sub: string; + + /** User email */ + email: string; + + /** User role (user, admin, service) */ + role: string; + + /** Session ID for reference */ + sid: string; +} + +/** + * Create Better Auth instance + * + * @param databaseUrl - PostgreSQL connection URL + * @returns Better Auth instance + */ +export function createBetterAuth(databaseUrl: string) { + const db = getDb(databaseUrl); + + return betterAuth({ + // Database adapter (Drizzle with PostgreSQL) + database: drizzleAdapter(db, { + provider: 'pg', + schema: { + // Auth tables (actual Drizzle table objects) + user: users, + session: sessions, + account: accounts, + verification: verificationTokens, + + // Organization tables + organization: organizations, + member: members, + invitation: invitations, + + // JWT plugin table + jwks: jwks, + + // Two-Factor Authentication table + twoFactor: twoFactorAuth, + + // OIDC Provider tables + oauthApplication: oauthApplications, + oauthAccessToken: oauthAccessTokens, + oauthAuthorizationCode: oauthAuthorizationCodes, + oauthConsent: oauthConsents, + }, + }), + + // Email/password authentication with password reset + emailAndPassword: { + enabled: true, + requireEmailVerification: true, + minPasswordLength: 8, + maxPasswordLength: 128, + + /** + * Password Reset Configuration + * + * Better Auth provides password reset via: + * - auth.api.requestPasswordReset({ body: { email } }) - Sends reset email + * - auth.api.resetPassword({ body: { newPassword, token } }) - Resets password + * + * The reset URL is modified to include callbackURL parameter + * so users are redirected back to the app they requested reset from. + * + * @see https://www.better-auth.com/docs/authentication/email-password#password-reset + */ + sendResetPassword: async ({ + user, + url, + }: { + user: { email: string; name: string }; + url: string; + }) => { + // Check if we have a redirect URL stored for this user's password reset request + const redirectUrl = passwordResetRedirectStore.get(user.email); + + // Modify reset URL to include callbackURL parameter + let resetUrl = url; + if (redirectUrl) { + const urlObj = new URL(url); + urlObj.searchParams.set('callbackURL', redirectUrl); + resetUrl = urlObj.toString(); + } + + await sendPasswordResetEmail(user.email, resetUrl, user.name); + }, + }, + + /** + * Email Verification Configuration + * + * Sends verification email when user registers. + * User must verify email before they can log in. + * + * The verification URL is modified to include redirectTo parameter + * so users are redirected back to the app they registered from. + */ + emailVerification: { + sendOnSignUp: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ + user, + url, + }: { + user: { email: string; name: string }; + url: string; + }) => { + // Check if we have a source app URL stored for this user + // Note: We get the URL without deleting it here since it might be needed + // during the verification process in the passthrough controller + const sourceAppUrl = sourceAppStore.get(user.email); + + // Modify verification URL to include redirectTo parameter + let verificationUrl = url; + if (sourceAppUrl) { + const urlObj = new URL(url); + urlObj.searchParams.set('redirectTo', sourceAppUrl); + verificationUrl = urlObj.toString(); + } + + await sendVerificationEmail(user.email, verificationUrl, user.name); + }, + }, + + // Session configuration + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // Update session once per day + }, + + // Base URL for callbacks and redirects + baseURL: process.env.BASE_URL || 'http://localhost:3001', + + /** + * Advanced Cookie Configuration for Cross-Domain SSO + * + * By setting the cookie domain to '.mana.how', session cookies are shared + * across all subdomains (calendar.mana.how, todo.mana.how, etc.). + * This enables Single Sign-On: login once, authenticated everywhere. + * + * For local development (localhost), leave domain undefined to use default behavior. + */ + advanced: { + // Cookie prefix for all auth cookies + cookiePrefix: 'mana', + + // Cross-subdomain cookie configuration + crossSubDomainCookies: { + // Enable cross-subdomain cookies in production + enabled: !!process.env.COOKIE_DOMAIN, + // Domain for cookies (e.g., '.mana.how' - note the leading dot) + domain: process.env.COOKIE_DOMAIN || undefined, + }, + + // Default cookie options for all auth cookies + defaultCookieAttributes: { + // Secure in production, allow http in development + secure: process.env.NODE_ENV === 'production', + // SameSite=None is required for cross-subdomain SSO via fetch() + // Lax only sends cookies on top-level navigations, not programmatic fetch() + // None requires Secure=true (ensured by production check above) + sameSite: process.env.COOKIE_DOMAIN ? ('none' as const) : ('lax' as const), + // Cookies accessible to all paths + path: '/', + // Prevent JavaScript access to cookies + httpOnly: true, + }, + }, + + // Trusted origins for cross-origin requests (must match CORS_ORIGINS in production) + // IMPORTANT: Every app that uses SSO must be listed here, otherwise + // Better Auth will reject cross-origin requests with credentials. + // When adding a new app, add its production domain here AND to + // CORS_ORIGINS in docker-compose.macmini.yml. + trustedOrigins: [ + // Production domains - auth service + 'https://auth.mana.how', + 'https://mana.how', + // Production domains - all apps (keep alphabetical) + 'https://calendar.mana.how', + 'https://chat.mana.how', + 'https://clock.mana.how', + 'https://contacts.mana.how', + 'https://context.mana.how', + 'https://docs.mana.how', + 'https://element.mana.how', + 'https://inventar.mana.how', + 'https://link.mana.how', + 'https://manadeck.mana.how', + 'https://matrix.mana.how', + 'https://mchat.mana.how', + 'https://mukke.mana.how', + 'https://nutriphi.mana.how', + 'https://photos.mana.how', + 'https://picture.mana.how', + 'https://planta.mana.how', + 'https://playground.mana.how', + 'https://presi.mana.how', + 'https://questions.mana.how', + 'https://skilltree.mana.how', + 'https://storage.mana.how', + 'https://todo.mana.how', + 'https://traces.mana.how', + 'https://zitare.mana.how', + // Local development + 'http://localhost:3001', + 'http://localhost:5173', + 'http://localhost:5174', + 'http://localhost:5190', + ], + + // Plugins + plugins: [ + /** + * Organization Plugin (B2B) + * + * Provides complete organization management: + * - Create/update/delete organizations + * - Invite/add/remove members + * - Role-based access control + * - Active organization tracking (session.activeOrganizationId) + * + * Client apps use these endpoints for org context: + * - GET /organization/get-active-member + * - GET /organization/get-active-member-role + * - POST /organization/set-active + */ + organization({ + // Allow users to create their own organizations + allowUserToCreateOrganization: true, + + // Email invitation handler + async sendInvitationEmail(data) { + const { email, organization, inviter } = data; + const baseUrl = process.env.BASE_URL || 'https://mana.how'; + const inviteUrl = `${baseUrl}/accept-invitation?id=${data.id}`; + await sendInvitationEmail( + email, + organization.name, + inviter?.user?.name || 'Ein Teammitglied', + inviteUrl + ); + }, + + // Custom roles and permissions + organizationRole: { + owner: { + permissions: [ + 'organization:update', + 'organization:delete', + 'members:invite', + 'members:remove', + 'members:update_role', + 'credits:allocate', + 'credits:view_all', + ], + }, + admin: { + permissions: [ + 'organization:update', + 'members:invite', + 'members:remove', + 'credits:view_all', + ], + }, + member: { + permissions: ['credits:view_own'], + }, + }, + }), + + /** + * JWT Plugin + * + * Generates JWT tokens with MINIMAL claims. + * + * DO NOT add complex claims like: + * - credit_balance (stale after 15min, fetch via API instead) + * - organization details (use Better Auth org plugin APIs) + * - customer_type (derive from activeOrganizationId presence) + * + * Apps should call APIs for dynamic data: + * - Credits: GET /api/v1/credits/balance + * - Org info: GET /organization/get-active-member + */ + jwt({ + jwt: { + // For OIDC compatibility, issuer MUST match the discovery document + // Use BASE_URL to match /.well-known/openid-configuration issuer + issuer: process.env.BASE_URL || process.env.JWT_ISSUER || 'http://localhost:3001', + audience: process.env.JWT_AUDIENCE || 'manacore', + expirationTime: '15m', + + /** + * Define minimal JWT payload + * + * Only includes static user info that doesn't change frequently. + */ + definePayload({ user, session }: { user: any; session: any }) { + return { + sub: user.id, + email: user.email, + role: (user as { role?: string }).role || 'user', + sid: session.id, + }; + }, + }, + }), + + /** + * OIDC Provider Plugin + * + * Enables Mana Core Auth to act as an OpenID Connect Provider. + * This allows Matrix/Synapse and other services to use SSO. + * + * Endpoints provided: + * - GET /.well-known/openid-configuration + * - GET /api/oidc/authorize + * - POST /api/oidc/token + * - GET /api/oidc/userinfo + * - GET /api/oidc/jwks + */ + oidcProvider({ + // Login page for OIDC authorization + loginPage: '/login', + // Consent page (skipped for trusted clients) + consentPage: '/consent', + // Use JWT plugin for token signing (EdDSA instead of HS256) + // This is required for Synapse OIDC which verifies via JWKS + useJWTPlugin: true, + metadata: { + issuer: process.env.BASE_URL || 'http://localhost:3001', + }, + // Trusted clients that skip consent screen + // These clients are considered first-party and don't need user consent + trustedClients: [ + { + clientId: 'matrix-synapse', + clientSecret: process.env.SYNAPSE_OIDC_CLIENT_SECRET || '', + name: 'Matrix Synapse', + type: 'web', + disabled: false, + metadata: {}, + redirectUrls: ['https://matrix.mana.how/_synapse/client/oidc/callback'], + skipConsent: true, + }, + ], + }), + /** + * Two-Factor Authentication Plugin (TOTP) + * + * Provides TOTP-based 2FA with backup codes. + * Endpoints provided automatically by Better Auth passthrough: + * - POST /two-factor/enable (requires password) + * - POST /two-factor/disable (requires password) + * - POST /two-factor/verify-totp (during login) + * - POST /two-factor/verify-backup-code (during login) + * - POST /two-factor/get-totp-uri + * - POST /two-factor/generate-backup-codes + */ + twoFactor({ + issuer: 'ManaCore', + }), + /** + * Magic Link Plugin (Passwordless Email Login) + * + * Sends a one-time login link via email. + * Endpoints via Better Auth passthrough: + * - POST /magic-link/send-magic-link + * - GET /magic-link/verify (callback from email) + */ + magicLink({ + sendMagicLink: async ({ email, url }: { email: string; url: string }) => { + await sendMagicLinkEmail(email, url); + }, + expiresIn: 600, // 10 minutes + }), + ], + }); +} + +/** + * Export type for Better Auth instance + */ +export type BetterAuthInstance = ReturnType; diff --git a/services/mana-auth/src/auth/stores.ts b/services/mana-auth/src/auth/stores.ts new file mode 100644 index 000000000..15bce9a7a --- /dev/null +++ b/services/mana-auth/src/auth/stores.ts @@ -0,0 +1,34 @@ +/** + * In-memory stores for cross-request state. + * Used to pass redirect URLs from registration/reset requests to email handlers. + */ + +const TTL = 10 * 60 * 1000; // 10 minutes + +function createStore() { + const map = new Map(); + + return { + set(key: string, value: string) { + map.set(key, { value, expires: Date.now() + TTL }); + }, + get(key: string): string | undefined { + const entry = map.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expires) { + map.delete(key); + return undefined; + } + return entry.value; + }, + delete(key: string) { + map.delete(key); + }, + }; +} + +/** Stores source app URL for email verification redirects */ +export const sourceAppStore = createStore(); + +/** Stores redirect URL for password reset callbacks */ +export const passwordResetRedirectStore = createStore(); diff --git a/services/mana-auth/src/config.ts b/services/mana-auth/src/config.ts new file mode 100644 index 000000000..9cdfac063 --- /dev/null +++ b/services/mana-auth/src/config.ts @@ -0,0 +1,40 @@ +export interface Config { + port: number; + databaseUrl: string; + baseUrl: string; + cookieDomain: string; + nodeEnv: string; + serviceKey: string; + cors: { origins: string[] }; + smtp: { + host: string; + port: number; + user: string; + pass: string; + }; + manaCreditsUrl: string; + manaSubscriptionsUrl: string; + synapseOidcClientSecret: string; +} + +export function loadConfig(): Config { + const env = (key: string, fallback?: string) => process.env[key] || fallback || ''; + return { + port: parseInt(env('PORT', '3001'), 10), + databaseUrl: env('DATABASE_URL', 'postgresql://manacore:devpassword@localhost:5432/mana_auth'), + baseUrl: env('BASE_URL', 'http://localhost:3001'), + cookieDomain: env('COOKIE_DOMAIN'), + nodeEnv: env('NODE_ENV', 'development'), + serviceKey: env('MANA_CORE_SERVICE_KEY', 'dev-service-key'), + cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') }, + smtp: { + host: env('SMTP_HOST', 'smtp-relay.brevo.com'), + port: parseInt(env('SMTP_PORT', '587'), 10), + user: env('SMTP_USER'), + pass: env('SMTP_PASS'), + }, + manaCreditsUrl: env('MANA_CREDITS_URL', 'http://localhost:3061'), + manaSubscriptionsUrl: env('MANA_SUBSCRIPTIONS_URL', 'http://localhost:3063'), + synapseOidcClientSecret: env('SYNAPSE_OIDC_CLIENT_SECRET'), + }; +} diff --git a/services/mana-auth/src/db/connection.ts b/services/mana-auth/src/db/connection.ts new file mode 100644 index 000000000..f0892a6d4 --- /dev/null +++ b/services/mana-auth/src/db/connection.ts @@ -0,0 +1,15 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema/index'; + +let db: ReturnType> | null = null; + +export function getDb(databaseUrl: string) { + if (!db) { + const client = postgres(databaseUrl, { max: 20 }); + db = drizzle(client, { schema }); + } + return db; +} + +export type Database = ReturnType; diff --git a/services/mana-auth/src/db/schema/api-keys.ts b/services/mana-auth/src/db/schema/api-keys.ts new file mode 100644 index 000000000..31491adea --- /dev/null +++ b/services/mana-auth/src/db/schema/api-keys.ts @@ -0,0 +1,32 @@ +import { text, timestamp, jsonb, integer, index } from 'drizzle-orm/pg-core'; +import { authSchema, users } from './auth.schema'; + +/** + * API Keys table for programmatic access to services. + * Keys are hashed using SHA-256 for security - the full key is only shown once at creation. + */ +export const apiKeys = authSchema.table( + 'api_keys', + { + id: text('id').primaryKey(), // nanoid + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + name: text('name').notNull(), // User-friendly name for the key + keyPrefix: text('key_prefix').notNull(), // "sk_live_abc..." for display (first 12 chars) + keyHash: text('key_hash').notNull(), // SHA-256 hash of the full key + scopes: jsonb('scopes').$type().default(['stt', 'tts']).notNull(), // Allowed service scopes + rateLimitRequests: integer('rate_limit_requests').default(60).notNull(), // Requests per window + rateLimitWindow: integer('rate_limit_window').default(60).notNull(), // Window in seconds + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + }, + (table) => [ + index('api_keys_user_id_idx').on(table.userId), + index('api_keys_key_hash_idx').on(table.keyHash), + ] +); + +export type ApiKey = typeof apiKeys.$inferSelect; +export type NewApiKey = typeof apiKeys.$inferInsert; diff --git a/services/mana-auth/src/db/schema/auth.ts b/services/mana-auth/src/db/schema/auth.ts new file mode 100644 index 000000000..355f86f71 --- /dev/null +++ b/services/mana-auth/src/db/schema/auth.ts @@ -0,0 +1,261 @@ +import { + pgSchema, + uuid, + text, + timestamp, + boolean, + jsonb, + pgEnum, + index, + integer, +} from 'drizzle-orm/pg-core'; + +export const authSchema = pgSchema('auth'); + +// Enum for user roles +export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'service']); + +// Users table (Better Auth schema) +export const users = authSchema.table('users', { + id: text('id').primaryKey(), // Better Auth generates nanoid + name: text('name').notNull(), + email: text('email').unique().notNull(), + emailVerified: boolean('email_verified').default(false).notNull(), + image: text('image'), // Better Auth uses 'image' not 'avatarUrl' + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + // Custom fields (not required by Better Auth) + role: userRoleEnum('role').default('user').notNull(), + twoFactorEnabled: boolean('two_factor_enabled').default(false), + deletedAt: timestamp('deleted_at', { withTimezone: true }), +}); + +// Sessions table (Better Auth schema) +export const sessions = authSchema.table('sessions', { + id: text('id').primaryKey(), // Better Auth generates nanoid + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + token: text('token').unique().notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + // Custom fields (not required by Better Auth) + refreshToken: text('refresh_token').unique(), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }), + deviceId: text('device_id'), + deviceName: text('device_name'), + lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow(), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + rememberMe: boolean('remember_me').default(false), +}); + +// Accounts table (for OAuth providers and credentials - Better Auth schema) +export const accounts = authSchema.table('accounts', { + id: text('id').primaryKey(), // Better Auth generates nanoid + accountId: text('account_id').notNull(), // Better Auth field + providerId: text('provider_id').notNull(), // Better Auth field (was 'provider') + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }), + scope: text('scope'), + password: text('password'), // Better Auth stores hashed password here for credential provider + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +// Verification table (Better Auth schema - for email verification, password reset) +export const verificationTokens = authSchema.table( + 'verification', + { + id: text('id').primaryKey(), // Better Auth generates nanoid + identifier: text('identifier').notNull(), // Better Auth uses identifier (e.g., email) + value: text('value').notNull(), // Better Auth uses value (the token) + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + identifierIdx: index('verification_identifier_idx').on(table.identifier), + }) +); + +// Password table (separate for security) +export const passwords = authSchema.table('passwords', { + userId: text('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + hashedPassword: text('hashed_password').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +// Two-factor authentication +export const twoFactorAuth = authSchema.table('two_factor_auth', { + userId: text('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + secret: text('secret').notNull(), + enabled: boolean('enabled').default(false).notNull(), + backupCodes: text('backup_codes').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + enabledAt: timestamp('enabled_at', { withTimezone: true }), +}); + +// Security events log +export const securityEvents = authSchema.table('security_events', { + id: uuid('id').primaryKey().defaultRandom(), // Our table, can keep UUID + userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }), + eventType: text('event_type').notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + metadata: jsonb('metadata'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); + +// JWKS table (Better Auth JWT plugin - stores signing keys) +export const jwks = authSchema.table('jwks', { + id: text('id').primaryKey(), + publicKey: text('public_key').notNull(), + privateKey: text('private_key').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); + +// OIDC Provider tables (Better Auth OIDC Provider plugin) +// OAuth Applications (OIDC Clients like Matrix/Synapse) +export const oauthApplications = authSchema.table('oauth_applications', { + id: text('id').primaryKey(), + name: text('name').notNull(), + icon: text('icon'), + metadata: text('metadata'), + clientId: text('client_id').unique().notNull(), + clientSecret: text('client_secret').notNull(), + redirectUrls: text('redirect_urls').notNull(), // Comma-separated URLs (Better Auth expects 'redirectUrls' property name) + type: text('type').notNull().default('web'), // web, native, spa + disabled: boolean('disabled').default(false).notNull(), + userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +// OAuth Access Tokens +export const oauthAccessTokens = authSchema.table('oauth_access_tokens', { + id: text('id').primaryKey(), + accessToken: text('access_token').unique().notNull(), + refreshToken: text('refresh_token').unique(), + accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }).notNull(), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }), + clientId: text('client_id').notNull(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + scopes: text('scopes').notNull(), // JSON array as text + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +// OAuth Authorization Codes +export const oauthAuthorizationCodes = authSchema.table('oauth_authorization_codes', { + id: text('id').primaryKey(), + code: text('code').unique().notNull(), + clientId: text('client_id').notNull(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + scopes: text('scopes').notNull(), // JSON array as text + redirectUri: text('redirect_uri').notNull(), + codeChallenge: text('code_challenge'), + codeChallengeMethod: text('code_challenge_method'), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); + +// OAuth Consents (user consent records for OIDC scopes) +export const oauthConsents = authSchema.table('oauth_consents', { + id: text('id').primaryKey(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + clientId: text('client_id').notNull(), + scopes: text('scopes').notNull(), // JSON array as text + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +// Matrix User Links table (for Bot SSO) +// Links Matrix user IDs to Mana user accounts for automatic bot authentication +export const matrixUserLinks = authSchema.table( + 'matrix_user_links', + { + id: text('id').primaryKey(), // nanoid + matrixUserId: text('matrix_user_id').unique().notNull(), // e.g., @user:matrix.mana.how + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + linkedAt: timestamp('linked_at', { withTimezone: true }).defaultNow().notNull(), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + // Optional: store email for convenience (denormalized from users table) + email: text('email'), + }, + (table) => ({ + userIdIdx: index('matrix_user_links_user_id_idx').on(table.userId), + }) +); + +// Passkeys table (WebAuthn credentials) +export const passkeys = authSchema.table( + 'passkeys', + { + id: text('id').primaryKey(), // nanoid + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + credentialId: text('credential_id').unique().notNull(), // base64url-encoded + publicKey: text('public_key').notNull(), // base64url-encoded COSE public key + counter: integer('counter').default(0).notNull(), // signature counter + deviceType: text('device_type').notNull(), // 'singleDevice' | 'multiDevice' + backedUp: boolean('backed_up').default(false).notNull(), + transports: jsonb('transports').$type(), // ['internal', 'hybrid', etc.] + friendlyName: text('friendly_name'), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdIdx: index('passkeys_user_id_idx').on(table.userId), + }) +); + +// User settings table (synced across all apps) +export const userSettings = authSchema.table('user_settings', { + userId: text('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + + // Global defaults (applies to all apps) + // { nav: { desktopPosition, sidebarCollapsed }, theme: { mode, colorScheme }, locale } + globalSettings: jsonb('global_settings') + .default({ + nav: { desktopPosition: 'top', sidebarCollapsed: false }, + theme: { mode: 'system', colorScheme: 'ocean' }, + locale: 'de', + }) + .notNull(), + + // Per-app overrides (applies to all devices) + // { "calendar": { nav: {...}, theme: {...} }, "chat": {...} } + appOverrides: jsonb('app_overrides').default({}).notNull(), + + // Per-device settings (device-specific app settings) + // { "device-abc-123": { deviceName: "MacBook", deviceType: "desktop", lastSeen: "...", apps: { "calendar": { dayStartHour: 6, ... } } } } + deviceSettings: jsonb('device_settings').default({}).notNull(), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); diff --git a/services/mana-auth/src/db/schema/index.ts b/services/mana-auth/src/db/schema/index.ts new file mode 100644 index 000000000..fae636ca4 --- /dev/null +++ b/services/mana-auth/src/db/schema/index.ts @@ -0,0 +1,4 @@ +export * from './auth'; +export * from './organizations'; +export * from './api-keys'; +export * from './login-attempts'; diff --git a/services/mana-auth/src/db/schema/login-attempts.ts b/services/mana-auth/src/db/schema/login-attempts.ts new file mode 100644 index 000000000..63811089a --- /dev/null +++ b/services/mana-auth/src/db/schema/login-attempts.ts @@ -0,0 +1,22 @@ +/** + * Login Attempts Schema + * + * Tracks login attempts for account lockout functionality. + * Failed attempts within a time window trigger account lockout. + */ + +import { pgSchema, text, boolean, timestamp, index, serial } from 'drizzle-orm/pg-core'; + +const authSchema = pgSchema('auth'); + +export const loginAttempts = authSchema.table( + 'login_attempts', + { + id: serial('id').primaryKey(), + email: text('email').notNull(), + ipAddress: text('ip_address'), + successful: boolean('successful').default(false).notNull(), + attemptedAt: timestamp('attempted_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [index('login_attempts_email_attempted_at_idx').on(table.email, table.attemptedAt)] +); diff --git a/services/mana-auth/src/db/schema/organizations.ts b/services/mana-auth/src/db/schema/organizations.ts new file mode 100644 index 000000000..90e38d881 --- /dev/null +++ b/services/mana-auth/src/db/schema/organizations.ts @@ -0,0 +1,72 @@ +import { pgSchema, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core'; +import { authSchema, users } from './auth.schema'; + +/** + * Better Auth Organization Tables + * These tables follow Better Auth's organization plugin schema requirements + * @see https://www.better-auth.com/docs/plugins/organization + * + * Note: Better Auth uses TEXT for IDs (nanoid/ULID), but we use UUID for users. + * The foreign key constraints will be added via raw SQL migration to handle the type difference. + */ + +// Organizations table +export const organizations = authSchema.table( + 'organizations', + { + id: text('id').primaryKey(), // Better Auth uses TEXT IDs (ULIDs/nanoids) + name: text('name').notNull(), + slug: text('slug').unique(), + logo: text('logo'), + metadata: jsonb('metadata'), // Additional organization data + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + slugIdx: index('organizations_slug_idx').on(table.slug), + }) +); + +// Members table (links users to organizations with roles) +export const members = authSchema.table( + 'members', + { + id: text('id').primaryKey(), // Better Auth uses TEXT IDs + organizationId: text('organization_id') + .references(() => organizations.id, { onDelete: 'cascade' }) + .notNull(), + userId: text('user_id').notNull(), // References auth.users.id (UUID cast to TEXT) + role: text('role').notNull(), // 'owner', 'admin', 'member', or custom roles + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + organizationIdIdx: index('members_organization_id_idx').on(table.organizationId), + userIdIdx: index('members_user_id_idx').on(table.userId), + organizationUserIdx: index('members_organization_user_idx').on( + table.organizationId, + table.userId + ), + }) +); + +// Invitations table (for inviting users to organizations) +export const invitations = authSchema.table( + 'invitations', + { + id: text('id').primaryKey(), // Better Auth uses TEXT IDs + organizationId: text('organization_id') + .references(() => organizations.id, { onDelete: 'cascade' }) + .notNull(), + email: text('email').notNull(), + role: text('role').notNull(), // Role they'll have when they accept + status: text('status').notNull(), // 'pending', 'accepted', 'rejected', 'canceled' + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + inviterId: text('inviter_id'), // References auth.users.id (UUID cast to TEXT) + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + organizationIdIdx: index('invitations_organization_id_idx').on(table.organizationId), + emailIdx: index('invitations_email_idx').on(table.email), + statusIdx: index('invitations_status_idx').on(table.status), + }) +); diff --git a/services/mana-auth/src/email/send.ts b/services/mana-auth/src/email/send.ts new file mode 100644 index 000000000..d1c3f8541 --- /dev/null +++ b/services/mana-auth/src/email/send.ts @@ -0,0 +1,85 @@ +/** + * Email sending functions using nodemailer. + * German language emails with Brevo SMTP. + */ + +import nodemailer from 'nodemailer'; + +let transporter: nodemailer.Transporter | null = null; + +export function initializeEmail(smtp: { host: string; port: number; user: string; pass: string }) { + if (!smtp.host || !smtp.user) { + console.warn('SMTP not configured — emails will be logged to console'); + return; + } + transporter = nodemailer.createTransport({ + host: smtp.host, + port: smtp.port, + secure: false, + auth: { user: smtp.user, pass: smtp.pass }, + }); +} + +async function send(to: string, subject: string, html: string): Promise { + if (!transporter) { + console.log(`[EMAIL] To: ${to} | Subject: ${subject}`); + return true; + } + try { + await transporter.sendMail({ + from: '"ManaCore" ', + to, + subject, + html, + }); + return true; + } catch (error) { + console.error('Failed to send email:', error); + return false; + } +} + +export async function sendVerificationEmail(email: string, url: string, name?: string) { + return send( + email, + 'E-Mail bestätigen — ManaCore', + `

Hallo ${name || ''},

Bitte bestätige deine E-Mail-Adresse:

E-Mail bestätigen

Oder kopiere diesen Link: ${url}

` + ); +} + +export async function sendPasswordResetEmail(email: string, url: string, name?: string) { + return send( + email, + 'Passwort zurücksetzen — ManaCore', + `

Hallo ${name || ''},

Klicke hier um dein Passwort zurückzusetzen:

Passwort zurücksetzen

Der Link ist 1 Stunde gültig.

` + ); +} + +export async function sendInvitationEmail( + email: string, + orgName: string, + inviterName: string, + url: string +) { + return send( + email, + `Einladung: ${orgName} — ManaCore`, + `

${inviterName} hat dich zu ${orgName} eingeladen.

Einladung annehmen

` + ); +} + +export async function sendMagicLinkEmail(email: string, url: string) { + return send( + email, + 'Login-Link — ManaCore', + `

Klicke hier um dich anzumelden:

Jetzt anmelden

Der Link ist 10 Minuten gültig.

` + ); +} + +export async function sendAccountDeletionEmail(email: string, name?: string) { + return send( + email, + 'Konto gelöscht — ManaCore', + `

Hallo ${name || ''},

Dein ManaCore-Konto wurde erfolgreich gelöscht. Alle deine Daten wurden entfernt.

` + ); +} diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts new file mode 100644 index 000000000..0f6d1e325 --- /dev/null +++ b/services/mana-auth/src/index.ts @@ -0,0 +1,206 @@ +/** + * mana-auth — Central authentication service + * + * 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'; +import { cors } from 'hono/cors'; +import { loadConfig } from './config'; +import { getDb } from './db/connection'; +import { createBetterAuth } from './auth/better-auth.config'; +import { errorHandler } from './middleware/error-handler'; +import { jwtAuth } from './middleware/jwt-auth'; +import { serviceAuth } from './middleware/service-auth'; +import { initializeEmail } from './email/send'; + +// ─── Bootstrap ────────────────────────────────────────────── + +const config = loadConfig(); +const db = getDb(config.databaseUrl); +const auth = createBetterAuth(config.databaseUrl); + +// Initialize email transport +initializeEmail(config.smtp); + +// ─── App ──────────────────────────────────────────────────── + +const app = new Hono(); + +app.onError(errorHandler); + +// CORS — must match Better Auth trustedOrigins +app.use( + '*', + cors({ + origin: config.cors.origins, + credentials: true, + allowHeaders: ['Content-Type', 'Authorization', 'X-Service-Key', 'X-App-Id'], + exposeHeaders: ['Set-Cookie'], + }) +); + +// ─── Health ───────────────────────────────────────────────── + +app.get('/health', (c) => + c.json({ status: 'ok', service: 'mana-auth', timestamp: new Date().toISOString() }) +); + +// ─── 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; +}); + +// ─── 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(); + + // 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); + } + + // 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], + }), + }) + ); + + 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); + } + + // Forward error response + const errorBody = await response.text(); + return c.text(errorBody, response.status as any); +}); + +app.post('/api/v1/auth/login', async (c) => { + const body = await c.req.json(); + + 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 }), + }) + ); + + // Copy Set-Cookie headers for SSO + const newResponse = new Response(response.body, { + status: response.status, + headers: response.headers, + }); + return newResponse; +}); + +app.post('/api/v1/auth/validate', jwtAuth(config.baseUrl), async (c) => { + const user = c.get('user'); + return c.json({ valid: true, payload: user }); +}); + +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) => { + 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 [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 || '', + }); +}); + +// ─── Login Page (for OIDC) ────────────────────────────────── + +app.get('/login', (c) => { + const query = c.req.query(); + return c.html(` +ManaCore Login + +

ManaCore Login

+
+ + + + +
+`); +}); + +// ─── Start ────────────────────────────────────────────────── + +console.log(`mana-auth starting on port ${config.port}...`); + +export default { + port: config.port, + fetch: app.fetch, +}; diff --git a/services/mana-auth/src/lib/errors.ts b/services/mana-auth/src/lib/errors.ts new file mode 100644 index 000000000..d3b2c3392 --- /dev/null +++ b/services/mana-auth/src/lib/errors.ts @@ -0,0 +1,43 @@ +import { HTTPException } from 'hono/http-exception'; + +export class BadRequestError extends HTTPException { + constructor(message: string) { + super(400, { message }); + } +} + +export class UnauthorizedError extends HTTPException { + constructor(message = 'Unauthorized') { + super(401, { message }); + } +} + +export class ForbiddenError extends HTTPException { + constructor(message = 'Forbidden') { + super(403, { message }); + } +} + +export class NotFoundError extends HTTPException { + constructor(message = 'Not found') { + super(404, { message }); + } +} + +export class ConflictError extends HTTPException { + constructor(message = 'Conflict') { + super(409, { message }); + } +} + +export class InsufficientCreditsError extends HTTPException { + constructor( + public readonly required: number, + public readonly available: number + ) { + super(402, { + message: 'Insufficient credits', + cause: { required, available }, + }); + } +} diff --git a/services/mana-auth/src/middleware/error-handler.ts b/services/mana-auth/src/middleware/error-handler.ts new file mode 100644 index 000000000..cec6640e8 --- /dev/null +++ b/services/mana-auth/src/middleware/error-handler.ts @@ -0,0 +1,29 @@ +/** + * Global error handler middleware for Hono. + */ + +import type { ErrorHandler } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +export const errorHandler: ErrorHandler = (err, c) => { + if (err instanceof HTTPException) { + const cause = err.cause as Record | undefined; + return c.json( + { + statusCode: err.status, + message: err.message, + ...(cause ? { details: cause } : {}), + }, + err.status + ); + } + + console.error('Unhandled error:', err); + return c.json( + { + statusCode: 500, + message: 'Internal server error', + }, + 500 + ); +}; diff --git a/services/mana-auth/src/middleware/jwt-auth.ts b/services/mana-auth/src/middleware/jwt-auth.ts new file mode 100644 index 000000000..390319288 --- /dev/null +++ b/services/mana-auth/src/middleware/jwt-auth.ts @@ -0,0 +1,57 @@ +/** + * JWT Authentication Middleware + * + * Validates Bearer tokens via JWKS from mana-core-auth. + * Uses jose library with EdDSA algorithm. + */ + +import type { MiddlewareHandler } from 'hono'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { UnauthorizedError } from '../lib/errors'; + +let jwks: ReturnType | null = null; + +function getJwks(authUrl: string) { + if (!jwks) { + jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl)); + } + return jwks; +} + +export interface AuthUser { + userId: string; + email: string; + role: string; +} + +/** + * Middleware that validates JWT tokens from Authorization: Bearer header. + * Sets c.set('user', { userId, email, role }) on success. + */ +export function jwtAuth(authUrl: string): MiddlewareHandler { + return async (c, next) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedError('Missing or invalid Authorization header'); + } + + const token = authHeader.slice(7); + try { + const { payload } = await jwtVerify(token, getJwks(authUrl), { + issuer: authUrl, + audience: 'manacore', + }); + + const user: AuthUser = { + userId: payload.sub || '', + email: (payload.email as string) || '', + role: (payload.role as string) || 'user', + }; + + c.set('user', user); + await next(); + } catch { + throw new UnauthorizedError('Invalid or expired token'); + } + }; +} diff --git a/services/mana-auth/src/middleware/service-auth.ts b/services/mana-auth/src/middleware/service-auth.ts new file mode 100644 index 000000000..a1012a11d --- /dev/null +++ b/services/mana-auth/src/middleware/service-auth.ts @@ -0,0 +1,26 @@ +/** + * Service-to-Service Authentication Middleware + * + * Validates X-Service-Key header for backend-to-backend calls. + * Used by /internal/* routes. + */ + +import type { MiddlewareHandler } from 'hono'; +import { UnauthorizedError } from '../lib/errors'; + +/** + * Middleware that validates X-Service-Key header. + * Sets c.set('appId', ...) from X-App-Id header. + */ +export function serviceAuth(serviceKey: string): MiddlewareHandler { + return async (c, next) => { + const key = c.req.header('X-Service-Key'); + if (!key || key !== serviceKey) { + throw new UnauthorizedError('Invalid or missing service key'); + } + + const appId = c.req.header('X-App-Id') || 'unknown'; + c.set('appId', appId); + await next(); + }; +} diff --git a/services/mana-auth/tsconfig.json b/services/mana-auth/tsconfig.json new file mode 100644 index 000000000..8c513d34d --- /dev/null +++ b/services/mana-auth/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +}