fix(oauth): purge tokens whose refresh token expired a month ago

Every refresh writes a new oauth_tokens row and nothing ever deleted the
old ones, so an MCP client refreshing hourly grew the table, and every
backup with it, without end. A nightly job now deletes tokens whose
refresh token expired more than 30 days ago, leaves first, and keeps any
row a live chain still points at for replay detection. Also types the
backup's per-entry store flag.
This commit is contained in:
Maurice
2026-10-07 16:45:21 +02:00
parent a869269591
commit 76df44d8de
7 changed files with 146 additions and 4 deletions
@@ -352,6 +352,35 @@ export class OauthTokensRepository extends TrekRepository<OauthTokens> {
return rows.map((r) => r.id);
}
// ---------------------------------------------------------------------
// Retention
// ---------------------------------------------------------------------
/**
* Delete tokens whose refresh token expired before `cutoffIso`, oldest
* generation first. A row is only deleted once no other token names it as
* parent: `parent_token_id` has no ON DELETE action, and a token still in a
* live chain is what replay detection walks. Each pass removes the current
* leaves, so a whole expired chain goes over a few passes; `maxPasses`
* bounds a chain that is still growing. Returns the number deleted.
*/
async deleteExpiredBefore(cutoffIso: string, maxPasses = 50): Promise<number> {
let total = 0;
for (let pass = 0; pass < maxPasses; pass++) {
const result = await this.kysely<OauthTokensKyselyDB>()
.deleteFrom('oauth_tokens')
.where('refresh_token_expires_at', '<', cutoffIso)
.where('id', 'not in', (eb) =>
eb.selectFrom('oauth_tokens as child').select('child.parent_token_id').where('child.parent_token_id', 'is not', null),
)
.executeTakeFirst();
const deleted = Number(result.numDeletedRows ?? 0);
total += deleted;
if (deleted === 0) break;
}
return total;
}
// ---------------------------------------------------------------------
// OA19 — chain-wide revoke
// ---------------------------------------------------------------------
+2 -1
View File
@@ -301,7 +301,8 @@ export async function createBackup(storage: StorageService, prefix: 'backup' | '
// Photos, videos and PDFs are compressed already; deflating them at level
// 9 costs most of a backup's CPU time for a few bytes. They are stored.
for (const entry of uploadEntries) {
archive.file(entry.absPath, { name: entry.name, store: PRECOMPRESSED.test(entry.name) });
const data: archiver.ZipEntryData = { name: entry.name, store: PRECOMPRESSED.test(entry.name) };
archive.file(entry.absPath, data);
}
// Plugin data — each plugin's own SQLite file and any blobs. This is the ONLY
@@ -0,0 +1,40 @@
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { OauthTokens } from '../../db/entities/OauthTokens.entity';
import type { OauthTokensRepository } from '../../db/repositories/OauthTokens.repository';
import { logError, logInfo } from '../audit/audit-log.logger';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
/** How long a token is kept after its refresh token expired. */
export const OAUTH_TOKEN_RETENTION_DAYS = 30;
/**
* Nightly purge of OAuth tokens whose refresh token expired a month ago.
* Every refresh writes a new row and nothing ever deleted the old ones, so
* an MCP client refreshing hourly grew the table, and with it every backup,
* without end. An expired refresh token is refused on its own, so a row this
* old serves nothing; the repository keeps any row a live chain still points
* at.
*/
@Injectable()
export class OauthTokenRetentionJob implements OnApplicationBootstrap {
constructor(
@InjectRepository(OauthTokens) private readonly tokens: OauthTokensRepository,
private readonly registrar: CronRegistrarService,
) {}
onApplicationBootstrap(): void {
if (!this.registrar.isEnabled()) return;
this.registrar.register('oauth-token-retention', '30 3 * * *', () => this.tick());
}
async tick(now: Date = new Date()): Promise<void> {
try {
const cutoff = new Date(now.getTime() - OAUTH_TOKEN_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString();
const removed = await this.tokens.deleteExpiredBefore(cutoff);
if (removed > 0) logInfo(`OAuth token retention: removed ${removed} expired token(s)`);
} catch (err: unknown) {
logError(`OAuth token retention: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
+4 -2
View File
@@ -7,6 +7,8 @@ import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth
import { OauthPublicController } from './oauth-public.controller';
import { OauthApiController } from './oauth-api.controller';
import { OauthService } from './oauth.service';
import { OauthTokenRetentionJob } from './oauth-token-retention.job';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { TrekClientsStore, TrekOAuthProvider } from './oauth-sdk.provider';
import { AuditModule } from '../audit/audit.module';
import { AddonsModule } from '../addons/addons.module';
@@ -52,9 +54,9 @@ import { Users } from '../../db/entities/Users.entity';
* `Users` call (Task 1's ruling: "do not add users methods" for this join).
*/
@Module({
imports: [RateLimitModule, AuditModule, AddonsModule, MikroOrmModule.forFeature([OauthClients, OauthTokens, OauthConsents, Users])],
imports: [RateLimitModule, AuditModule, AddonsModule, SchedulingModule, MikroOrmModule.forFeature([OauthClients, OauthTokens, OauthConsents, Users])],
controllers: [OauthPublicController, OauthApiController],
providers: [OauthService, TrekClientsStore, TrekOAuthProvider],
providers: [OauthService, TrekClientsStore, TrekOAuthProvider, OauthTokenRetentionJob],
exports: [OauthService],
})
export class OauthModule implements NestModule {