refactor(server): providerize the OAuth SDK adapter behind the container

This commit is contained in:
jubnl
2026-08-11 22:44:20 +02:00
parent 1390bfbe06
commit fac442086f
13 changed files with 967 additions and 219 deletions
+19
View File
@@ -0,0 +1,19 @@
import type { RequestHandler } from 'express';
import { AddonsService } from './addons.service';
import { ADDON_IDS } from '../../addons';
/**
* Gate: 404 (empty body) when the MCP addon is disabled (M2 — prevents feature
* fingerprinting). A factory over the injected AddonsService rather than a
* middleware class: consumers build it inside their module's configure() and
* hand the bare RequestHandler to consumer.apply() ahead of the SDK routers.
*/
export function createMcpAddonGate(addons: AddonsService): RequestHandler {
return (_req, res, next) => {
if (!addons.isAddonEnabled(ADDON_IDS.MCP)) {
res.status(404).end();
return;
}
next();
};
}
@@ -1,36 +1,24 @@
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import type { OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider';
import type { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider';
import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth';
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
import type { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider';
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients';
import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors';
import { db } from '../db/database';
import {
createOAuthClient,
consumeAuthCode,
issueTokens,
refreshTokens,
revokeToken as serviceRevokeToken,
verifyPKCE,
getUserByAccessToken,
} from '../nest/oauth/oauth.bridge';
import { ALL_SCOPES } from './scopes';
import { getMcpSafeUrl } from '../app-config';
import { writeAudit } from '../nest/audit/audit.bridge';
import { OauthService } from './oauth.service';
import { AuditService } from '../audit/audit.service';
import { ALL_SCOPES } from '../../mcp/scopes';
import { getMcpSafeUrl } from '../../app-config';
// ---------------------------------------------------------------------------
// DB row type (mirrors oauthService.ts)
// ---------------------------------------------------------------------------
interface OAuthClientRow {
client_id: string;
name: string;
redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array
is_public: number; // 0 | 1
created_via: string;
}
/**
* TREK's adapters behind the MCP SDK's OAuth server interfaces, wrapping the
* injected OauthService. The SDK's authorizationHandler / clientRegistrationHandler
* Express routers are built over these instances in OauthModule.configure().
*
* Pending authorization codes stay module-scoped in oauth.pending-codes.ts: the
* consent controller writes a code through the container singleton and
* exchangeAuthorizationCode reads it back through the same injected singleton.
*/
// ---------------------------------------------------------------------------
// Redirect URI validation (mirrors oauth.ts DCR checks)
@@ -60,7 +48,7 @@ function assertValidRedirectUris(uris: string[]): void {
// Row → SDK client info shape
// ---------------------------------------------------------------------------
function rowToInfo(row: OAuthClientRow): OAuthClientInformationFull {
function rowToInfo(row: NonNullable<ReturnType<OauthService['getSdkClient']>>): OAuthClientInformationFull {
return {
client_id: row.client_id,
client_name: row.name,
@@ -76,13 +64,14 @@ function rowToInfo(row: OAuthClientRow): OAuthClientInformationFull {
// Clients store
// ---------------------------------------------------------------------------
export const trekClientsStore: OAuthRegisteredClientsStore = {
@Injectable()
export class TrekClientsStore implements OAuthRegisteredClientsStore {
constructor(private readonly oauth: OauthService) {}
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
const row = db.prepare(
'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?'
).get(clientId) as OAuthClientRow | undefined;
const row = this.oauth.getSdkClient(clientId);
return row ? rowToInfo(row) : undefined;
},
}
async registerClient(
metadata: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
@@ -99,7 +88,7 @@ export const trekClientsStore: OAuthRegisteredClientsStore = {
const scopes = rawScopes.filter(s => (ALL_SCOPES as string[]).includes(s));
if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested');
const result = createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
const result = this.oauth.createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
if (result.error) throw new InvalidClientMetadataError(result.error);
const c = result.client!;
@@ -113,15 +102,22 @@ export const trekClientsStore: OAuthRegisteredClientsStore = {
response_types: ['code'],
...(c.client_secret ? { client_secret: c.client_secret as string, client_secret_expires_at: 0 } : {}),
};
},
};
}
}
// ---------------------------------------------------------------------------
// OAuthServerProvider
// ---------------------------------------------------------------------------
export const trekOAuthProvider: OAuthServerProvider = {
get clientsStore() { return trekClientsStore; },
@Injectable()
export class TrekOAuthProvider implements OAuthServerProvider {
constructor(
private readonly clients: TrekClientsStore,
private readonly oauth: OauthService,
private readonly audit: AuditService,
) {}
get clientsStore(): OAuthRegisteredClientsStore { return this.clients; }
// Redirects browser to the SPA consent page with OAuth params forwarded.
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
@@ -149,15 +145,15 @@ export const trekOAuthProvider: OAuthServerProvider = {
const base = getMcpSafeUrl().replace(/\/+$/, '');
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
},
}
// Not called because skipLocalPkceValidation = true.
// PKCE verification is done inline in exchangeAuthorizationCode.
skipLocalPkceValidation: true,
readonly skipLocalPkceValidation = true;
async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _code: string): Promise<string> {
throw new ServerError('PKCE validation is handled by the provider directly');
},
}
async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
@@ -166,7 +162,7 @@ export const trekOAuthProvider: OAuthServerProvider = {
redirectUri?: string,
resource?: URL,
): Promise<OAuthTokens> {
const pending = consumeAuthCode(code);
const pending = this.oauth.consumeAuthCode(code);
if (!pending || pending.clientId !== client.client_id)
throw new Error('Authorization grant is invalid.');
@@ -177,18 +173,18 @@ export const trekOAuthProvider: OAuthServerProvider = {
if (pending.resource && resourceStr && pending.resource !== resourceStr)
throw new Error('Authorization grant is invalid.');
if (codeVerifier && !verifyPKCE(codeVerifier, pending.codeChallenge))
if (codeVerifier && !this.oauth.verifyPKCE(codeVerifier, pending.codeChallenge))
throw new Error('Authorization grant is invalid.');
const tokens = issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
writeAudit({
const tokens = this.oauth.issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
this.audit.writeAudit({
userId: pending.userId,
action: 'oauth.token.issue',
details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null },
ip: null,
});
return tokens;
},
}
async exchangeRefreshToken(
client: OAuthClientInformationFull,
@@ -196,13 +192,13 @@ export const trekOAuthProvider: OAuthServerProvider = {
_scopes?: string[],
_resource?: URL,
): Promise<OAuthTokens> {
const result = refreshTokens(refreshToken, client.client_id, client.client_secret, null);
const result = this.oauth.refreshTokens(refreshToken, client.client_id, client.client_secret, null);
if (result.error) throw new Error(result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired');
return result.tokens!;
},
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
const info = getUserByAccessToken(token);
const info = this.oauth.getUserByAccessToken(token);
if (!info) throw new Error('Invalid or expired token');
return {
token,
@@ -210,12 +206,12 @@ export const trekOAuthProvider: OAuthServerProvider = {
scopes: info.scopes,
extra: { user: info.user },
};
},
}
async revokeToken(
client: OAuthClientInformationFull,
request: OAuthTokenRevocationRequest,
): Promise<void> {
serviceRevokeToken(request.token, client.client_id, undefined, null);
},
};
this.oauth.revokeToken(request.token, client.client_id, undefined, null);
}
}
+8 -37
View File
@@ -5,22 +5,17 @@ import { DatabaseService } from '../database/database.service';
import { OauthService } from './oauth.service';
/**
* Non-Nest entry point for the OAuth domain — for code running OUTSIDE the
* Nest container: the MCP transport (src/mcp/index.ts token verification) and
* the SDK provider adapter (src/mcp/oauthProvider.ts), which
* Non-Nest entry point for the OAuth domain — down to its last consumer: the
* MCP transport's token verification (src/mcp/index.ts), which
* platform.routes.ts mounts on the Express app BEFORE app.init(), so
* `app.get(OauthService)` is not available to it. Exports the legacy
* services/oauthService names 1:1 so repointing a consumer is an
* import-path-only diff. Inside the container, inject OauthService instead.
* Delete this file when the SDK mount moves behind the container.
* `app.get(OauthService)` is not available to it. The SDK provider adapter
* moved behind the container (oauth-sdk.provider.ts) and dropped the other six
* exports. Inside the container, inject OauthService instead. Delete this file
* when the /mcp mount moves behind the container.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton. The one piece of shared state — the
* pending authorization codes — is module-scoped in oauth.pending-codes.ts, so
* this instance and the container singleton see the same map. That is load-
* bearing: the consent controller writes the code through the container and
* the SDK reads it back through here (which is why consumeAuthCode is bridged
* and createAuthCode is not).
* the shared better-sqlite3 singleton, and getUserByAccessToken touches no
* module-scoped state beyond it.
*/
const dbs = new DatabaseService(db);
const oauth = new OauthService(dbs, new AddonsService(dbs), new AuditService(dbs));
@@ -28,27 +23,3 @@ const oauth = new OauthService(dbs, new AddonsService(dbs), new AuditService(dbs
export function getUserByAccessToken(rawToken: string) {
return oauth.getUserByAccessToken(rawToken);
}
export function createOAuthClient(...args: Parameters<OauthService['createOAuthClient']>) {
return oauth.createOAuthClient(...args);
}
export function consumeAuthCode(code: string) {
return oauth.consumeAuthCode(code);
}
export function issueTokens(...args: Parameters<OauthService['issueTokens']>) {
return oauth.issueTokens(...args);
}
export function refreshTokens(...args: Parameters<OauthService['refreshTokens']>) {
return oauth.refreshTokens(...args);
}
export function revokeToken(...args: Parameters<OauthService['revokeToken']>) {
return oauth.revokeToken(...args);
}
export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean {
return oauth.verifyPKCE(codeVerifier, codeChallenge);
}
+49 -10
View File
@@ -1,27 +1,66 @@
import { RateLimitModule } from '../common/rate-limit.module';
import { Module } from '@nestjs/common';
import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
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 { 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';
/**
* OAuth 2.1 server (MCP). Public token/userinfo/revoke endpoints + the SPA's
* authenticated consent/client/session management. /oauth/authorize,
* /oauth/register and /oauth/consent are still served by the MCP SDK's own
* router, mounted on the Express platform before app.init() — that path
* reaches the domain through oauth.bridge.ts, which is why the pending
* authorization codes live in a module, not on the provider.
* 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.
*
* Exports OauthService for AdminController, which serves the admin OAuth-session
* panel under /api/admin. That is the one in-container consumer; everything
* outside the container still goes through oauth.bridge.ts.
* 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 module-scoped in oauth.pending-codes.ts:
* 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.
*/
@Module({
imports: [RateLimitModule, AuditModule, AddonsModule],
controllers: [OauthPublicController, OauthApiController],
providers: [OauthService],
providers: [OauthService, TrekClientsStore, TrekOAuthProvider],
exports: [OauthService],
})
export class OauthModule {}
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');
}
}
+8 -7
View File
@@ -1,13 +1,14 @@
/**
* The short-lived authorization-code store.
*
* Module-scoped + import-time interval on purpose (legacy parity, shared
* between bridge and container instances — atlas-geo interval precedent).
* This is not a stylistic choice: the SDK-mounted /oauth/authorize path reads
* a code through oauth.bridge (mcp/oauthProvider.ts) that the Nest consent
* controller wrote through the container singleton. Two instances would mean
* two maps, and the authorization-code flow would fail *silently* — the SDK
* only ever answers "Authorization grant is invalid."
* Module-scoped + import-time interval on purpose (legacy parity — atlas-geo
* interval precedent). This is not a stylistic choice: the consent controller
* writes a code through the container OauthService and the SDK exchange path
* (oauth-sdk.provider.ts) reads it back — through the same injected singleton
* in production, but the integration harness and any second OauthService
* instance must keep seeing the same map. Two maps would make the
* authorization-code flow fail *silently* — the SDK only ever answers
* "Authorization grant is invalid."
*
* No DB persistence: codes live two minutes and a restart invalidating them
* is the correct behaviour.
+16
View File
@@ -349,6 +349,22 @@ export class OauthService {
// Token verification (used by MCP handler on every request)
// -------------------------------------------------------------------------
/** SDK clients-store read: the exact row shape the MCP SDK adapter
* (oauth-sdk.provider.ts) maps to OAuthClientInformationFull. */
getSdkClient(clientId: string): {
client_id: string;
name: string;
redirect_uris: string;
allowed_scopes: string;
is_public: number;
created_via: string;
} | undefined {
return this.db.get(
'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?',
clientId,
);
}
getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
const hash = hashToken(rawToken);
const row = this.db.get<OAuthTokenRow & { username: string; email: string; role: string }>(`
+6 -24
View File
@@ -6,11 +6,6 @@ import { readEnv } from '../../app-config';
import { verifyJwtAndLoadUser } from '../auth/jwt-verify';
import { db } from '../../db/database';
import { mcpHandler } from '../../mcp';
import { trekOAuthProvider, trekClientsStore } from '../../mcp/oauthProvider';
import { isAddonEnabled } from '../addons/addons.bridge';
import { ADDON_IDS } from '../../addons';
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
// Platform / transport routes extracted verbatim from createApp() (app.ts) so they can be
// mounted on either the legacy Express app or the NestJS Express instance (strangler A6/A8).
@@ -100,27 +95,14 @@ export function applyPlatformUploads(app: express.Application): void {
}
/**
* The OAuth SDK + /mcp transport mounts still living on the pre-init Express
* layer. The rest of the former transport surface is behind the container now:
* /api/health (FeaturesController), OAuth discovery (DiscoveryController + the
* McpMetadataMiddleware bootstrap applies pre-init), and the /oauth/consent
* COOP override (ConsentCoopMiddleware via PlatformModule.configure).
* The /mcp transport mounts — the last pre-init platform routes. Everything
* else that used to live here is behind the container now: /api/health
* (FeaturesController), OAuth discovery (DiscoveryController + the metadata
* middleware bootstrap applies pre-init), /oauth/authorize + /oauth/register
* (OauthModule.configure over the injected SDK adapters), and the
* /oauth/consent COOP override (ConsentCoopMiddleware).
*/
export function applyPlatformTransport(app: express.Application): void {
// OAuth 2.1 — public endpoints
// Gate: 404 when MCP addon is disabled (M2 — prevents feature fingerprinting)
const mcpAddonGate = (_req: Request, res: Response, next: NextFunction) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
next();
};
// SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects
// to the SPA consent page at /oauth/consent
app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider }));
// SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2)
app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore }));
// MCP endpoint
app.post('/mcp', mcpHandler);
app.get('/mcp', mcpHandler);