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