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:
Till JS 2026-03-28 02:43:44 +01:00
parent 924c15277a
commit 61ee1ae269
20 changed files with 1518 additions and 0 deletions

View 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)

View 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"]

View 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'],
});

View 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"
}
}

View 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>;

View 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();

View 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'),
};
}

View 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>;

View 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;

View 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(),
});

View file

@ -0,0 +1,4 @@
export * from './auth';
export * from './organizations';
export * from './api-keys';
export * from './login-attempts';

View 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)]
);

View 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),
})
);

View 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>`
);
}

View 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,
};

View 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 },
});
}
}

View 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
);
};

View 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');
}
};
}

View 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();
};
}

View 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"]
}