mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-27 13:58:10 +02:00
feat(bots): enable Redis SSO for todo-bot and calendar-bot
- Activate Redis session storage in both bots for cross-bot SSO - Update SessionHelper to async methods for Redis-backed SessionService - Fix async/await issues in todo-bot and calendar-bot matrix.service.ts - Remove unused imports from calendar-api and todo-api services - Add CALENDAR_BACKEND_URL and MANA_CORE_SERVICE_KEY to .env.development Note: SessionService methods are now async (Redis-backed). Other bots need their matrix.service.ts updated to await these async calls. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7bad849258
commit
2777f604fd
27 changed files with 2997 additions and 838 deletions
|
|
@ -3,7 +3,9 @@ import { AuthController } from './auth.controller';
|
|||
import { BetterAuthPassthroughController } from './better-auth-passthrough.controller';
|
||||
import { OidcController } from './oidc.controller';
|
||||
import { OidcLoginController } from './oidc-login.controller';
|
||||
import { MatrixSessionController } from './matrix-session.controller';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { MatrixSessionService } from './services/matrix-session.service';
|
||||
import { ReferralsModule } from '../referrals/referrals.module';
|
||||
|
||||
@Module({
|
||||
|
|
@ -13,8 +15,9 @@ import { ReferralsModule } from '../referrals/referrals.module';
|
|||
BetterAuthPassthroughController,
|
||||
OidcController,
|
||||
OidcLoginController,
|
||||
MatrixSessionController,
|
||||
],
|
||||
providers: [BetterAuthService],
|
||||
exports: [BetterAuthService],
|
||||
providers: [BetterAuthService, MatrixSessionService],
|
||||
exports: [BetterAuthService, MatrixSessionService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
|
|
|||
208
services/mana-core-auth/src/auth/matrix-session.controller.ts
Normal file
208
services/mana-core-auth/src/auth/matrix-session.controller.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Headers,
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { MatrixSessionService } from './services/matrix-session.service';
|
||||
|
||||
/**
|
||||
* DTO for linking a Matrix user to a Mana account
|
||||
*/
|
||||
class LinkMatrixUserDto {
|
||||
/** Matrix user ID (e.g., @user:matrix.mana.how) */
|
||||
matrixUserId!: string;
|
||||
/** User's email (optional, for convenience) */
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matrix Session Controller
|
||||
*
|
||||
* Provides endpoints for Matrix bot authentication via SSO.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /api/v1/auth/matrix-user-links - Link Matrix user to Mana account
|
||||
* - GET /api/v1/auth/matrix-session/:matrixUserId - Get JWT for linked Matrix user
|
||||
* - DELETE /api/v1/auth/matrix-user-links/:matrixUserId - Unlink Matrix user
|
||||
* - GET /api/v1/auth/matrix-user-links/check/:matrixUserId - Check if user is linked
|
||||
*
|
||||
* Authentication:
|
||||
* - POST /link requires Bearer token (user authenticating)
|
||||
* - GET /session requires X-Service-Key (internal bot service)
|
||||
* - DELETE requires Bearer token (user unlinking)
|
||||
* - GET /check requires X-Service-Key (internal bot service)
|
||||
*/
|
||||
@Controller('api/v1/auth')
|
||||
export class MatrixSessionController {
|
||||
constructor(private readonly matrixSessionService: MatrixSessionService) {}
|
||||
|
||||
/**
|
||||
* Link a Matrix user ID to a Mana account
|
||||
*
|
||||
* Called by bots after successful !login command.
|
||||
* Requires the user's JWT token from login.
|
||||
*
|
||||
* @example
|
||||
* POST /api/v1/auth/matrix-user-links
|
||||
* Authorization: Bearer <jwt-token>
|
||||
* Body: { "matrixUserId": "@user:matrix.mana.how", "email": "user@example.com" }
|
||||
*/
|
||||
@Post('matrix-user-links')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async linkMatrixUser(
|
||||
@Body() dto: LinkMatrixUserDto,
|
||||
@Headers('authorization') authHeader?: string,
|
||||
@Headers('x-service-key') serviceKey?: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
// Two auth methods: Bearer token (from user login) or Service key (from bot)
|
||||
let manaUserId: string;
|
||||
|
||||
if (serviceKey && this.matrixSessionService.validateServiceKey(serviceKey)) {
|
||||
// Service key auth - must provide userId in body
|
||||
const bodyWithUserId = dto as LinkMatrixUserDto & { userId?: string };
|
||||
if (!bodyWithUserId.userId) {
|
||||
throw new UnauthorizedException('userId required when using service key');
|
||||
}
|
||||
manaUserId = bodyWithUserId.userId;
|
||||
} else if (authHeader?.startsWith('Bearer ')) {
|
||||
// JWT auth - extract user ID from token
|
||||
const token = authHeader.substring(7);
|
||||
const payload = this.decodeToken(token);
|
||||
if (!payload?.sub) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
manaUserId = payload.sub;
|
||||
} else {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
if (!dto.matrixUserId) {
|
||||
throw new UnauthorizedException('matrixUserId is required');
|
||||
}
|
||||
|
||||
await this.matrixSessionService.linkMatrixUser(dto.matrixUserId, manaUserId, dto.email);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Matrix user ${dto.matrixUserId} linked successfully`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a JWT token for a linked Matrix user
|
||||
*
|
||||
* Called by bots to auto-authenticate users.
|
||||
* Requires service key (internal service authentication).
|
||||
*
|
||||
* @example
|
||||
* GET /api/v1/auth/matrix-session/@user:matrix.mana.how
|
||||
* X-Service-Key: <service-key>
|
||||
*/
|
||||
@Get('matrix-session/:matrixUserId')
|
||||
async getMatrixSession(
|
||||
@Param('matrixUserId') matrixUserId: string,
|
||||
@Headers('x-service-key') serviceKey?: string
|
||||
): Promise<{ token: string; email: string }> {
|
||||
// Require service key for this endpoint
|
||||
if (!serviceKey || !this.matrixSessionService.validateServiceKey(serviceKey)) {
|
||||
throw new UnauthorizedException('Valid service key required');
|
||||
}
|
||||
|
||||
const result = await this.matrixSessionService.getSessionForMatrixUser(
|
||||
decodeURIComponent(matrixUserId)
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException('No link found for this Matrix user');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a Matrix user from a Mana account
|
||||
*
|
||||
* Called when user wants to disconnect their Matrix account.
|
||||
* Requires the user's JWT token.
|
||||
*
|
||||
* @example
|
||||
* DELETE /api/v1/auth/matrix-user-links/@user:matrix.mana.how
|
||||
* Authorization: Bearer <jwt-token>
|
||||
*/
|
||||
@Delete('matrix-user-links/:matrixUserId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async unlinkMatrixUser(
|
||||
@Param('matrixUserId') matrixUserId: string,
|
||||
@Headers('authorization') authHeader?: string,
|
||||
@Headers('x-service-key') serviceKey?: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
// Allow both Bearer token and service key
|
||||
if (
|
||||
!authHeader?.startsWith('Bearer ') &&
|
||||
!this.matrixSessionService.validateServiceKey(serviceKey || '')
|
||||
) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
const deleted = await this.matrixSessionService.unlinkMatrixUser(
|
||||
decodeURIComponent(matrixUserId)
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
throw new NotFoundException('No link found for this Matrix user');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Matrix user ${matrixUserId} unlinked successfully`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Matrix user is linked
|
||||
*
|
||||
* Requires service key (internal service authentication).
|
||||
*
|
||||
* @example
|
||||
* GET /api/v1/auth/matrix-user-links/check/@user:matrix.mana.how
|
||||
* X-Service-Key: <service-key>
|
||||
*/
|
||||
@Get('matrix-user-links/check/:matrixUserId')
|
||||
async checkMatrixLink(
|
||||
@Param('matrixUserId') matrixUserId: string,
|
||||
@Headers('x-service-key') serviceKey?: string
|
||||
): Promise<{ linked: boolean }> {
|
||||
// Require service key for this endpoint
|
||||
if (!serviceKey || !this.matrixSessionService.validateServiceKey(serviceKey)) {
|
||||
throw new UnauthorizedException('Valid service key required');
|
||||
}
|
||||
|
||||
const linked = await this.matrixSessionService.isLinked(decodeURIComponent(matrixUserId));
|
||||
|
||||
return { linked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token to get payload (without verification)
|
||||
* Note: This is used only to extract user ID after the bot has verified the token
|
||||
*/
|
||||
private decodeToken(token: string): { sub?: string } | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const payload = Buffer.from(parts[1], 'base64url').toString('utf-8');
|
||||
return JSON.parse(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1346,6 +1346,66 @@ export class BetterAuthService {
|
|||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Matrix Bot SSO Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Generate a JWT token for a specific user (used by Matrix bots)
|
||||
*
|
||||
* This method generates a fresh JWT token for an existing user,
|
||||
* without requiring password authentication. It's used by the
|
||||
* Matrix-SSO-Link system to auto-authenticate bot users.
|
||||
*
|
||||
* @param userId - Mana Core Auth user ID
|
||||
* @returns JWT access token or null if user not found
|
||||
*/
|
||||
async generateTokenForUser(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const db = getDb(this.databaseUrl);
|
||||
const { users } = await import('../../db/schema/auth.schema');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
|
||||
// Get user from database
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
this.logger.warn('generateTokenForUser: User not found', { userId });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate JWT using Better Auth's signJWT
|
||||
const api = this.auth.api as any;
|
||||
|
||||
const jwtResult = await api.signJWT({
|
||||
body: {
|
||||
payload: {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role || 'user',
|
||||
sid: `bot-session-${Date.now()}`, // Pseudo session ID for bots
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const token = jwtResult?.token;
|
||||
|
||||
if (!token) {
|
||||
this.logger.error('generateTokenForUser: signJWT returned empty token');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.debug('Generated token for user via Matrix-SSO-Link', { userId });
|
||||
return token;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'generateTokenForUser failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SSO Methods
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
import { Injectable, Logger, UnauthorizedException, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getDb } from '../../db/connection';
|
||||
import { matrixUserLinks, users } from '../../db/schema/auth.schema';
|
||||
import { BetterAuthService } from './better-auth.service';
|
||||
|
||||
/**
|
||||
* Matrix Session Service
|
||||
*
|
||||
* Manages the link between Matrix user IDs and Mana Core Auth accounts.
|
||||
* Enables automatic bot authentication for users who have linked their accounts.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User logs into a Matrix bot via !login email password
|
||||
* 2. Bot calls POST /api/v1/auth/matrix-user-links to store the link
|
||||
* 3. Later, bot can call GET /api/v1/auth/matrix-session/:matrixUserId
|
||||
* 4. If a link exists, a fresh JWT token is returned
|
||||
*/
|
||||
@Injectable()
|
||||
export class MatrixSessionService {
|
||||
private readonly logger = new Logger(MatrixSessionService.name);
|
||||
private readonly db;
|
||||
private readonly serviceKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly betterAuthService: BetterAuthService
|
||||
) {
|
||||
const databaseUrl = this.configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required');
|
||||
}
|
||||
this.db = getDb(databaseUrl);
|
||||
this.serviceKey = this.configService.get<string>('MANA_CORE_SERVICE_KEY', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate service key from X-Service-Key header
|
||||
*/
|
||||
validateServiceKey(providedKey: string): boolean {
|
||||
if (!this.serviceKey) {
|
||||
this.logger.warn('MANA_CORE_SERVICE_KEY not configured - service key validation disabled');
|
||||
return false;
|
||||
}
|
||||
return providedKey === this.serviceKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a link between a Matrix user ID and a Mana user
|
||||
*
|
||||
* @param matrixUserId - Matrix user ID (e.g., @user:matrix.mana.how)
|
||||
* @param manaUserId - Mana Core Auth user ID
|
||||
* @param email - User's email (optional, for convenience)
|
||||
*/
|
||||
async linkMatrixUser(matrixUserId: string, manaUserId: string, email?: string): Promise<void> {
|
||||
// Check if link already exists
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(matrixUserLinks)
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing link
|
||||
await this.db
|
||||
.update(matrixUserLinks)
|
||||
.set({
|
||||
userId: manaUserId,
|
||||
email,
|
||||
lastUsedAt: new Date(),
|
||||
})
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId));
|
||||
|
||||
this.logger.log(`Updated Matrix link: ${matrixUserId} -> ${manaUserId}`);
|
||||
} else {
|
||||
// Create new link
|
||||
await this.db.insert(matrixUserLinks).values({
|
||||
id: nanoid(),
|
||||
matrixUserId,
|
||||
userId: manaUserId,
|
||||
email,
|
||||
linkedAt: new Date(),
|
||||
});
|
||||
|
||||
this.logger.log(`Created Matrix link: ${matrixUserId} -> ${manaUserId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link for a Matrix user ID
|
||||
*/
|
||||
async unlinkMatrixUser(matrixUserId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(matrixUserLinks)
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||
.returning();
|
||||
|
||||
if (result.length > 0) {
|
||||
this.logger.log(`Removed Matrix link: ${matrixUserId}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a fresh JWT token for a linked Matrix user
|
||||
*
|
||||
* @param matrixUserId - Matrix user ID
|
||||
* @returns JWT token or null if no link exists
|
||||
*/
|
||||
async getSessionForMatrixUser(
|
||||
matrixUserId: string
|
||||
): Promise<{ token: string; email: string } | null> {
|
||||
// Find the link
|
||||
const links = await this.db
|
||||
.select({
|
||||
userId: matrixUserLinks.userId,
|
||||
email: matrixUserLinks.email,
|
||||
})
|
||||
.from(matrixUserLinks)
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||
.limit(1);
|
||||
|
||||
if (links.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const link = links[0];
|
||||
|
||||
// Update last used timestamp
|
||||
await this.db
|
||||
.update(matrixUserLinks)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId));
|
||||
|
||||
// Get user details if email not stored
|
||||
let email = link.email;
|
||||
if (!email) {
|
||||
const userRecords = await this.db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.id, link.userId))
|
||||
.limit(1);
|
||||
|
||||
if (userRecords.length > 0) {
|
||||
email = userRecords[0].email;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a fresh JWT token for this user
|
||||
const token = await this.betterAuthService.generateTokenForUser(link.userId);
|
||||
|
||||
if (!token) {
|
||||
this.logger.error(`Failed to generate token for user ${link.userId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.debug(`Generated token for Matrix user ${matrixUserId}`);
|
||||
return { token, email: email || '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Matrix links for a Mana user
|
||||
*/
|
||||
async getLinksForUser(manaUserId: string): Promise<{ matrixUserId: string; linkedAt: Date }[]> {
|
||||
const links = await this.db
|
||||
.select({
|
||||
matrixUserId: matrixUserLinks.matrixUserId,
|
||||
linkedAt: matrixUserLinks.linkedAt,
|
||||
})
|
||||
.from(matrixUserLinks)
|
||||
.where(eq(matrixUserLinks.userId, manaUserId));
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Matrix user is linked
|
||||
*/
|
||||
async isLinked(matrixUserId: string): Promise<boolean> {
|
||||
const links = await this.db
|
||||
.select({ id: matrixUserLinks.id })
|
||||
.from(matrixUserLinks)
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||
.limit(1);
|
||||
|
||||
return links.length > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -187,6 +187,26 @@ export const oauthConsents = authSchema.table('oauth_consents', {
|
|||
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),
|
||||
})
|
||||
);
|
||||
|
||||
// User settings table (synced across all apps)
|
||||
export const userSettings = authSchema.table('user_settings', {
|
||||
userId: text('user_id')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue