mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 12:46:42 +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
|
|
@ -1,4 +1,11 @@
|
|||
export { SessionService } from './session.service';
|
||||
export { SessionService, REDIS_SESSION_PROVIDER } from './session.service';
|
||||
export { SessionModule } from './session.module';
|
||||
export type { UserSession, LoginResult, SessionStats, SessionModuleOptions } from './types';
|
||||
export { RedisSessionProvider, REDIS_CLIENT } from './redis-session.provider';
|
||||
export type {
|
||||
UserSession,
|
||||
LoginResult,
|
||||
SessionStats,
|
||||
SessionModuleOptions,
|
||||
SessionStorageMode,
|
||||
} from './types';
|
||||
export { SESSION_MODULE_OPTIONS, DEFAULT_SESSION_EXPIRY_MS } from './types';
|
||||
|
|
|
|||
245
packages/bot-services/src/session/redis-session.provider.ts
Normal file
245
packages/bot-services/src/session/redis-session.provider.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
Inject,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
import {
|
||||
UserSession,
|
||||
SessionModuleOptions,
|
||||
SESSION_MODULE_OPTIONS,
|
||||
DEFAULT_SESSION_EXPIRY_MS,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Injection token for Redis client
|
||||
*/
|
||||
export const REDIS_CLIENT = 'REDIS_CLIENT';
|
||||
|
||||
/**
|
||||
* Key prefix for bot sessions in Redis
|
||||
*/
|
||||
const KEY_PREFIX = 'bot:session:';
|
||||
|
||||
/**
|
||||
* Redis-based session provider for cross-bot SSO
|
||||
*
|
||||
* Sessions are stored in Redis with automatic TTL expiration.
|
||||
* All bots using this provider share the same session store.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // User logs in via todo-bot
|
||||
* await sessionProvider.setSession('@user:matrix.mana.how', session);
|
||||
*
|
||||
* // Same user in picture-bot - already logged in!
|
||||
* const session = await sessionProvider.getSession('@user:matrix.mana.how');
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisSessionProvider implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(RedisSessionProvider.name);
|
||||
private client: Redis | null = null;
|
||||
private readonly sessionExpirySeconds: number;
|
||||
|
||||
constructor(
|
||||
@Optional() private configService: ConfigService,
|
||||
@Optional() @Inject(SESSION_MODULE_OPTIONS) private options?: SessionModuleOptions
|
||||
) {
|
||||
const expiryMs = options?.sessionExpiryMs || DEFAULT_SESSION_EXPIRY_MS;
|
||||
this.sessionExpirySeconds = Math.floor(expiryMs / 1000);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
const host =
|
||||
this.options?.redisHost || this.configService?.get<string>('REDIS_HOST', 'localhost');
|
||||
const port = this.options?.redisPort || this.configService?.get<number>('REDIS_PORT', 6379);
|
||||
const password =
|
||||
this.options?.redisPassword || this.configService?.get<string>('REDIS_PASSWORD');
|
||||
|
||||
try {
|
||||
this.client = new Redis({
|
||||
host,
|
||||
port,
|
||||
password: password || undefined,
|
||||
retryStrategy: (times) => {
|
||||
if (times > 3) {
|
||||
this.logger.warn('Redis connection failed, falling back to in-memory sessions');
|
||||
return null;
|
||||
}
|
||||
return Math.min(times * 200, 2000);
|
||||
},
|
||||
maxRetriesPerRequest: 1,
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
this.logger.error(`Redis error: ${err.message}`);
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
this.logger.log(`Connected to Redis at ${host}:${port} for session storage`);
|
||||
});
|
||||
|
||||
// Test connection
|
||||
await this.client.ping();
|
||||
this.logger.log('Redis session provider initialized');
|
||||
} catch (error) {
|
||||
this.logger.warn(`Could not connect to Redis: ${error}. Falling back to in-memory sessions.`);
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.client) {
|
||||
await this.client.quit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Redis key for a Matrix user ID
|
||||
*/
|
||||
private buildKey(matrixUserId: string): string {
|
||||
return `${KEY_PREFIX}${matrixUserId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Redis is connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.client !== null && this.client.status === 'ready';
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a session in Redis
|
||||
*/
|
||||
async setSession(matrixUserId: string, session: UserSession): Promise<void> {
|
||||
if (!this.client) return;
|
||||
|
||||
try {
|
||||
const data = {
|
||||
token: session.token,
|
||||
email: session.email,
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
data: session.data || {},
|
||||
};
|
||||
|
||||
await this.client.setex(
|
||||
this.buildKey(matrixUserId),
|
||||
this.sessionExpirySeconds,
|
||||
JSON.stringify(data)
|
||||
);
|
||||
this.logger.debug(`Session stored for ${matrixUserId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to store session: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session from Redis
|
||||
*/
|
||||
async getSession(matrixUserId: string): Promise<UserSession | null> {
|
||||
if (!this.client) return null;
|
||||
|
||||
try {
|
||||
const data = await this.client.get(this.buildKey(matrixUserId));
|
||||
if (!data) return null;
|
||||
|
||||
const parsed = JSON.parse(data);
|
||||
const session: UserSession = {
|
||||
token: parsed.token,
|
||||
email: parsed.email,
|
||||
expiresAt: new Date(parsed.expiresAt),
|
||||
data: parsed.data,
|
||||
};
|
||||
|
||||
// Check if expired (should not happen due to TTL, but double-check)
|
||||
if (session.expiresAt < new Date()) {
|
||||
await this.deleteSession(matrixUserId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get session: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only the token from a session
|
||||
*/
|
||||
async getToken(matrixUserId: string): Promise<string | null> {
|
||||
const session = await this.getSession(matrixUserId);
|
||||
return session?.token ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session from Redis
|
||||
*/
|
||||
async deleteSession(matrixUserId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
|
||||
try {
|
||||
await this.client.del(this.buildKey(matrixUserId));
|
||||
this.logger.debug(`Session deleted for ${matrixUserId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to delete session: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session data without changing the token
|
||||
*/
|
||||
async updateSessionData(matrixUserId: string, key: string, value: unknown): Promise<void> {
|
||||
const session = await this.getSession(matrixUserId);
|
||||
if (!session) return;
|
||||
|
||||
session.data = session.data || {};
|
||||
session.data[key] = value;
|
||||
await this.setSession(matrixUserId, session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session data
|
||||
*/
|
||||
async getSessionData<T = unknown>(matrixUserId: string, key: string): Promise<T | null> {
|
||||
const session = await this.getSession(matrixUserId);
|
||||
return (session?.data?.[key] as T) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active session keys (for debugging/stats)
|
||||
*/
|
||||
async getActiveSessionCount(): Promise<number> {
|
||||
if (!this.client) return 0;
|
||||
|
||||
try {
|
||||
const keys = await this.client.keys(`${KEY_PREFIX}*`);
|
||||
return keys.length;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get session count: ${error}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async healthCheck(): Promise<{ status: string; latency: number }> {
|
||||
if (!this.client) {
|
||||
return { status: 'disconnected', latency: 0 };
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
await this.client.ping();
|
||||
return { status: 'ok', latency: Date.now() - start };
|
||||
} catch {
|
||||
return { status: 'error', latency: Date.now() - start };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Module, DynamicModule, Global } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SessionService } from './session.service';
|
||||
import { SessionService, REDIS_SESSION_PROVIDER } from './session.service';
|
||||
import { RedisSessionProvider } from './redis-session.provider';
|
||||
import { SessionModuleOptions, SESSION_MODULE_OPTIONS } from './types';
|
||||
|
||||
/**
|
||||
|
|
@ -11,19 +12,31 @@ import { SessionModuleOptions, SESSION_MODULE_OPTIONS } from './types';
|
|||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With explicit configuration
|
||||
* // Basic usage (in-memory sessions, per bot)
|
||||
* @Module({
|
||||
* imports: [SessionModule.forRoot()]
|
||||
* })
|
||||
*
|
||||
* // With Redis for cross-bot SSO
|
||||
* @Module({
|
||||
* imports: [
|
||||
* SessionModule.register({
|
||||
* authUrl: 'http://mana-core-auth:3001',
|
||||
* sessionExpiryMs: 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
* SessionModule.forRoot({
|
||||
* storageMode: 'redis',
|
||||
* redisHost: 'localhost',
|
||||
* redisPort: 6379,
|
||||
* })
|
||||
* ]
|
||||
* })
|
||||
*
|
||||
* // With ConfigService (reads from auth.url or MANA_CORE_AUTH_URL)
|
||||
* // With Matrix-SSO-Link (automatic login)
|
||||
* @Module({
|
||||
* imports: [SessionModule.forRoot()]
|
||||
* imports: [
|
||||
* SessionModule.forRoot({
|
||||
* storageMode: 'redis',
|
||||
* enableMatrixSsoLink: true,
|
||||
* serviceKey: process.env.MANA_CORE_SERVICE_KEY,
|
||||
* })
|
||||
* ]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
|
@ -34,29 +47,76 @@ export class SessionModule {
|
|||
* Register module with explicit options
|
||||
*/
|
||||
static register(options: SessionModuleOptions = {}): DynamicModule {
|
||||
const providers: any[] = [
|
||||
{
|
||||
provide: SESSION_MODULE_OPTIONS,
|
||||
useValue: options,
|
||||
},
|
||||
];
|
||||
|
||||
// Add Redis provider if storage mode is redis
|
||||
if (options.storageMode === 'redis') {
|
||||
providers.push({
|
||||
provide: REDIS_SESSION_PROVIDER,
|
||||
useClass: RedisSessionProvider,
|
||||
});
|
||||
}
|
||||
|
||||
providers.push(SessionService);
|
||||
|
||||
return {
|
||||
module: SessionModule,
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
{
|
||||
provide: SESSION_MODULE_OPTIONS,
|
||||
useValue: options,
|
||||
},
|
||||
SessionService,
|
||||
],
|
||||
providers,
|
||||
exports: [SessionService],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register module with ConfigService (reads auth.url or MANA_CORE_AUTH_URL from config)
|
||||
* Register module with ConfigService
|
||||
*
|
||||
* Reads configuration from environment:
|
||||
* - MANA_CORE_AUTH_URL: Auth service URL
|
||||
* - REDIS_HOST, REDIS_PORT: Redis for cross-bot SSO
|
||||
* - MANA_CORE_SERVICE_KEY: For Matrix-SSO-Link
|
||||
* - SESSION_STORAGE_MODE: 'memory' or 'redis'
|
||||
*/
|
||||
static forRoot(): DynamicModule {
|
||||
static forRoot(options: SessionModuleOptions = {}): DynamicModule {
|
||||
const providers: any[] = [
|
||||
{
|
||||
provide: SESSION_MODULE_OPTIONS,
|
||||
useValue: options,
|
||||
},
|
||||
];
|
||||
|
||||
// Add Redis provider if storage mode is redis
|
||||
if (options.storageMode === 'redis') {
|
||||
providers.push({
|
||||
provide: REDIS_SESSION_PROVIDER,
|
||||
useClass: RedisSessionProvider,
|
||||
});
|
||||
}
|
||||
|
||||
providers.push(SessionService);
|
||||
|
||||
return {
|
||||
module: SessionModule,
|
||||
imports: [ConfigModule],
|
||||
providers: [SessionService],
|
||||
providers,
|
||||
exports: [SessionService],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register module with Redis enabled for cross-bot SSO
|
||||
*
|
||||
* Convenience method that enables Redis storage and Matrix-SSO-Link.
|
||||
*/
|
||||
static forRootWithRedis(options: Omit<SessionModuleOptions, 'storageMode'> = {}): DynamicModule {
|
||||
return this.forRoot({
|
||||
...options,
|
||||
storageMode: 'redis',
|
||||
enableMatrixSsoLink: options.enableMatrixSsoLink ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,21 +8,31 @@ import {
|
|||
SESSION_MODULE_OPTIONS,
|
||||
DEFAULT_SESSION_EXPIRY_MS,
|
||||
} from './types';
|
||||
import { RedisSessionProvider } from './redis-session.provider';
|
||||
|
||||
/**
|
||||
* Injection token for Redis session provider
|
||||
*/
|
||||
export const REDIS_SESSION_PROVIDER = 'REDIS_SESSION_PROVIDER';
|
||||
|
||||
/**
|
||||
* Shared session management service for Matrix bots
|
||||
*
|
||||
* Manages user authentication sessions linking Matrix user IDs to mana-core-auth JWT tokens.
|
||||
* Sessions are stored in-memory and automatically expire.
|
||||
*
|
||||
* Features:
|
||||
* - **In-memory mode** (default): Sessions stored per bot instance
|
||||
* - **Redis mode**: Sessions shared across ALL bots (SSO)
|
||||
* - **Matrix-SSO-Link**: Automatic login for users who logged into Matrix via OIDC
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In NestJS module
|
||||
* imports: [SessionModule.register({ authUrl: 'http://mana-core-auth:3001' })]
|
||||
* // In NestJS module - with Redis for cross-bot SSO
|
||||
* imports: [SessionModule.forRoot({ storageMode: 'redis' })]
|
||||
*
|
||||
* // In service/controller
|
||||
* const result = await sessionService.login(matrixUserId, email, password);
|
||||
* const token = sessionService.getToken(matrixUserId);
|
||||
* const token = await sessionService.getToken(matrixUserId);
|
||||
* // Token is available across ALL bots!
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
|
|
@ -32,10 +42,13 @@ export class SessionService {
|
|||
private readonly authUrl: string;
|
||||
private readonly sessionExpiryMs: number;
|
||||
private readonly loginPath: string;
|
||||
private readonly enableMatrixSsoLink: boolean;
|
||||
private readonly serviceKey: string | undefined;
|
||||
|
||||
constructor(
|
||||
@Optional() private configService: ConfigService,
|
||||
@Optional() @Inject(SESSION_MODULE_OPTIONS) private options?: SessionModuleOptions
|
||||
@Optional() @Inject(SESSION_MODULE_OPTIONS) private options?: SessionModuleOptions,
|
||||
@Optional() @Inject(REDIS_SESSION_PROVIDER) private redisProvider?: RedisSessionProvider
|
||||
) {
|
||||
// Priority: module options > config > environment > default
|
||||
this.authUrl =
|
||||
|
|
@ -47,7 +60,125 @@ export class SessionService {
|
|||
this.sessionExpiryMs = options?.sessionExpiryMs || DEFAULT_SESSION_EXPIRY_MS;
|
||||
this.loginPath = options?.loginPath || '/api/v1/auth/login';
|
||||
|
||||
this.logger.log(`Auth URL: ${this.authUrl}`);
|
||||
// Matrix-SSO-Link settings
|
||||
this.enableMatrixSsoLink = options?.enableMatrixSsoLink ?? options?.storageMode === 'redis';
|
||||
this.serviceKey =
|
||||
options?.serviceKey || this.configService?.get<string>('MANA_CORE_SERVICE_KEY');
|
||||
|
||||
const mode = this.redisProvider?.isConnected() ? 'redis' : 'memory';
|
||||
this.logger.log(
|
||||
`Auth URL: ${this.authUrl}, Storage: ${mode}, Matrix-SSO-Link: ${this.enableMatrixSsoLink}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using Redis storage
|
||||
*/
|
||||
private useRedis(): boolean {
|
||||
return this.redisProvider?.isConnected() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a session for a Matrix user
|
||||
*
|
||||
* This method tries multiple sources in order:
|
||||
* 1. Redis cache (if enabled)
|
||||
* 2. In-memory cache
|
||||
* 3. Matrix-SSO-Link lookup (automatic login if user logged into Matrix via OIDC)
|
||||
*
|
||||
* @param matrixUserId - Matrix user ID (e.g., "@user:matrix.mana.how")
|
||||
* @returns JWT token or null if not logged in
|
||||
*/
|
||||
async getToken(matrixUserId: string): Promise<string | null> {
|
||||
// 1. Try Redis first
|
||||
if (this.useRedis()) {
|
||||
const token = await this.redisProvider!.getToken(matrixUserId);
|
||||
if (token) return token;
|
||||
}
|
||||
|
||||
// 2. Try in-memory cache
|
||||
const session = this.sessions.get(matrixUserId);
|
||||
if (session) {
|
||||
if (session.expiresAt < new Date()) {
|
||||
this.sessions.delete(matrixUserId);
|
||||
} else {
|
||||
return session.token;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try Matrix-SSO-Link (automatic login)
|
||||
if (this.enableMatrixSsoLink) {
|
||||
const token = await this.fetchMatrixLinkedToken(matrixUserId);
|
||||
if (token) {
|
||||
// Cache the token
|
||||
await this.storeSession(matrixUserId, {
|
||||
token,
|
||||
email: '', // Unknown from SSO link
|
||||
expiresAt: new Date(Date.now() + this.sessionExpiryMs),
|
||||
});
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch token via Matrix-SSO-Link from mana-core-auth
|
||||
*
|
||||
* If the user logged into Matrix via OIDC (Sign in with Mana Core),
|
||||
* their Matrix user ID is linked to their Mana account.
|
||||
* This method fetches a JWT token for that link.
|
||||
*/
|
||||
private async fetchMatrixLinkedToken(matrixUserId: string): Promise<string | null> {
|
||||
if (!this.serviceKey) {
|
||||
this.logger.debug('Matrix-SSO-Link disabled: no service key configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.authUrl}/api/v1/auth/matrix-session/${encodeURIComponent(matrixUserId)}`,
|
||||
{
|
||||
headers: {
|
||||
'X-Service-Key': this.serviceKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// 404 = no link exists, which is normal for users who didn't use OIDC
|
||||
if (response.status !== 404) {
|
||||
this.logger.warn(`Matrix-SSO-Link lookup failed: ${response.status}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { token?: string };
|
||||
if (data.token) {
|
||||
this.logger.log(`Matrix-SSO-Link: auto-login for ${matrixUserId}`);
|
||||
return data.token;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.debug(`Matrix-SSO-Link lookup error: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store session in Redis and/or memory
|
||||
*/
|
||||
private async storeSession(matrixUserId: string, session: UserSession): Promise<void> {
|
||||
// Store in Redis if available
|
||||
if (this.useRedis()) {
|
||||
await this.redisProvider!.setSession(matrixUserId, session);
|
||||
}
|
||||
|
||||
// Also store in memory as fallback
|
||||
this.sessions.set(matrixUserId, session);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -81,12 +212,14 @@ export class SessionService {
|
|||
return { success: false, error: 'Kein Token erhalten' };
|
||||
}
|
||||
|
||||
// Store session with expiry
|
||||
this.sessions.set(matrixUserId, {
|
||||
// Store session
|
||||
const session: UserSession = {
|
||||
token,
|
||||
email,
|
||||
expiresAt: new Date(Date.now() + this.sessionExpiryMs),
|
||||
});
|
||||
};
|
||||
|
||||
await this.storeSession(matrixUserId, session);
|
||||
|
||||
this.logger.log(`User ${matrixUserId} logged in as ${email}`);
|
||||
return { success: true, email };
|
||||
|
|
@ -102,56 +235,66 @@ export class SessionService {
|
|||
/**
|
||||
* Logout a Matrix user
|
||||
*/
|
||||
logout(matrixUserId: string): void {
|
||||
async logout(matrixUserId: string): Promise<void> {
|
||||
// Remove from Redis
|
||||
if (this.useRedis()) {
|
||||
await this.redisProvider!.deleteSession(matrixUserId);
|
||||
}
|
||||
|
||||
// Remove from memory
|
||||
this.sessions.delete(matrixUserId);
|
||||
this.logger.log(`User ${matrixUserId} logged out`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWT token for a Matrix user (null if not logged in or expired)
|
||||
*/
|
||||
getToken(matrixUserId: string): string | null {
|
||||
const session = this.sessions.get(matrixUserId);
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
// Check if token expired
|
||||
if (session.expiresAt < new Date()) {
|
||||
this.sessions.delete(matrixUserId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Matrix user is logged in
|
||||
*/
|
||||
isLoggedIn(matrixUserId: string): boolean {
|
||||
return this.getToken(matrixUserId) !== null;
|
||||
async isLoggedIn(matrixUserId: string): Promise<boolean> {
|
||||
const token = await this.getToken(matrixUserId);
|
||||
return token !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full session object for a Matrix user
|
||||
*/
|
||||
getSession(matrixUserId: string): UserSession | null {
|
||||
const token = this.getToken(matrixUserId); // This handles expiry check
|
||||
if (!token) return null;
|
||||
return this.sessions.get(matrixUserId) || null;
|
||||
async getSession(matrixUserId: string): Promise<UserSession | null> {
|
||||
// Try Redis first
|
||||
if (this.useRedis()) {
|
||||
const session = await this.redisProvider!.getSession(matrixUserId);
|
||||
if (session) return session;
|
||||
}
|
||||
|
||||
// Try memory
|
||||
const session = this.sessions.get(matrixUserId);
|
||||
if (!session) return null;
|
||||
|
||||
// Check expiry
|
||||
if (session.expiresAt < new Date()) {
|
||||
this.sessions.delete(matrixUserId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email for a logged-in Matrix user
|
||||
*/
|
||||
getEmail(matrixUserId: string): string | null {
|
||||
const session = this.getSession(matrixUserId);
|
||||
async getEmail(matrixUserId: string): Promise<string | null> {
|
||||
const session = await this.getSession(matrixUserId);
|
||||
return session?.email || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store custom data in a user's session
|
||||
*/
|
||||
setSessionData(matrixUserId: string, key: string, value: unknown): void {
|
||||
async setSessionData(matrixUserId: string, key: string, value: unknown): Promise<void> {
|
||||
// Update in Redis
|
||||
if (this.useRedis()) {
|
||||
await this.redisProvider!.updateSessionData(matrixUserId, key, value);
|
||||
}
|
||||
|
||||
// Update in memory
|
||||
const session = this.sessions.get(matrixUserId);
|
||||
if (session) {
|
||||
session.data = session.data || {};
|
||||
|
|
@ -162,13 +305,20 @@ export class SessionService {
|
|||
/**
|
||||
* Get custom data from a user's session
|
||||
*/
|
||||
getSessionData<T = unknown>(matrixUserId: string, key: string): T | null {
|
||||
const session = this.getSession(matrixUserId);
|
||||
async getSessionData<T = unknown>(matrixUserId: string, key: string): Promise<T | null> {
|
||||
// Try Redis first
|
||||
if (this.useRedis()) {
|
||||
const data = await this.redisProvider!.getSessionData<T>(matrixUserId, key);
|
||||
if (data !== null) return data;
|
||||
}
|
||||
|
||||
// Try memory
|
||||
const session = await this.getSession(matrixUserId);
|
||||
return (session?.data?.[key] as T) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total session count (including expired)
|
||||
* Get total session count (including expired in memory)
|
||||
*/
|
||||
getSessionCount(): number {
|
||||
return this.sessions.size;
|
||||
|
|
@ -177,27 +327,40 @@ export class SessionService {
|
|||
/**
|
||||
* Get count of active (non-expired) sessions
|
||||
*/
|
||||
getActiveSessionCount(): number {
|
||||
const now = new Date();
|
||||
async getActiveSessionCount(): Promise<number> {
|
||||
let count = 0;
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.expiresAt > now) count++;
|
||||
|
||||
// Count Redis sessions
|
||||
if (this.useRedis()) {
|
||||
count = await this.redisProvider!.getActiveSessionCount();
|
||||
}
|
||||
|
||||
// If not using Redis, count memory sessions
|
||||
if (count === 0) {
|
||||
const now = new Date();
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.expiresAt > now) count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics
|
||||
*/
|
||||
getStats(): SessionStats {
|
||||
async getStats(): Promise<SessionStats> {
|
||||
const active = await this.getActiveSessionCount();
|
||||
return {
|
||||
total: this.getSessionCount(),
|
||||
active: this.getActiveSessionCount(),
|
||||
active,
|
||||
storageMode: this.useRedis() ? 'redis' : 'memory',
|
||||
matrixSsoLinkEnabled: this.enableMatrixSsoLink,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions (can be called periodically)
|
||||
* Clean up expired sessions (only for in-memory, Redis auto-expires)
|
||||
*/
|
||||
cleanupExpiredSessions(): number {
|
||||
const now = new Date();
|
||||
|
|
@ -218,7 +381,7 @@ export class SessionService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get all active session user IDs
|
||||
* Get all active session user IDs (memory only)
|
||||
*/
|
||||
getActiveUserIds(): string[] {
|
||||
const now = new Date();
|
||||
|
|
@ -232,4 +395,18 @@ export class SessionService {
|
|||
|
||||
return userIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async healthCheck(): Promise<{
|
||||
redis: { status: string; latency: number } | null;
|
||||
memory: number;
|
||||
}> {
|
||||
const redisHealth = this.redisProvider ? await this.redisProvider.healthCheck() : null;
|
||||
return {
|
||||
redis: redisHealth,
|
||||
memory: this.sessions.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,8 +33,17 @@ export interface SessionStats {
|
|||
total: number;
|
||||
/** Active (non-expired) sessions */
|
||||
active: number;
|
||||
/** Storage mode being used */
|
||||
storageMode?: 'memory' | 'redis';
|
||||
/** Whether Matrix-SSO-Link is enabled */
|
||||
matrixSsoLinkEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session storage mode
|
||||
*/
|
||||
export type SessionStorageMode = 'memory' | 'redis';
|
||||
|
||||
/**
|
||||
* Session module configuration options
|
||||
*/
|
||||
|
|
@ -45,6 +54,22 @@ export interface SessionModuleOptions {
|
|||
sessionExpiryMs?: number;
|
||||
/** Custom login endpoint path */
|
||||
loginPath?: string;
|
||||
|
||||
// Redis configuration (for cross-bot SSO)
|
||||
/** Storage mode: 'memory' (default) or 'redis' */
|
||||
storageMode?: SessionStorageMode;
|
||||
/** Redis host (default: localhost) */
|
||||
redisHost?: string;
|
||||
/** Redis port (default: 6379) */
|
||||
redisPort?: number;
|
||||
/** Redis password (optional) */
|
||||
redisPassword?: string;
|
||||
|
||||
// Matrix-SSO-Link configuration (automatic login via Matrix OIDC)
|
||||
/** Enable Matrix-SSO-Link lookup (default: true when using Redis) */
|
||||
enableMatrixSsoLink?: boolean;
|
||||
/** Service key for internal API calls to mana-core-auth */
|
||||
serviceKey?: string;
|
||||
}
|
||||
|
||||
export const SESSION_MODULE_OPTIONS = 'SESSION_MODULE_OPTIONS';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue