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');
+ });
+});