diff --git a/DECISIONS.md b/DECISIONS.md index c60165ba..90cf4e5a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -244,3 +244,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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 (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. diff --git a/app/.well-known/oauth-authorization-server/route.ts b/app/.well-known/oauth-authorization-server/route.ts index 3e8226d0..c2cc2a72 100644 --- a/app/.well-known/oauth-authorization-server/route.ts +++ b/app/.well-known/oauth-authorization-server/route.ts @@ -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, diff --git a/app/.well-known/oauth-protected-resource/route.ts b/app/.well-known/oauth-protected-resource/route.ts index ff582f5d..e991042a 100644 --- a/app/.well-known/oauth-protected-resource/route.ts +++ b/app/.well-known/oauth-protected-resource/route.ts @@ -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`, diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx index d96a99f0..93577645 100644 --- a/components/salary/AGIPanel.tsx +++ b/components/salary/AGIPanel.tsx @@ -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. diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx index 81353c5d..fc84b654 100644 --- a/components/settings/SkatteverketConnectPanel.tsx +++ b/components/settings/SkatteverketConnectPanel.tsx @@ -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. diff --git a/extensions/general/skatteverket/__tests__/oauth-callback.test.ts b/extensions/general/skatteverket/__tests__/oauth-callback.test.ts index 6c32e9cf..6c073380 100644 --- a/extensions/general/skatteverket/__tests__/oauth-callback.test.ts +++ b/extensions/general/skatteverket/__tests__/oauth-callback.test.ts @@ -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 = {}) { +function makeServiceSupabase(overrides: Record = {}) { const values: Record = { 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 = {}) { 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'), diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index c2417e75..760e36a8 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -187,6 +187,22 @@ async function requireAgiWriteRole(ctx: ExtensionContext): Promise 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 => { + 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) diff --git a/lib/api/v1/__tests__/discovery-base-url.test.ts b/lib/api/v1/__tests__/discovery-base-url.test.ts new file mode 100644 index 00000000..b92ad186 --- /dev/null +++ b/lib/api/v1/__tests__/discovery-base-url.test.ts @@ -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', + ) + }) +}) diff --git a/lib/api/v1/base-url.ts b/lib/api/v1/base-url.ts index 78b6a122..582680c0 100644 --- a/lib/api/v1/base-url.ts +++ b/lib/api/v1/base-url.ts @@ -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 +} diff --git a/lib/skatteverket/__tests__/popup-origin.test.ts b/lib/skatteverket/__tests__/popup-origin.test.ts new file mode 100644 index 00000000..3b841ec3 --- /dev/null +++ b/lib/skatteverket/__tests__/popup-origin.test.ts @@ -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) + }) +}) diff --git a/lib/skatteverket/popup-origin.ts b/lib/skatteverket/popup-origin.ts new file mode 100644 index 00000000..92485c31 --- /dev/null +++ b/lib/skatteverket/popup-origin.ts @@ -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 + } +} diff --git a/next.config.ts b/next.config.ts index 4723feaa..db17959c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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() {