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({});
  }
}