mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
feat(services): create mana-auth (Hono + Bun) — Phase 5 auth rewrite
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) <noreply@anthropic.com>
This commit is contained in:
parent
924c15277a
commit
61ee1ae269
20 changed files with 1518 additions and 0 deletions
74
services/mana-auth/CLAUDE.md
Normal file
74
services/mana-auth/CLAUDE.md
Normal file
|
|
@ -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)
|
||||
16
services/mana-auth/Dockerfile
Normal file
16
services/mana-auth/Dockerfile
Normal file
|
|
@ -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"]
|
||||
11
services/mana-auth/drizzle.config.ts
Normal file
11
services/mana-auth/drizzle.config.ts
Normal file
|
|
@ -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'],
|
||||
});
|
||||
29
services/mana-auth/package.json
Normal file
29
services/mana-auth/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
449
services/mana-auth/src/auth/better-auth.config.ts
Normal file
449
services/mana-auth/src/auth/better-auth.config.ts
Normal file
|
|
@ -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<typeof createBetterAuth>;
|
||||
34
services/mana-auth/src/auth/stores.ts
Normal file
34
services/mana-auth/src/auth/stores.ts
Normal file
|
|
@ -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<string, { value: string; expires: number }>();
|
||||
|
||||
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();
|
||||
40
services/mana-auth/src/config.ts
Normal file
40
services/mana-auth/src/config.ts
Normal file
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
15
services/mana-auth/src/db/connection.ts
Normal file
15
services/mana-auth/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema/index';
|
||||
|
||||
let db: ReturnType<typeof drizzle<typeof schema>> | 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<typeof getDb>;
|
||||
32
services/mana-auth/src/db/schema/api-keys.ts
Normal file
32
services/mana-auth/src/db/schema/api-keys.ts
Normal file
|
|
@ -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<string[]>().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;
|
||||
261
services/mana-auth/src/db/schema/auth.ts
Normal file
261
services/mana-auth/src/db/schema/auth.ts
Normal file
|
|
@ -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<string[]>(), // ['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(),
|
||||
});
|
||||
4
services/mana-auth/src/db/schema/index.ts
Normal file
4
services/mana-auth/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './auth';
|
||||
export * from './organizations';
|
||||
export * from './api-keys';
|
||||
export * from './login-attempts';
|
||||
22
services/mana-auth/src/db/schema/login-attempts.ts
Normal file
22
services/mana-auth/src/db/schema/login-attempts.ts
Normal file
|
|
@ -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)]
|
||||
);
|
||||
72
services/mana-auth/src/db/schema/organizations.ts
Normal file
72
services/mana-auth/src/db/schema/organizations.ts
Normal file
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
85
services/mana-auth/src/email/send.ts
Normal file
85
services/mana-auth/src/email/send.ts
Normal file
|
|
@ -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<boolean> {
|
||||
if (!transporter) {
|
||||
console.log(`[EMAIL] To: ${to} | Subject: ${subject}`);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: '"ManaCore" <noreply@mana.how>',
|
||||
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',
|
||||
`<p>Hallo ${name || ''},</p><p>Bitte bestätige deine E-Mail-Adresse:</p><p><a href="${url}">E-Mail bestätigen</a></p><p>Oder kopiere diesen Link: ${url}</p>`
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(email: string, url: string, name?: string) {
|
||||
return send(
|
||||
email,
|
||||
'Passwort zurücksetzen — ManaCore',
|
||||
`<p>Hallo ${name || ''},</p><p>Klicke hier um dein Passwort zurückzusetzen:</p><p><a href="${url}">Passwort zurücksetzen</a></p><p>Der Link ist 1 Stunde gültig.</p>`
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendInvitationEmail(
|
||||
email: string,
|
||||
orgName: string,
|
||||
inviterName: string,
|
||||
url: string
|
||||
) {
|
||||
return send(
|
||||
email,
|
||||
`Einladung: ${orgName} — ManaCore`,
|
||||
`<p>${inviterName} hat dich zu <strong>${orgName}</strong> eingeladen.</p><p><a href="${url}">Einladung annehmen</a></p>`
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendMagicLinkEmail(email: string, url: string) {
|
||||
return send(
|
||||
email,
|
||||
'Login-Link — ManaCore',
|
||||
`<p>Klicke hier um dich anzumelden:</p><p><a href="${url}">Jetzt anmelden</a></p><p>Der Link ist 10 Minuten gültig.</p>`
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendAccountDeletionEmail(email: string, name?: string) {
|
||||
return send(
|
||||
email,
|
||||
'Konto gelöscht — ManaCore',
|
||||
`<p>Hallo ${name || ''},</p><p>Dein ManaCore-Konto wurde erfolgreich gelöscht. Alle deine Daten wurden entfernt.</p>`
|
||||
);
|
||||
}
|
||||
206
services/mana-auth/src/index.ts
Normal file
206
services/mana-auth/src/index.ts
Normal file
|
|
@ -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(`<!DOCTYPE html>
|
||||
<html><head><title>ManaCore Login</title></head>
|
||||
<body style="font-family:system-ui;max-width:400px;margin:80px auto;padding:20px;">
|
||||
<h1>ManaCore Login</h1>
|
||||
<form method="POST" action="/api/auth/sign-in/email">
|
||||
<input type="hidden" name="callbackURL" value="${query.callbackURL || '/'}" />
|
||||
<label>Email<br><input type="email" name="email" required style="width:100%;padding:8px;margin:4px 0 12px;"></label>
|
||||
<label>Password<br><input type="password" name="password" required style="width:100%;padding:8px;margin:4px 0 12px;"></label>
|
||||
<button type="submit" style="width:100%;padding:10px;background:#3b82f6;color:white;border:none;cursor:pointer;">Login</button>
|
||||
</form>
|
||||
</body></html>`);
|
||||
});
|
||||
|
||||
// ─── Start ──────────────────────────────────────────────────
|
||||
|
||||
console.log(`mana-auth starting on port ${config.port}...`);
|
||||
|
||||
export default {
|
||||
port: config.port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
43
services/mana-auth/src/lib/errors.ts
Normal file
43
services/mana-auth/src/lib/errors.ts
Normal file
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
29
services/mana-auth/src/middleware/error-handler.ts
Normal file
29
services/mana-auth/src/middleware/error-handler.ts
Normal file
|
|
@ -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<string, unknown> | 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
|
||||
);
|
||||
};
|
||||
57
services/mana-auth/src/middleware/jwt-auth.ts
Normal file
57
services/mana-auth/src/middleware/jwt-auth.ts
Normal file
|
|
@ -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<typeof createRemoteJWKSet> | 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');
|
||||
}
|
||||
};
|
||||
}
|
||||
26
services/mana-auth/src/middleware/service-auth.ts
Normal file
26
services/mana-auth/src/middleware/service-auth.ts
Normal file
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
13
services/mana-auth/tsconfig.json
Normal file
13
services/mana-auth/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue