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