Spaces:
Sleeping
Sleeping
File size: 8,858 Bytes
57a889c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | 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({});
}
}
|