Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions Docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ npm install
- `npm run build` - Build the project
- `npm run build:watch` - Watch for changes and rebuild automatically
- `npm run start` - Same as above
- `npm run dev:oauth-html` - Build and start a localhost server to preview OAuth HTML (landing, callback, implicit flows). Open the printed URL (default `http://127.0.0.1:8765/`). If that port is busy, the next free port is used (up to +30) and a message is printed. Override the first port to try with `OAUTH_HTML_PREVIEW_PORT` / host with `OAUTH_HTML_PREVIEW_HOST`.
- `npm run check` - Run type checking and linting
- `npm run test` - Run tests
- `npm run test:watch` - Run tests in watch mode
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ OAuth 2.1 provides the most secure and user-friendly experience with full MCP Au
- No need to manage passwords
- Automatic expiration handling

By default, your browser opens a **localhost landing page** first (`/oauth/start` on the callback server). You review the context, then click through to the real OAuth authorization URL. This avoids an immediate redirect to the IdP and reduces surprise / phishing risk. To restore the previous behavior (open the authorization URL directly), set `OAUTH_LANDING_PAGE=false`.

### 2. JWT Token Authentication

For server-to-server authentication or when OAuth is not available.
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"check": "prettier --check . && tsc",
"start": "NODE_ENV=development tsup --watch",
"dev": "NODE_ENV=development node dist/proxy.js",
"dev:oauth-html": "npm run build && node dist/dev/oauth-html-preview.js",
"test": "jest",
"test:watch": "jest --watch",
"test:unit": "jest --testPathPattern=unit",
Expand Down Expand Up @@ -90,7 +91,8 @@
"tsup": {
"entry": [
"src/proxy.ts",
"src/lib.ts"
"src/lib.ts",
"src/dev/oauth-html-preview.ts"
],
"format": [
"esm"
Expand Down
152 changes: 152 additions & 0 deletions src/dev/oauth-html-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Dev-only: serve OAuth HTML flows in the browser without MCP or WordPress.
*
* Usage: npm run dev:oauth-html
* Env: OAUTH_HTML_PREVIEW_HOST (default 127.0.0.1), OAUTH_HTML_PREVIEW_PORT (default 8765).
* If the preferred port is in use, the next free port is tried (up to +30) and a line is printed.
*/

import http from 'node:http';
import express from 'express';
import type { AddressInfo } from 'node:net';
import {
buildOAuthLandingHtml,
buildLandingUnavailableHtml,
AUTHORIZATION_CODE_HTML,
OAUTH_PAGE_SHARED_CSS,
} from '../lib/oauth-html-templates.js';

const HOST = process.env.OAUTH_HTML_PREVIEW_HOST || '127.0.0.1';
const PREFERRED_PORT = Number(process.env.OAUTH_HTML_PREVIEW_PORT) || 8765;

/** Query param: authorization code that triggers a simulated failed POST (tests error UI). */
const PREVIEW_FAIL_CODE = '__preview_fail__';

const app = express();
app.use(express.json());

function previewBase(req: express.Request): string {
const host = req.get('host') || `${HOST}:${PREFERRED_PORT}`;
return `${req.protocol}://${host}`;
}

function buildMockAuthorizeUrl(base: string): string {
return (
'https://public-api.wordpress.com/oauth/authorize?' +
new URLSearchParams({
client_id: 'dev_preview',
response_type: 'code',
state: 'dev_state',
redirect_uri: `${base}/oauth/callback`,
}).toString()
);
}

app.get('/', (req, res) => {
const base = previewBase(req);
const implicitSuccess = `${base}/oauth/callback#access_token=preview_token&token_type=Bearer&expires_in=3600&state=s1`;
const implicitError = `${base}/oauth/callback#error=access_denied&error_description=User%20cancelled`;
const portLabel = req.get('host')?.split(':').pop() ?? String(PREFERRED_PORT);

res.type('html').send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OAuth HTML preview (dev)</title>
<style>${OAUTH_PAGE_SHARED_CSS}</style>
</head>
<body class="a8c-oauth-page">
<div class="a8c-oauth-wrap a8c-oauth-dev-index">
<main class="a8c-oauth-card" role="main">
<h1 class="a8c-oauth-title">OAuth HTML preview</h1>
<p class="a8c-oauth-lead">Same templates as production (<span class="a8c-oauth-code">oauth-html-templates.ts</span>). POST handlers are mocked (no token storage).</p>
<ul>
<li><a href="/oauth/start">Landing page</a> — continue link uses a fake authorize URL</li>
<li><a href="/preview/unavailable">Session not ready</a> (HTTP 400, same body as production)</li>
<li><a href="/oauth/callback">Callback shell</a> — no query/hash → &quot;no code&quot; client error</li>
<li><a href="/oauth/callback?code=mock_code&amp;state=dev_state">Callback</a> — auth code → mock POST success</li>
<li><a href="/oauth/callback?code=${PREVIEW_FAIL_CODE}&amp;state=x">Callback</a> — simulated POST failure</li>
<li><a href="/oauth/callback?error=access_denied&amp;error_description=User%20cancelled">Callback</a> — OAuth error (query)</li>
<li><a href="${implicitSuccess}">Implicit success</a> (hash)</li>
<li><a href="${implicitError}">Implicit error</a> (hash)</li>
</ul>
<p class="a8c-oauth-dev-note">Listening on port <span class="a8c-oauth-code">${portLabel}</span> · Set <span class="a8c-oauth-code">OAUTH_HTML_PREVIEW_PORT</span> for the first port to try.</p>
</main>
</div>
</body>
</html>`);
});

app.get('/oauth/start', (req, res) => {
const base = previewBase(req);
res.type('html').send(buildOAuthLandingHtml(buildMockAuthorizeUrl(base), 'mysite.wordpress.com'));
});

app.get('/preview/unavailable', (_req, res) => {
res.status(400).type('html').send(buildLandingUnavailableHtml());
});

app.get('/oauth/callback', (_req, res) => {
res.type('html').send(AUTHORIZATION_CODE_HTML);
});

app.post('/oauth/callback', (req, res) => {
const code = req.body?.code as string | undefined;
if (code === PREVIEW_FAIL_CODE) {
res.status(400).json({ error: 'Simulated token exchange failure (dev preview)' });
return;
}
res.json({ success: true, message: 'Authorization code received successfully' });
});

app.post('/oauth/tokens', (_req, res) => {
res.json({ success: true, message: 'Tokens saved successfully' });
});

async function startServer(): Promise<void> {
const maxPort = PREFERRED_PORT + 30;

for (let port = PREFERRED_PORT; port <= maxPort; port++) {
const server = http.createServer(app);
try {
await new Promise<void>((resolve, reject) => {
const onError = (err: NodeJS.ErrnoException) => {
server.removeListener('listening', onListening);
reject(err);
};
const onListening = () => {
server.removeListener('error', onError);
resolve();
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(port, HOST);
});

const addr = server.address() as AddressInfo;
const actualPort = addr.port;
if (actualPort !== PREFERRED_PORT) {
process.stdout.write(`Port ${PREFERRED_PORT} in use; using ${actualPort} instead.\n`);
}
process.stdout.write(`OAuth HTML preview → http://${HOST}:${actualPort}/\n`);
return;
} catch (e: unknown) {
const err = e as NodeJS.ErrnoException;
if (err.code === 'EADDRINUSE') {
server.close();
continue;
}
throw e;
}
}

throw new Error(
`No free port found between ${PREFERRED_PORT} and ${maxPort} (set OAUTH_HTML_PREVIEW_PORT or free a port)`
);
}

startServer().catch(err => {
process.stderr.write(String(err) + '\n');
process.exit(1);
});
5 changes: 5 additions & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export const CONFIG = {
? parseInt(process.env.OAUTH_CALLBACK_PORT)
: undefined,
OAUTH_HOST: process.env.OAUTH_HOST || '127.0.0.1',
/** When true (default), opens browser to a localhost landing page before the OAuth URL */
OAUTH_LANDING_PAGE: process.env.OAUTH_LANDING_PAGE !== 'false',
WP_OAUTH_CLIENT_ID: process.env.WP_OAUTH_CLIENT_ID || '', // No default - site-specific

// OAuth flow type - authorization_code (recommended) or implicit (legacy)
Expand Down Expand Up @@ -110,6 +112,9 @@ export const getConfig = () => ({
/** Hostname for OAuth callback */
oauthHost: CONFIG.OAUTH_HOST,

/** Show localhost landing page before redirecting to OAuth (set OAUTH_LANDING_PAGE=false to skip) */
oauthLandingPage: CONFIG.OAUTH_LANDING_PAGE,

/** WordPress OAuth client ID */
wpOAuthClientId: CONFIG.WP_OAUTH_CLIENT_ID,

Expand Down
26 changes: 21 additions & 5 deletions src/lib/mcp-oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ import {
buildAuthorizationUrl,
generateSecureState,
} from './mcp-oauth-utils.js';
import { setupWPOAuthCallbackServer } from './oauth-callback-server.js';
import {
setupWPOAuthCallbackServer,
formatSiteLabelForOAuthLanding,
} from './oauth-callback-server.js';
import { logger } from './utils.js';
import { CONFIG, getDefaultOAuthScopes, getOAuthCallbackPort, getCustomHeaders } from './config.js';
import { proxyFetch } from './fetch-utils.js';
Expand Down Expand Up @@ -388,15 +391,28 @@ export class MCPOAuthProvider {
logger.oauth('Built OAuth 2.1 authorization URL');
logger.debug('Authorization URL', 'OAUTH', { url: authUrl });

// Step 6: Open browser for user authorization
callbackServer.setLandingContext(
authUrl,
formatSiteLabelForOAuthLanding(this.config.serverUrl)
);
const urlToOpen = CONFIG.OAUTH_LANDING_PAGE ? callbackServer.getLandingUrl() : authUrl;

// Step 6: Open browser (localhost landing page first, or authorize URL if disabled)
try {
await open(authUrl);
await open(urlToOpen);
logger.oauth('Browser opened successfully');
} catch (browserError) {
logger.error('Failed to open browser automatically', 'OAUTH', browserError);
logger.info('\n=== MANUAL ACTION REQUIRED ===');
logger.info('Please manually open the following URL in your browser:');
logger.info(`${authUrl}`);
if (CONFIG.OAUTH_LANDING_PAGE) {
logger.info('Open this page in your browser (review, then continue to OAuth):');
logger.info(`${callbackServer.getLandingUrl()}`);
logger.info('Or open the authorization URL directly:');
logger.info(`${authUrl}`);
} else {
logger.info('Please manually open the following URL in your browser:');
logger.info(`${authUrl}`);
}
logger.info('===============================\n');
}

Expand Down
Loading