feat(domains): dual-domain cutover to app.accounted.se (#1087)
* feat(domains): dual-domain cutover to app.accounted.se The user-facing app moves to app.accounted.se while app.gnubok.se stays alive for machine traffic (MCP connectors, API keys, third-party OAuth callbacks, webhooks, crons), so no third-party callback registration is on the critical path. - next.config: host redirect app.gnubok.se -> NEXT_PUBLIC_APP_URL for page traffic only (/api, /.well-known, /_next excluded). Arms itself only once NEXT_PUBLIC_APP_URL leaves the legacy host, so merging this is inert and the cutover is a pure env flip + redeploy. - skatteverket: redirect_uri pinned via NEXT_PUBLIC_SKV_OAUTH_BASE_URL (Utvecklarportalen registration is slow to change); the OAuth callback now resolves the flow from the state token + stored oauth_user_id via the service client instead of session cookies, which no longer exist on the OAuth host. Legacy same-domain flows fall back to the session. - popup listeners (SkatteverketConnectPanel, AGIPanel) accept postMessage from the pinned OAuth origin; event.source identity check unchanged. - /.well-known discovery docs reflect the allowlisted request host so existing MCP connectors on app.gnubok.se keep a self-consistent issuer/resource after the flip; spoofed hosts fall back to canonical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: log dual-domain cutover decision Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): recency-bound SKV state lookup, exact localhost match in discovery allowlist - The oauth_state lookup now only considers rows updated in the last 10 minutes: bounds how long a leaked/phished authorize URL stays completable, keeps the row set far below PostgREST's 1000-row cap, and surfaces query errors instead of misreporting them as CSRF. - resolveDiscoveryBaseUrl matches localhost/127.0.0.1 exactly; the prefix check reflected spoofed hosts like localhost.evil.example. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3d97b95197
commit
b420f3e1d9
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import { resolveDiscoveryBaseUrl } from '../base-url'
|
||||
|
||||
const CANONICAL = 'https://app.accounted.se'
|
||||
|
||||
function requestWithHost(host?: string) {
|
||||
return new Request('https://ignored.example/.well-known/oauth-authorization-server', {
|
||||
headers: host ? { host } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
describe('resolveDiscoveryBaseUrl', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('returns the canonical URL for the canonical host', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
|
||||
expect(resolveDiscoveryBaseUrl(requestWithHost('app.accounted.se'))).toBe(CANONICAL)
|
||||
})
|
||||
|
||||
it('reflects the legacy host so existing MCP connectors stay self-consistent', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
|
||||
expect(resolveDiscoveryBaseUrl(requestWithHost('app.gnubok.se'))).toBe(
|
||||
'https://app.gnubok.se',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to canonical for a spoofed Host header', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
|
||||
expect(resolveDiscoveryBaseUrl(requestWithHost('evil.example'))).toBe(CANONICAL)
|
||||
})
|
||||
|
||||
it('falls back to canonical when the Host header is missing', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
|
||||
expect(resolveDiscoveryBaseUrl(requestWithHost(undefined))).toBe(CANONICAL)
|
||||
})
|
||||
|
||||
it('reflects localhost with its port for local development', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
|
||||
expect(resolveDiscoveryBaseUrl(requestWithHost('localhost:3000'))).toBe(
|
||||
'http://localhost:3000',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not reflect hosts that merely start with localhost or 127.0.0.1', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
|
||||
expect(resolveDiscoveryBaseUrl(requestWithHost('localhost.evil.example'))).toBe(CANONICAL)
|
||||
expect(resolveDiscoveryBaseUrl(requestWithHost('127.0.0.1.evil.example'))).toBe(CANONICAL)
|
||||
})
|
||||
|
||||
it('is case-insensitive on the host comparison', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
|
||||
expect(resolveDiscoveryBaseUrl(requestWithHost('App.Gnubok.SE'))).toBe(
|
||||
'https://app.gnubok.se',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -18,3 +18,41 @@ export function getCanonicalBaseUrl(): string {
|
||||
if (fromEnv) return fromEnv.replace(/\/$/, '')
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL for the OAuth/MCP discovery documents (/.well-known/*).
|
||||
*
|
||||
* The app is served on two domains since the accounted.se cutover:
|
||||
* app.accounted.se for humans and app.gnubok.se for machine traffic
|
||||
* (existing MCP connectors and API clients were configured against the
|
||||
* legacy host and it can never be retired). RFC 8414/9728 clients validate
|
||||
* that the metadata they fetch is self-consistent with the host they
|
||||
* fetched it from, so discovery must reflect the host the client actually
|
||||
* used. The Host header stays attacker-controlled at the edge, so only
|
||||
* allowlisted hosts are reflected; anything else falls back to the
|
||||
* canonical base URL.
|
||||
*/
|
||||
const LEGACY_DISCOVERY_HOSTS = new Set(['app.gnubok.se'])
|
||||
|
||||
export function resolveDiscoveryBaseUrl(request: Request): string {
|
||||
const canonical = getCanonicalBaseUrl()
|
||||
const host = request.headers.get('host')?.trim().toLowerCase()
|
||||
if (!host) return canonical
|
||||
|
||||
let canonicalHost: string | null = null
|
||||
try {
|
||||
canonicalHost = new URL(canonical).host.toLowerCase()
|
||||
} catch {
|
||||
canonicalHost = null
|
||||
}
|
||||
|
||||
if (host === canonicalHost) return canonical
|
||||
if (LEGACY_DISCOVERY_HOSTS.has(host)) return `https://${host}`
|
||||
// Exact hostname match: a prefix check would reflect spoofed hosts like
|
||||
// localhost.evil.example into the discovery documents.
|
||||
const hostname = host.replace(/:\d+$/, '')
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return `http://${host}`
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user