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:
Jakob Wennberg
2026-07-21 11:39:42 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 3d97b95197
commit b420f3e1d9
12 changed files with 373 additions and 92 deletions
+1
View File
@@ -244,3 +244,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-20] Onboarding backdrop reuses marketing-site halftone webp assets copied into public/illustrations/ (not hotlinked, not regenerated): keeps app self-contained and signup->app visually continuous; decorative art uses plain <img> (physics sizes by %, next/image adds nothing for 1-35KB webp).
[2026-07-20] Removed Dependabot entirely (.github/dependabot.yml deleted, open PRs #1083/#1082/#1012 closed) on Emil's request: weekly grouped bumps were noise and the #884 bedrock-sdk incident showed the risk profile. Dependency bumps are now manual/deliberate; the bedrock-sdk 0.29.1 exact pin stays enforced by scripts/checks/no-new-antipatterns.mjs.
[2026-07-20] Bulk reject (/pending) reuses the exact bulk-approve selection set: high-risk and locked-period ops stay one-by-one for reject too, keeping one selection model instead of per-action eligibility. Server-side bulk-reject has NO high-risk skip (rejecting posts nothing), so the API stays permissive; the UI is the gate.
[2026-07-21] Domain cutover is dual-domain, not full migration: app.gnubok.se stays serving /api + /.well-known forever (MCP connectors, API keys, SKV callback registered in Utvecklarportalen); only page traffic redirects to app.accounted.se, gated on NEXT_PUBLIC_APP_URL so the merge is inert. SKV OAuth callback rewritten cookie-free (state + stored oauth_user_id) because sessions no longer exist on the OAuth host; discovery docs host-reflect (allowlisted) for RFC 8414/9728 self-consistency.
@@ -1,12 +1,18 @@
import { NextResponse } from 'next/server'
import { PUBLIC_OAUTH_METADATA_SCOPES } from '@/lib/auth/api-keys'
import { resolveDiscoveryBaseUrl } from '@/lib/api/v1/base-url'
/**
* RFC 8414: OAuth 2.0 Authorization Server Metadata.
* Tells MCP clients where the authorize/token endpoints are.
*
* The issuer reflects the (allowlisted) request host: clients that
* connected via the legacy app.gnubok.se domain must keep seeing a
* self-consistent issuer there, or their issuer validation breaks on
* re-auth after the app.accounted.se cutover.
*/
export async function GET() {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
export async function GET(request: Request) {
const appUrl = resolveDiscoveryBaseUrl(request)
return NextResponse.json({
issuer: appUrl,
@@ -1,11 +1,17 @@
import { NextResponse } from 'next/server'
import { resolveDiscoveryBaseUrl } from '@/lib/api/v1/base-url'
/**
* RFC 9728: Protected Resource Metadata.
* Tells MCP clients which authorization server to use.
*
* The resource/AS URLs reflect the (allowlisted) request host: MCP clients
* validate the advertised resource against the server URL they were
* configured with, and existing connectors point at the legacy
* app.gnubok.se domain after the app.accounted.se cutover.
*/
export async function GET() {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
export async function GET(request: Request) {
const appUrl = resolveDiscoveryBaseUrl(request)
return NextResponse.json({
resource: `${appUrl}/api/extensions/ext/mcp-server/mcp`,
+4 -1
View File
@@ -23,6 +23,7 @@ import { InfoTooltip } from '@/components/ui/info-tooltip'
import { useToast } from '@/components/ui/use-toast'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { useCapability } from '@/contexts/CompanyContext'
import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiSubmissionState } from '@/lib/salary/agi-submission-state'
@@ -244,7 +245,9 @@ export function AGIPanel(props: AGIPanelProps) {
// "expired" / not-connected to "Ansluten" without a full page reload.
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return
// The popup runs on the pinned SKV OAuth host, which differs from the
// app origin after the app.accounted.se cutover.
if (!isAllowedSkvPopupOrigin(event.origin, window.location.origin)) return
// Source-identity check: only the popup this component opened can
// trigger the handler; a window reference cannot be forged by other
// same-origin scripts.
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { useCapability } from '@/contexts/CompanyContext'
import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { CheckCircle2, ExternalLink, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react'
@@ -130,7 +131,9 @@ function SkatteverketPersonalConnectionCard() {
// the settings page never navigates and we just re-fetch the status.
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return
// The popup runs on the pinned SKV OAuth host, which differs from the
// app origin after the app.accounted.se cutover.
if (!isAllowedSkvPopupOrigin(event.origin, window.location.origin)) return
// Source-identity check: only the popup this component opened can
// trigger the handler; a window reference cannot be forged by other
// same-origin scripts.
@@ -27,13 +27,13 @@ vi.mock('../lib/post-connect-refresh', () => ({
runPostConnectRefresh: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
const { mockCreateClient, mockCreateServiceClient } = vi.hoisted(() => ({
mockCreateClient: vi.fn(),
mockCreateServiceClient: vi.fn(),
}))
const { mockCreateClient } = vi.hoisted(() => ({ mockCreateClient: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({
createClient: mockCreateClient,
createServiceClient: mockCreateServiceClient,
}))
import { after } from 'next/server'
@@ -49,23 +49,24 @@ const mockRefresh = vi.mocked(runPostConnectRefresh)
const STATE = 'state-1'
/**
* Supabase mock covering the callback's extension_data reads (keyed lookups
* for oauth_state / oauth_redirect_uri / oauth_code_verifier /
* oauth_return_to) and the post-exchange cleanup delete.
* Service-client mock covering the callback's extension_data access:
* awaiting the query chain directly returns the oauth_state row listing
* (the company resolution), .maybeSingle() returns the per-key setting for
* whichever key the chain last filtered on, and the post-exchange cleanup
* delete resolves via .in().
*/
function makeSupabase(overrides: Record<string, string | null> = {}) {
function makeServiceSupabase(overrides: Record<string, string | null> = {}) {
const values: Record<string, string | null> = {
oauth_state: STATE,
oauth_user_id: 'user-1',
oauth_redirect_uri: 'https://app.example/api/extensions/ext/skatteverket/callback',
oauth_code_verifier: 'verifier-1',
oauth_return_to: '/settings/tax',
...overrides,
}
const gte = vi.fn()
const from = vi.fn(() => {
let key: string | null = null
const result = () => ({
data: key !== null && values[key] != null ? { value: values[key] } : null,
})
const chain: any = {
select: vi.fn(() => chain),
delete: vi.fn(() => chain),
@@ -73,17 +74,30 @@ function makeSupabase(overrides: Record<string, string | null> = {}) {
if (col === 'key') key = val
return chain
}),
gte: gte.mockImplementation(() => chain),
in: vi.fn(() => Promise.resolve({ error: null })),
single: vi.fn(async () => result()),
maybeSingle: vi.fn(async () => result()),
maybeSingle: vi.fn(async () => ({
data: key !== null && values[key] != null ? { value: values[key] } : null,
})),
then: (resolve: any, reject: any) => {
const rows =
values.oauth_state != null
? [{ company_id: 'company-1', value: values.oauth_state }]
: []
return Promise.resolve({ data: rows }).then(resolve, reject)
},
}
return chain
})
return { from, gte }
}
/** Cookie-bound client: only consulted by the legacy session fallback. */
function makeCookieClient(userId: string | null) {
return {
auth: {
getUser: vi.fn(async () => ({ data: { user: { id: 'user-1' } } })),
getUser: vi.fn(async () => ({ data: { user: userId ? { id: userId } : null } })),
},
from,
}
}
@@ -105,7 +119,11 @@ function callbackRequest(params: string) {
describe('skatteverket OAuth callback', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateClient.mockResolvedValue(makeSupabase() as any)
mockCreateServiceClient.mockReturnValue(makeServiceSupabase() as any)
// No session cookies by default: the callback is served on the pinned
// OAuth host (app.gnubok.se), where the user-facing app's session does
// not exist. Every happy-path test doubles as a cookie-free proof.
mockCreateClient.mockResolvedValue(makeCookieClient(null) as any)
mockExchange.mockResolvedValue({
access_token: 'at',
refresh_token: 'rt',
@@ -144,6 +162,13 @@ describe('skatteverket OAuth callback', () => {
expect.objectContaining({ access_token: 'at' }),
'company-1',
)
// The stored oauth_user_id resolved the user; the cookie-bound client
// must never be needed on the modern path.
expect(mockCreateClient).not.toHaveBeenCalled()
// The state lookup must be recency-bounded: an old state row (leaked or
// phished authorize URL) must not stay completable indefinitely.
const service = mockCreateServiceClient.mock.results[0]!.value
expect(service.gte).toHaveBeenCalledWith('updated_at', expect.any(String))
// The refresh was started eagerly and handed to after() so it survives
// past the response; it must not gate the response itself.
expect(refreshStarted).toBe(true)
@@ -164,6 +189,43 @@ describe('skatteverket OAuth callback', () => {
expect(await response.text()).toContain('skatteverket-oauth-success')
})
it('falls back to the session cookie for flows started before oauth_user_id shipped', async () => {
mockCreateServiceClient.mockReturnValue(
makeServiceSupabase({ oauth_user_id: null }) as any,
)
mockCreateClient.mockResolvedValue(makeCookieClient('legacy-user') as any)
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 })
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
expect(await response.text()).toContain('skatteverket-oauth-success')
expect(mockStoreTokens).toHaveBeenCalledWith(
expect.anything(),
'legacy-user',
expect.objectContaining({ access_token: 'at' }),
'company-1',
)
})
it('returns the error page when neither a stored user id nor a session exists', async () => {
mockCreateServiceClient.mockReturnValue(
makeServiceSupabase({ oauth_user_id: null }) as any,
)
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('skatteverket-oauth-error')
expect(mockExchange).not.toHaveBeenCalled()
expect(mockRefresh).not.toHaveBeenCalled()
})
it('returns the error page on a state (CSRF) mismatch without exchanging the code', async () => {
const response = await callbackRoute().handler(
callbackRequest('code=abc&state=wrong-state'),
+88 -70
View File
@@ -187,6 +187,22 @@ async function requireAgiWriteRole(ctx: ExtensionContext): Promise<NextResponse
return null
}
/**
* Base URL for the OAuth redirect_uri registered with Skatteverket in
* Utvecklarportalen. Registration changes there are slow, so after the
* user-facing app moved to app.accounted.se the redirect_uri stays pinned
* to the legacy domain via NEXT_PUBLIC_SKV_OAUTH_BASE_URL (hosted value:
* https://app.gnubok.se). Self-hosted deployments leave it unset and the
* regular app URL is used.
*/
function getSkvOauthBaseUrl(): string {
return (
process.env.NEXT_PUBLIC_SKV_OAUTH_BASE_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
'http://localhost:3000'
)
}
export const skatteverketExtension: Extension = {
id: 'skatteverket',
name: 'Skatteverket Integration',
@@ -212,8 +228,7 @@ export const skatteverketExtension: Extension = {
if (blocked) return blocked
const state = crypto.randomUUID()
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const redirectUri = `${appUrl}/api/extensions/ext/skatteverket/callback`
const redirectUri = `${getSkvOauthBaseUrl()}/api/extensions/ext/skatteverket/callback`
// Optional: where to send the user after the BankID round-trip.
// Allowlisted to internal in-app paths to avoid open-redirect abuse.
@@ -229,8 +244,12 @@ export const skatteverketExtension: Extension = {
// tokens unless PKCE is present, so we always send it.
const pkce = generatePkcePair()
// Store state for CSRF validation in callback
// Store state for CSRF validation in callback. The user id is stored
// alongside it because the callback runs on the OAuth host (see
// getSkvOauthBaseUrl), where the browser carries no session cookies
// once the user-facing app lives on its own domain.
await ctx.settings.set('oauth_state', state)
await ctx.settings.set('oauth_user_id', ctx.userId)
await ctx.settings.set('oauth_redirect_uri', redirectUri)
await ctx.settings.set('oauth_code_verifier', pkce.verifier)
if (returnTo) await ctx.settings.set('oauth_return_to', returnTo)
@@ -336,85 +355,84 @@ export const skatteverketExtension: Extension = {
)
}
// Exchange code FIRST: 5-minute expiry, do this before anything else
const { createClient } = await import('@/lib/supabase/server')
const { requireCompanyId } = await import('@/lib/company/context')
const supabase = await createClient()
// This callback is served on the OAuth host (see getSkvOauthBaseUrl),
// where the browser has no session cookies once the user-facing app
// lives on its own domain. The flow is resolved entirely from the
// state token: /authorize stored state, user id, redirect_uri and
// PKCE verifier keyed on company_id, and the state value is an
// unguessable single-use UUID, so the bare lookup by value doubles
// as the CSRF check.
const { createClient, createServiceClient } = await import('@/lib/supabase/server')
const db = createServiceClient()
// Verify user session (browser should still have cookies)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
// Login redirects always go to a full page: popups can't render the
// login form usefully, so this is the one path that keeps a hard
// redirect even from inside the popup.
return NextResponse.redirect(
`${appUrl}/login?redirect=${encodeURIComponent('/reports?tab=vat-declaration')}`
)
}
// Resolve the active company: state/redirect_uri were stored keyed on company_id
// by ctx.settings.set() in the /authorize handler.
let companyId: string
try {
companyId = await requireCompanyId(supabase, user.id)
} catch {
return respondWithError(
'Inget företag valt',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Inget företag valt')}`,
)
}
// Validate CSRF state
const { data: settingsData } = await supabase
// States are single-use and short-lived: the recency bound both
// caps how long a leaked/phished authorize URL stays completable
// (the row expires ten minutes after /authorize refreshed it) and
// keeps the row set far below PostgREST's silent 1000-row cap.
// value is jsonb, so equality is matched in JS rather than in the
// PostgREST filter, where JSON serialization rules would apply.
const stateCutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString()
const { data: stateRows, error: stateError } = await db
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.select('company_id, value')
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_state')
.single()
.gte('updated_at', stateCutoff)
if (!settingsData || settingsData.value !== state) {
if (stateError) {
log.error('oauth state lookup failed', stateError)
return respondWithError(
'Ett tekniskt fel uppstod. Försök igen.',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Ett tekniskt fel uppstod')}`,
)
}
const stateMatch = (stateRows ?? []).find((row) => row.value === state)
if (!stateMatch) {
return respondWithError(
'Ogiltig state-parameter (CSRF)',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Ogiltig state-parameter (CSRF)')}`,
)
}
const companyId = stateMatch.company_id as string
// Get the stored redirect URI
const { data: redirectData } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_redirect_uri')
.single()
const readSetting = async (key: string): Promise<string | null> => {
const { data } = await db
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', key)
.maybeSingle()
return (data?.value as string | null) ?? null
}
const redirectUri = redirectData?.value ||
`${appUrl}/api/extensions/ext/skatteverket/callback`
// Flows that started before oauth_user_id shipped ran on the same
// domain as the app and still carry session cookies; fall back to
// those so in-flight connects survive the deploy boundary.
let userId = await readSetting('oauth_user_id')
if (!userId) {
const cookieClient = await createClient()
const { data: { user } } = await cookieClient.auth.getUser()
userId = user?.id ?? null
}
if (!userId) {
return respondWithError(
'Sessionen har gått ut. Stäng fliken och försök ansluta igen.',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Sessionen har gått ut')}`,
)
}
const redirectUri = (await readSetting('oauth_redirect_uri')) ||
`${getSkvOauthBaseUrl()}/api/extensions/ext/skatteverket/callback`
// Retrieve the PKCE verifier stored in /authorize. Optional only for
// backward compatibility with in-flight flows that started before the
// PKCE rollout: once those drain, this can be made required.
const { data: verifierData } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_code_verifier')
.maybeSingle()
const codeVerifier = (verifierData?.value as string | null) || undefined
const codeVerifier = (await readSetting('oauth_code_verifier')) || undefined
// Optional in-app destination set by /authorize?return_to=...
const { data: returnToData } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_return_to')
.maybeSingle()
const returnTo = (returnToData?.value as string | null) || null
const returnTo = await readSetting('oauth_return_to')
const successPath = returnTo
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_connected=true`
: `/reports?tab=vat-declaration&skv_connected=true`
@@ -425,15 +443,15 @@ export const skatteverketExtension: Extension = {
try {
const tokens = await exchangeCodeForTokens(code, redirectUri, codeVerifier)
await storeTokens(supabase, user.id, tokens, companyId)
await storeTokens(db, userId, tokens, companyId)
// Clean up CSRF state + the one-shot return_to + PKCE verifier.
await supabase
// Clean up CSRF state + the one-shot user id/return_to/PKCE verifier.
await db
.from('extension_data')
.delete()
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.in('key', ['oauth_state', 'oauth_return_to', 'oauth_code_verifier'])
.in('key', ['oauth_state', 'oauth_user_id', 'oauth_return_to', 'oauth_code_verifier'])
// Refresh Skatteverket-derived data AFTER the response is sent.
// Right-after-consent is still the one reliable window for a
@@ -448,10 +466,10 @@ export const skatteverketExtension: Extension = {
// alive until the refresh settles; the connect panels do a delayed
// status refetch to pick up the synced data. Best-effort: a
// refresh failure must never fail the connect that just succeeded.
const refreshPromise = runPostConnectRefresh(supabase, user.id, companyId)
const refreshPromise = runPostConnectRefresh(db, userId, companyId)
.then(() => undefined)
.catch((refreshErr) => {
log.error('post-connect refresh failed', refreshErr, { companyId, userId: user.id })
log.error('post-connect refresh failed', refreshErr, { companyId, userId })
})
try {
after(() => refreshPromise)
@@ -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',
)
})
})
+38
View File
@@ -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
}
@@ -0,0 +1,40 @@
import { describe, it, expect, afterEach, vi } from 'vitest'
import { isAllowedSkvPopupOrigin } from '../popup-origin'
const APP = 'https://app.accounted.se'
const OAUTH = 'https://app.gnubok.se'
describe('isAllowedSkvPopupOrigin', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('always allows the window own origin', () => {
expect(isAllowedSkvPopupOrigin(APP, APP)).toBe(true)
})
it('allows the pinned SKV OAuth origin when configured', () => {
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', OAUTH)
expect(isAllowedSkvPopupOrigin(OAUTH, APP)).toBe(true)
})
it('normalises the pinned base URL to an origin (path/trailing slash ignored)', () => {
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', `${OAUTH}/`)
expect(isAllowedSkvPopupOrigin(OAUTH, APP)).toBe(true)
})
it('rejects foreign origins even when a pin is configured', () => {
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', OAUTH)
expect(isAllowedSkvPopupOrigin('https://evil.example', APP)).toBe(false)
})
it('rejects cross-origin messages when no pin is configured', () => {
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', '')
expect(isAllowedSkvPopupOrigin(OAUTH, APP)).toBe(false)
})
it('rejects cross-origin messages when the pin is malformed', () => {
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'not a url')
expect(isAllowedSkvPopupOrigin(OAUTH, APP)).toBe(false)
})
})
+23
View File
@@ -0,0 +1,23 @@
/**
* Origins the Skatteverket OAuth popup may post back from.
*
* The OAuth callback is served from the host pinned by
* NEXT_PUBLIC_SKV_OAUTH_BASE_URL: the redirect_uri registered with
* Skatteverket in Utvecklarportalen, kept on the legacy app.gnubok.se
* domain after the user-facing app moved to app.accounted.se. The panels
* that open the popup therefore accept postMessage events from that origin
* in addition to their own.
*
* Origin alone is never sufficient: callers must also verify that
* event.source is the popup window they themselves opened.
*/
export function isAllowedSkvPopupOrigin(eventOrigin: string, windowOrigin: string): boolean {
if (eventOrigin === windowOrigin) return true
const base = process.env.NEXT_PUBLIC_SKV_OAUTH_BASE_URL
if (!base) return false
try {
return eventOrigin === new URL(base).origin
} catch {
return false
}
}
+23
View File
@@ -68,6 +68,7 @@ const nextConfig: NextConfig = {
optimizePackageImports: ['recharts', 'date-fns', 'framer-motion'],
},
async redirects() {
const appUrlForRedirect = process.env.NEXT_PUBLIC_APP_URL?.trim().replace(/\/$/, '')
return [
{
source: '/nyckeltal',
@@ -93,6 +94,28 @@ const nextConfig: NextConfig = {
destination: 'https://docs.gnubok.se/llms-full.txt',
permanent: true,
},
// Dual-domain cutover (2026-07): the user-facing app moves to
// app.accounted.se; app.gnubok.se stays alive for machine traffic
// (MCP connectors, API keys, the Skatteverket OAuth callback,
// webhooks, crons). Only browser page traffic is forwarded: /api and
// /.well-known must keep answering on the legacy host, and /_next is
// excluded so already-open tabs keep loading assets until their next
// navigation. The redirect arms itself only once NEXT_PUBLIC_APP_URL
// points somewhere other than the legacy host, so merging this is
// inert and the actual cutover is the env flip + redeploy. Kept
// non-permanent until the cutover has soaked.
...(appUrlForRedirect &&
appUrlForRedirect.startsWith('https://') &&
!appUrlForRedirect.includes('app.gnubok.se')
? [
{
source: '/:path((?!api/|\\.well-known/|_next/).*)',
has: [{ type: 'host' as const, value: 'app.gnubok.se' }],
destination: `${appUrlForRedirect}/:path`,
permanent: false,
},
]
: []),
]
},
async headers() {