Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer

Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.
This commit is contained in:
Maurice
2026-05-30 02:39:26 +02:00
parent 6d2dd37414
commit fc7d8b5d12
347 changed files with 31278 additions and 10381 deletions
@@ -0,0 +1,178 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import { OauthService } from './oauth.service';
import { RateLimitService } from '../auth/rate-limit.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CookieAuthGuard } from '../auth/cookie-auth.guard';
import { OptionalJwtGuard } from '../auth/optional-jwt.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../../services/auditLog';
import type { User } from '../../types';
import type { AuthorizeParams } from '../../services/oauthService';
const MIN = 60_000;
/**
* Authenticated OAuth management endpoints (the SPA's consent + client/session
* UI) — byte-identical to the legacy oauthApiRouter (server/src/routes/oauth.ts):
* MCP-addon gated (404 on the anonymous validate to avoid fingerprinting, 403
* elsewhere), optional-auth on validate, cookie-only auth on state-changing
* routes (consent/create/rotate/delete/revoke) and Bearer-or-cookie auth on the
* read lists. create answers 201; the rest 200.
*/
@Controller('api/oauth')
export class OauthApiController {
constructor(private readonly oauth: OauthService, private readonly rl: RateLimitService) {}
private requireMcp403(): void {
if (!this.oauth.mcpEnabled()) {
throw new HttpException({ error: 'MCP is not enabled' }, 403);
}
}
@Get('authorize/validate')
@UseGuards(OptionalJwtGuard)
validate(@Req() req: Request, @Query() params: Partial<AuthorizeParams>, @Res({ passthrough: true }) res: Response) {
if (!this.rl.check('oauth_validate', req.ip || 'unknown', 30, MIN, Date.now())) {
throw new HttpException({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' }, 429);
}
if (!this.oauth.mcpEnabled()) {
// 404 (not 403) with an empty body so anonymous callers can't fingerprint the feature.
res.status(404).end();
return undefined;
}
const userId = (req.user as User | undefined)?.id ?? null;
const result = this.oauth.validateAuthorizeRequest(
{
response_type: params.response_type || '',
client_id: params.client_id || '',
redirect_uri: params.redirect_uri || '',
scope: params.scope || '',
state: params.state,
code_challenge: params.code_challenge || '',
code_challenge_method: params.code_challenge_method || '',
resource: typeof params.resource === 'string' ? params.resource : undefined,
},
userId,
);
if (userId === null && result.valid) {
return { valid: result.valid, loginRequired: true };
}
if (userId === null && !result.valid) {
return { valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' };
}
return result;
}
@Post('authorize')
@HttpCode(200) // Express answers consent with res.json (200), not the POST-default 201.
@UseGuards(CookieAuthGuard)
authorize(@CurrentUser() user: User, @Body() body: {
client_id: string; redirect_uri: string; scope: string; state?: string;
code_challenge: string; code_challenge_method: string; approved: boolean; resource?: string;
}, @Req() req: Request) {
const ip = getClientIp(req);
if (!this.oauth.mcpEnabled()) {
throw new HttpException({ error: 'MCP is not enabled' }, 403);
}
if (!body.approved) {
const url = new URL(body.redirect_uri);
url.searchParams.set('error', 'access_denied');
url.searchParams.set('error_description', 'User denied the authorization request');
if (body.state) url.searchParams.set('state', body.state);
return { redirect: url.toString() };
}
const params: AuthorizeParams = {
response_type: 'code',
client_id: body.client_id,
redirect_uri: body.redirect_uri,
scope: body.scope,
state: body.state,
code_challenge: body.code_challenge,
code_challenge_method: body.code_challenge_method,
resource: body.resource,
};
const validation = this.oauth.validateAuthorizeRequest(params, user.id);
if (!validation.valid) {
throw new HttpException({ error: validation.error, error_description: validation.error_description }, 400);
}
const scopes = validation.scopes!;
this.oauth.saveConsent(body.client_id, user.id, scopes, ip);
const code = this.oauth.createAuthCode({
clientId: body.client_id,
userId: user.id,
redirectUri: body.redirect_uri,
scopes,
resource: validation.resource ?? null,
codeChallenge: body.code_challenge,
codeChallengeMethod: 'S256',
});
if (!code) {
throw new HttpException({ error: 'server_error', error_description: 'Authorization server is temporarily unavailable' }, 503);
}
const url = new URL(body.redirect_uri);
url.searchParams.set('code', code);
if (body.state) url.searchParams.set('state', body.state);
return { redirect: url.toString() };
}
@Get('clients')
@UseGuards(JwtAuthGuard)
listClients(@CurrentUser() user: User) {
this.requireMcp403();
return { clients: this.oauth.listOAuthClients(user.id) };
}
@Post('clients')
@HttpCode(201)
@UseGuards(CookieAuthGuard)
createClient(@CurrentUser() user: User, @Body() body: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }, @Req() req: Request) {
this.requireMcp403();
const result = this.oauth.createOAuthClient(user.id, body.name, body.redirect_uris ?? [], body.allowed_scopes, getClientIp(req), { allowsClientCredentials: body.allows_client_credentials });
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
return result;
}
@Post('clients/:id/rotate')
@HttpCode(200)
@UseGuards(CookieAuthGuard)
rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
this.requireMcp403();
const result = this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
return { client_secret: result.client_secret };
}
@Delete('clients/:id')
@UseGuards(CookieAuthGuard)
deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
this.requireMcp403();
const result = this.oauth.deleteOAuthClient(user.id, id, getClientIp(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
return { success: true };
}
@Get('sessions')
@UseGuards(JwtAuthGuard)
listSessions(@CurrentUser() user: User) {
this.requireMcp403();
return { sessions: this.oauth.listOAuthSessions(user.id) };
}
@Delete('sessions/:id')
@UseGuards(CookieAuthGuard)
revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
this.requireMcp403();
const result = this.oauth.revokeSession(user.id, Number(id), getClientIp(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
return { success: true };
}
}
@@ -0,0 +1,165 @@
import { Controller, Get, Headers, HttpCode, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { OauthService } from './oauth.service';
import { RateLimitService } from '../auth/rate-limit.service';
import { writeAudit, getClientIp, logWarn } from '../../services/auditLog';
const MIN = 60_000;
/**
* Public OAuth 2.1 endpoints (no session) — byte-identical to the legacy
* oauthPublicRouter (server/src/routes/oauth.ts): MCP-addon gated (404), the
* per-(ip,client) token / per-ip revoke rate limits, no-store cache headers on
* /token, the WWW-Authenticate challenge on /userinfo, the three grant types
* and the RFC 7009 always-200 revoke. Uses @Res directly because every branch
* sets headers + a specific status the way the spec requires.
*/
@Controller('oauth')
export class OauthPublicController {
constructor(private readonly oauth: OauthService, private readonly rl: RateLimitService) {}
@Post('token')
@HttpCode(200) // token success uses res.json without an explicit status; Express defaults to 200 (Nest POST would default to 201).
token(@Req() req: Request, @Res() res: Response): void {
if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; }
const body: Record<string, string> = typeof req.body === 'object' && req.body ? req.body : {};
if (!this.rl.check('oauth_token', `${req.ip}|${body.client_id ?? ''}`, 30, MIN, Date.now())) {
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
return;
}
res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache');
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body;
const ip = getClientIp(req);
if (!client_id) {
res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
return;
}
if (grant_type === 'authorization_code') {
if (!code || !redirect_uri || !code_verifier) {
res.status(400).json({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
return;
}
const pending = this.oauth.consumeAuthCode(code);
const invalidGrant = (reason: string, userId: number | null) => {
writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip });
res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
};
if (!pending) return invalidGrant('code_invalid_or_expired', null);
if (pending.clientId !== client_id) return invalidGrant('client_id_mismatch', pending.userId);
if (pending.redirectUri !== redirect_uri) return invalidGrant('redirect_uri_mismatch', pending.userId);
if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) return invalidGrant('resource_mismatch', pending.userId);
if (!this.oauth.authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
return;
}
if (!this.oauth.verifyPKCE(code_verifier, pending.codeChallenge)) return invalidGrant('pkce_failed', pending.userId);
const tokens = this.oauth.issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
res.json(tokens);
return;
}
if (grant_type === 'refresh_token') {
if (!refresh_token) {
res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
return;
}
const result = this.oauth.refreshTokens(refresh_token, client_id, client_secret, ip);
if (result.error) {
if (result.error === 'invalid_client') logWarn(`[OAuth] Invalid client credentials on refresh for client_id=${client_id} ip=${ip ?? '-'}`);
res.status(result.status || 400).json({ error: result.error, error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired' });
return;
}
res.json(result.tokens);
return;
}
if (grant_type === 'client_credentials') {
if (!client_secret) {
res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
return;
}
const client = this.oauth.authenticateClient(client_id, client_secret);
if (!client) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
return;
}
if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
return;
}
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
let grantedScopes: string[];
if (body.scope) {
const requested = body.scope.split(' ').filter(Boolean);
const invalid = requested.filter((s) => !allowedScopes.includes(s));
if (invalid.length > 0) {
res.status(400).json({ error: 'invalid_scope', error_description: `Scopes not allowed for this client: ${invalid.join(', ')}` });
return;
}
grantedScopes = requested;
} else {
grantedScopes = allowedScopes;
}
const audience = resource ? resource.replace(/\/+$/, '') : `${this.oauth.mcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const tokens = this.oauth.issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
res.json(tokens);
return;
}
res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
}
@Get('userinfo')
userinfo(@Headers('authorization') auth: string | undefined, @Res() res: Response): void {
if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; }
if (!auth || !auth.toLowerCase().startsWith('bearer ')) {
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"');
res.status(401).json({ error: 'invalid_token' });
return;
}
const info = this.oauth.getUserByAccessToken(auth.slice(7));
if (!info) {
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"');
res.status(401).json({ error: 'invalid_token' });
return;
}
res.json({ sub: String(info.user.id), email: info.user.email, email_verified: true, preferred_username: info.user.username });
}
@Post('revoke')
revoke(@Req() req: Request, @Res() res: Response): void {
if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; }
if (!this.rl.check('oauth_revoke', req.ip || 'unknown', 10, MIN, Date.now())) {
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
return;
}
const body: Record<string, string> = typeof req.body === 'object' && req.body ? req.body : {};
const { token, client_id, client_secret } = body;
const ip = getClientIp(req);
if (!token || !client_id) {
res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id are required' });
return;
}
if (!this.oauth.authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
return;
}
this.oauth.revokeToken(token, client_id, undefined, ip);
res.status(200).json({});
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { OauthPublicController } from './oauth-public.controller';
import { OauthApiController } from './oauth-api.controller';
import { OauthService } from './oauth.service';
import { RateLimitService } from '../auth/rate-limit.service';
/**
* OAuth 2.1 server (MCP). Public token/userinfo/revoke endpoints + the SPA's
* authenticated consent/client/session management. The SDK-mounted
* /oauth/authorize, /oauth/register and /oauth/consent stay on Express, so the
* strangler lists /oauth/token, /oauth/userinfo, /oauth/revoke explicitly.
*/
@Module({
controllers: [OauthPublicController, OauthApiController],
providers: [OauthService, RateLimitService],
})
export class OauthModule {}
+36
View File
@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import * as oauth from '../../services/oauthService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { getMcpSafeUrl } from '../../services/notifications';
/**
* Thin Nest wrapper around the existing OAuth 2.1 service. The grant handling,
* PKCE, client auth, consent storage, token issue/refresh/revoke and the
* client/session CRUD all reuse the legacy code unchanged.
*/
@Injectable()
export class OauthService {
mcpEnabled(): boolean { return isAddonEnabled(ADDON_IDS.MCP); }
mcpSafeUrl(): string { return getMcpSafeUrl(); }
consumeAuthCode(code: string) { return oauth.consumeAuthCode(code); }
authenticateClient(clientId: string, clientSecret?: string) { return oauth.authenticateClient(clientId, clientSecret); }
verifyPKCE(verifier: string, challenge: string) { return oauth.verifyPKCE(verifier, challenge); }
issueTokens(...args: Parameters<typeof oauth.issueTokens>) { return oauth.issueTokens(...args); }
issueClientCredentialsToken(...args: Parameters<typeof oauth.issueClientCredentialsToken>) { return oauth.issueClientCredentialsToken(...args); }
refreshTokens(...args: Parameters<typeof oauth.refreshTokens>) { return oauth.refreshTokens(...args); }
revokeToken(...args: Parameters<typeof oauth.revokeToken>) { return oauth.revokeToken(...args); }
getUserByAccessToken(token: string) { return oauth.getUserByAccessToken(token); }
validateAuthorizeRequest(params: oauth.AuthorizeParams, userId: number | null) { return oauth.validateAuthorizeRequest(params, userId); }
saveConsent(...args: Parameters<typeof oauth.saveConsent>) { return oauth.saveConsent(...args); }
createAuthCode(...args: Parameters<typeof oauth.createAuthCode>) { return oauth.createAuthCode(...args); }
listOAuthClients(userId: number) { return oauth.listOAuthClients(userId); }
createOAuthClient(...args: Parameters<typeof oauth.createOAuthClient>) { return oauth.createOAuthClient(...args); }
rotateOAuthClientSecret(userId: number, id: string, ip: string | undefined) { return oauth.rotateOAuthClientSecret(userId, id, ip); }
deleteOAuthClient(userId: number, id: string, ip: string | undefined) { return oauth.deleteOAuthClient(userId, id, ip); }
listOAuthSessions(userId: number) { return oauth.listOAuthSessions(userId); }
revokeSession(userId: number, id: number, ip: string | undefined) { return oauth.revokeSession(userId, id, ip); }
}