diff --git a/Docs/development.md b/Docs/development.md index 38989d4..c905c5c 100644 --- a/Docs/development.md +++ b/Docs/development.md @@ -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 diff --git a/README.md b/README.md index 8e26466..b8600d0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index a3ab820..bbeae24 100644 --- a/package.json +++ b/package.json @@ -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", @@ -90,7 +91,8 @@ "tsup": { "entry": [ "src/proxy.ts", - "src/lib.ts" + "src/lib.ts", + "src/dev/oauth-html-preview.ts" ], "format": [ "esm" diff --git a/src/dev/oauth-html-preview.ts b/src/dev/oauth-html-preview.ts new file mode 100644 index 0000000..1bb52a5 --- /dev/null +++ b/src/dev/oauth-html-preview.ts @@ -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(` + + + + + OAuth HTML preview (dev) + + + +
+
+

OAuth HTML preview

+

Same templates as production (oauth-html-templates.ts). POST handlers are mocked (no token storage).

+ +

Listening on port ${portLabel} · Set OAUTH_HTML_PREVIEW_PORT for the first port to try.

+
+
+ +`); +}); + +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 { + const maxPort = PREFERRED_PORT + 30; + + for (let port = PREFERRED_PORT; port <= maxPort; port++) { + const server = http.createServer(app); + try { + await new Promise((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); +}); diff --git a/src/lib/config.ts b/src/lib/config.ts index 60e8670..5a49755 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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) @@ -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, diff --git a/src/lib/mcp-oauth-provider.ts b/src/lib/mcp-oauth-provider.ts index d9b7982..31ab0bd 100644 --- a/src/lib/mcp-oauth-provider.ts +++ b/src/lib/mcp-oauth-provider.ts @@ -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'; @@ -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'); } diff --git a/src/lib/oauth-callback-server.ts b/src/lib/oauth-callback-server.ts index d7de9cd..adf6911 100644 --- a/src/lib/oauth-callback-server.ts +++ b/src/lib/oauth-callback-server.ts @@ -8,199 +8,31 @@ import { EventEmitter } from 'node:events'; import { OAuthCallbackServerOptions, WPTokens, OAuthError } from './oauth-types.js'; import { writeTokens } from './persistent-auth-config.js'; import { logger } from './utils.js'; +import { + buildOAuthLandingHtml, + buildLandingUnavailableHtml, + AUTHORIZATION_CODE_HTML, +} from './oauth-html-templates.js'; /** - * HTML page for handling OAuth 2.1 authorization code callback - * Updated for MCP Authorization specification 2025-06-18 compliance + * Human-readable site label for the OAuth landing page (hostname preferred). */ -const AUTHORIZATION_CODE_HTML = ` - - - - MCP Client Authorization - - - -
-

MCP Client Authorization

-
-
Processing authorization code...
-
-
- - - - -`; +export function formatSiteLabelForOAuthLanding(serverUrl: string): string { + try { + const u = new URL(serverUrl); + return u.host; + } catch { + return serverUrl; + } +} export class OAuthCallbackServer { private app: express.Application; private server: Server | null = null; private events: EventEmitter; private options: OAuthCallbackServerOptions; + private landingAuthUrl: string | null = null; + private landingSiteLabel: string = ''; constructor(options: OAuthCallbackServerOptions, events: EventEmitter) { this.options = options; @@ -209,10 +41,36 @@ export class OAuthCallbackServer { this.setupRoutes(); } + /** + * Set context for GET /oauth/start (localhost landing page before the OAuth authorize URL). + * Call after building the authorization URL and before opening the browser. + */ + setLandingContext(authUrl: string, siteLabel: string): void { + this.landingAuthUrl = authUrl; + this.landingSiteLabel = siteLabel; + } + + /** + * URL of the localhost landing page (opens in the browser instead of the authorize URL when enabled). + */ + getLandingUrl(): string { + return `http://${this.options.host}:${this.options.port}/oauth/start`; + } + private setupRoutes(): void { // Parse JSON bodies this.app.use(express.json()); + // Localhost landing page: user confirms before visiting the OAuth authorize URL + this.app.get('/oauth/start', (req, res) => { + logger.oauth('OAuth landing page requested'); + if (!this.landingAuthUrl) { + res.status(400).type('html').send(buildLandingUnavailableHtml()); + return; + } + res.type('html').send(buildOAuthLandingHtml(this.landingAuthUrl, this.landingSiteLabel)); + }); + // Serve the authorization code callback page this.app.get('/oauth/callback', (req, res) => { logger.oauth('OAuth 2.1 authorization callback page requested'); @@ -327,6 +185,8 @@ export class OAuthCallbackServer { } async stop(): Promise { + this.landingAuthUrl = null; + this.landingSiteLabel = ''; if (!this.server) return; const server = this.server; this.server = null; diff --git a/src/lib/oauth-html-templates.ts b/src/lib/oauth-html-templates.ts new file mode 100644 index 0000000..747ac10 --- /dev/null +++ b/src/lib/oauth-html-templates.ts @@ -0,0 +1,378 @@ +/** + * OAuth-related HTML used by the callback server and dev preview. + * Kept separate so `dev:oauth-html` can bundle without the full MCP stack. + * + * Visual style aligns with Automattic’s public pages (e.g. Press) — clean editorial + * layout, neutral background, readable type. See https://automattic.com/press/ + */ + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Shared CSS for OAuth static pages — consistent with automattic.com editorial styling + * (light canvas, high-contrast body text, blue interactive accents). + */ +export const OAUTH_PAGE_SHARED_CSS = ` + .a8c-oauth-page { + box-sizing: border-box; + margin: 0; + min-height: 100vh; + padding: clamp(1.5rem, 5vw, 3rem) 1.25rem; + background: #fafafa; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, + Cantarell, "Helvetica Neue", sans-serif; + font-size: 16px; + line-height: 1.65; + color: #1d2327; + -webkit-font-smoothing: antialiased; + } + .a8c-oauth-page *, + .a8c-oauth-page *::before, + .a8c-oauth-page *::after { + box-sizing: border-box; + } + .a8c-oauth-wrap { + max-width: 40rem; + margin: 0 auto; + } + .a8c-oauth-card { + background: #fff; + border: 1px solid #dcdcde; + border-radius: 2px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + padding: clamp(1.75rem, 4vw, 2.5rem); + } + .a8c-oauth-card--center { + text-align: center; + } + .a8c-oauth-title { + margin: 0 0 0.75rem; + font-size: clamp(1.375rem, 2.5vw, 1.625rem); + font-weight: 600; + line-height: 1.25; + color: #101517; + letter-spacing: -0.02em; + } + .a8c-oauth-lead { + margin: 0 0 1rem; + font-size: 1rem; + color: #50575e; + } + .a8c-oauth-lead:last-child { + margin-bottom: 0; + } + .a8c-oauth-site { + font-weight: 600; + color: #1d2327; + word-break: break-word; + } + .a8c-oauth-notice { + margin: 1.5rem 0; + padding: 1rem 1.125rem; + background: #f0f6fc; + border-left: 4px solid #3858e9; + font-size: 0.9375rem; + color: #1d2327; + line-height: 1.55; + } + .a8c-oauth-actions { + margin: 1.5rem 0 0; + } + .a8c-oauth-btn { + display: inline-block; + padding: 0.625rem 1.25rem; + background: #3858e9; + color: #fff !important; + text-decoration: none; + border-radius: 2px; + font-weight: 600; + font-size: 0.9375rem; + line-height: 1.4; + border: none; + cursor: pointer; + transition: background 0.15s ease; + } + .a8c-oauth-btn:hover { + background: #213fd4; + } + .a8c-oauth-btn:focus-visible { + outline: 2px solid #3858e9; + outline-offset: 2px; + } + .a8c-oauth-muted { + margin: 1.25rem 0 0; + font-size: 0.8125rem; + line-height: 1.5; + color: #646970; + } + .a8c-oauth-code { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.8125rem; + color: #50575e; + word-break: break-all; + } + .a8c-oauth-footer-note { + margin: 1.5rem 0 0; + font-size: 0.8125rem; + color: #787c82; + text-align: center; + max-width: 40rem; + margin-left: auto; + margin-right: auto; + } + .a8c-oauth-spinner { + border: 3px solid #dcdcde; + border-top-color: #3858e9; + border-radius: 50%; + width: 2rem; + height: 2rem; + animation: a8c-oauth-spin 0.85s linear infinite; + margin: 0 auto 1rem; + } + @keyframes a8c-oauth-spin { + to { + transform: rotate(360deg); + } + } + .a8c-oauth-status { + font-size: 1rem; + font-weight: 500; + margin: 0.5rem 0; + } + .a8c-oauth-status--loading { + color: #50575e; + } + .a8c-oauth-status--success { + color: #008a20; + } + .a8c-oauth-status--error { + color: #d63638; + } + .a8c-oauth-details { + margin-top: 0.75rem; + font-size: 0.875rem; + line-height: 1.5; + color: #646970; + } + .a8c-oauth-dev-index a { + color: #3858e9; + text-decoration: underline; + text-underline-offset: 2px; + } + .a8c-oauth-dev-index a:hover { + color: #213fd4; + } + .a8c-oauth-dev-index ul { + margin: 1rem 0 0; + padding-left: 1.25rem; + line-height: 1.75; + color: #50575e; + } + .a8c-oauth-dev-note { + margin-top: 1.5rem; + font-size: 0.875rem; + color: #646970; + } +`; + +export function buildOAuthLandingHtml(authUrl: string, siteLabel: string): string { + const safeUrl = escapeHtml(authUrl); + const safeSite = escapeHtml(siteLabel); + let authHost = ''; + try { + authHost = escapeHtml(new URL(authUrl).hostname); + } catch { + /* ignore */ + } + + return ` + + + + + Continue to WordPress authorization + + + +
+
+

Connect WordPress

+

MCP WordPress Remote on your computer is asking to sign in so it can access your site.

+

Site: ${safeSite}

+ ${ + authHost + ? `

You will be sent to ${authHost} to approve access.

` + : '' + } +
+ Only click below if you just started this from your AI assistant or terminal on this machine. + If this tab opened unexpectedly, close it. +
+

Continue to authorization

+

This page is served only from your device (127.0.0.1). It is not hosted by WordPress.com or your site.

+
+
+ +`; +} + +export function buildLandingUnavailableHtml(): string { + return ` + + + + + Authorization unavailable + + + +
+
+

Session not ready

+

This authorization step has expired or was already completed. Close this tab and start sign-in again from your AI assistant.

+
+
+ +`; +} + +/** + * HTML page for handling OAuth 2.1 authorization code callback (and implicit flow client JS). + * MCP Authorization specification 2025-06-18 compliance. + */ +export const AUTHORIZATION_CODE_HTML = ` + + + + + + MCP Client Authorization + + + +
+
+

MCP Client Authorization

+ +
Processing authorization code…
+
+
+
+ + + + +`; diff --git a/src/lib/persistent-oauth-client-provider.ts b/src/lib/persistent-oauth-client-provider.ts index ca8537e..58b21d3 100644 --- a/src/lib/persistent-oauth-client-provider.ts +++ b/src/lib/persistent-oauth-client-provider.ts @@ -12,7 +12,10 @@ import { isTokenValid, } from './persistent-auth-config.js'; import { WPTokens, WPClientInfo, OAuthError, WPOAuthOptions } from './oauth-types.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 } from './config.js'; @@ -230,16 +233,29 @@ export class PersistentWPOAuthClientProvider { logger.oauth(`Built authorization URL: ${authUrl}`); logger.debug(`Callback URL: ${callbackServer.getCallbackUrl()}`, 'OAUTH'); - // Open browser to authorization URL + callbackServer.setLandingContext( + authUrl, + formatSiteLabelForOAuthLanding(this.options.serverUrl) + ); + const urlToOpen = CONFIG.OAUTH_LANDING_PAGE ? callbackServer.getLandingUrl() : authUrl; + + // Open browser (localhost landing first, or authorize URL if OAUTH_LANDING_PAGE=false) logger.oauth('Attempting to open browser...'); 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'); // Don't throw here, continue waiting for manual authorization } diff --git a/tests/unit/oauth-landing.test.ts b/tests/unit/oauth-landing.test.ts new file mode 100644 index 0000000..1165e6e --- /dev/null +++ b/tests/unit/oauth-landing.test.ts @@ -0,0 +1,20 @@ +/** + * OAuth landing page helpers + */ + +import { describe, it, expect } from '@jest/globals'; +import { formatSiteLabelForOAuthLanding } from '../../src/lib/oauth-callback-server.js'; + +describe('formatSiteLabelForOAuthLanding', () => { + it('returns host for HTTPS URL with path', () => { + expect(formatSiteLabelForOAuthLanding('https://mysite.com/blog')).toBe('mysite.com'); + }); + + it('returns host for URL with port', () => { + expect(formatSiteLabelForOAuthLanding('http://localhost:8888')).toBe('localhost:8888'); + }); + + it('returns original string when URL is invalid', () => { + expect(formatSiteLabelForOAuthLanding('not-a-valid-url')).toBe('not-a-valid-url'); + }); +});