From acf4512e906a65271d324dd2bea5a2c9240527c3 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sat, 14 Feb 2026 12:19:08 +0100 Subject: [PATCH] fix(session): add auto-refresh for expired JWT tokens - Add isTokenValid() to decode JWT and check exp claim - Refresh tokens 60 seconds before expiry (buffer) - Auto-fetch fresh token via SSO-Link when cached token expires - Clear invalid sessions when refresh fails - Prevents "exp claim timestamp check failed" errors JWT tokens from mana-core-auth expire after 15 minutes, but sessions were cached for 7 days. Now tokens are transparently refreshed when they expire, keeping users authenticated. Co-Authored-By: Claude Opus 4.5 --- .../src/session/session.service.ts | 111 +++++++++++- services/matrix-stats-bot/CLAUDE.md | 14 +- .../matrix-stats-bot/src/bot/bot.module.ts | 2 + .../src/bot/matrix.service.ts | 41 ++++- .../src/mydata/mydata.module.ts | 10 ++ .../src/mydata/mydata.service.ts | 168 ++++++++++++++++++ 6 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 services/matrix-stats-bot/src/mydata/mydata.module.ts create mode 100644 services/matrix-stats-bot/src/mydata/mydata.service.ts diff --git a/packages/bot-services/src/session/session.service.ts b/packages/bot-services/src/session/session.service.ts index 8ce476115..f97ab90bd 100644 --- a/packages/bot-services/src/session/session.service.ts +++ b/packages/bot-services/src/session/session.service.ts @@ -35,6 +35,12 @@ export const REDIS_SESSION_PROVIDER = 'REDIS_SESSION_PROVIDER'; * // Token is available across ALL bots! * ``` */ +/** + * Buffer time before JWT expiry to trigger refresh (in seconds) + * Refresh tokens 60 seconds before they expire to avoid edge cases + */ +const JWT_REFRESH_BUFFER_SECONDS = 60; + @Injectable() export class SessionService { private readonly logger = new Logger(SessionService.name); @@ -78,14 +84,59 @@ export class SessionService { return this.redisProvider?.isConnected() ?? false; } + /** + * Decode JWT and check if it's expired or about to expire + * + * @param token - JWT token string + * @returns true if token is valid and not expired, false otherwise + */ + private isTokenValid(token: string): boolean { + try { + // JWT format: header.payload.signature + const parts = token.split('.'); + if (parts.length !== 3) { + this.logger.debug('Invalid JWT format'); + return false; + } + + // Decode payload (base64url) + const payload = JSON.parse( + Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8') + ); + + if (!payload.exp) { + this.logger.debug('JWT has no exp claim'); + return true; // No expiry = valid + } + + // Check if expired (with buffer) + const now = Math.floor(Date.now() / 1000); + const expiresAt = payload.exp; + const isValid = expiresAt > now + JWT_REFRESH_BUFFER_SECONDS; + + if (!isValid) { + this.logger.debug( + `JWT expired or expiring soon: exp=${expiresAt}, now=${now}, buffer=${JWT_REFRESH_BUFFER_SECONDS}s` + ); + } + + return isValid; + } catch (error) { + this.logger.debug(`Failed to decode JWT: ${error}`); + return 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 + * 1. Redis cache (if enabled) - validates JWT expiry + * 2. In-memory cache - validates JWT expiry * 3. Matrix-SSO-Link lookup (automatic login if user logged into Matrix via OIDC) * + * If a cached token is expired, it automatically fetches a fresh one via SSO-Link. + * * @param matrixUserId - Matrix user ID (e.g., "@user:matrix.mana.how") * @returns JWT token or null if not logged in */ @@ -94,8 +145,19 @@ export class SessionService { if (this.useRedis()) { const token = await this.redisProvider!.getToken(matrixUserId); if (token) { - this.logger.debug(`Found token in Redis for ${matrixUserId}`); - return token; + // Check if JWT is still valid + if (this.isTokenValid(token)) { + this.logger.debug(`Found valid token in Redis for ${matrixUserId}`); + return token; + } + // Token expired - try to refresh via SSO-Link + this.logger.debug(`Token in Redis expired for ${matrixUserId}, refreshing...`); + const freshToken = await this.refreshToken(matrixUserId); + if (freshToken) { + return freshToken; + } + // Refresh failed - clear invalid session + await this.redisProvider!.deleteSession(matrixUserId); } } @@ -104,9 +166,18 @@ export class SessionService { if (session) { if (session.expiresAt < new Date()) { this.sessions.delete(matrixUserId); - } else { - this.logger.debug(`Found token in memory for ${matrixUserId}`); + } else if (this.isTokenValid(session.token)) { + this.logger.debug(`Found valid token in memory for ${matrixUserId}`); return session.token; + } else { + // Token expired - try to refresh via SSO-Link + this.logger.debug(`Token in memory expired for ${matrixUserId}, refreshing...`); + const freshToken = await this.refreshToken(matrixUserId); + if (freshToken) { + return freshToken; + } + // Refresh failed - clear invalid session + this.sessions.delete(matrixUserId); } } @@ -131,6 +202,34 @@ export class SessionService { return null; } + /** + * Refresh an expired token via Matrix-SSO-Link + * + * @param matrixUserId - Matrix user ID + * @returns Fresh JWT token or null if refresh failed + */ + private async refreshToken(matrixUserId: string): Promise { + if (!this.enableMatrixSsoLink) { + this.logger.debug('Cannot refresh token: SSO-Link disabled'); + return null; + } + + const freshToken = await this.fetchMatrixLinkedToken(matrixUserId); + if (freshToken) { + this.logger.log(`Token refreshed via SSO-Link for ${matrixUserId}`); + // Update cached session with fresh token + await this.storeSession(matrixUserId, { + token: freshToken, + email: '', // Unknown from SSO link + expiresAt: new Date(Date.now() + this.sessionExpiryMs), + }); + return freshToken; + } + + this.logger.warn(`Token refresh failed for ${matrixUserId}`); + return null; + } + /** * Fetch token via Matrix-SSO-Link from mana-core-auth * diff --git a/services/matrix-stats-bot/CLAUDE.md b/services/matrix-stats-bot/CLAUDE.md index 062439ec1..67e3da2a2 100644 --- a/services/matrix-stats-bot/CLAUDE.md +++ b/services/matrix-stats-bot/CLAUDE.md @@ -22,7 +22,14 @@ pnpm type-check # TypeScript check ## Matrix Commands -### Analytics (Umami) +### Personal Stats (requires login) + +| Command | Description | +|---------|-------------| +| `!mystats` | Your personal statistics across all ManaCore apps | +| `!status` | Account status and credit balance | + +### Global Analytics (Umami) | Command | Description | |---------|-------------| @@ -41,11 +48,12 @@ pnpm type-check # TypeScript check | `!db` | PostgreSQL & Redis status | | `!growth` | User growth statistics | -### General +### Account | Command | Description | |---------|-------------| -| `!status` | Account status | +| `!login email password` | Login with ManaCore credentials | +| `!logout` | Logout from current session | | `!help` | Show available commands | ## Scheduled Reports diff --git a/services/matrix-stats-bot/src/bot/bot.module.ts b/services/matrix-stats-bot/src/bot/bot.module.ts index 8b2e59c33..6d8c7ff4a 100644 --- a/services/matrix-stats-bot/src/bot/bot.module.ts +++ b/services/matrix-stats-bot/src/bot/bot.module.ts @@ -3,6 +3,7 @@ import { MatrixService } from './matrix.service'; import { AnalyticsModule } from '../analytics/analytics.module'; import { UsersModule } from '../users/users.module'; import { InfrastructureModule } from '../infrastructure/infrastructure.module'; +import { MyDataModule } from '../mydata/mydata.module'; import { TranscriptionModule, SessionModule, CreditModule } from '@manacore/bot-services'; @Module({ @@ -10,6 +11,7 @@ import { TranscriptionModule, SessionModule, CreditModule } from '@manacore/bot- AnalyticsModule, UsersModule, InfrastructureModule, + MyDataModule, TranscriptionModule.register({ sttUrl: process.env.STT_URL || 'http://localhost:3020', }), diff --git a/services/matrix-stats-bot/src/bot/matrix.service.ts b/services/matrix-stats-bot/src/bot/matrix.service.ts index b1e9a9ec7..ef07869dc 100644 --- a/services/matrix-stats-bot/src/bot/matrix.service.ts +++ b/services/matrix-stats-bot/src/bot/matrix.service.ts @@ -10,6 +10,7 @@ import { import { AnalyticsService } from '../analytics/analytics.service'; import { UsersService } from '../users/users.service'; import { InfrastructureService } from '../infrastructure/infrastructure.service'; +import { MyDataService } from '../mydata/mydata.service'; import { TranscriptionService, SessionService, CreditService } from '@manacore/bot-services'; @Injectable() @@ -28,6 +29,7 @@ export class MatrixService extends BaseMatrixService { { keywords: ['traffic', 'requests', 'http', 'api'], command: 'traffic' }, { keywords: ['db', 'database', 'datenbank', 'postgres', 'redis'], command: 'db' }, { keywords: ['growth', 'wachstum', 'registrierungen'], command: 'growth' }, + { keywords: ['mystats', 'meinestats', 'meinedaten', 'mydata'], command: 'mystats' }, ]); constructor( @@ -35,6 +37,7 @@ export class MatrixService extends BaseMatrixService { private analyticsService: AnalyticsService, private usersService: UsersService, private infrastructureService: InfrastructureService, + private myDataService: MyDataService, private readonly transcriptionService: TranscriptionService, private sessionService: SessionService, private creditService: CreditService @@ -146,6 +149,10 @@ export class MatrixService extends BaseMatrixService { await this.sendGrowth(roomId); break; + case 'mystats': + await this.sendMyStats(roomId, sender); + break; + default: await this.sendMessage(roomId, `Unbekannter Befehl: !${command}\n\nVerwende !help`); } @@ -154,7 +161,11 @@ export class MatrixService extends BaseMatrixService { private async sendHelp(roomId: string) { const helpText = `**📊 ManaCore Stats Bot** -**Analytics (Umami):** +**Persönliche Stats:** +- \`!mystats\` - Deine persönlichen Statistiken +- \`!status\` - Account Status + +**Globale Analytics (Umami):** - \`!stats\` - Übersicht aller Apps (30 Tage) - \`!today\` - Heutige Statistiken - \`!week\` - Wochenstatistiken @@ -168,7 +179,8 @@ export class MatrixService extends BaseMatrixService { - \`!growth\` - User Wachstum **Account:** -- \`!status\` - Account Status +- \`!login email passwort\` - Anmelden +- \`!logout\` - Abmelden - \`!help\` - Diese Hilfe`; await this.sendMessage(roomId, helpText); @@ -314,6 +326,31 @@ export class MatrixService extends BaseMatrixService { } } + private async sendMyStats(roomId: string, sender: string) { + const token = await this.sessionService.getToken(sender); + + if (!token) { + await this.sendMessage(roomId, this.myDataService.formatNotLoggedIn()); + return; + } + + try { + await this.sendMessage(roomId, '📊 Lade deine persönlichen Stats...'); + const userData = await this.myDataService.getUserData(token); + + if (!userData) { + await this.sendMessage(roomId, this.myDataService.formatError()); + return; + } + + const report = this.myDataService.formatUserStats(userData); + await this.sendMessage(roomId, report); + } catch (error) { + this.logger.error('Failed to fetch user stats:', error); + await this.sendMessage(roomId, this.myDataService.formatError()); + } + } + private async handleStatus(roomId: string, sender: string) { const loggedIn = await this.sessionService.isLoggedIn(sender); const session = await this.sessionService.getSession(sender); diff --git a/services/matrix-stats-bot/src/mydata/mydata.module.ts b/services/matrix-stats-bot/src/mydata/mydata.module.ts new file mode 100644 index 000000000..d30d60536 --- /dev/null +++ b/services/matrix-stats-bot/src/mydata/mydata.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { MyDataService } from './mydata.service'; + +@Module({ + imports: [ConfigModule], + providers: [MyDataService], + exports: [MyDataService], +}) +export class MyDataModule {} diff --git a/services/matrix-stats-bot/src/mydata/mydata.service.ts b/services/matrix-stats-bot/src/mydata/mydata.service.ts new file mode 100644 index 000000000..065f50f1e --- /dev/null +++ b/services/matrix-stats-bot/src/mydata/mydata.service.ts @@ -0,0 +1,168 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface EntityCount { + entity: string; + count: number; + label: string; +} + +interface ProjectDataSummary { + projectId: string; + projectName: string; + icon: string; + available: boolean; + error?: string; + entities: EntityCount[]; + totalCount: number; + lastActivityAt?: string; +} + +interface UserDataSummary { + user: { + id: string; + email: string; + name: string; + role: string; + createdAt: string; + emailVerified: boolean; + }; + auth: { + sessionsCount: number; + accountsCount: number; + has2FA: boolean; + lastLoginAt: string | null; + }; + credits: { + balance: number; + totalEarned: number; + totalSpent: number; + transactionsCount: number; + }; + projects: ProjectDataSummary[]; + totals: { + totalEntities: number; + projectsWithData: number; + }; +} + +@Injectable() +export class MyDataService { + private readonly logger = new Logger(MyDataService.name); + private readonly authUrl: string; + + constructor(private configService: ConfigService) { + this.authUrl = this.configService.get('MANA_CORE_AUTH_URL') || 'http://localhost:3001'; + } + + async getUserData(token: string): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/me/data`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + this.logger.error(`Failed to fetch user data: ${response.status}`); + return null; + } + + return (await response.json()) as UserDataSummary; + } catch (error) { + this.logger.error(`Error fetching user data: ${error}`); + return null; + } + } + + formatUserStats(data: UserDataSummary): string { + const formatDate = (dateStr: string): string => { + const date = new Date(dateStr); + return date.toLocaleDateString('de-DE', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + }; + + const formatNumber = (num: number): string => { + return num.toLocaleString('de-DE'); + }; + + let report = '**📊 Deine ManaCore Stats**\n\n'; + + // Account info + report += '**👤 Account**\n'; + report += `- Email: ${data.user.email}\n`; + report += `- Name: ${data.user.name || 'Nicht angegeben'}\n`; + report += `- Mitglied seit: ${formatDate(data.user.createdAt)}\n`; + report += `- Email verifiziert: ${data.user.emailVerified ? '✅' : '❌'}\n`; + if (data.auth.has2FA) { + report += `- 2FA: ✅ Aktiv\n`; + } + report += '\n'; + + // Credits + report += '**⚡ Credits**\n'; + report += `- Guthaben: ${data.credits.balance.toFixed(2)}\n`; + report += `- Verdient: ${data.credits.totalEarned.toFixed(2)}\n`; + report += `- Ausgegeben: ${data.credits.totalSpent.toFixed(2)}\n`; + report += `- Transaktionen: ${formatNumber(data.credits.transactionsCount)}\n`; + report += '\n'; + + // Projects with data + const projectsWithData = data.projects.filter((p) => p.available && p.totalCount > 0); + + if (projectsWithData.length > 0) { + report += '**📱 Deine Nutzung**\n'; + + for (const project of projectsWithData) { + const entitySummary = project.entities + .filter((e) => e.count > 0) + .map((e) => `${formatNumber(e.count)} ${e.label}`) + .join(', '); + + report += `${project.icon} **${project.projectName}:** ${entitySummary}\n`; + } + report += '\n'; + } + + // Summary + report += '**📈 Gesamt**\n'; + report += `- Datenpunkte: ${formatNumber(data.totals.totalEntities)}\n`; + report += `- Aktive Apps: ${data.totals.projectsWithData}/${data.projects.length}\n`; + + // Last activity + const lastActivities = projectsWithData + .filter((p) => p.lastActivityAt) + .map((p) => ({ name: p.projectName, date: new Date(p.lastActivityAt!) })) + .sort((a, b) => b.date.getTime() - a.date.getTime()); + + if (lastActivities.length > 0) { + const latest = lastActivities[0]; + report += `- Letzte Aktivität: ${latest.name} (${formatDate(latest.date.toISOString())})`; + } + + return report; + } + + formatNotLoggedIn(): string { + return `**❌ Nicht angemeldet** + +Um deine persönlichen Stats zu sehen, melde dich an: + +\`!login deine@email.de deinpasswort\` + +Nach der Anmeldung kannst du mit \`!mystats\` deine Daten abrufen.`; + } + + formatError(): string { + return `**❌ Fehler beim Laden** + +Deine Stats konnten nicht abgerufen werden. Bitte versuche es später erneut. + +Falls das Problem weiterhin besteht, melde dich neu an: +\`!logout\` und dann \`!login\``; + } +}