diff --git a/.env.example b/.env.example index 8e98547..94421e4 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,9 @@ SEATABLE_MODE=selfhosted # CORS: comma-separated list of allowed origins (HTTP mode only). If empty, CORS is disabled. # CORS_ALLOWED_ORIGINS=https://cloud.seatable.io,https://seatable-demo.de,http://localhost:3000 + +# OAuth (managed mode only): comma-separated list of trusted redirect_uri hosts. +# Loopback (localhost/127.0.0.1/::1) is always trusted. Listed https hosts are shown +# without a warning; any other https host still works but the user sees a warning +# before submitting their token. Remote http and non-https/loopback URIs are rejected. +# SEATABLE_OAUTH_TRUSTED_REDIRECT_HOSTS=claude.ai,claude.com,chatgpt.com diff --git a/CLAUDE.md b/CLAUDE.md index ac50b99..0e43e6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,8 @@ Auth (one required in selfhosted): `SEATABLE_API_TOKEN` (single-base) or `SEATAB Optional: `SEATABLE_MODE` (`selfhosted`|`managed`, default `selfhosted`), `SEATABLE_MOCK=true` (offline mock), `SEATABLE_ENABLE_DEBUG_TOOLS=1` (enables `echo_args` tool) +Managed-mode OAuth: `SEATABLE_OAUTH_TRUSTED_REDIRECT_HOSTS` (comma-separated https hosts shown without a warning on the authorize page; loopback is always trusted, unknown https hosts are allowed but warned, remote http is rejected) + Copy `.env.example` to `.env` for local development. ## Architecture diff --git a/SECURITY_OAUTH_REDIRECT_HARDENING.md b/SECURITY_OAUTH_REDIRECT_HARDENING.md new file mode 100644 index 0000000..cae0e5f --- /dev/null +++ b/SECURITY_OAUTH_REDIRECT_HARDENING.md @@ -0,0 +1,57 @@ +# OAuth redirect_uri Hardening (Posture D) + +Härtung des OAuth-Authorize-Flows gegen Phishing/Token-Diebstahl über nicht +validierte `redirect_uri`. Betrifft ausschließlich den **`managed`-Modus** +(SeaTable-Cloud); im `selfhosted`-Default ist der OAuth-Stack inaktiv. + +## Problem + +Der `/authorize`-Flow sammelt das SeaTable-API-Token des Users und leitete den +resultierenden Auth-Code (der das Token freischaltet) an eine **beliebige, +ungeprüfte `redirect_uri`** weiter. Ein Angreifer konnte damit einen Link auf die +echte Cloud-Domain bauen (`.../authorize?redirect_uri=https://evil.example/cb`), +ein Opfer zum Eintippen seines Tokens verleiten und den Code abgreifen. + +PKCE, der `redirect_uri`-Match am `/token` und die offene Dynamic Client +Registration verhindern das nicht, da der Angreifer den gesamten Flow selbst +initiiert. + +## Lösung: Klassifizierung des redirect_uri (Posture D) + +`kuratieren statt enumerieren` — keine vollständige Client-Allowlist nötig: + +| redirect_uri | Verhalten | +|---|---| +| Loopback (`localhost`/`127.0.0.1`/`::1`, http/https) | erlaubt, **keine Warnung** (Code landet auf dem Rechner des Users, sicher by design) | +| Konfigurierter Trusted-Host (https) | erlaubt, **keine Warnung** | +| Unbekannter https-Host | **erlaubt, aber Warn-Banner** vor der Token-Eingabe | +| Remote http / fremdes Schema / kaputte URL | **abgelehnt (400)** | + +Zusätzlich: + +- **Ziel-Host** wird auf der Token-Seite immer angezeigt. +- Enforcement bei **GET und POST** `/authorize` (direkter POST umgeht die Prüfung nicht). +- **PKCE nur noch S256** (`plain` abgelehnt, Metadata entsprechend). + +## Konfiguration + +```bash +# Komma-separierte https-Hosts, die ohne Warnung angezeigt werden. +# Leer = nur Loopback ist trusted. +SEATABLE_OAUTH_TRUSTED_REDIRECT_HOSTS=claude.ai,claude.com,chatgpt.com +``` + +## Geänderte Dateien + +- `src/auth/oauthProvider.ts` — `classifyRedirectUri()`, Ziel-/Warn-Anzeige, Error-Page, S256-only +- `src/http/httpServer.ts` — `SEATABLE_OAUTH_TRUSTED_REDIRECT_HOSTS` parsen und übergeben +- `.env.example`, `CLAUDE.md` — Doku +- `tests/oauthProvider.spec.ts` — bestehende Tests angepasst + 8 neue + +## Bekannte Grenze + +Entschärft den gemeldeten Vektor proportional (Angreifer-Ziel wird abgelehnt bzw. +laut gewarnt), beseitigt aber nicht das Grundmodell „langlebiges Token in ein +Webformular tippen". Der robuste Umbau (Delegation der Authentifizierung an +cloud.seatable.io mit echter Session/Consent) bleibt der langfristige Weg und +setzt einen OAuth-Authorization-Server im SeaTable-Core voraus. diff --git a/src/auth/oauthProvider.ts b/src/auth/oauthProvider.ts index 04df0d7..0c29568 100644 --- a/src/auth/oauthProvider.ts +++ b/src/auth/oauthProvider.ts @@ -18,6 +18,24 @@ export interface OAuthProviderOptions { hostname?: string /** Optional callback to validate an API token before issuing an authorization code. */ validateToken?: (token: string) => Promise + /** + * Hosts (lowercased, without scheme/port) whose https redirect_uris are treated as + * trusted and shown without a warning. Loopback hosts are always trusted regardless. + * Unknown https hosts are still permitted, but the user is warned before submitting. + */ + trustedRedirectHosts?: string[] +} + +/** Result of classifying a redirect_uri for the authorization flow. */ +interface RedirectClassification { + /** Whether the redirect_uri is permitted at all. */ + ok: boolean + /** Whether it is trusted (no warning shown). Only meaningful when ok is true. */ + trusted: boolean + /** The redirect target host, for display. */ + host: string + /** Human-readable rejection reason when ok is false. */ + reason?: string } export class OAuthProvider { @@ -25,16 +43,58 @@ export class OAuthProvider { private readonly cleanupInterval: ReturnType private readonly configuredHostname?: string private readonly validateToken?: (token: string) => Promise + private readonly trustedRedirectHosts: Set constructor(options?: OAuthProviderOptions) { this.configuredHostname = options?.hostname this.validateToken = options?.validateToken + this.trustedRedirectHosts = new Set( + (options?.trustedRedirectHosts ?? []).map((h) => h.trim().toLowerCase()).filter(Boolean), + ) this.cleanupInterval = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS) if (this.cleanupInterval.unref) { this.cleanupInterval.unref() } } + /** + * Classify a redirect_uri (Posture D): + * - Loopback (localhost / 127.0.0.1 / ::1) over http|https → trusted (code lands on the + * user's own machine, safe by construction). + * - Configured trusted host over https → trusted. + * - Any other https host → permitted but untrusted (user is warned before submitting). + * - Remote http, unknown schemes, or malformed URIs → rejected. + */ + private classifyRedirectUri(uri: string): RedirectClassification { + let parsed: URL + try { + parsed = new URL(uri) + } catch { + return { ok: false, trusted: false, host: '', reason: 'redirect_uri is not a valid URL.' } + } + + const scheme = parsed.protocol.replace(/:$/, '').toLowerCase() + const host = parsed.hostname.toLowerCase() + const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' + + if (isLoopback) { + if (scheme === 'http' || scheme === 'https') { + return { ok: true, trusted: true, host } + } + return { ok: false, trusted: false, host, reason: 'Loopback redirect_uri must use http or https.' } + } + + if (scheme !== 'https') { + return { ok: false, trusted: false, host, reason: 'A non-loopback redirect_uri must use https.' } + } + + if (this.trustedRedirectHosts.has(host)) { + return { ok: true, trusted: true, host } + } + + return { ok: true, trusted: false, host } + } + /** * Derive the base URL from SEATABLE_MCP_HOSTNAME or the incoming Host header. */ @@ -60,7 +120,7 @@ export class OAuthProvider { response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], token_endpoint_auth_methods_supported: ['none'], - code_challenge_methods_supported: ['S256', 'plain'], + code_challenge_methods_supported: ['S256'], } res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(metadata)) } @@ -111,8 +171,17 @@ export class OAuthProvider { const codeChallengeMethod = url.searchParams.get('code_challenge_method') ?? '' if (req.method === 'GET') { + const classification = redirectUri + ? this.classifyRedirectUri(redirectUri) + : { ok: false, trusted: false, host: '', reason: 'Missing redirect_uri.' } + if (!classification.ok) { + logger.warn({ host: classification.host, reason: classification.reason }, 'OAuth authorize rejected: invalid redirect_uri') + res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }) + res.end(this.renderErrorPage('Invalid redirect target', classification.reason ?? 'The redirect_uri is not allowed.')) + return + } res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) - res.end(this.renderAuthorizePage(clientId, redirectUri, state, responseType, codeChallenge, codeChallengeMethod)) + res.end(this.renderAuthorizePage(clientId, redirectUri, state, responseType, codeChallenge, codeChallengeMethod, classification)) return } @@ -124,9 +193,27 @@ export class OAuthProvider { const formCodeChallenge = body.get('code_challenge') ?? codeChallenge const formCodeChallengeMethod = body.get('code_challenge_method') ?? codeChallengeMethod + // Validate the redirect target first — never issue a token-bearing code to a + // disallowed destination, even if the request bypassed the GET form. + const classification = this.classifyRedirectUri(formRedirectUri) + if (!classification.ok) { + logger.warn({ host: classification.host, reason: classification.reason }, 'OAuth authorize rejected: invalid redirect_uri') + res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }) + res.end(this.renderErrorPage('Invalid redirect target', classification.reason ?? 'The redirect_uri is not allowed.')) + return + } + + // Enforce PKCE S256-only: reject 'plain' (and any implied default) when a challenge is present. + if (formCodeChallenge && formCodeChallengeMethod !== 'S256') { + logger.warn({ method: formCodeChallengeMethod || '(default)' }, 'OAuth authorize rejected: unsupported PKCE method') + res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }) + res.end(this.renderErrorPage('Unsupported PKCE method', 'Only the S256 code_challenge_method is supported.')) + return + } + if (!apiToken) { res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }) - res.end(this.renderAuthorizePage(clientId, formRedirectUri, formState, responseType, formCodeChallenge, formCodeChallengeMethod, 'Please enter your API token.')) + res.end(this.renderAuthorizePage(clientId, formRedirectUri, formState, responseType, formCodeChallenge, formCodeChallengeMethod, classification, 'Please enter your API token.')) return } @@ -136,16 +223,11 @@ export class OAuthProvider { if (!valid) { logger.warn('OAuth authorization rejected: invalid API token') res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }) - res.end(this.renderAuthorizePage(clientId, formRedirectUri, formState, responseType, formCodeChallenge, formCodeChallengeMethod, 'Invalid API token. Please check your token and try again.')) + res.end(this.renderAuthorizePage(clientId, formRedirectUri, formState, responseType, formCodeChallenge, formCodeChallengeMethod, classification, 'Invalid API token. Please check your token and try again.')) return } } - if (!formRedirectUri) { - res.writeHead(400, { 'content-type': 'text/plain' }).end('Missing redirect_uri') - return - } - const code = randomBytes(32).toString('hex') this.codes.set(code, { apiToken, @@ -247,9 +329,7 @@ export class OAuthProvider { return } - const expected = stored.codeChallengeMethod === 'plain' - ? codeVerifier - : base64UrlEncode(createHash('sha256').update(codeVerifier).digest()) + const expected = base64UrlEncode(createHash('sha256').update(codeVerifier).digest()) if (expected !== stored.codeChallenge) { logger.warn('OAuth PKCE verification failed') @@ -332,9 +412,16 @@ export class OAuthProvider { }) } - private renderAuthorizePage(clientId: string, redirectUri: string, state: string, responseType: string, codeChallenge: string, codeChallengeMethod: string, error?: string): string { + private renderAuthorizePage(clientId: string, redirectUri: string, state: string, responseType: string, codeChallenge: string, codeChallengeMethod: string, classification: RedirectClassification, error?: string): string { const errorHtml = error ? `
${this.escapeHtml(error)}
` : '' + const host = this.escapeHtml(classification.host) + const destinationHtml = classification.trusted + ? `
Access will be sent to ${host}.
` + : `
⚠️ Unrecognized destination: ${host}
+ Your API token will be sent here. We don't recognize this service — only continue if + you started this connection from a tool you trust. If you didn't, close this page.
` + return ` @@ -385,6 +472,8 @@ export class OAuthProvider { } button:hover { background: #e07b00; } .error { background: #fee; color: #c00; padding: 10px 12px; border-radius: 6px; margin-bottom: 16px; font-size: 0.9em; } + .destination { background: #f0f7ff; color: #345; padding: 10px 12px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85em; } + .warning { background: #fff4e5; color: #8a4b00; border: 1px solid #ffcc80; padding: 12px 14px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85em; line-height: 1.5; } .hint { margin-top: 16px; font-size: 0.8em; color: #999; line-height: 1.4; } @@ -392,6 +481,7 @@ export class OAuthProvider {

SeaTable MCP

Enter your SeaTable API token to authorize access to your base.

+ ${destinationHtml} ${errorHtml}
@@ -410,6 +500,29 @@ export class OAuthProvider { ` } + private renderErrorPage(title: string, message: string): string { + return ` + + + + + SeaTable MCP — ${this.escapeHtml(title)} + + + +
+

${this.escapeHtml(title)}

+

${this.escapeHtml(message)}

+
+ +` + } + private escapeHtml(str: string): string { return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } diff --git a/src/http/httpServer.ts b/src/http/httpServer.ts index 57b48d2..6e24b0b 100644 --- a/src/http/httpServer.ts +++ b/src/http/httpServer.ts @@ -66,6 +66,12 @@ function parseCorsOrigins(): string[] { return raw.split(',').map(o => o.trim()).filter(Boolean) } +function parseTrustedRedirectHosts(): string[] { + const raw = process.env.SEATABLE_OAUTH_TRUSTED_REDIRECT_HOSTS + if (!raw) return [] + return raw.split(',').map(h => h.trim().toLowerCase()).filter(Boolean) +} + function setCorsHeaders(req: IncomingMessage, res: ServerResponse, allowedOrigins: string[]): void { const origin = req.headers.origin if (!origin || !allowedOrigins.includes(origin)) return @@ -90,6 +96,7 @@ export async function startHttpServer(options: StartHttpServerOptions = {}) { const oauthProvider = mode === 'managed' ? new OAuthProvider({ hostname: process.env.SEATABLE_MCP_HOSTNAME, validateToken: tokenValidator ? (token) => tokenValidator.validate(token) : undefined, + trustedRedirectHosts: parseTrustedRedirectHosts(), }) : undefined const toolDefinitions = getStaticToolDefinitions() diff --git a/tests/oauthProvider.spec.ts b/tests/oauthProvider.spec.ts index 3d25f2e..b545cb1 100644 --- a/tests/oauthProvider.spec.ts +++ b/tests/oauthProvider.spec.ts @@ -73,7 +73,7 @@ describe('OAuthProvider', () => { const res = await fetch(base('/register'), { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ client_name: 'test-client', redirect_uris: ['http://example.com/cb'] }), + body: JSON.stringify({ client_name: 'test-client', redirect_uris: ['https://example.com/cb'] }), }) expect(res.status).toBe(201) const data = await res.json() @@ -84,7 +84,7 @@ describe('OAuthProvider', () => { describe('authorize', () => { it('GET /authorize renders HTML form', async () => { - const res = await fetch(base('/authorize?client_id=test&redirect_uri=http://example.com/cb&state=abc123')) + const res = await fetch(base('/authorize?client_id=test&redirect_uri=https://example.com/cb&state=abc123')) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toContain('text/html') const html = await res.text() @@ -94,7 +94,7 @@ describe('OAuthProvider', () => { }) it('POST /authorize without token returns error', async () => { - const res = await fetch(base('/authorize?redirect_uri=http://example.com/cb&state=xyz'), { + const res = await fetch(base('/authorize?redirect_uri=https://example.com/cb&state=xyz'), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'api_token=', @@ -109,12 +109,12 @@ describe('OAuthProvider', () => { const res = await fetch(base('/authorize'), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: 'api_token=my-secret-token&redirect_uri=http://example.com/cb&state=xyz', + body: 'api_token=my-secret-token&redirect_uri=https://example.com/cb&state=xyz', redirect: 'manual', }) expect(res.status).toBe(302) const location = res.headers.get('location')! - expect(location).toContain('http://example.com/cb') + expect(location).toContain('https://example.com/cb') expect(location).toContain('code=') expect(location).toContain('state=xyz') }) @@ -125,7 +125,7 @@ describe('OAuthProvider', () => { const authorizeRes = await fetch(base('/authorize'), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: 'api_token=test-api-token-123&redirect_uri=http://example.com/cb&state=s1', + body: 'api_token=test-api-token-123&redirect_uri=https://example.com/cb&state=s1', redirect: 'manual', }) const location = new URL(authorizeRes.headers.get('location')!) @@ -135,7 +135,7 @@ describe('OAuthProvider', () => { const tokenRes = await fetch(base('/token'), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: `grant_type=authorization_code&code=${code}&redirect_uri=http://example.com/cb`, + body: `grant_type=authorization_code&code=${code}&redirect_uri=https://example.com/cb`, }) expect(tokenRes.status).toBe(200) const tokenData = await tokenRes.json() @@ -148,7 +148,7 @@ describe('OAuthProvider', () => { const authorizeRes = await fetch(base('/authorize'), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: 'api_token=single-use-token&redirect_uri=http://example.com/cb', + body: 'api_token=single-use-token&redirect_uri=https://example.com/cb', redirect: 'manual', }) const location = new URL(authorizeRes.headers.get('location')!) @@ -209,7 +209,7 @@ describe('OAuthProvider', () => { const authorizeRes = await fetch(base('/authorize'), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: 'api_token=mismatch-token&redirect_uri=http://example.com/cb', + body: 'api_token=mismatch-token&redirect_uri=https://example.com/cb', redirect: 'manual', }) const location = new URL(authorizeRes.headers.get('location')!) @@ -218,7 +218,7 @@ describe('OAuthProvider', () => { const res = await fetch(base('/token'), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: `grant_type=authorization_code&code=${code}&redirect_uri=http://other.com/cb`, + body: `grant_type=authorization_code&code=${code}&redirect_uri=https://other.com/cb`, }) expect(res.status).toBe(400) const data = await res.json() @@ -237,7 +237,7 @@ describe('OAuthProvider', () => { const authorizeRes = await fetch(base('/authorize'), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: `api_token=pkce-token&redirect_uri=http://example.com/cb&code_challenge=${codeChallenge}&code_challenge_method=S256`, + body: `api_token=pkce-token&redirect_uri=https://example.com/cb&code_challenge=${codeChallenge}&code_challenge_method=S256`, redirect: 'manual', }) const location = new URL(authorizeRes.headers.get('location')!) @@ -261,7 +261,7 @@ describe('OAuthProvider', () => { const authorizeRes = await fetch(base('/authorize'), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: `api_token=pkce-token&redirect_uri=http://example.com/cb&code_challenge=${codeChallenge}&code_challenge_method=S256`, + body: `api_token=pkce-token&redirect_uri=https://example.com/cb&code_challenge=${codeChallenge}&code_challenge_method=S256`, redirect: 'manual', }) const location = new URL(authorizeRes.headers.get('location')!) @@ -286,7 +286,7 @@ describe('OAuthProvider', () => { const authorizeRes = await fetch(base('/authorize'), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: `api_token=pkce-token&redirect_uri=http://example.com/cb&code_challenge=${codeChallenge}&code_challenge_method=S256`, + body: `api_token=pkce-token&redirect_uri=https://example.com/cb&code_challenge=${codeChallenge}&code_challenge_method=S256`, redirect: 'manual', }) const location = new URL(authorizeRes.headers.get('location')!) @@ -302,5 +302,94 @@ describe('OAuthProvider', () => { expect(data.error).toBe('invalid_request') expect(data.error_description).toContain('code_verifier') }) + + it('rejects plain PKCE at /authorize', async () => { + const res = await fetch(base('/authorize'), { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'api_token=t&redirect_uri=https://example.com/cb&code_challenge=abc&code_challenge_method=plain', + redirect: 'manual', + }) + expect(res.status).toBe(400) + const html = await res.text() + expect(html).toContain('S256') + }) + }) + + describe('redirect_uri validation (Posture D)', () => { + it('rejects a remote http redirect_uri', async () => { + const res = await fetch(base('/authorize?redirect_uri=http://evil.example/cb')) + expect(res.status).toBe(400) + const html = await res.text() + expect(html).toContain('https') + }) + + it('rejects a non-http(s) scheme', async () => { + const res = await fetch(base('/authorize?redirect_uri=' + encodeURIComponent('javascript:alert(1)'))) + expect(res.status).toBe(400) + }) + + it('rejects a malformed redirect_uri', async () => { + const res = await fetch(base('/authorize?redirect_uri=not-a-url')) + expect(res.status).toBe(400) + }) + + it('allows loopback without a warning', async () => { + const res = await fetch(base('/authorize?redirect_uri=' + encodeURIComponent('http://localhost:8080/cb'))) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('localhost') + expect(html).not.toContain('Unrecognized destination') + }) + + it('allows an unknown https host but shows a warning', async () => { + const res = await fetch(base('/authorize?redirect_uri=' + encodeURIComponent('https://unknown.example/cb'))) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('Unrecognized destination') + expect(html).toContain('unknown.example') + }) + + it('rejects a remote http redirect_uri on POST too', async () => { + const res = await fetch(base('/authorize'), { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'api_token=t&redirect_uri=http://evil.example/cb', + redirect: 'manual', + }) + expect(res.status).toBe(400) + }) + }) + + describe('trusted redirect hosts', () => { + let trustedServer: Server + let trustedPort: number + let trustedProvider: OAuthProvider + + beforeAll(async () => { + trustedProvider = new OAuthProvider({ trustedRedirectHosts: ['trusted.example'] }) + trustedServer = createServer(async (req, res) => { + const url = new URL(req.url!, 'http://localhost') + if (url.pathname === '/authorize') await trustedProvider.handleAuthorize(req, res, url) + else res.writeHead(404).end() + }) + await new Promise((resolve) => trustedServer.listen(0, () => { + trustedPort = (trustedServer.address() as any).port + resolve() + })) + }) + + afterAll(() => { + trustedProvider.destroy() + trustedServer.close() + }) + + it('shows a configured https host without a warning', async () => { + const res = await fetch(`http://localhost:${trustedPort}/authorize?redirect_uri=${encodeURIComponent('https://trusted.example/cb')}`) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('trusted.example') + expect(html).not.toContain('Unrecognized destination') + }) }) })