Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ import { ProxyModule } from '../proxy/proxy.module';
imports: [ProxyModule],
controllers: [AuthController],
providers: [OAuthService, DiscoveryService],
exports: [OAuthService],
exports: [OAuthService, DiscoveryService],
})
export class AuthModule {}
34 changes: 34 additions & 0 deletions src/auth/services/discovery.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { buildSubdomainUrl } from '../../common/utils/url.utils';

/**
* OAuth 2.1 Discovery Service
Expand Down Expand Up @@ -85,4 +86,37 @@ export class DiscoveryService {
op_tos_uri: `${baseUrl}/terms`,
};
}

getSubdomainResourceMetadata(alias: string): any {
const baseUrl = this.configService.get<string>('BASE_URL', 'http://localhost:3001');
const subdomainUrl = buildSubdomainUrl(baseUrl, alias);

this.logger.log(`Generating subdomain resource metadata for alias: ${alias}`);

return {
resource: subdomainUrl,
authorization_servers: [subdomainUrl],
bearer_methods_supported: ['header'],
scopes_supported: ['mcp.tools.read', 'mcp.tools.write'],
};
}

getSubdomainAuthServerMetadata(alias: string): any {
const baseUrl = this.configService.get<string>('BASE_URL', 'http://localhost:3001');
const subdomainUrl = buildSubdomainUrl(baseUrl, alias);

this.logger.log(`Generating subdomain auth server metadata for alias: ${alias}`);

return {
issuer: subdomainUrl,
authorization_endpoint: `${subdomainUrl}/authorize`,
token_endpoint: `${subdomainUrl}/token`,
registration_endpoint: `${subdomainUrl}/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'],
code_challenge_methods_supported: ['S256'],
scopes_supported: ['mcp.tools.read', 'mcp.tools.write'],
};
}
}
3 changes: 2 additions & 1 deletion src/auth/templates/login-page.template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function generateLoginPage(
},
meta?: AliasMeta,
error?: string,
loginActionUrl?: string,
): string {
const title = meta?.loginTitle ?? meta?.brandName ?? 'IoT Cloud';
const logo = meta?.loginLogo ?? '🔐';
Expand Down Expand Up @@ -130,7 +131,7 @@ export function generateLoginPage(
</div>

${error ? `<div style="background:#ffe6e6;border:1px solid #ffb3b3;color:#8a1f1f;padding:12px;border-radius:6px;margin-bottom:16px;">${error}</div>` : ''}
<form method="POST" action="/auth/${projectApiKey}/login">
<form method="POST" action="${loginActionUrl ?? `/auth/${projectApiKey}/login`}">
<div class="form-group">
<label for="email">Email</label>
<input
Expand Down
18 changes: 18 additions & 0 deletions src/common/utils/url.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* @example
* buildSubdomainUrl('https://mcp-stag.dash.id.vn', 'rogo-64770705')
* // → 'https://rogo-64770705.mcp-stag.dash.id.vn'
*
* buildSubdomainUrl('http://localhost:3001', 'rogo-64770705')
* // → 'http://localhost:3001' (localhost/IP — no subdomain)
*/
export function buildSubdomainUrl(baseUrl: string, alias: string): string {
const url = new URL(baseUrl);

if (url.hostname === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(url.hostname)) {
return baseUrl.replace(/\/+$/, '');
}

url.hostname = `${alias}.${url.hostname}`;
return url.origin;
}
219 changes: 219 additions & 0 deletions src/mcp/mcp-auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import {
Controller,
Get,
Post,
Options,
Body,
Query,
Param,
Res,
Headers,
HttpStatus,
Logger,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { Response } from 'express';
import { OAuthService } from '../auth/services/oauth.service';
import { DiscoveryService } from '../auth/services/discovery.service';
import { AuthorizeQueryDto } from '../auth/dto/authorize.dto';
import { TokenRequestDto } from '../auth/dto/token-request.dto';
import { TokenResponseDto } from '../auth/dto/token-response.dto';
import { generateLoginPage } from '../auth/templates/login-page.template';
import { PartnerMetaService } from '../alias/partner-meta.service';
import { AliasService } from '../alias/alias.service';

/**
* Subdomain-routed OAuth controller.
*
* Nginx rewrites {alias}.domain.com/* → /mcp/{alias}/*
* This controller mirrors AuthController's endpoints under that prefix
* so OAuth flows work through wildcard subdomains.
*
* Base-domain access (/auth/{alias}/*) continues to work via AuthController.
*/
@Controller('mcp/:alias')
export class McpAuthController {
private readonly logger = new Logger(McpAuthController.name);

constructor(
private readonly oauthService: OAuthService,
private readonly discoveryService: DiscoveryService,
private readonly partnerMetaService: PartnerMetaService,
private readonly aliasService: AliasService,
) {}

private async resolveOrFail(alias: string): Promise<string> {
const apiKey = await this.aliasService.resolveAlias(alias);
if (!apiKey) {
throw new NotFoundException(`Unknown alias '${alias}'`);
}
return apiKey;
}

@Get('.well-known/oauth-protected-resource')
async getResourceMetadata(@Param('alias') alias: string): Promise<any> {
await this.resolveOrFail(alias);
return this.discoveryService.getSubdomainResourceMetadata(alias);
}

@Get('.well-known/oauth-authorization-server')
async getAuthServerMetadata(@Param('alias') alias: string): Promise<any> {
await this.resolveOrFail(alias);
return this.discoveryService.getSubdomainAuthServerMetadata(alias);
}

@Get('authorize')
async authorize(
@Param('alias') alias: string,
@Query() query: AuthorizeQueryDto,
@Res() res: Response,
): Promise<void> {
this.logger.log(`Subdomain authorize for alias: ${alias}`);

const projectApiKey = await this.resolveOrFail(alias);
const meta = await this.partnerMetaService.getAliasMeta(alias);

const html = generateLoginPage(alias, query, meta ?? undefined, undefined, '/login');
res.status(HttpStatus.OK).contentType('text/html').send(html);
Comment on lines +77 to +78
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Incorrect form action URL will break the login flow.

The loginActionUrl is set to '/login' which is an absolute path pointing to the root /login endpoint, not the subdomain-scoped /mcp/:alias/login. The form submission will fail with a 404.

Proposed fix

Use a relative path 'login' (without leading slash) so the browser resolves it relative to the current /mcp/:alias/authorize path:

-    const html = generateLoginPage(alias, query, meta ?? undefined, undefined, '/login');
+    const html = generateLoginPage(alias, query, meta ?? undefined, undefined, 'login');

Alternatively, construct the absolute path:

-    const html = generateLoginPage(alias, query, meta ?? undefined, undefined, '/login');
+    const html = generateLoginPage(alias, query, meta ?? undefined, undefined, `/mcp/${alias}/login`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const html = generateLoginPage(alias, query, meta ?? undefined, undefined, '/login');
res.status(HttpStatus.OK).contentType('text/html').send(html);
const html = generateLoginPage(alias, query, meta ?? undefined, undefined, 'login');
res.status(HttpStatus.OK).contentType('text/html').send(html);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/mcp/mcp-auth.controller.ts` around lines 77 - 78, The form action
currently passed to generateLoginPage is an absolute '/login' which breaks
subdomain-scoped routes; update the call in mcp-auth.controller.ts
(generateLoginPage(alias, query, meta ?? undefined, undefined, '/login')) to use
a relative path 'login' so the browser posts to the current
/mcp/:alias/authorize context, or construct the proper absolute path using the
alias (e.g., `/mcp/${alias}/login`) so the form submits to the correct
/mcp/:alias/login endpoint.

}

@Post('login')
async login(
@Param('alias') alias: string,
@Body()
body: {
email: string;
password: string;
client_id: string;
redirect_uri: string;
state: string;
code_challenge: string;
code_challenge_method: string;
scope?: string;
resource?: string;
},
@Res() res: Response,
): Promise<void> {
this.logger.log(`Subdomain login for alias: ${alias}`);

const projectApiKey = await this.resolveOrFail(alias);
const meta = await this.partnerMetaService.getAliasMeta(alias);

try {
const authCode = await this.oauthService.handleLogin(
projectApiKey,
body.email,
body.password,
body.code_challenge,
body.code_challenge_method,
body.redirect_uri,
body.state,
body.scope,
body.resource,
);

const redirectUrl = new URL(body.redirect_uri);
redirectUrl.searchParams.set('code', authCode);
redirectUrl.searchParams.set('state', body.state);

this.logger.log(`Login successful, redirecting to ${redirectUrl.toString()}`);
res.redirect(HttpStatus.FOUND, redirectUrl.toString());
} catch (err) {
this.logger.warn(`Login failed for ${body.email}: ${err.message}`);

const oauthParams = {
client_id: body.client_id,
redirect_uri: body.redirect_uri,
state: body.state,
code_challenge: body.code_challenge,
code_challenge_method: body.code_challenge_method,
scope: body.scope,
response_type: 'code' as const,
resource: body.resource,
};

const html = generateLoginPage(
alias,
oauthParams,
meta ?? undefined,
err.message || 'Login failed',
'/login',
);
Comment on lines +136 to +142
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same incorrect form action URL in the error path.

Apply the same fix here to ensure the re-rendered login form posts to the correct endpoint.

Proposed fix
       const html = generateLoginPage(
         alias,
         oauthParams,
         meta ?? undefined,
         err.message || 'Login failed',
-        '/login',
+        'login',
       );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const html = generateLoginPage(
alias,
oauthParams,
meta ?? undefined,
err.message || 'Login failed',
'/login',
);
const html = generateLoginPage(
alias,
oauthParams,
meta ?? undefined,
err.message || 'Login failed',
'login',
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/mcp/mcp-auth.controller.ts` around lines 136 - 142, The generateLoginPage
call in the error path currently passes a hardcoded '/login' as the form action;
change that to use the same action value used for the normal (non-error) render
so the re-rendered form posts to the correct endpoint—replace the '/login'
argument in the generateLoginPage(...) invocation with the same
variable/constant used in the successful render (e.g., the login action variable
or oauthParams.action / LOGIN_POST_PATH used elsewhere) so both paths use the
identical form action.

res.status(HttpStatus.UNAUTHORIZED).contentType('text/html').send(html);
}
}

@Options('token')
tokenOptions(@Res() res: Response): void {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.header(
'Access-Control-Allow-Headers',
'Content-Type, Authorization, Accept, x-admin-api-key, x-project-api-key, mcp-protocol-version',
);
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Max-Age', '86400');
res.status(HttpStatus.NO_CONTENT).send();
}
Comment on lines +147 to +158
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Invalid CORS configuration: wildcard origin with credentials.

Setting Access-Control-Allow-Origin: '*' together with Access-Control-Allow-Credentials: 'true' violates the CORS specification. Browsers will reject this configuration and block the preflight response.

Either:

  • Remove Access-Control-Allow-Credentials if you don't need credentialed requests, or
  • Use a specific origin instead of '*' when credentials are required
Proposed fix (option 1: remove credentials)
   `@Options`('token')
   tokenOptions(`@Res`() res: Response): void {
     res.header('Access-Control-Allow-Origin', '*');
     res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
     res.header(
       'Access-Control-Allow-Headers',
       'Content-Type, Authorization, Accept, x-admin-api-key, x-project-api-key, mcp-protocol-version',
     );
-    res.header('Access-Control-Allow-Credentials', 'true');
     res.header('Access-Control-Max-Age', '86400');
     res.status(HttpStatus.NO_CONTENT).send();
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Options('token')
tokenOptions(@Res() res: Response): void {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.header(
'Access-Control-Allow-Headers',
'Content-Type, Authorization, Accept, x-admin-api-key, x-project-api-key, mcp-protocol-version',
);
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Max-Age', '86400');
res.status(HttpStatus.NO_CONTENT).send();
}
`@Options`('token')
tokenOptions(`@Res`() res: Response): void {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.header(
'Access-Control-Allow-Headers',
'Content-Type, Authorization, Accept, x-admin-api-key, x-project-api-key, mcp-protocol-version',
);
res.header('Access-Control-Max-Age', '86400');
res.status(HttpStatus.NO_CONTENT).send();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/mcp/mcp-auth.controller.ts` around lines 147 - 158, The tokenOptions
method sets Access-Control-Allow-Origin to '*' while also setting
Access-Control-Allow-Credentials to 'true', which violates CORS; update
tokenOptions to either remove the Access-Control-Allow-Credentials header (if
credentialed requests are not needed) or replace the wildcard origin with a
concrete origin value (e.g., read from config/env and set that value in the
Access-Control-Allow-Origin header) so that credentials are only allowed with a
specific origin; ensure the chosen approach updates the headers set in
tokenOptions(`@Res`() res: Response) and keep the rest of the preflight headers
unchanged.


@Post('token')
async token(
@Param('alias') alias: string,
@Body() body: TokenRequestDto,
@Headers() headers: Record<string, string>,
): Promise<TokenResponseDto> {
this.logger.log(`Subdomain token request for alias: ${alias}, grant_type: ${body.grant_type}`);

const projectApiKey = await this.resolveOrFail(alias);

let clientId: string | undefined;
const authHeader = headers.authorization || headers.Authorization;
if (authHeader && authHeader.startsWith('Basic ')) {
try {
const credentials = Buffer.from(authHeader.substring(6), 'base64').toString('utf-8');
const [id] = credentials.split(':');
clientId = id;
this.logger.debug(`Basic Auth parsed: client_id=${clientId}`);
} catch (error) {
this.logger.warn(`Failed to parse Basic Auth header: ${error.message}`);
}
}

if (body.grant_type === 'authorization_code') {
if (!body.code) {
throw new BadRequestException('code is required for authorization_code grant');
}
return this.oauthService.exchangeCode(
projectApiKey,
body.code,
body.code_verifier,
body.redirect_uri,
body.resource,
);
}

if (body.grant_type === 'refresh_token') {
if (!body.refresh_token) {
throw new BadRequestException('refresh_token is required for refresh_token grant');
}
return this.oauthService.refreshToken(projectApiKey, body.refresh_token, body.resource);
}

throw new BadRequestException('Unsupported grant_type');
}

@Post('register')
async register(@Param('alias') alias: string): Promise<any> {
this.logger.log(`Subdomain client registration for alias: ${alias}`);
await this.resolveOrFail(alias);

return {
client_id: 'web-client-static',
client_id_issued_at: Math.floor(Date.now() / 1000),
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
};
}
}
7 changes: 5 additions & 2 deletions src/mcp/mcp.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { randomUUID } from 'crypto';
import { SessionManagerService } from './services/session-manager.service';
import { McpServerFactory } from './services/mcp-server.factory';
import { decodeJwt } from '../common/utils/jwt.utils';
import { buildSubdomainUrl } from '../common/utils/url.utils';
import { ConfigService } from '@nestjs/config';
import { AliasService } from '../alias/alias.service';
import { PartnerMetaService } from '../alias/partner-meta.service';
Expand Down Expand Up @@ -86,9 +87,10 @@ export class McpController {
if (!authorization || !authorization.startsWith('Bearer ')) {
this.logger.warn(`Missing or invalid Authorization header for alias: ${alias}`);
const baseUrl = this.configService.get<string>('BASE_URL', 'http://localhost:3001');
const subdomainUrl = buildSubdomainUrl(baseUrl, alias);
res.setHeader(
'WWW-Authenticate',
`Bearer realm="MCP Gateway", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource/mcp/${alias}"`,
`Bearer realm="MCP Gateway", resource_metadata="${subdomainUrl}/.well-known/oauth-protected-resource"`,
);
res.status(HttpStatus.UNAUTHORIZED).json({
jsonrpc: '2.0',
Expand Down Expand Up @@ -119,9 +121,10 @@ export class McpController {
} catch (error) {
this.logger.error(`JWT decode failed for alias ${alias}: ${error.message}`);
const baseUrl = this.configService.get<string>('BASE_URL', 'http://localhost:3001');
const subdomainUrl = buildSubdomainUrl(baseUrl, alias);
res.setHeader(
'WWW-Authenticate',
`Bearer realm="MCP Gateway", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource/mcp/${alias}"`,
`Bearer realm="MCP Gateway", resource_metadata="${subdomainUrl}/.well-known/oauth-protected-resource"`,
);
res.status(HttpStatus.UNAUTHORIZED).json({
jsonrpc: '2.0',
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/mcp.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { McpController } from './mcp.controller';
import { McpAuthController } from './mcp-auth.controller';
import { SessionManagerService } from './services/session-manager.service';
import { McpServerFactory } from './services/mcp-server.factory';
import { RedisSessionRepository } from './services/redis-session.repository';
Expand Down Expand Up @@ -27,7 +28,7 @@ import { CommonModule } from '../common/common.module';
AuthModule, // For JWT validation
CommonModule, // For shared utilities and decorators
],
controllers: [McpController],
controllers: [McpAuthController, McpController],
providers: [SessionManagerService, McpServerFactory, RedisSessionRepository],
exports: [SessionManagerService, McpServerFactory, RedisSessionRepository],
})
Expand Down
Loading
Loading