mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 11:46:43 +02:00
🐛 fix(auth): use Better Auth native JWT validation with EdDSA
- Replace jsonwebtoken RS256 validation with jose EdDSA - Add JWKS endpoint to expose Better Auth public keys - Use createRemoteJWKSet for token validation - Fix issuer mismatch (use env var consistently) - Add jwks table to schema for Better Auth JWT plugin - Install jose library for JWT verification
This commit is contained in:
parent
2a002bf6be
commit
8dd1e4326c
10 changed files with 573 additions and 555 deletions
|
|
@ -5,8 +5,9 @@ export default defineConfig({
|
|||
schema: './src/db/schema/index.ts',
|
||||
out: './src/db/migrations',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore',
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/manacore',
|
||||
},
|
||||
schemaFilter: ['auth', 'credits', 'public'],
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"helmet": "^8.0.0",
|
||||
"jose": "^6.1.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nanoid": "^5.0.9",
|
||||
"postgres": "^3.4.5",
|
||||
|
|
|
|||
|
|
@ -126,6 +126,17 @@ export class AuthController {
|
|||
return this.betterAuthService.validateToken(body.token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWKS (JSON Web Key Set)
|
||||
*
|
||||
* Returns public keys for JWT verification.
|
||||
* This is a passthrough to Better Auth's JWKS.
|
||||
*/
|
||||
@Get('jwks')
|
||||
async getJwks() {
|
||||
return this.betterAuthService.getJwks();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// B2B Registration
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@
|
|||
* This file configures Better Auth with:
|
||||
* - Email/password authentication
|
||||
* - Organization plugin for B2B (multi-tenant)
|
||||
* - JWT plugin with custom claims (credit_balance, customer_type, organization)
|
||||
* - 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
|
||||
* @see BETTER_AUTH_FINAL_PLAN.md
|
||||
*/
|
||||
|
||||
import { betterAuth } from 'better-auth';
|
||||
|
|
@ -16,16 +19,32 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|||
import { jwt } from 'better-auth/plugins/jwt';
|
||||
import { organization } from 'better-auth/plugins/organization';
|
||||
import { getDb } from '../db/connection';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { balances } from '../db/schema/credits.schema';
|
||||
import { organizations, members } from '../db/schema/organizations.schema';
|
||||
import {
|
||||
organizations,
|
||||
members,
|
||||
invitations,
|
||||
} from '../db/schema/organizations.schema';
|
||||
import {
|
||||
users,
|
||||
sessions,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
jwks,
|
||||
} from '../db/schema/auth.schema';
|
||||
import type { JWTPayloadContext } from './types/better-auth.types';
|
||||
|
||||
/**
|
||||
* JWT Custom Payload Interface
|
||||
*
|
||||
* Defines the structure of custom claims included in JWT tokens.
|
||||
* These claims are added to the standard JWT payload (sub, iat, exp, etc.)
|
||||
* 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) */
|
||||
|
|
@ -37,145 +56,13 @@ export interface JWTCustomPayload {
|
|||
/** User role (user, admin, service) */
|
||||
role: string;
|
||||
|
||||
/** Customer type: B2C (individual) or B2B (organization member) */
|
||||
customer_type: 'b2c' | 'b2b';
|
||||
|
||||
/** Organization context (null for B2C users) */
|
||||
organization: {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'owner' | 'admin' | 'member';
|
||||
} | null;
|
||||
|
||||
/** User's credit balance (personal for B2C, allocated for B2B) */
|
||||
credit_balance: number;
|
||||
|
||||
/** Application ID (memoro, chat, picture, etc.) */
|
||||
app_id?: string;
|
||||
|
||||
/** Device ID (for mobile apps) */
|
||||
device_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get personal credit balance (B2C users)
|
||||
*
|
||||
* @param userId - User ID
|
||||
* @param databaseUrl - Database connection URL
|
||||
* @returns Credit balance or 0 if not found
|
||||
*/
|
||||
async function getPersonalCreditBalance(userId: string, databaseUrl: string): Promise<number> {
|
||||
try {
|
||||
const db = getDb(databaseUrl);
|
||||
|
||||
const [balance] = await db
|
||||
.select({ balance: balances.balance })
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return balance?.balance ?? 0;
|
||||
} catch (error) {
|
||||
console.error('Error fetching personal credit balance:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get employee credit balance (B2B users)
|
||||
*
|
||||
* For B2B employees, this returns their allocated credit balance.
|
||||
* The balance is stored in the same balances table but tracked separately per employee.
|
||||
*
|
||||
* @param userId - Employee user ID
|
||||
* @param organizationId - Organization ID
|
||||
* @param databaseUrl - Database connection URL
|
||||
* @returns Allocated credit balance or 0 if not found
|
||||
*/
|
||||
async function getEmployeeCreditBalance(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
databaseUrl: string
|
||||
): Promise<number> {
|
||||
try {
|
||||
const db = getDb(databaseUrl);
|
||||
|
||||
// Get employee's personal balance (which represents their allocated credits from the org)
|
||||
const [balance] = await db
|
||||
.select({ balance: balances.balance })
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return balance?.balance ?? 0;
|
||||
} catch (error) {
|
||||
console.error('Error fetching employee credit balance:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get organization membership data
|
||||
*
|
||||
* Queries the organization and member tables to get:
|
||||
* - Organization name
|
||||
* - User's role in the organization
|
||||
*
|
||||
* @param userId - User ID
|
||||
* @param organizationId - Organization ID
|
||||
* @param databaseUrl - Database connection URL
|
||||
* @returns Organization data with name and role, or null if not found
|
||||
*/
|
||||
async function getOrganizationMembership(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
databaseUrl: string
|
||||
): Promise<{ name: string; role: 'owner' | 'admin' | 'member' } | null> {
|
||||
try {
|
||||
const db = getDb(databaseUrl);
|
||||
|
||||
// Query member table to get user's role in the organization
|
||||
const [memberRecord] = await db
|
||||
.select({
|
||||
role: members.role,
|
||||
})
|
||||
.from(members)
|
||||
.where(and(eq(members.userId, userId), eq(members.organizationId, organizationId)))
|
||||
.limit(1);
|
||||
|
||||
if (!memberRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Query organization table to get organization name
|
||||
const [orgRecord] = await db
|
||||
.select({
|
||||
name: organizations.name,
|
||||
})
|
||||
.from(organizations)
|
||||
.where(eq(organizations.id, organizationId))
|
||||
.limit(1);
|
||||
|
||||
if (!orgRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: orgRecord.name,
|
||||
role: memberRecord.role as 'owner' | 'admin' | 'member',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching organization membership:', error);
|
||||
return null;
|
||||
}
|
||||
/** Session ID for reference */
|
||||
sid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Better Auth instance
|
||||
*
|
||||
* This function initializes Better Auth with the database connection URL.
|
||||
* It must be called with the database URL from the configuration.
|
||||
*
|
||||
* @param databaseUrl - PostgreSQL connection URL
|
||||
* @returns Better Auth instance
|
||||
*/
|
||||
|
|
@ -187,16 +74,19 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
schema: {
|
||||
// Auth tables
|
||||
user: 'auth.users',
|
||||
session: 'auth.sessions',
|
||||
account: 'auth.accounts',
|
||||
verification: 'auth.verification_tokens',
|
||||
// Auth tables (actual Drizzle table objects)
|
||||
user: users,
|
||||
session: sessions,
|
||||
account: accounts,
|
||||
verification: verificationTokens,
|
||||
|
||||
// Organization tables (Better Auth creates these schemas)
|
||||
organization: 'auth.organizations',
|
||||
member: 'auth.members',
|
||||
invitation: 'auth.invitations',
|
||||
// Organization tables
|
||||
organization: organizations,
|
||||
member: members,
|
||||
invitation: invitations,
|
||||
|
||||
// JWT plugin table
|
||||
jwks: jwks,
|
||||
},
|
||||
}),
|
||||
|
||||
|
|
@ -226,7 +116,12 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
* - Create/update/delete organizations
|
||||
* - Invite/add/remove members
|
||||
* - Role-based access control
|
||||
* - Email-based invitations
|
||||
* - 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
|
||||
|
|
@ -242,22 +137,10 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
organization: organization.name,
|
||||
invitationId: data.id,
|
||||
});
|
||||
|
||||
// Example email template:
|
||||
// Subject: Join ${organization.name} on Mana Universe
|
||||
// Body: You've been invited to join ${organization.name}
|
||||
// Click here to accept: ${baseURL}/invite/${data.id}
|
||||
},
|
||||
|
||||
// Custom roles and permissions
|
||||
organizationRole: {
|
||||
/**
|
||||
* Owner Role
|
||||
* - Full organization control
|
||||
* - Can delete organization
|
||||
* - Can manage all members
|
||||
* - Can allocate credits to employees
|
||||
*/
|
||||
owner: {
|
||||
permissions: [
|
||||
'organization:update',
|
||||
|
|
@ -265,17 +148,10 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
'members:invite',
|
||||
'members:remove',
|
||||
'members:update_role',
|
||||
'credits:allocate', // Custom permission
|
||||
'credits:view_all', // Custom permission
|
||||
'credits:allocate',
|
||||
'credits:view_all',
|
||||
],
|
||||
},
|
||||
|
||||
/**
|
||||
* Admin Role
|
||||
* - Can update organization settings
|
||||
* - Can invite and remove members
|
||||
* - Can view all credit usage
|
||||
*/
|
||||
admin: {
|
||||
permissions: [
|
||||
'organization:update',
|
||||
|
|
@ -284,12 +160,6 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
'credits:view_all',
|
||||
],
|
||||
},
|
||||
|
||||
/**
|
||||
* Member Role
|
||||
* - Basic organization access
|
||||
* - Can only view their own credits
|
||||
*/
|
||||
member: {
|
||||
permissions: ['credits:view_own'],
|
||||
},
|
||||
|
|
@ -299,99 +169,35 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
/**
|
||||
* JWT Plugin
|
||||
*
|
||||
* Generates JWT tokens with custom claims for:
|
||||
* - Credit balance
|
||||
* - Customer type (B2C vs B2B)
|
||||
* - Organization context
|
||||
* - App/device metadata
|
||||
* 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: {
|
||||
issuer: 'mana-core',
|
||||
issuer: process.env.JWT_ISSUER || 'manacore',
|
||||
audience: process.env.JWT_AUDIENCE || 'manacore',
|
||||
expirationTime: '15m', // 15 minutes for access tokens
|
||||
expirationTime: '15m',
|
||||
|
||||
/**
|
||||
* Define custom JWT payload
|
||||
* Define minimal JWT payload
|
||||
*
|
||||
* This function is called when generating a JWT token.
|
||||
* It enriches the standard JWT claims with custom data.
|
||||
*
|
||||
* @param context - JWT payload context with user and session
|
||||
* @returns Custom JWT payload
|
||||
* Only includes static user info that doesn't change frequently.
|
||||
*/
|
||||
async definePayload({ user, session }: JWTPayloadContext) {
|
||||
// Get user's active organization (from session metadata or first membership)
|
||||
const activeOrgId = session.activeOrganizationId;
|
||||
|
||||
let organizationData: JWTCustomPayload['organization'] = null;
|
||||
let creditBalance = 0;
|
||||
let customerType: 'b2c' | 'b2b' = 'b2c';
|
||||
|
||||
if (activeOrgId) {
|
||||
// B2B user - get organization membership from database
|
||||
try {
|
||||
// Query actual organization and membership data
|
||||
const membership = await getOrganizationMembership(
|
||||
user.id,
|
||||
activeOrgId,
|
||||
databaseUrl
|
||||
);
|
||||
|
||||
if (membership) {
|
||||
// Get employee's allocated credit balance
|
||||
creditBalance = await getEmployeeCreditBalance(
|
||||
user.id,
|
||||
activeOrgId,
|
||||
databaseUrl
|
||||
);
|
||||
|
||||
organizationData = {
|
||||
id: activeOrgId,
|
||||
name: membership.name,
|
||||
role: membership.role,
|
||||
};
|
||||
|
||||
customerType = 'b2b';
|
||||
} else {
|
||||
// User is not a member of this organization, fall back to B2C
|
||||
console.warn(
|
||||
`User ${user.id} is not a member of organization ${activeOrgId}`
|
||||
);
|
||||
creditBalance = await getPersonalCreditBalance(user.id, databaseUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching organization data:', error);
|
||||
// Fall back to B2C on error
|
||||
creditBalance = await getPersonalCreditBalance(user.id, databaseUrl);
|
||||
}
|
||||
} else {
|
||||
// B2C user - get personal credit balance
|
||||
creditBalance = await getPersonalCreditBalance(user.id, databaseUrl);
|
||||
}
|
||||
|
||||
// Build custom JWT payload
|
||||
const payload: Partial<JWTCustomPayload> = {
|
||||
// Standard claims
|
||||
definePayload({ user, session }: JWTPayloadContext) {
|
||||
return {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role || 'user',
|
||||
|
||||
// Customer type
|
||||
customer_type: customerType,
|
||||
|
||||
// Organization (null for B2C)
|
||||
organization: organizationData,
|
||||
|
||||
// Credits
|
||||
credit_balance: creditBalance,
|
||||
|
||||
// App metadata (from session)
|
||||
app_id: (session.metadata?.appId as string) || undefined,
|
||||
device_id: (session.metadata?.deviceId as string) || undefined,
|
||||
role: (user as { role?: string }).role || 'user',
|
||||
sid: session.id,
|
||||
};
|
||||
|
||||
return payload;
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import type {
|
|||
BetterAuthSession,
|
||||
} from '../types/better-auth.types';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||
|
||||
// Re-export DTOs and result types for external use
|
||||
export type {
|
||||
|
|
@ -418,6 +419,73 @@ export class BetterAuthService {
|
|||
|
||||
const { user } = result;
|
||||
|
||||
// Get session token (used as refresh token)
|
||||
const session = hasSession(result) ? result.session : null;
|
||||
const sessionToken = session?.token || (hasToken(result) ? result.token : '');
|
||||
|
||||
// Generate JWT access token using Better Auth's JWT plugin
|
||||
let accessToken = '';
|
||||
try {
|
||||
const api = this.auth.api as any;
|
||||
|
||||
// Use Better Auth's signJWT with the jwks table
|
||||
const jwtResult = await api.signJWT({
|
||||
body: {
|
||||
payload: {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: (user as BetterAuthUser).role || 'user',
|
||||
sid: session?.id || '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
accessToken = jwtResult?.token || '';
|
||||
|
||||
// Fallback to manual JWT if Better Auth fails
|
||||
if (!accessToken) {
|
||||
throw new Error('Better Auth signJWT returned empty token');
|
||||
}
|
||||
} catch (jwtError) {
|
||||
console.warn('[signIn] Better Auth signJWT failed, using manual JWT generation:', jwtError);
|
||||
|
||||
// Fallback: Generate JWT manually using jsonwebtoken
|
||||
const privateKey = this.configService.get<string>('jwt.privateKey');
|
||||
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
|
||||
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
|
||||
|
||||
console.log('[signIn] Private key exists:', !!privateKey);
|
||||
console.log('[signIn] Private key length:', privateKey?.length);
|
||||
console.log('[signIn] Private key starts with:', privateKey?.substring(0, 30));
|
||||
console.log('[signIn] Issuer:', issuer);
|
||||
console.log('[signIn] Audience:', audience);
|
||||
|
||||
if (privateKey) {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: (user as BetterAuthUser).role || 'user',
|
||||
sid: session?.id || '',
|
||||
};
|
||||
|
||||
accessToken = jwt.sign(payload, privateKey, {
|
||||
algorithm: 'RS256',
|
||||
expiresIn: '15m',
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
console.log('[signIn] Generated JWT (first 50 chars):', accessToken?.substring(0, 50));
|
||||
// Decode to verify
|
||||
const decoded = jwt.decode(accessToken, { complete: true });
|
||||
console.log('[signIn] Generated JWT header:', decoded?.header);
|
||||
console.log('[signIn] Generated JWT payload:', decoded?.payload);
|
||||
} else {
|
||||
console.error('[signIn] No JWT private key configured');
|
||||
accessToken = sessionToken;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
|
|
@ -425,7 +493,9 @@ export class BetterAuthService {
|
|||
name: user.name,
|
||||
role: (user as BetterAuthUser).role,
|
||||
},
|
||||
token: hasToken(result) ? result.token : '',
|
||||
accessToken,
|
||||
refreshToken: sessionToken,
|
||||
expiresIn: 15 * 60, // 15 minutes in seconds
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
|
|
@ -617,7 +687,7 @@ export class BetterAuthService {
|
|||
}
|
||||
|
||||
// Check if refresh token is expired
|
||||
if (new Date() > session.refreshTokenExpiresAt) {
|
||||
if (!session.refreshTokenExpiresAt || new Date() > session.refreshTokenExpiresAt) {
|
||||
throw new UnauthorizedException('Refresh token expired');
|
||||
}
|
||||
|
||||
|
|
@ -715,26 +785,44 @@ export class BetterAuthService {
|
|||
*/
|
||||
async validateToken(token: string): Promise<ValidateTokenResult> {
|
||||
try {
|
||||
const publicKey = this.configService.get<string>('jwt.publicKey');
|
||||
if (!publicKey) {
|
||||
throw new Error('JWT public key not configured');
|
||||
}
|
||||
console.log('[validateToken] Token (first 50 chars):', token?.substring(0, 50));
|
||||
|
||||
const audience = this.configService.get<string>('jwt.audience');
|
||||
const issuer = this.configService.get<string>('jwt.issuer');
|
||||
// Decode to check the algorithm
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
console.log('[validateToken] Decoded header:', decoded?.header);
|
||||
|
||||
const payload = jwt.verify(token, publicKey, {
|
||||
algorithms: ['RS256'],
|
||||
audience,
|
||||
// Use our JWKS endpoint (NestJS prefix: /api/v1)
|
||||
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
|
||||
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
|
||||
|
||||
console.log('[validateToken] Using JWKS from:', jwksUrl.toString());
|
||||
|
||||
// Create JWKS fetcher
|
||||
const JWKS = createRemoteJWKSet(jwksUrl);
|
||||
|
||||
// Get issuer/audience from config (Better Auth uses BASE_URL by default)
|
||||
const issuer = this.configService.get<string>('jwt.issuer') || baseUrl;
|
||||
const audience = this.configService.get<string>('jwt.audience') || baseUrl;
|
||||
|
||||
console.log('[validateToken] Issuer:', issuer);
|
||||
console.log('[validateToken] Audience:', audience);
|
||||
|
||||
// Verify using jose library with Better Auth's JWKS
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer,
|
||||
}) as TokenPayload;
|
||||
audience,
|
||||
});
|
||||
|
||||
console.log('[validateToken] Verification SUCCESS');
|
||||
console.log('[validateToken] Payload:', payload);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
payload,
|
||||
payload: payload as unknown as TokenPayload,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[validateToken] Verification FAILED:', errorMessage);
|
||||
return {
|
||||
valid: false,
|
||||
error: errorMessage,
|
||||
|
|
@ -742,6 +830,46 @@ export class BetterAuthService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWKS (JSON Web Key Set)
|
||||
*
|
||||
* Returns public keys for JWT verification.
|
||||
* Proxies to Better Auth's internal JWKS.
|
||||
*
|
||||
* @returns JWKS with public keys
|
||||
*/
|
||||
async getJwks(): Promise<{ keys: unknown[] }> {
|
||||
try {
|
||||
// Better Auth exposes JWKS via auth.api
|
||||
const api = this.auth.api as any;
|
||||
|
||||
// Try to get JWKS from Better Auth
|
||||
if (api.getJwks) {
|
||||
const result = await api.getJwks();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback: read from jwks table directly
|
||||
const db = getDb(this.databaseUrl);
|
||||
const { jwks } = await import('../../db/schema/auth.schema');
|
||||
const keys = await db.select().from(jwks);
|
||||
|
||||
// Convert to JWKS format (EdDSA public keys)
|
||||
return {
|
||||
keys: keys.map((key) => {
|
||||
try {
|
||||
return JSON.parse(key.publicKey);
|
||||
} catch {
|
||||
return { kid: key.id, publicKey: key.publicKey };
|
||||
}
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[getJwks] Error:', error);
|
||||
return { keys: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Helper Methods
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -441,9 +441,9 @@ export interface SignInResult {
|
|||
name: string | null;
|
||||
role?: string;
|
||||
};
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ export default () => ({
|
|||
},
|
||||
|
||||
jwt: {
|
||||
publicKey: process.env.JWT_PUBLIC_KEY || '',
|
||||
privateKey: process.env.JWT_PRIVATE_KEY || '',
|
||||
// Convert \n string literals to actual newlines for PEM format
|
||||
publicKey: (process.env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'),
|
||||
privateKey: (process.env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'),
|
||||
accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m',
|
||||
refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d',
|
||||
issuer: process.env.JWT_ISSUER || 'manacore',
|
||||
|
|
|
|||
|
|
@ -1,78 +1,83 @@
|
|||
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum, index } 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
|
||||
// Users table (Better Auth schema)
|
||||
export const users = authSchema.table('users', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
id: text('id').primaryKey(), // Better Auth generates nanoid
|
||||
name: text('name').notNull(),
|
||||
email: text('email').unique().notNull(),
|
||||
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||
name: text('name'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
role: userRoleEnum('role').default('user').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(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// Sessions table
|
||||
// Sessions table (Better Auth schema)
|
||||
export const sessions = authSchema.table('sessions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
id: text('id').primaryKey(), // Better Auth generates nanoid
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
token: text('token').unique().notNull(),
|
||||
refreshToken: text('refresh_token').unique().notNull(),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }).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().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow(),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// Accounts table (for OAuth providers)
|
||||
// Accounts table (for OAuth providers and credentials - Better Auth schema)
|
||||
export const accounts = authSchema.table('accounts', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
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(),
|
||||
provider: text('provider').notNull(), // 'google', 'github', 'apple', etc.
|
||||
providerAccountId: text('provider_account_id').notNull(),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
tokenType: text('token_type'),
|
||||
scope: text('scope'),
|
||||
idToken: text('id_token'),
|
||||
metadata: jsonb('metadata'),
|
||||
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 tokens (for email verification, password reset)
|
||||
export const verificationTokens = authSchema.table('verification_tokens', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
token: text('token').unique().notNull(),
|
||||
type: text('type').notNull(), // 'email_verification', 'password_reset'
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
});
|
||||
// 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: uuid('user_id')
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
hashedPassword: text('hashed_password').notNull(),
|
||||
|
|
@ -82,23 +87,31 @@ export const passwords = authSchema.table('passwords', {
|
|||
|
||||
// Two-factor authentication
|
||||
export const twoFactorAuth = authSchema.table('two_factor_auth', {
|
||||
userId: uuid('user_id')
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
secret: text('secret').notNull(),
|
||||
enabled: boolean('enabled').default(false).notNull(),
|
||||
backupCodes: jsonb('backup_codes'), // Array of hashed backup codes
|
||||
backupCodes: jsonb('backup_codes'),
|
||||
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(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
eventType: text('event_type').notNull(), // 'login', 'logout', 'password_reset', 'suspicious_activity'
|
||||
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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const transactionStatusEnum = pgEnum('transaction_status', [
|
|||
|
||||
// Credit balances (one per user)
|
||||
export const balances = creditsSchema.table('balances', {
|
||||
userId: uuid('user_id')
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
balance: integer('balance').default(0).notNull(),
|
||||
|
|
@ -43,7 +43,7 @@ export const balances = creditsSchema.table('balances', {
|
|||
lastDailyResetAt: timestamp('last_daily_reset_at', { withTimezone: true }).defaultNow(),
|
||||
totalEarned: integer('total_earned').default(0).notNull(),
|
||||
totalSpent: integer('total_spent').default(0).notNull(),
|
||||
version: integer('version').default(0).notNull(), // For optimistic locking
|
||||
version: integer('version').default(0).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
|
@ -53,7 +53,7 @@ export const transactions = creditsSchema.table(
|
|||
'transactions',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
type: transactionTypeEnum('type').notNull(),
|
||||
|
|
@ -61,10 +61,10 @@ export const transactions = creditsSchema.table(
|
|||
amount: integer('amount').notNull(),
|
||||
balanceBefore: integer('balance_before').notNull(),
|
||||
balanceAfter: integer('balance_after').notNull(),
|
||||
appId: text('app_id').notNull(), // 'memoro', 'chat', 'picture', etc.
|
||||
appId: text('app_id').notNull(),
|
||||
description: text('description').notNull(),
|
||||
organizationId: text('organization_id').references(() => organizations.id), // NULL for B2C, set for B2B
|
||||
metadata: jsonb('metadata'), // Additional context
|
||||
organizationId: text('organization_id').references(() => organizations.id),
|
||||
metadata: jsonb('metadata'),
|
||||
idempotencyKey: text('idempotency_key').unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
|
|
@ -83,8 +83,8 @@ export const packages = creditsSchema.table('packages', {
|
|||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
credits: integer('credits').notNull(), // Number of credits
|
||||
priceEuroCents: integer('price_euro_cents').notNull(), // Price in euro cents
|
||||
credits: integer('credits').notNull(),
|
||||
priceEuroCents: integer('price_euro_cents').notNull(),
|
||||
stripePriceId: text('stripe_price_id').unique(),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
|
|
@ -98,7 +98,7 @@ export const purchases = creditsSchema.table(
|
|||
'purchases',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
packageId: uuid('package_id').references(() => packages.id),
|
||||
|
|
@ -124,7 +124,7 @@ export const usageStats = creditsSchema.table(
|
|||
'usage_stats',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
appId: text('app_id').notNull(),
|
||||
|
|
@ -143,12 +143,12 @@ export const organizationBalances = creditsSchema.table('organization_balances',
|
|||
organizationId: text('organization_id')
|
||||
.primaryKey()
|
||||
.references(() => organizations.id, { onDelete: 'cascade' }),
|
||||
balance: integer('balance').default(0).notNull(), // Total purchased credits
|
||||
allocatedCredits: integer('allocated_credits').default(0).notNull(), // Sum of credits allocated to employees
|
||||
availableCredits: integer('available_credits').default(0).notNull(), // balance - allocated_credits
|
||||
totalPurchased: integer('total_purchased').default(0).notNull(), // Total credits ever purchased
|
||||
totalAllocated: integer('total_allocated').default(0).notNull(), // Total ever allocated
|
||||
version: integer('version').default(0).notNull(), // For optimistic locking
|
||||
balance: integer('balance').default(0).notNull(),
|
||||
allocatedCredits: integer('allocated_credits').default(0).notNull(),
|
||||
availableCredits: integer('available_credits').default(0).notNull(),
|
||||
totalPurchased: integer('total_purchased').default(0).notNull(),
|
||||
totalAllocated: integer('total_allocated').default(0).notNull(),
|
||||
version: integer('version').default(0).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
|
@ -161,17 +161,17 @@ export const creditAllocations = creditsSchema.table(
|
|||
organizationId: text('organization_id')
|
||||
.references(() => organizations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
employeeId: uuid('employee_id')
|
||||
employeeId: text('employee_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
amount: integer('amount').notNull(), // Amount allocated (can be positive or negative)
|
||||
allocatedBy: uuid('allocated_by')
|
||||
amount: integer('amount').notNull(),
|
||||
allocatedBy: text('allocated_by')
|
||||
.references(() => users.id)
|
||||
.notNull(), // Owner or admin who made the allocation
|
||||
reason: text('reason'), // Optional reason for allocation
|
||||
balanceBefore: integer('balance_before').notNull(), // Employee balance before
|
||||
balanceAfter: integer('balance_after').notNull(), // Employee balance after
|
||||
metadata: jsonb('metadata'), // Additional context
|
||||
.notNull(),
|
||||
reason: text('reason'),
|
||||
balanceBefore: integer('balance_before').notNull(),
|
||||
balanceAfter: integer('balance_after').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue