Files
TREK/server/src/nest/oauth/oauth-api.controller.ts
T
Maurice b3a085df3f refactor(server): make the state ports async and route every reader through the provided store
The process-state ports were meant to let a store shared between
processes replace the in-memory one, but they could not. The readers
outside the container (the broadcast functions, the backup restore's
permissions-cache flush, the pending-code sweep, hand-built consumers)
went straight to the module-level in-memory instances, so an overridden
provider was used by the gateway and the services while the broadcasts
and the restore flush kept using the old store. And every port was
synchronous, while a store in the database or on the network is async,
so plugging one in would have changed every signature anyway.

RateLimitStore, PendingCodeStore, OidcFlowStore and
PermissionsCacheStore now return promises, and RateLimitService,
OauthService, OidcService, PermissionsService and their callers await
them. The in-memory stores still decide synchronously, so who gets a
429, a code or a cached level is unchanged. RoomRegistry stays
synchronous: membership holds live sockets and broadcast() is a
synchronous call every domain makes, so several processes need a
fan-out in front of broadcast(), not a different place for the sets.

The process-wide ports get a ProcessStoreSlot. PermissionsModule,
OauthModule and RealtimeGatewayModule install the store the container
resolved and release it on shutdown, and the free functions, the sweep
and hand-built consumers read the slot, so overriding the provider
changes every reader. Tests provide fake stores and check that the
broadcasts, the restore flush and the sweep use them.

Two callers needed more than an await. The transit MCP tools counted in
a RateLimitService of their own that no provider could reach; they now
take the one RateLimitModule provides (their mcp_transit_* buckets never
overlap the REST ones). The link preview claimed its in-flight slot only
after the budget check, which now yields, so simultaneous askers would
each have been charged; it now claims the slot first, and an asker that
joined someone out of budget asks again on its own budget.

The unused putPendingCode/takePendingCode helpers are gone.
2026-10-08 18:41:01 +02:00

181 lines
7.4 KiB
TypeScript

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 '../common/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 '../audit/client-ip';
import type { User } from '../../types';
import type { AuthorizeParams } from './oauth.service';
import { OauthClientCreateDto, OauthConsentDto } from './oauth.dto';
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 async requireMcp403(): Promise<void> {
if (!(await this.oauth.mcpEnabled())) {
throw new HttpException({ error: 'MCP is not enabled' }, 403);
}
}
@Get('authorize/validate')
@UseGuards(OptionalJwtGuard)
async validate(@Req() req: Request, @Query() params: Partial<AuthorizeParams>, @Res({ passthrough: true }) res: Response) {
if (!(await 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 (!(await 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 = await 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)
async authorize(@CurrentUser() user: User, @Body() body: OauthConsentDto, @Req() req: Request) {
const ip = getClientIp(req);
if (!(await this.oauth.mcpEnabled())) {
throw new HttpException({ error: 'MCP is not enabled' }, 403);
}
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,
};
// Validate before branching on approved: the deny path builds a redirect out
// of body.redirect_uri too, and only validateAuthorizeRequest checks it
// against the client's registered URIs. A string that is not a URL at all
// would additionally throw out of `new URL()` as a 500.
const validation = await this.oauth.validateAuthorizeRequest(params, user.id);
if (!validation.valid) {
throw new HttpException({ error: validation.error, error_description: validation.error_description }, 400);
}
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 scopes = validation.scopes!;
await this.oauth.saveConsent(body.client_id, user.id, scopes, ip);
const code = await 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)
async listClients(@CurrentUser() user: User) {
await this.requireMcp403();
return { clients: await this.oauth.listOAuthClients(user.id) };
}
@Post('clients')
@HttpCode(201)
@UseGuards(CookieAuthGuard)
async createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) {
await this.requireMcp403();
const result = await 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)
async rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
await this.requireMcp403();
const result = await 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)
async deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
await this.requireMcp403();
const result = await 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)
async listSessions(@CurrentUser() user: User) {
await this.requireMcp403();
return { sessions: await this.oauth.listOAuthSessions(user.id) };
}
@Delete('sessions/:id')
@UseGuards(CookieAuthGuard)
async revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
await this.requireMcp403();
const result = await this.oauth.revokeSession(user.id, Number(id), getClientIp(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
return { success: true };
}
}