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
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user