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
@@ -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)