Files
TREK/server/src/nest/oauth/oauth.module.ts
T
Maurice 06dfdc6943 refactor(server): put process-local state behind injectable stores
Rate-limit counters, OAuth pending codes, OIDC login states and codes,
the permissions cache and the WebSocket rooms were Maps on module or
instance scope. That is right while TREK runs as one process, but nothing
could be swapped without editing every consumer.

Each now sits behind a port with an in-memory implementation that keeps
the old behaviour exactly: RateLimitStore, PendingCodeStore,
OidcFlowStore, PermissionsCacheStore and RoomRegistry. The owning module
provides the implementation with the same lifetime the state had
(process-wide for the pending codes, the permissions cache and the rooms,
per instance for the rate limits and the OIDC flows), the consumers take
it through their constructor, and a hand-built consumer falls back to the
same in-memory store, so tests and the free-function paths (the backup
restore's cache flush, the broadcasts) see what they saw before. A store
shared between processes can replace a provider later without touching
the services.
2026-10-08 18:40:37 +02:00

92 lines
4.6 KiB
TypeScript

import { RateLimitModule } from '../common/rate-limit.module';
import { Module } from '@nestjs/common';
import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
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';
import { AddonsService } from '../addons/addons.service';
import { createMcpAddonGate } from '../addons/mcp-addon-gate';
import { OauthClients } from '../../db/entities/OauthClients.entity';
import { OauthTokens } from '../../db/entities/OauthTokens.entity';
import { OauthConsents } from '../../db/entities/OauthConsents.entity';
import { Users } from '../../db/entities/Users.entity';
import { PendingCodeStore, processPendingCodes } from './oauth.pending-codes';
/**
* OAuth 2.1 server (MCP). Public token/userinfo/revoke endpoints + the SPA's
* authenticated consent/client/session management, plus the SDK's own
* /oauth/authorize and /oauth/register Express routers, mounted here through
* MiddlewareConsumer over the injected TrekOAuthProvider/TrekClientsStore.
* forRoutes('<path>') is an Express prefix mount — the same app.use('/path')
* shape (and the same req.url stripping) the pre-init platform mount used, and
* exactly what the SDK routers are built for. The handlers are constructed once
* per app instance in configure(), preserving the per-app lifetime of the DCR
* handler's built-in rate limiter.
*
* The handlers bring their own body parsers; the global 100kb parsers run
* first and theirs no-op on the already-consumed stream. The one observable
* difference vs the pre-init mount: bracketed keys in a POST /oauth/authorize
* body now parse with qs (extended:true) rather than querystring — the SDK
* zod-rejects such bodies either way.
*
* Pending authorization codes live behind the PendingCodeStore port
* (oauth.pending-codes.ts), provided here as the process-wide in-memory
* instance: the consent controller (container singleton) writes them, the
* SDK exchange path reads them back through the same injected singleton.
*
* Exports OauthService for AdminController (admin OAuth-session panel) and the
* MCP transport's token verification.
*
* `MikroOrmModule.forFeature([OauthClients, OauthTokens, OauthConsents,
* Users])` (Plan 3b Task 4 deliverable): `Users` is registered for cross-
* module availability (inventory §6 — `OauthTokensRepository` needs to be
* importable from `AuthModule` too, Task 5's `revokeAllForUser`, the same
* shape `McpTokensRepository`/`InviteTokensRepository` already use across
* `nest/tokens`/`nest/auth`/`nest/oidc`) even though `OauthService` itself
* never injects `UsersRepository` directly — OA16/OA31 read `users` columns
* THROUGH `OauthTokensRepository`'s own join methods, not a separate
* `Users` call (Task 1's ruling: "do not add users methods" for this join).
*/
@Module({
imports: [RateLimitModule, AuditModule, AddonsModule, SchedulingModule, MikroOrmModule.forFeature([OauthClients, OauthTokens, OauthConsents, Users])],
controllers: [OauthPublicController, OauthApiController],
providers: [
OauthService,
TrekClientsStore,
TrekOAuthProvider,
OauthTokenRetentionJob,
{ provide: PendingCodeStore, useValue: processPendingCodes },
],
exports: [OauthService],
})
export class OauthModule implements NestModule {
constructor(
private readonly addons: AddonsService,
private readonly provider: TrekOAuthProvider,
private readonly clients: TrekClientsStore,
) {}
configure(consumer: MiddlewareConsumer): void {
const mcpAddonGate = createMcpAddonGate(this.addons);
// SDK authorize handler: validates OAuth params, calls provider.authorize()
// which redirects to the SPA consent page at /oauth/consent
consumer
.apply(mcpAddonGate, authorizationHandler({ provider: this.provider }))
.forRoutes('oauth/authorize');
// SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2)
consumer
.apply(mcpAddonGate, clientRegistrationHandler({ clientsStore: this.clients }))
.forRoutes('oauth/register');
}
}