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 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-14 12:19:08 +01:00
parent 4b950b7083
commit acf4512e90
6 changed files with 335 additions and 11 deletions

View file

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

View file

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

View file

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

View file

@ -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 {}

View file

@ -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<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
}
async getUserData(token: string): Promise<UserDataSummary | null> {
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\``;
}
}