This commit is contained in:
seb
2026-10-13 00:36:38 +02:00
parent 2f92199d7f
commit 97540fb745
14 changed files with 1049 additions and 158 deletions

View File

@@ -20,6 +20,26 @@ function getBaseUrl(req) {
return `${proto}://${host}`;
}
function getFrontendUrl(req) {
if (process.env.FRONTEND_URL) {
return process.env.FRONTEND_URL.replace(/\/$/, '');
}
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
const host = req.get('host') || `127.0.0.1:${env.port}`;
const hostname = host.split(':')[0];
return `${proto}://${hostname}:4209`;
}
function mcpErrorStatus(error) {
if (!error) return 500;
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized' || error.message === 'User is no longer authorized') return 403;
if (error.message === 'Unauthorized') return 401;
if (error.message?.endsWith(' not found') || error.message?.includes('not found')) return 404;
if (error.message?.includes('required') || error.message?.includes('Invalid')) return 400;
return 500;
}
/**
* Checks if a candidate redirect_uri matches any registered redirect URI.
* Per RFC 8252 Section 7.3 (OAuth 2.0 for Native Apps):
@@ -177,7 +197,7 @@ function mcpAuthMiddleware(req, res, next) {
* @param {import('express').Application} app
* @param {string} [_basePath='']
*/
export function register(app, _basePath = '') {
export function register(app, basePath = '') {
/**
* @openapi
* /.well-known/oauth-protected-resource:
@@ -216,9 +236,10 @@ export function register(app, _basePath = '') {
*/
const handleAuthServerMetadata = (req, res) => {
const baseUrl = getBaseUrl(req);
const frontendUrl = getFrontendUrl(req);
res.json({
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/oauth/authorize`,
authorization_endpoint: `${frontendUrl}/oauth/authorize`,
token_endpoint: `${baseUrl}/oauth/token`,
registration_endpoint: `${baseUrl}/oauth/register`,
introspection_endpoint: `${baseUrl}/oauth/introspect`,
@@ -308,175 +329,41 @@ export function register(app, _basePath = '') {
* get:
* tags: [MCP]
* summary: OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636)
* description: Authorization prompt for end-users to grant access to an MCP client.
* description: Redirects browser to frontend authorization consent page.
* security: []
* parameters:
* - in: query
* name: client_id
* required: true
* schema:
* type: string
* - in: query
* name: redirect_uri
* required: true
* schema:
* type: string
* - in: query
* name: response_type
* required: true
* schema:
* type: string
* enum: [code]
* - in: query
* name: code_challenge
* required: true
* schema:
* type: string
* - in: query
* name: code_challenge_method
* required: true
* schema:
* type: string
* enum: [S256]
* - in: query
* name: state
* schema:
* type: string
* responses:
* 200:
* description: Authorization / Consent HTML form
* 302:
* description: Redirect back to client callback
* description: Redirects to frontend /oauth/authorize
*/
app.get('/oauth/authorize', async (req, res) => {
const {
client_id,
redirect_uri,
response_type,
code_challenge,
code_challenge_method = 'S256',
state = '',
scope = 'mcp:tools',
} = req.query;
if (!client_id) {
return res.status(400).send('Missing client_id');
}
const client = await mcpDb.getClient(client_id);
if (!client) {
return res.status(400).send(`Unknown client_id: ${client_id}`);
}
let registeredUris = [];
try {
registeredUris = JSON.parse(client.redirectUris);
} catch {
registeredUris = [client.redirectUris];
}
if (!redirect_uri || !isRedirectUriAllowed(redirect_uri, registeredUris)) {
return res.status(400).send('Invalid or unregistered redirect_uri');
}
if (response_type !== 'code') {
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set('error', 'unsupported_response_type');
if (state) redirectUrl.searchParams.set('state', state);
return res.redirect(redirectUrl.toString());
}
if (!code_challenge || code_challenge_method !== 'S256') {
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set('error', 'invalid_request');
redirectUrl.searchParams.set('error_description', 'PKCE code_challenge with method S256 is required');
if (state) redirectUrl.searchParams.set('state', state);
return res.redirect(redirectUrl.toString());
}
// If user is logged in
if (req.user) {
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Authorize MCP Client</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f8fafc; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { background: #1e293b; padding: 2.5rem; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); width: 100%; max-width: 440px; text-align: center; border: 1px solid #334155; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #38bdf8; }
p { color: #94a3b8; font-size: 0.95rem; line-height: 1.5; }
.user-box { background: #0f172a; border-radius: 8px; padding: 0.75rem 1rem; margin: 1.25rem 0; text-align: left; border: 1px solid #334155; }
.user-email { font-weight: 600; color: #f1f5f9; }
.btn-row { display: flex; gap: 1rem; margin-top: 1.5rem; }
button { flex: 1; padding: 0.75rem 1rem; border-radius: 6px; font-weight: 600; cursor: pointer; border: none; font-size: 0.95rem; transition: background 0.2s; }
.btn-allow { background: #0284c7; color: #fff; }
.btn-allow:hover { background: #0369a1; }
.btn-deny { background: #334155; color: #94a3b8; }
.btn-deny:hover { background: #475569; color: #f8fafc; }
</style>
</head>
<body>
<div class="card">
<h1>Authorize Application</h1>
<p><strong>${escapeHtml(client.clientName)}</strong> would like access to your Dashback MCP tools.</p>
<div class="user-box">
<div style="font-size:0.75rem; color:#64748b; text-transform:uppercase; letter-spacing:0.05em;">Authorized Account</div>
<div class="user-email">${escapeHtml(req.user.email)}</div>
</div>
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}">
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge)}">
<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method)}">
<input type="hidden" name="state" value="${escapeHtml(state)}">
<input type="hidden" name="scope" value="${escapeHtml(scope)}">
<div class="btn-row">
<button type="submit" name="action" value="deny" class="btn-deny">Cancel</button>
<button type="submit" name="action" value="allow" class="btn-allow">Authorize</button>
</div>
</form>
</div>
</body>
</html>`;
return res.setHeader('Content-Type', 'text/html').send(html);
}
// If user is not logged in: display login form with allowlisted email input
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Sign in to Authorize</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f8fafc; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { background: #1e293b; padding: 2.5rem; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); width: 100%; max-width: 440px; text-align: center; border: 1px solid #334155; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #38bdf8; }
p { color: #94a3b8; font-size: 0.95rem; line-height: 1.5; margin-bottom: 1.5rem; }
input[type="email"] { width: 100%; box-sizing: border-box; padding: 0.75rem 1rem; border-radius: 6px; border: 1px solid #334155; background: #0f172a; color: #f8fafc; font-size: 1rem; margin-bottom: 1.25rem; }
input[type="email"]:focus { outline: none; border-color: #38bdf8; }
button { width: 100%; padding: 0.75rem 1rem; border-radius: 6px; font-weight: 600; cursor: pointer; border: none; font-size: 0.95rem; background: #0284c7; color: #fff; transition: background 0.2s; }
button:hover { background: #0369a1; }
</style>
</head>
<body>
<div class="card">
<h1>Dashback Sign In</h1>
<p>Sign in with your authorized Dashback email to grant <strong>${escapeHtml(client.clientName)}</strong> access.</p>
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}">
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge)}">
<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method)}">
<input type="hidden" name="state" value="${escapeHtml(state)}">
<input type="hidden" name="scope" value="${escapeHtml(scope)}">
<input type="email" name="email" placeholder="user@example.com" required autofocus>
<button type="submit" name="action" value="allow">Sign In &amp; Authorize</button>
</form>
</div>
</body>
</html>`;
return res.setHeader('Content-Type', 'text/html').send(html);
const frontendUrl = getFrontendUrl(req);
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
return res.redirect(`${frontendUrl}/oauth/authorize${qs}`);
});
app.post('/oauth/authorize', async (req, res) => {
@@ -798,8 +685,412 @@ export function register(app, _basePath = '') {
app.get('/mcp', mcpAuthMiddleware, handleSessionRequest);
app.delete('/mcp', mcpAuthMiddleware, handleSessionRequest);
/**
* @openapi
* /mcp/authorize:
* get:
* tags: [MCP]
* summary: Get MCP authorization prompt details
* description: Fetches client information and current user context for the frontend MCP authorization consent screen.
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: client_id
* required: true
* schema:
* type: string
* - in: query
* name: redirect_uri
* required: true
* schema:
* type: string
* - in: query
* name: response_type
* schema:
* type: string
* enum: [code]
* - in: query
* name: code_challenge
* schema:
* type: string
* - in: query
* name: code_challenge_method
* schema:
* type: string
* enum: [S256]
* - in: query
* name: state
* schema:
* type: string
* - in: query
* name: scope
* schema:
* type: string
* responses:
* 200:
* description: Client and user consent metadata
* 400:
* description: Invalid request or unregistered redirect_uri
* 401:
* description: User not authenticated
* 404:
* description: Client not found
*/
app.get(`${basePath}/mcp/authorize`, async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
const {
client_id,
redirect_uri,
response_type = 'code',
code_challenge,
code_challenge_method = 'S256',
state = '',
scope = 'mcp:tools',
} = req.query;
if (!client_id) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing client_id',
});
}
const client = await mcpDb.getClient(client_id);
if (!client) {
return res.status(404).json({
error: 'invalid_client',
error_description: `Unknown client_id: ${client_id}`,
});
}
let registeredUris = [];
try {
registeredUris = JSON.parse(client.redirectUris);
} catch {
registeredUris = [client.redirectUris];
}
if (!redirect_uri || !isRedirectUriAllowed(redirect_uri, registeredUris)) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Invalid or unregistered redirect_uri',
});
}
if (response_type !== 'code') {
return res.status(400).json({
error: 'unsupported_response_type',
error_description: 'Only response_type=code is supported',
});
}
if (!code_challenge || code_challenge_method !== 'S256') {
return res.status(400).json({
error: 'invalid_request',
error_description: 'PKCE code_challenge with method S256 is required',
});
}
return res.json({
client: {
clientId: client.clientId,
clientName: client.clientName,
scope: client.scope,
},
user: {
id: req.user.id,
email: req.user.email,
},
params: {
clientId: client_id,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
codeChallengeMethod: code_challenge_method,
state,
scope: scope || client.scope || 'mcp:tools',
},
});
} catch (err) {
console.error('[mcp] Authorize prompt error:', err);
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
}
});
/**
* @openapi
* /mcp/authorize:
* post:
* tags: [MCP]
* summary: Grant or deny MCP client authorization
* description: Issues an OAuth authorization code or refusal for the requesting MCP client on behalf of the authenticated user.
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [client_id, redirect_uri, action]
* properties:
* client_id:
* type: string
* redirect_uri:
* type: string
* code_challenge:
* type: string
* code_challenge_method:
* type: string
* state:
* type: string
* scope:
* type: string
* action:
* type: string
* enum: [allow, deny]
* responses:
* 200:
* description: Success with redirect URL containing code or error
* 400:
* description: Invalid request parameters
* 401:
* description: User not authenticated
* 404:
* description: Client not found
*/
app.post(`${basePath}/mcp/authorize`, async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
const {
client_id,
redirect_uri,
code_challenge,
code_challenge_method = 'S256',
state = '',
scope = 'mcp:tools',
action = 'allow',
} = req.body || {};
if (!client_id) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing client_id',
});
}
const client = await mcpDb.getClient(client_id);
if (!client) {
return res.status(404).json({
error: 'invalid_client',
error_description: `Unknown client_id: ${client_id}`,
});
}
let registeredUris = [];
try {
registeredUris = JSON.parse(client.redirectUris);
} catch {
registeredUris = [client.redirectUris];
}
if (!redirect_uri || !isRedirectUriAllowed(redirect_uri, registeredUris)) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Invalid or unregistered redirect_uri',
});
}
const redirectUrl = new URL(redirect_uri);
if (action === 'deny') {
redirectUrl.searchParams.set('error', 'access_denied');
if (state) redirectUrl.searchParams.set('state', state);
if (req.is('application/json') || req.headers.accept?.includes('application/json')) {
return res.json({ ok: false, redirectUrl: redirectUrl.toString() });
}
return res.redirect(redirectUrl.toString());
}
if (!code_challenge || code_challenge_method !== 'S256') {
return res.status(400).json({
error: 'invalid_request',
error_description: 'PKCE code_challenge with method S256 is required',
});
}
const code = randomBytes(32).toString('hex');
await mcpDb.createAuthCode({
code,
clientId: client_id,
googleAuthId: req.user.id,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
codeChallengeMethod: code_challenge_method,
scope: scope || client.scope || 'mcp:tools',
expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
});
redirectUrl.searchParams.set('code', code);
if (state) redirectUrl.searchParams.set('state', state);
if (req.is('application/json') || req.headers.accept?.includes('application/json')) {
return res.json({ ok: true, redirectUrl: redirectUrl.toString() });
}
return res.redirect(redirectUrl.toString());
} catch (err) {
console.error('[mcp] Authorize action error:', err);
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
}
});
/**
* @openapi
* /mcp/authorizations:
* get:
* tags: [MCP]
* summary: List active MCP client authorizations
* description: Lists all MCP clients authorized by the authenticated user with token counts and authorization timestamps.
* security:
* - cookieAuth: []
* responses:
* 200:
* description: List of active user authorizations
* 401:
* description: User not authenticated
*/
app.get(`${basePath}/mcp/authorizations`, async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
const authorizations = await mcpDb.listAuthorizations(req.user.id);
res.json({ authorizations });
} catch (err) {
console.error('[mcp] List authorizations error:', err);
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
}
});
/**
* @openapi
* /mcp/authorizations/{clientId}:
* delete:
* tags: [MCP]
* summary: Revoke MCP client authorizations
* description: Revokes all tokens and authorizations granted to an MCP client by the authenticated user.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: clientId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Authorizations revoked
* 400:
* description: Missing clientId
* 401:
* description: User not authenticated
*/
app.delete(`${basePath}/mcp/authorizations/:clientId`, async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { clientId } = req.params;
if (!clientId) {
return res.status(400).json({ error: 'invalid_request', error_description: 'clientId is required' });
}
await mcpDb.revokeAuthorizations(req.user.id, clientId);
res.json({ ok: true, message: 'Authorizations revoked' });
} catch (err) {
console.error('[mcp] Revoke authorizations error:', err);
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
}
});
/**
* @openapi
* /mcp/clients:
* get:
* tags: [MCP]
* summary: List registered MCP clients
* description: Lists all registered OAuth MCP clients.
* security:
* - cookieAuth: []
* responses:
* 200:
* description: List of registered clients
* 401:
* description: User not authenticated
*/
app.get(`${basePath}/mcp/clients`, async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
const clients = await mcpDb.listClients();
res.json({ clients });
} catch (err) {
console.error('[mcp] List clients error:', err);
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
}
});
/**
* @openapi
* /mcp/clients/{clientId}:
* delete:
* tags: [MCP]
* summary: Delete registered MCP client
* description: Deletes an MCP client registration.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: clientId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Client deleted
* 400:
* description: Missing clientId
* 401:
* description: User not authenticated
*/
app.delete(`${basePath}/mcp/clients/:clientId`, async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { clientId } = req.params;
if (!clientId) {
return res.status(400).json({ error: 'invalid_request', error_description: 'clientId is required' });
}
await mcpDb.deleteClient(clientId);
res.json({ ok: true, message: 'Client deleted' });
} catch (err) {
console.error('[mcp] Delete client error:', err);
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
}
});
}
function escapeHtml(str) {
if (typeof str !== 'string') return '';
return str