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:
Till-JS 2026-02-02 14:51:23 +01:00
parent 7bad849258
commit 2777f604fd
27 changed files with 2997 additions and 838 deletions

View file

@ -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';

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

View file

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

View file

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

View file

@ -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';