fix(security): audit remediation 2026-09-01: api_keys identity, viewer gates, OAuth binding, XSS, MFA gate (#2155)

* fix(security): bind api_keys to the caller, lock hash-as-bearer RPCs and provider token tables

Security audit 2026-09-01, critical items.

- api_keys INSERT requires user_id = auth.uid() again (an admin could
  forge a key for any co-member and act as them in every company they
  belong to); SELECT is own-keys-or-admin; a BEFORE trigger freezes the
  identity and credential columns against user-session UPDATEs.
- rotate_mcp_refresh_token and validate_and_increment_api_key become
  service_role only: they match rows by a presented SHA-256, so a hash
  readable by co-members was a bearer credential.
- validate_and_increment_api_key fails closed when the key's user is no
  longer a member of the key's company.
- provider_consent_tokens and provider_otc: the DELETE policies collapsed
  to "caller has any team row" (correlated subquery on a non-existent
  team_members.company_id). All member policies dropped; service_role
  only, matching every existing code path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): role gates, ownership guards and posting integrity in the database

Security audit 2026-09-01, high items at the database layer.

- One table-level guard, enforce_company_writer_role(), blocks the
  read-only viewer role on 55 company-scoped tables including through
  the 15 membership-only SECURITY DEFINER writers. Keyed on the JWT role
  claim so it fires inside definer bodies; no-op for service_role and
  trigger cascades.
- company_members user_id/company_id immutable from user sessions;
  invitations can never grant owner; team_members gains a transition
  guard (admins keep non-owner role moves); companies team_id and
  archiving are owner-only and team attachment needs team membership.
- Direct statements (current_user = authenticated) can no longer insert
  posted headers, add lines under posted verifikat, or post a draft with
  a voucher number the sequence never issued. Sanctioned RPCs run as the
  definer and are untouched; the engine's own draft-then-post shapes
  still pass.
- create_document_version refuses viewers and foreign storage paths;
  validate_version_chain needs membership and loses anon EXECUTE;
  match_documents / match_booking_templates lose anon; cron maintenance
  RPCs become service_role only; the production-only
  seed_asset_categories is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* build: pin tsx as an exact devDependency instead of fetching it with npx at build time

prebuild ran "npx tsx" with no lockfile entry, so every Vercel, Docker
and CI build downloaded tsx@latest and its transitive tree from the
registry with no integrity check, inside the build environment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): refuse the viewer role on API-key and MCP write paths

The v1 wrapper and the MCP company routing checked company membership
but never role, and both run as service role, so a read-only viewer
holding an API key could post vouchers and change settings through the
API. Mutating methods and non-read scopes now return 403 ROLE_READ_ONLY
for viewers on v1; MCP write tools refuse viewers the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): stop serving uploaded SVG, XML and HTML as executable content on the app origin

Uploads persisted the browser-declared mime type and the inline proxy
served it verbatim, sandboxing only text/html; the storage proxy
forwarded the uploader's Content-Type. Any writer, or any Peppol sender,
could plant a scripted SVG or XHTML that executed on app.gnubok.se.

- inline route: allow-list of natively safe types (PDF, raster images)
  served as before; everything else gets the opaque sandbox CSP.
- storage proxy: octet-stream + attachment + sandbox unless the DB
  mime for the key is on the allow-list.
- document-service: the stored mime is the magic-byte validated type.
- logo upload: magic-byte validation, SVG refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): byrå brand logo upload decides the type by magic bytes and drops SVG

Same pattern as the company logo route: the logos bucket is public, so a
scripted SVG (or anything declared as an image) must never land there.
The upload pickers stop advertising SVG.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): bind Enable Banking, Stripe and WooCommerce callbacks to the initiating user

The callbacks resolved the pending row by oauth_state alone, so a
victim who completed an attacker-initiated consent had their bank
account, merchant account or store attached to the attacker's company.
requireFlowInitiator() now requires the cookie session of the user who
started the flow: no session redirects to login with the callback URL
preserved, a different user is refused and nothing is exchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): guard tenant-controlled outbound fetches and surface the disabled rate limiter

WooCommerce and Shopify syncs fetched a member-editable store URL with
plain fetch() and redirect following under the service role, and the
invoice PDF renderer fetched company_settings.logo_url unguarded. All
three go through a new safeFetch() (public-IP validation via url-guard,
https only, redirect: 'manual', body size cap) and re-normalise the
stored host at use time. checkRateLimit() keeps failing open on hosted
but logs one error per process when Upstash is not configured and
exports isRateLimiterConfigured().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): decide the API MFA gate from server-authenticated factors, not the session cookie

getAuthenticatorAssuranceLevel() without arguments derives nextLevel
from session.user.factors, which comes from the unsigned sb-*-auth-token
cookie. Deleting factors from the cookie made an enrolled account look
like it had nothing to step up to, on every /api route and in
requireAuth. Both gates now read factors from the getUser() result or
listFactors() and the level from the verified JWT claim, and fail closed
on errors. Page-branch gate hardened the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): bind Fortnox/Visma, Gmail and Skatteverket callbacks to the initiating user

The arcim-migration callback exchanged the provider code onto whatever
consent the one-time state named, with no check of who completed the
flow and no org-number comparison, so a phished Fortnox admin handed
their ledger to the attacker's company. provider_otc now records the
initiating user (migration 20260902100000); the callback requires that
session and, after the exchange, refuses a provider company whose org
number differs from the consent's company. The Gmail and Skatteverket
callbacks enforce the same initiator check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): BankID signup confirms the email before linking the identity

Signup created an email-confirmed, MFA-exempt account for any address
the caller typed and returned a magic link, so an attacker could
pre-register a victim's email and keep a permanent BankID login into the
account the victim later adopted. The user is now created unconfirmed,
the identity carries email_verified_at NULL (migration 20260902101000),
bankid_linked is not set until the mailed confirmation is clicked, and
BankID login of a pending identity is refused with the confirmation
re-sent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): bind MCP OAuth redirect URIs to the consenting user and cap scopes

A user-registered redirect URI was allowlisted globally, the consent page
named no client, and all scopes were pre-checked, so one phishing link
handed an attacker a full-scope key for the victim's company. Registered
URIs now resolve only for the registrant or a colleague sharing a
company; the consent page shows the client identity and redirect host;
non-built-in clients default to read-only pre-checks; scopes are capped
by the user's role (viewer: read only) at consent and at /token.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(auth): client follow-ups for BankID confirmation, callback mismatch copy and decision log

- register client handles the new confirmation_sent response from BankID
  signup with the existing inbox screen instead of calling verifyOtp.
- BankID login surfaces the email_unconfirmed explanation.
- WooCommerce settings map woocommerce_error=wrong_user to its own copy.
- Logo help text no longer advertises SVG.
- DECISIONS.md records the audit remediation choices.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(mcp-oauth): literal SoD columns in the api_keys insert so the phantom-column scanner resolves them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(logo): type the upload fixtures as Uint8Array<ArrayBuffer> so they are valid BlobParts

Fixes the typecheck ratchet on PR #2155 and ratchets the baseline down
by the one legacy error the change removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 11:38:30 +02:00
committed by GitHub
parent 6e8d76a9cb
commit 18cbc4c30a
92 changed files with 10355 additions and 657 deletions
+7
View File
@@ -1482,4 +1482,11 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-01] ENABLE_BANKING_SANDBOX removed from the enable-banking manifest and the index.ts header (#2131): the variable was declared as optional but never read anywhere; sandbox vs production is decided by ENABLE_BANKING_API_URL (api.tilisy.com vs api.enablebanking.com, api-client.ts derives isSandbox from the host). A dead variable declared in the manifest is what the self-hosting docs would otherwise have copied. The manifest now lists the two optional variables the code actually reads (API_URL, PSU_TYPE); the _PRODUCTION aliases stay undeclared on purpose, they are a hosted Vercel convention, not an operator contract.
[2026-09-01] PR #2130 security-scan round: the register's djuplank is validated (https + skatteverket.se host) before it is returned or navigated to, since the settings page follows it; a contested org number now WITHDRAWS an already-recorded grant nightly (not only blocks new ones), outside the downgrade guards on purpose. NOT done: proof of org-number ownership (Bolagsverket firmatecknare / BankID) before any ombud grant; the org number is tenant-editable across the product (AGI, invoices, årsredovisning) and binding it to a verified identity is a product decision for Emil, tracked as a follow-up rather than declined.
[2026-09-02] Viewer write gate as ONE table-level trigger (enforce_company_writer_role) instead of re-emitting 15 SECURITY DEFINER bodies and ~130 policies: keyed on the JWT role claim so it fires inside definer functions too; no-op for service_role and trigger cascades. agent_conversations/agent_messages and telemetry tables deliberately excluded.
[2026-09-02] Posting-integrity guards key on current_user IN ('anon','authenticated'), not the JWT claim: inside SECURITY DEFINER RPCs current_user is the definer, so commit_journal_entry, SIE import, storno and rättelse keep working while direct PostgREST manipulation of posted vouchers is blocked. Residual: a direct draft->posted flip may still reuse an unused number below the sequence high-water mark.
[2026-09-02] Kept lib/auth/rate-limit-http.ts fail-open on hosted with an error-level log instead of failing closed: production has no Upstash configured, so fail-closed would 503 every rate-limited route until the env is set. Operator action: set UPSTASH_REDIS_REST_URL/TOKEN.
[2026-09-02] api_keys hash columns stay readable to company admins (SELECT restricted to own-or-admin, no column-level REVOKE): app/(dashboard)/page.tsx counts with select('*'), and the hashes are inert now that rotate/validate RPCs are service_role only.
[2026-09-02] arcim-migration callback refuses pre-migration provider_otc rows (user_id NULL) instead of falling back to the old unbound behaviour: the 10-minute state expiry makes the cost at most one retried connect at deploy time.
[2026-09-02] MCP OAuth redirect allowlist: registered URIs resolve with a service-role lookup bound explicitly to the consenting user (registrant or a colleague sharing a company) instead of relying on RLS select_own; DB-registered clients default to read-only pre-checks while the built-in Claude/ChatGPT clients keep the full pre-check; member role keeps approve with the SoD acknowledgement recorded at /token, mirroring settings/api-keys.
[2026-09-02] BankID signup no longer returns a magic link: the account is created unconfirmed and the typed address must click a mailed link before bankid_linked (and the MFA exemption) is set. Chosen over a pending-signup table: one nullable column (bankid_identities.email_verified_at) and the existing auth callback carry the state.
[2026-09-02] Removed the skattekonto drift email (skattekonto.drift_detected event, handler, /api/extensions/skatteverket/skattekonto/drift route, cron hook) instead of fixing it: it alerted on raw saldo-vs-1630 gaps that unbooked rows explain by construction (2026-09-02: Arcim 35 842 kr, 100% explained, while the Hem notice and reconciliation page said nothing was wrong), repeated every 24 h, and was the only surface of a May-2026 feature whose promised dashboard tile was never built. Since 2026-08-25 the reconciliation page and the Hem notice (detectSkvUnexplained, gated on unexplained_difference) are the surface. Considered gating the mail on unexplained_difference + once per episode (built, then dropped): after that gate it only fires on integrity findings the engine itself calls 'never a user task'. skattekonto_drift_tolerance stays (Hem notice reads it); stale skattekonto_drift_last_alert_at rows in extension_data are inert.
@@ -0,0 +1,257 @@
/**
* Pending BankID identities at /auth/callback (security audit 2026-09).
*
* The BankID signup leaves the auth user unconfirmed with
* app_metadata.bankid_pending and a bankid_identities row whose
* email_verified_at is NULL. Clicking the confirmation mail lands here: the
* identity is promoted (email_verified_at set, bankid_linked granted, flag
* removed) unless the account was adopted through another credential in the
* meantime, in which case the pending link is revoked instead.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
const verifyOtp = vi.fn()
const exchangeCodeForSession = vi.fn()
const getUserById = vi.fn()
const updateUserById = vi.fn()
/** Every builder call on the service client, in order: { table, method, args }. */
const serviceCalls: Array<{ table: string; method: string; args: unknown[] }> = []
let pendingLookup: { data: unknown; error: unknown } = { data: null, error: null }
let writeResult: { error: unknown } = { error: null }
function serviceChain(table: string): unknown {
const handler: ProxyHandler<object> = {
get(_t, prop) {
if (prop === 'then') {
// select().eq().is().maybeSingle() resolves to the lookup; delete()/
// update() chains resolve to the write result.
const isLookup = serviceCalls.some((c) => c.table === table && c.method === 'select')
&& !serviceCalls.some((c) => c.table === table && (c.method === 'delete' || c.method === 'update'))
return (resolve: (v: unknown) => void) => resolve(isLookup ? pendingLookup : writeResult)
}
return (...args: unknown[]) => {
serviceCalls.push({ table, method: String(prop), args })
return serviceChain(table)
}
},
}
return new Proxy({}, handler)
}
// The SSR client is created twice per confirmation: once with the anon key
// (verifyOtp, getUser) and once with the service-role key (bankid_identities,
// auth.admin). Route by key so the test sees exactly what each one did.
vi.mock('@supabase/ssr', () => ({
createServerClient: vi.fn((_url: string, key: string) => {
if (key === 'service-role-key') {
return {
from: vi.fn((table: string) => serviceChain(table)),
auth: { admin: { getUserById, updateUserById } },
}
}
const chain: Record<string, ReturnType<typeof vi.fn>> = {
select: vi.fn(() => chain),
eq: vi.fn(() => chain),
limit: vi.fn(() => chain),
maybeSingle: vi.fn().mockResolvedValue({ data: { team_id: 'team-1' }, error: null }),
}
return {
auth: {
verifyOtp,
exchangeCodeForSession,
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
mfa: {
getAuthenticatorAssuranceLevel: vi.fn().mockResolvedValue({ data: null }),
},
},
from: vi.fn(() => chain),
rpc: vi.fn(),
}
}),
}))
vi.mock('@/lib/auth/invite-tokens', () => ({ hashInviteToken: vi.fn() }))
vi.mock('@/lib/company/pending-invites', () => ({
acceptPendingTeamInviteByToken: vi.fn().mockResolvedValue({ status: 'invalid' }),
}))
vi.mock('@/lib/company/landing-server', () => ({
resolveLandingDestination: vi.fn().mockResolvedValue('/'),
}))
import { GET } from '../route'
const pendingUser = {
id: 'user-1',
email: 'typed@example.com',
email_confirmed_at: undefined,
identities: [{ provider: 'email' }],
app_metadata: { bankid_pending: true, has_password: false },
}
function request(type: string, next?: string) {
const url = new URL('http://localhost:3000/auth/callback')
url.searchParams.set('token_hash', 'abc')
url.searchParams.set('type', type)
if (next) url.searchParams.set('next', next)
return new NextRequest(url)
}
function identityWrites() {
return serviceCalls.filter((c) => c.table === 'bankid_identities')
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'https://x.supabase.co')
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', 'anon-key')
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'service-role-key')
serviceCalls.length = 0
pendingLookup = { data: { id: 'identity-1' }, error: null }
writeResult = { error: null }
getUserById.mockResolvedValue({ data: { user: pendingUser }, error: null })
updateUserById.mockResolvedValue({ data: {}, error: null })
})
describe('GET /auth/callback: pending BankID identity', () => {
it('promotes the identity when the BankID holder clicks the confirmation mail', async () => {
verifyOtp.mockResolvedValue({ data: { user: pendingUser, session: {} }, error: null })
const response = await GET(request('magiclink'))
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe('http://localhost:3000/')
const writes = identityWrites()
expect(writes.map((c) => c.method)).toEqual([
'select', 'eq', 'is', 'maybeSingle', // pending lookup
'update', 'eq', 'is', // promotion, scoped to this user's unverified row
])
expect(writes[1].args).toEqual(['user_id', 'user-1'])
expect(writes[2].args).toEqual(['email_verified_at', null])
const [payload] = writes[4].args as [Record<string, unknown>]
expect(typeof payload.email_verified_at).toBe('string')
expect(writes[5].args).toEqual(['user_id', 'user-1'])
expect(writes[6].args).toEqual(['email_verified_at', null])
// Only now does the account get the MFA exemption; the flag goes away.
expect(updateUserById).toHaveBeenCalledWith('user-1', {
app_metadata: { has_password: false, bankid_linked: true, bankid_pending: null },
})
})
it('does nothing for a confirmation without the bankid_pending flag (ordinary signups cost nothing)', async () => {
verifyOtp.mockResolvedValue({
data: { user: { id: 'user-1', app_metadata: { provider: 'email' } }, session: {} },
error: null,
})
await GET(request('signup'))
expect(identityWrites()).toHaveLength(0)
expect(getUserById).not.toHaveBeenCalled()
expect(updateUserById).not.toHaveBeenCalled()
})
it('does nothing when the OTP was rejected', async () => {
verifyOtp.mockResolvedValue({ data: { user: null }, error: { message: 'expired' } })
const response = await GET(request('magiclink'))
expect(response.headers.get('location')).toBe(
'http://localhost:3000/login?error=auth_error&flow=signup',
)
expect(identityWrites()).toHaveLength(0)
expect(updateUserById).not.toHaveBeenCalled()
})
it('revokes instead of promoting when the link is a password reset (address owner adopting via forgot-password)', async () => {
verifyOtp.mockResolvedValue({ data: { user: pendingUser, session: {} }, error: null })
const response = await GET(request('recovery', '/reset-password'))
// The recovery flow itself is untouched.
expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password')
const writes = identityWrites()
expect(writes.map((c) => c.method)).toEqual([
'select', 'eq', 'is', 'maybeSingle',
'delete', 'eq', 'is',
])
expect(writes[5].args).toEqual(['user_id', 'user-1'])
expect(writes[6].args).toEqual(['email_verified_at', null])
expect(updateUserById).toHaveBeenCalledWith('user-1', {
app_metadata: { has_password: false, bankid_pending: null },
})
// Never the MFA exemption.
const written = updateUserById.mock.calls[0][1].app_metadata as Record<string, unknown>
expect(written.bankid_linked).toBeUndefined()
})
it('revokes when the account already carries a Google identity, even on a confirmation link', async () => {
// The victim signed in with Google first; the BankID holder's stale
// confirmation click must not attach their BankID to the victim's account.
verifyOtp.mockResolvedValue({ data: { user: pendingUser, session: {} }, error: null })
getUserById.mockResolvedValue({
data: {
user: {
...pendingUser,
identities: [{ provider: 'email' }, { provider: 'google' }],
},
},
error: null,
})
await GET(request('magiclink'))
const writes = identityWrites()
expect(writes.map((c) => c.method)).toContain('delete')
expect(writes.map((c) => c.method)).not.toContain('update')
expect(updateUserById).toHaveBeenCalledWith('user-1', {
app_metadata: { has_password: false, bankid_pending: null },
})
})
it('revokes when the user has set a password themselves', async () => {
verifyOtp.mockResolvedValue({ data: { user: pendingUser, session: {} }, error: null })
getUserById.mockResolvedValue({
data: { user: { ...pendingUser, app_metadata: { bankid_pending: true, has_password: true } } },
error: null,
})
await GET(request('magiclink'))
expect(identityWrites().map((c) => c.method)).toContain('delete')
expect(updateUserById).toHaveBeenCalledWith('user-1', {
app_metadata: { has_password: true, bankid_pending: null },
})
})
it('only clears the stale flag when no pending row is left', async () => {
pendingLookup = { data: null, error: null }
verifyOtp.mockResolvedValue({ data: { user: pendingUser, session: {} }, error: null })
await GET(request('magiclink'))
const methods = identityWrites().map((c) => c.method)
expect(methods).not.toContain('delete')
expect(methods).not.toContain('update')
expect(updateUserById).toHaveBeenCalledWith('user-1', {
app_metadata: { has_password: false, bankid_pending: null },
})
})
it('leaves the identity pending (safe state) and still redirects when the promotion write fails', async () => {
verifyOtp.mockResolvedValue({ data: { user: pendingUser, session: {} }, error: null })
writeResult = { error: { message: 'boom' } }
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const response = await GET(request('magiclink'))
expect(response.status).toBe(307)
expect(updateUserById).not.toHaveBeenCalled()
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
})
})
+122
View File
@@ -20,6 +20,121 @@ function oauthResumePath(next: string): string | null {
return safe.startsWith('/api/mcp-oauth/authorize?') ? safe : null
}
/**
* Mirror of hasForeignCredential in extensions/general/tic/lib/bankid-pending.ts
* (core cannot import from extensions). A non-email identity (Google) or a
* password the user set themselves (`has_password: true`, written only by
* POST /api/account/password) means somebody proved ownership of the address
* by other means than the BankID signup's confirmation mail.
*/
function hasForeignCredential(user: {
identities?: Array<{ provider: string }>
app_metadata?: Record<string, unknown>
}): boolean {
if ((user.identities ?? []).some((identity) => identity.provider !== 'email')) return true
return user.app_metadata?.has_password === true
}
/**
* Pending BankID identities (security audit 2026-09, account pre-hijacking).
*
* A BankID signup (extensions/general/tic, POST /bankid/complete) creates the
* auth user with the typed address UNCONFIRMED, a bankid_identities row with
* email_verified_at NULL, and app_metadata.bankid_pending instead of
* bankid_linked. The confirmation mail it sends lands here, and this is the
* one place that promotes the identity: email_verified_at = now(),
* bankid_linked = true (the MFA exemption in lib/auth/mfa.ts), bankid_pending
* removed. Until then BankID login refuses the identity.
*
* Promotion is refused, and the pending link revoked instead, when the account
* was adopted through another credential in the meantime: this link is a
* password reset (type=recovery, "forgot password" on the address), or the
* user already carries a non-email identity (Google) or a password they set
* themselves. In each case the real owner of the address proved it by other
* means, and the BankID holder who typed that address must not end up with a
* login into their account.
*
* Gated on the bankid_pending flag so the ordinary confirmation and recovery
* paths cost nothing extra. Failures are logged and never block the redirect:
* a pending identity simply stays pending, which is the safe state.
*/
async function reconcilePendingBankIdIdentity(
user: { id: string; app_metadata?: Record<string, unknown> },
type: string,
): Promise<void> {
if (user.app_metadata?.bankid_pending !== true) return
try {
const service = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ cookies: { getAll: () => [], setAll: () => {} } }
)
const { data: pending, error: lookupError } = await service
.from('bankid_identities')
.select('id')
.eq('user_id', user.id)
.is('email_verified_at', null)
.maybeSingle()
if (lookupError) {
console.error('[auth/callback] pending BankID lookup failed:', lookupError.message)
return
}
// Fresh, authoritative copy: identities and app_metadata as of now.
const { data: userData, error: userError } = await service.auth.admin.getUserById(user.id)
const current = userData?.user
if (userError || !current) {
console.error('[auth/callback] pending BankID user lookup failed:', userError?.message)
return
}
const prior = current.app_metadata ?? {}
if (!pending || type === 'recovery' || hasForeignCredential(current)) {
// Adopted, or nothing left to promote: drop the unverified link and the
// flag. bankid_linked is untouched (a pending signup never set it).
// null removes the key under GoTrue's merge semantics and is falsy if
// app_metadata is ever replaced wholesale instead.
if (pending) {
const { error: deleteError } = await service
.from('bankid_identities')
.delete()
.eq('user_id', user.id)
.is('email_verified_at', null)
if (deleteError) {
console.error('[auth/callback] pending BankID revoke failed:', deleteError.message)
return
}
console.warn(
'[auth/callback] pending BankID identity revoked: account adopted through another credential',
{ userId: user.id, type },
)
}
await service.auth.admin.updateUserById(user.id, {
app_metadata: { ...prior, bankid_pending: null },
})
return
}
// The click proves the address for the BankID holder who typed it.
const { error: promoteError } = await service
.from('bankid_identities')
.update({ email_verified_at: new Date().toISOString() })
.eq('user_id', user.id)
.is('email_verified_at', null)
if (promoteError) {
console.error('[auth/callback] pending BankID promotion failed:', promoteError.message)
return
}
await service.auth.admin.updateUserById(user.id, {
app_metadata: { ...prior, bankid_linked: true, bankid_pending: null },
})
} catch (err) {
console.error('[auth/callback] pending BankID reconciliation failed:', err)
}
}
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
@@ -88,6 +203,13 @@ export async function GET(request: NextRequest) {
return response
}
// A BankID signup proves its address through this very link; a password
// reset on that address proves the opposite. Runs before the recovery
// early-return below so both outcomes are handled here.
if (!error && data?.user) {
await reconcilePendingBankIdIdentity(data.user, type)
}
authenticated = !error
}
+6
View File
@@ -255,6 +255,12 @@ export function LoginClient({
return
}
if (result.error === 'email_unconfirmed') {
// The BankID identity exists but its e-mail was never confirmed: the
// server re-sent the confirmation mail and explains what to do.
setFormError({ kind: 'bankid', message: result.message ?? tAuth('bankid_email_unconfirmed') })
return
}
if (result.error) {
setFormError({ kind: 'bankid', message: tAuth('login_failed_bankid') })
return
+9
View File
@@ -246,6 +246,15 @@ export function RegisterClient({ authSettings }: { authSettings: GoTrueAuthSetti
return
}
// Since 20260902101000 a BankID signup confirms the typed e-mail before
// the identity is linked: no session is minted here, the user follows
// the mailed link. Same inbox screen as the e-mail signup path.
if (json.data?.status === 'confirmation_sent') {
setEmail(json.data.email ?? emailValue)
setIsRegistered(true)
return
}
// Exchange token hash for Supabase session
const { error } = await supabase.auth.verifyOtp({
token_hash: json.data.tokenHash,
@@ -28,7 +28,7 @@ export async function GET(request: Request) {
// and Codex send an HTTPS URL as client_id, and the spec then expects the
// authorization server to fetch that document and match redirect_uri
// exactly against its redirect_uris. Our authorize endpoint validates
// redirect_uri against the global allowlist only (lib/auth/oauth-allowlist.ts)
// redirect_uri against the user-bound allowlist (lib/auth/oauth-allowlist.ts)
// and never fetches client metadata, so advertising CIMD would claim a
// check we do not perform. The stateless register endpoint makes DCR
// free for us, so nothing is lost by waiting: add the flag together with
@@ -67,8 +67,17 @@ function ownerMembership() {
getByraMembershipMock.mockResolvedValue({ teamId: 'team-1', teamName: 'Siffra', role: 'owner' })
}
function uploadRequest(type = 'image/png', size = 128): Request {
const file = new File([new Uint8Array(size)], 'logo.png', { type })
// The route decides the type by magic bytes (never by the declared type), so
// the default fixture is a real PNG signature padded to `size`.
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
function fixtureBytes(magic: number[] | null, size: number): Uint8Array<ArrayBuffer> {
const bytes = new Uint8Array(new ArrayBuffer(Math.max(size, magic?.length ?? 0)))
if (magic) bytes.set(magic, 0)
return bytes
}
function uploadRequest(type = 'image/png', size = 128, magic: number[] | null = PNG_MAGIC): Request {
const file = new File([fixtureBytes(magic, size)], 'logo.png', { type })
const formData = new FormData()
formData.append('file', file)
return new Request('http://localhost/api/byra/brand/logo', { method: 'POST', body: formData })
@@ -122,7 +131,7 @@ describe('POST /api/byra/brand/logo', () => {
it('returns 400 for a disallowed file type', async () => {
authed()
ownerMembership()
const res = await POST(uploadRequest('application/pdf'))
const res = await POST(uploadRequest('application/pdf', 128, null))
expect(res.status).toBe(400)
})
@@ -177,4 +186,12 @@ describe('DELETE /api/byra/brand/logo', () => {
expect(updateMock).toHaveBeenCalledWith({ logo_url: null })
expect(clearBrandCacheMock).toHaveBeenCalled()
})
it('refuses an SVG even when declared as image/png (magic bytes decide)', async () => {
const svg = Array.from(new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"><script>1</script></svg>'))
const response = await POST(uploadRequest('image/png', svg.length, svg))
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toBe('Otillåten filtyp. Tillåtna: PNG, JPG, WebP.')
})
})
+17 -14
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { detectFileMagic } from '@/lib/core/documents/document-service'
import { requireAuth } from '@/lib/auth/require-auth'
import { createServiceClient } from '@/lib/supabase/server'
import { requireByraBrandAccess } from '@/lib/byra/brand-access'
@@ -23,7 +24,14 @@ import { LOGO_UPLOAD_MAX_BYTES, LOGO_UPLOAD_MAX_MB } from '@/lib/invoices/brandi
* instance that handled the write.
*/
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
// Raster formats only, decided by the file's magic bytes (detectFileMagic),
// never by the client-declared Content-Type: the logos bucket is PUBLIC and a
// scripted SVG on a public URL is a script-capable document.
const LOGO_TYPE_EXTENSIONS: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
}
async function purgeLogoFiles(
serviceClient: ReturnType<typeof createServiceClient>,
@@ -60,12 +68,6 @@ export async function POST(request: Request) {
if (!file) {
return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' },
{ status: 400 },
)
}
if (file.size > LOGO_UPLOAD_MAX_BYTES) {
return NextResponse.json(
{ error: `Filen är för stor (max ${LOGO_UPLOAD_MAX_MB} MB).` },
@@ -74,20 +76,21 @@ export async function POST(request: Request) {
}
const buffer = Buffer.from(await file.arrayBuffer())
const mimeToExt: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/svg+xml': 'svg',
'image/webp': 'webp',
const detectedType = detectFileMagic(new Uint8Array(buffer))
const ext = detectedType ? LOGO_TYPE_EXTENSIONS[detectedType] : undefined
if (!detectedType || !ext) {
return NextResponse.json(
{ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, WebP.' },
{ status: 400 },
)
}
const ext = mimeToExt[file.type] ?? 'png'
const storagePath = `byra/${teamId}/logo-${Date.now()}.${ext}`
await purgeLogoFiles(serviceClient, teamId)
const { error: uploadError } = await serviceClient.storage
.from('logos')
.upload(storagePath, buffer, { contentType: file.type, upsert: true })
.upload(storagePath, buffer, { contentType: detectedType, upsert: true })
if (uploadError) {
return NextResponse.json(
{ error: `Uppladdning misslyckades: ${getUserErrorMessage(uploadError)}` },
@@ -110,7 +110,19 @@ describe('GET /api/documents/[id]/inline', () => {
expect(disposition).toContain('filename="kvitto f_rvaring.pdf"')
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
// The mail-body CSP is HTML-only: it must not restrict PDF rendering.
// PDF is natively inline-safe: the sandboxing CSP would break Chrome's
// built-in viewer, so it must be absent here.
expect(res.headers.get('Content-Security-Policy')).toBeNull()
})
it('serves raster images without the sandboxing CSP', async () => {
enqueue({ data: makeDoc({ file_name: 'kvitto.png', mime_type: 'image/png' }), error: null })
downloadMock.mockResolvedValue({ data: new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])]), error: null })
const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' }))
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('image/png')
expect(res.headers.get('Content-Security-Policy')).toBeNull()
})
@@ -137,4 +149,72 @@ describe('GET /api/documents/[id]/inline', () => {
)
expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff')
})
// Allow-list, not deny-list: every type outside PDF/raster images is
// uploader-controlled active content on this origin and must be sandboxed,
// while still rendering inline (Peppol XML archives, iXBRL, JSON previews).
it.each([
['application/xml', 'peppol-faktura.xml', '<?xml version="1.0"?><Invoice/>'],
['text/xml', 'peppol-faktura.xml', '<?xml version="1.0"?><Invoice/>'],
[
'application/xhtml+xml',
'arsredovisning.xhtml',
'<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script></html>',
],
[
'image/svg+xml',
'logga.svg',
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.cookie)</script></svg>',
],
['application/json', 'psd2-svar.json', '{"transactions":[]}'],
])('serves %s inline but under the sandboxing CSP', async (mimeType, fileName, body) => {
enqueue({ data: makeDoc({ file_name: fileName, mime_type: mimeType }), error: null })
downloadMock.mockResolvedValue({ data: new Blob([body]), error: null })
const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' }))
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe(mimeType)
expect(res.headers.get('Content-Disposition')).toContain('inline')
expect(res.headers.get('Content-Security-Policy')).toBe(
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:",
)
expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff')
})
it('sandboxes a legacy row whose type is spoofed with casing or parameters', async () => {
enqueue({
data: makeDoc({ file_name: 'faktura.html', mime_type: 'TEXT/HTML; charset=utf-8' }),
error: null,
})
const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' }))
expect(res.status).toBe(200)
expect(res.headers.get('Content-Security-Policy')).toBe(
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:",
)
})
it('sandboxes an unknown type the extension fallback cannot resolve', async () => {
enqueue({ data: makeDoc({ file_name: 'underlag.bin', mime_type: null }), error: null })
const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' }))
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/octet-stream')
expect(res.headers.get('Content-Security-Policy')).toBe(
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:",
)
})
it('keeps the extension fallback for legacy PDF rows without adding the CSP', async () => {
enqueue({ data: makeDoc({ file_name: 'kvitto.pdf', mime_type: null }), error: null })
const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' }))
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Content-Security-Policy')).toBeNull()
})
})
+20 -14
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { createServiceClient } from '@/lib/supabase/server'
import { contentDisposition } from '@/lib/api/content-disposition'
import { withRouteContext } from '@/lib/api/with-route-context'
import { OPAQUE_DOCUMENT_CSP, inlineSafeMimeType } from '@/lib/core/documents/storage-proxy'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
@@ -18,6 +19,14 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
* Defense in depth: the user's cookie-bound client authorizes access
* (RLS + explicit company_id filter) before the service-role client
* fetches the file from the non-public `documents` bucket.
*
* Content types are served on an allow-list basis: only the natively
* inline-safe types (INLINE_SAFE_MIME_TYPES: PDF and raster images) render
* with the app origin's authority. Every other resolved type is served
* under OPAQUE_DOCUMENT_CSP, which keeps it opaque-origin and script-free
* while still letting the preview render (HTML mail bodies, Peppol XML,
* iXBRL). The mime_type column was client-declared for legacy rows, so the
* decision cannot trust it beyond membership in the allow-list.
*/
const EXTENSION_MIME_MAP: Record<string, string> = {
@@ -92,20 +101,17 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
// content. Without nosniff a tampered file_name extension could
// serve a stored document under an attacker-chosen MIME type.
'X-Content-Type-Options': 'nosniff',
// text/html documents are attacker-controlled mail bodies from the
// invoice inbox. Served inline on the app origin they would execute
// scripts with our origin's authority: CSP sandbox (no tokens) makes
// the rendered document opaque-origin and script-free wherever it is
// opened, iframe or direct tab. The source policy blocks outbound
// requests on top of that: sandbox alone still loads remote images,
// so a tracking pixel would notify the sender when the preview is
// opened. Inline styles and embedded data:/blob: images keep working.
...(contentType === 'text/html'
? {
'Content-Security-Policy':
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:",
}
: {}),
// Allow-list, not deny-list: anything that is not a natively
// inline-safe type (text/html mail bodies, XHTML, XML, SVG, JSON,
// unknown or legacy types) is uploader-controlled active content
// when rendered on this origin. The sandboxing policy neutralises
// scripts and outbound requests for all of them while the preview
// keeps rendering; see OPAQUE_DOCUMENT_CSP. PDF and raster images
// are exempt because the directive would also break Chrome's
// built-in PDF viewer (it renders through an internal <embed>).
...(inlineSafeMimeType(contentType)
? {}
: { 'Content-Security-Policy': OPAQUE_DOCUMENT_CSP }),
},
})
},
@@ -9,15 +9,31 @@ vi.mock('@/extensions/general/enable-banking/lib/api-client', () => ({
}))
// Use hoisted to safely create mock objects referenced in vi.mock factories
const { mockFrom, mockUpsertFromPsd2, mockAllocate, mockSupersede, mockCrossCompanyContext } =
vi.hoisted(() => {
const mockFrom = vi.fn()
const mockUpsertFromPsd2 = vi.fn()
const mockAllocate = vi.fn()
const mockSupersede = vi.fn()
const mockCrossCompanyContext = vi.fn()
return { mockFrom, mockUpsertFromPsd2, mockAllocate, mockSupersede, mockCrossCompanyContext }
})
const {
mockFrom,
mockUpsertFromPsd2,
mockAllocate,
mockSupersede,
mockCrossCompanyContext,
mockGetUser,
} = vi.hoisted(() => {
const mockFrom = vi.fn()
const mockUpsertFromPsd2 = vi.fn()
const mockAllocate = vi.fn()
const mockSupersede = vi.fn()
const mockCrossCompanyContext = vi.fn()
// The cookie session the callback binds the completion to. Every pending
// row in this suite belongs to 'user-1', so that is the default session.
const mockGetUser = vi.fn()
return {
mockFrom,
mockUpsertFromPsd2,
mockAllocate,
mockSupersede,
mockCrossCompanyContext,
mockGetUser,
}
})
// The supersede pass has its own unit tests (extensions/general/enable-banking/
// __tests__/supersede.test.ts); here it is mocked so these tests assert the
@@ -43,6 +59,9 @@ vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn().mockResolvedValue({
from: mockFrom,
}),
createClient: vi.fn().mockResolvedValue({
auth: { getUser: mockGetUser },
}),
}))
const CURRENCY_DEFAULTS: Record<string, string> = {
@@ -104,6 +123,7 @@ function mockChain(result: { data?: unknown; error?: unknown }) {
describe('GET /api/extensions/enable-banking/callback', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null })
mockUpsertFromPsd2.mockResolvedValue(undefined)
mockSupersede.mockResolvedValue({ supersededIds: [], dedupScopeByIban: new Map() })
// No sibling company claims anything by default; individual tests override.
@@ -149,6 +169,120 @@ describe('GET /api/extensions/enable-banking/callback', () => {
expect(decodeURIComponent(location)).toContain('Starta bankkopplingen på nytt')
})
// The state token proves the callback belongs to a flow WE started, not that
// the browser completing it is the initiator's. A victim lured into
// approving a consent someone else started must not have their bank
// attached to that someone's company.
describe('initiator binding', () => {
const PENDING_ROW = {
id: 'conn-1',
user_id: 'user-1',
company_id: 'company-1',
bank_name: 'TestBank',
status: 'pending',
session_id: null,
accounts_data: null,
}
it('refuses a consent completed by a different user and leaves the row untouched', async () => {
const chain = mockChain({ data: PENDING_ROW, error: null })
mockFrom.mockReturnValue(chain)
mockGetUser.mockResolvedValue({ data: { user: { id: 'user-2' } }, error: null })
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(307)
const location = new URL(response.headers.get('location') || '')
expect(location.pathname).toBe('/settings/banking')
expect(location.searchParams.get('bank_error')).toContain('annat användarkonto')
expect(location.searchParams.get('bank_name')).toBe('TestBank')
expect(location.searchParams.has('select_accounts')).toBe(false)
// The code was never exchanged and nothing was written: the row keeps
// waiting for its initiator (only the lookup touched the table).
expect(mockCreateSession).not.toHaveBeenCalled()
expect(mockFrom).toHaveBeenCalledTimes(1)
expect(chain.update).not.toHaveBeenCalled()
expect(chain.delete).not.toHaveBeenCalled()
expect(mockUpsertFromPsd2).not.toHaveBeenCalled()
})
it('sends an anonymous browser to /login with the callback URL preserved', async () => {
const chain = mockChain({ data: PENDING_ROW, error: null })
mockFrom.mockReturnValue(chain)
mockGetUser.mockResolvedValue({ data: { user: null }, error: null })
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(307)
const location = new URL(response.headers.get('location') || '')
expect(location.origin).toBe('http://localhost:3000')
expect(location.pathname).toBe('/login')
expect(location.searchParams.get('next')).toBe(
'/api/extensions/enable-banking/callback?code=auth-code&state=valid-state',
)
expect(mockCreateSession).not.toHaveBeenCalled()
expect(chain.update).not.toHaveBeenCalled()
expect(chain.delete).not.toHaveBeenCalled()
})
it('finalizes as before when the session belongs to the initiator', async () => {
// mockConnectionFlow is the suite's standard script for the finalize
// path (function declaration below, hoisted into this scope).
mockConnectionFlow(PENDING_ROW)
mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null })
mockCreateSession.mockResolvedValue({
session_id: 'sess-1',
accounts: [],
access: { valid_until: '2027-12-31T00:00:00Z' },
aspsp: { name: 'TestBank', country: 'SE' },
})
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(200)
expect(mockGetUser).toHaveBeenCalledTimes(1)
expect(mockCreateSession).toHaveBeenCalledWith('auth-code', undefined)
expect(await response.text()).toContain('select_accounts=conn-1')
})
it('does not consult the session for an unknown state (nothing to bind to)', async () => {
mockFrom.mockImplementation(() => mockChain({ data: null, error: { message: 'not found' } }))
await GET(makeRequest({ code: 'auth-code', state: 'unknown-state' }))
expect(mockGetUser).not.toHaveBeenCalled()
})
it('leaves the hosted connector bounce alone (server-to-server, HMAC-verified)', async () => {
// The hosted proxy callback never finalizes anything: it verifies the
// signed connector state and bounces the browser to the instance, whose
// own callback then runs the binding against ITS session.
vi.stubEnv('CONNECTOR_STATE_SECRET', 'test-connector-secret')
const { signConnectorState } = await import('@/lib/connect/hosted/state')
const connectorState = signConnectorState({
kid: 'key-1',
svc: 'bank',
ret: 'https://instance.example.se/api/extensions/enable-banking/callback',
st: 'instance-state',
cref: 'company-ref',
})
const response = await GET(makeRequest({ code: 'auth-code', state: connectorState }))
expect(response.status).toBe(307)
const location = new URL(response.headers.get('location') || '')
expect(location.origin).toBe('https://instance.example.se')
expect(location.searchParams.get('state')).toBe('instance-state')
expect(location.searchParams.get('code')).toBe('auth-code')
expect(mockGetUser).not.toHaveBeenCalled()
expect(mockFrom).not.toHaveBeenCalled()
vi.unstubAllEnvs()
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000')
})
})
it('threads connector_state from the query into createSession (connector mode)', async () => {
// In connector mode the hosted callback bounces the browser back here with
// the signed connector_state echoed alongside code + the instance's own
@@ -20,6 +20,10 @@ import { supersedeSiblingConnections } from '@/extensions/general/enable-banking
import { getBankConnectionErrorMessage } from '@/lib/errors/get-error-message'
import { renderFinalizeShell, renderFinalizeRedirect } from './finalize-page'
import { isConnectorState, verifyConnectorState } from '@/lib/connect/hosted/state'
import {
requireFlowInitiator,
FLOW_INITIATOR_MISMATCH_MESSAGE,
} from '@/lib/auth/oauth-flow-binding'
// This route emits bank_connection.consent_granted / .cash_account_mirror_failed
// (ASVS V16 / GDPR Art.30 audit events). ensureInitialized() must run at module
@@ -269,6 +273,31 @@ export async function GET(request: Request) {
)
}
// The state token proves this callback belongs to a flow we started; it
// says nothing about WHO is completing it. Bind the completion to the
// initiator's own cookie session before any finalize work: otherwise a
// victim lured into approving a consent someone else started would have
// their bank accounts attached to that someone's company. The connector
// branch above is exempt on purpose (server-to-server, HMAC-verified).
const initiator = await requireFlowInitiator(request, pendingConnection.user_id, {
flow: 'enable-banking.callback',
})
if (!initiator.ok) {
if (initiator.reason === 'no_session') {
// Session expired mid-flow: sign in and the callback re-runs with the
// same code + state. Nothing on the row changes.
return initiator.response
}
// A different user completed it. Refuse without exchanging the code and
// without touching the row: it keeps waiting for its initiator and the
// stale-pending cleanup reaps it if nobody comes back.
const params = new URLSearchParams({
bank_error: FLOW_INITIATOR_MISMATCH_MESSAGE,
...(pendingConnection.bank_name ? { bank_name: pendingConnection.bank_name } : {}),
})
return NextResponse.redirect(`${baseUrl}/settings/banking?${params.toString()}`)
}
// Kick the finalize work off eagerly, decoupled from the response stream:
// if the user closes the tab mid-stream, the stream is cancelled but this
// promise keeps running, so the session persistence, cash-account mirror
@@ -9,10 +9,16 @@ vi.mock('@/extensions/general/stripe/lib/connect', () => ({
fetchAccountDisplayName: (...args: unknown[]) => mockFetchAccountDisplayName(...args),
}))
const { mockFrom } = vi.hoisted(() => ({ mockFrom: vi.fn() }))
const { mockFrom, mockGetUser } = vi.hoisted(() => ({
mockFrom: vi.fn(),
// The cookie session the callback binds the completion to. Every pending
// row in this suite belongs to 'user-1', so that is the default session.
mockGetUser: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn().mockResolvedValue({ from: mockFrom }),
createClient: vi.fn().mockResolvedValue({ auth: { getUser: mockGetUser } }),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
@@ -50,6 +56,7 @@ describe('GET /api/extensions/stripe/callback', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null })
mockExchangeCodeForAccount.mockResolvedValue({
stripeAccountId: 'acct_123',
livemode: false,
@@ -146,4 +153,64 @@ describe('GET /api/extensions/stripe/callback', () => {
)
expect(mockFrom).not.toHaveBeenCalled()
})
// The state token proves the callback belongs to a flow WE started, not that
// the browser completing it is the initiator's. A victim lured into
// approving a Connect someone else started must not have their Stripe
// account attached to that someone's company.
describe('initiator binding', () => {
const PENDING_ROW = { id: CONNECTION_ID, user_id: 'user-1', company_id: 'company-1' }
it('refuses a consent completed by a different user without burning the code or marking the row', async () => {
const findChain = mockChain({ data: PENDING_ROW })
mockFrom.mockReturnValue(findChain)
mockGetUser.mockResolvedValue({ data: { user: { id: 'user-2' } }, error: null })
const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE }))
expect(response.status).toBe(307)
const location = decodeURIComponent(response.headers.get('location') || '')
expect(location.startsWith('http://localhost:3000/import?mode=stripe&stripe_error=')).toBe(true)
// The panel shows an unknown stripe_error value verbatim, so the param
// carries the Swedish explanation itself.
expect(location).toContain('annat användarkonto')
expect(location).not.toContain('stripe_connected')
// Only the lookup touched the database: no oauth_used_codes insert (the
// code stays valid for the initiator), no update on the row.
expect(mockFrom).toHaveBeenCalledTimes(1)
expect(findChain.insert).not.toHaveBeenCalled()
expect(findChain.update).not.toHaveBeenCalled()
expect(mockExchangeCodeForAccount).not.toHaveBeenCalled()
})
it('sends an anonymous browser to /login with the callback URL preserved', async () => {
const findChain = mockChain({ data: PENDING_ROW })
mockFrom.mockReturnValue(findChain)
mockGetUser.mockResolvedValue({ data: { user: null }, error: null })
const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE }))
expect(response.status).toBe(307)
const location = new URL(response.headers.get('location') || '')
expect(location.origin).toBe('http://localhost:3000')
expect(location.pathname).toBe('/login')
expect(location.searchParams.get('next')).toBe(
`/api/extensions/stripe/callback?code=ac_123&state=${OAUTH_STATE}`,
)
expect(mockFrom).toHaveBeenCalledTimes(1)
expect(findChain.insert).not.toHaveBeenCalled()
expect(findChain.update).not.toHaveBeenCalled()
expect(mockExchangeCodeForAccount).not.toHaveBeenCalled()
})
it('does not consult the session for an unknown state (nothing to bind to)', async () => {
mockFrom.mockReturnValueOnce(mockChain({ data: null, error: { code: 'PGRST116' } }))
await GET(makeRequest({ code: 'ac_123', state: 'unknown-state' }))
expect(mockGetUser).not.toHaveBeenCalled()
})
})
})
@@ -3,6 +3,10 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events/bus'
import { hashAuthCode } from '@/lib/auth/oauth-codes'
import {
requireFlowInitiator,
FLOW_INITIATOR_MISMATCH_MESSAGE,
} from '@/lib/auth/oauth-flow-binding'
import {
exchangeCodeForAccount,
fetchAccountDisplayName,
@@ -90,6 +94,29 @@ export async function GET(request: Request) {
)
}
// The state token proves this callback belongs to a flow we started; it
// says nothing about WHO is completing it. Bind the completion to the
// initiator's own cookie session BEFORE the code is burned below:
// otherwise a victim lured into approving a Stripe Connect someone else
// started would have their Stripe account attached to that someone's
// company. Stripe's redirect is a top-level navigation, so the
// initiator's cookies are present on the legitimate path.
const initiator = await requireFlowInitiator(request, pendingConnection.user_id, {
flow: 'stripe.callback',
})
if (!initiator.ok) {
if (initiator.reason === 'no_session') {
// Session expired mid-flow: sign in and the callback re-runs with
// the same (still unused) code + state. The row is untouched.
return initiator.response
}
// A different user completed it. Refuse without exchanging the code
// and without marking the row: it stays pending for its initiator.
return NextResponse.redirect(
`${returnUrl}&stripe_error=${encodeURIComponent(FLOW_INITIATOR_MISMATCH_MESSAGE)}`,
)
}
// Replay protection (OAuth 2.1 §4.1.2): a code may be exchanged once.
// The PRIMARY KEY on oauth_used_codes rejects a second insert.
const { error: replayError } = await supabase
@@ -150,7 +150,11 @@ describe('POST /api/extensions/woocommerce/callback', () => {
const activation = updates[0][0] as Record<string, string | boolean | null>
expect(activation.status).toBe('active')
expect(activation.transaction_sync_enabled).toBe(true)
expect(activation.oauth_state).toBeNull()
// The state survives activation on purpose: this POST has no browser
// session, so the browser leg (../return) binds the completion to the
// initiator by looking the row up with this same state and consumes it
// there. Replay is blocked by the status 'pending' scope instead.
expect(activation).not.toHaveProperty('oauth_state')
expect(activation.store_name).toBe('Testbutiken')
// Secrets never stored in plaintext, and they decrypt back.
expect(String(activation.consumer_key_encrypted)).not.toContain('ck_new')
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(),
createClient: vi.fn(),
}))
vi.mock('@/lib/extensions/loader', () => ({ loadExtensions: vi.fn() }))
vi.mock('@/lib/extensions/registry', () => ({ extensionRegistry: { get: vi.fn() } }))
import { GET } from '../return/route'
import { createServiceClient, createClient } from '@/lib/supabase/server'
import { extensionRegistry } from '@/lib/extensions/registry'
import { createQueuedMockSupabase } from '@/tests/helpers'
const STATE = '123e4567-e89b-12d3-a456-426614174000'
const BASE = 'http://localhost:3000'
const CONNECTED = `${BASE}/import?mode=woocommerce&woocommerce_connected=true`
function makeReturnRequest(params: Record<string, string>): Request {
const url = new URL(`${BASE}/api/extensions/woocommerce/return`)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
return new Request(url.toString())
}
function mockServiceClient() {
const queued = createQueuedMockSupabase()
vi.mocked(createServiceClient).mockResolvedValue(
queued.supabase as unknown as Awaited<ReturnType<typeof createServiceClient>>,
)
return queued
}
function mockSession(userId: string | null) {
const getUser = vi
.fn()
.mockResolvedValue({ data: { user: userId ? { id: userId } : null }, error: null })
vi.mocked(createClient).mockResolvedValue({ auth: { getUser } } as never)
return getUser
}
const ROW = (status: 'pending' | 'active') => ({
id: 'conn-1',
user_id: 'user-1',
status,
})
describe('GET /api/extensions/woocommerce/return', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', BASE)
vi.mocked(extensionRegistry.get).mockReturnValue(
{ id: 'woocommerce' } as ReturnType<typeof extensionRegistry.get>,
)
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('refuses with 503 when the extension is disabled', async () => {
vi.mocked(extensionRegistry.get).mockReturnValue(undefined)
const res = await GET(makeReturnRequest({ success: '1', user_id: STATE }))
expect(res.status).toBe(503)
})
it('closes the pending row and reports the denial when the store says no', async () => {
const { supabase, enqueue, findCall } = mockServiceClient()
enqueue({ data: null })
const res = await GET(makeReturnRequest({ success: '0', user_id: STATE }))
expect(res.headers.get('location')).toBe(
`${BASE}/import?mode=woocommerce&woocommerce_error=denied`,
)
const update = findCall('woocommerce_connections', 'update')?.[0] as Record<string, unknown>
expect(update).toMatchObject({ status: 'error', oauth_state: null })
expect(supabase.from).toHaveBeenCalledTimes(1)
})
describe('approved leg (success=1)', () => {
it('hands an active row over to its initiator and consumes the state', async () => {
const { enqueue, findCalls } = mockServiceClient()
const getUser = mockSession('user-1')
enqueue({ data: ROW('active') }) // lookup by oauth_state
enqueue({ data: null }) // consume update
const res = await GET(makeReturnRequest({ success: '1', user_id: STATE }))
expect(res.status).toBe(307)
expect(res.headers.get('location')).toBe(CONNECTED)
expect(getUser).toHaveBeenCalledTimes(1)
const updates = findCalls('woocommerce_connections', 'update')
expect(updates).toHaveLength(1)
expect(updates[0][0]).toEqual({ oauth_state: null })
})
it('leaves a still-pending row untouched for the initiator (the callback still needs the state)', async () => {
const { enqueue, findCalls } = mockServiceClient()
mockSession('user-1')
enqueue({ data: ROW('pending') })
const res = await GET(makeReturnRequest({ success: '1', user_id: STATE }))
expect(res.headers.get('location')).toBe(CONNECTED)
expect(findCalls('woocommerce_connections', 'update')).toHaveLength(0)
})
it('revokes an already-activated row when a different user completes the handshake', async () => {
const { enqueue, findCalls, calls } = mockServiceClient()
mockSession('user-2')
enqueue({ data: ROW('active') })
enqueue({ data: null }) // revoke update
const res = await GET(makeReturnRequest({ success: '1', user_id: STATE }))
expect(res.status).toBe(307)
expect(res.headers.get('location')).toBe(
`${BASE}/import?mode=woocommerce&woocommerce_error=wrong_user`,
)
const updates = findCalls('woocommerce_connections', 'update')
expect(updates).toHaveLength(1)
// The store's keys the callback stored for the wrong company are gone,
// the state is consumed, and the row says why.
expect(updates[0][0]).toMatchObject({
status: 'error',
oauth_state: null,
consumer_key_encrypted: null,
consumer_secret_encrypted: null,
})
expect(String((updates[0][0] as Record<string, unknown>).error_message)).toContain(
'annat användarkonto',
)
// Scoped to this row, never a blanket update.
const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args)
expect(eqCalls).toContainEqual(['id', 'conn-1'])
})
it('closes a still-pending row when a different user completes it, so the late callback cannot activate it', async () => {
const { enqueue, findCalls } = mockServiceClient()
mockSession('user-2')
enqueue({ data: ROW('pending') })
enqueue({ data: null })
const res = await GET(makeReturnRequest({ success: '1', user_id: STATE }))
expect(res.headers.get('location')).toBe(
`${BASE}/import?mode=woocommerce&woocommerce_error=wrong_user`,
)
const updates = findCalls('woocommerce_connections', 'update')
expect(updates).toHaveLength(1)
expect(updates[0][0]).toMatchObject({ status: 'error', oauth_state: null })
})
it('sends an anonymous browser to /login with the return URL preserved and touches nothing', async () => {
const { enqueue, findCalls } = mockServiceClient()
mockSession(null)
enqueue({ data: ROW('active') })
const res = await GET(makeReturnRequest({ success: '1', user_id: STATE }))
expect(res.status).toBe(307)
const location = new URL(res.headers.get('location') || '')
expect(location.origin).toBe(BASE)
expect(location.pathname).toBe('/login')
expect(location.searchParams.get('next')).toBe(
`/api/extensions/woocommerce/return?success=1&user_id=${STATE}`,
)
expect(findCalls('woocommerce_connections', 'update')).toHaveLength(0)
})
it('does not consult the session when no row carries the state (already consumed or unknown)', async () => {
const { enqueue, findCalls } = mockServiceClient()
const getUser = mockSession('user-2')
enqueue({ data: null, error: { message: 'no rows', code: 'PGRST116' } })
const res = await GET(makeReturnRequest({ success: '1', user_id: STATE }))
expect(res.headers.get('location')).toBe(CONNECTED)
expect(getUser).not.toHaveBeenCalled()
expect(findCalls('woocommerce_connections', 'update')).toHaveLength(0)
})
it('redirects without a database round trip when the state is missing or not a uuid', async () => {
const { supabase } = mockServiceClient()
const res1 = await GET(makeReturnRequest({ success: '1' }))
const res2 = await GET(makeReturnRequest({ success: '1', user_id: 'not-a-uuid' }))
expect(res1.headers.get('location')).toBe(CONNECTED)
expect(res2.headers.get('location')).toBe(CONNECTED)
expect(supabase.from).not.toHaveBeenCalled()
})
})
})
@@ -132,7 +132,12 @@ export async function POST(request: Request) {
status: 'active',
connected_at: new Date().toISOString(),
error_message: null,
oauth_state: null, // Clear to prevent replay
// oauth_state is deliberately KEPT here. This POST is server-to-server
// (the store calls it, no browser session), so the initiator check has
// to happen on the browser leg (../return), which locates the row by
// this same state and consumes it there. Replay is still blocked: both
// the lookup above and this update are scoped to status 'pending', so
// an active row can never be activated again.
// Feed-only product: connecting the store means fetching its orders, so
// the nightly feed starts on by default; the panel toggle is the opt-out.
transaction_sync_enabled: true,
+108 -3
View File
@@ -3,17 +3,32 @@ import { createServiceClient } from '@/lib/supabase/server'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import { createLogger } from '@/lib/logger'
import {
requireFlowInitiator,
FLOW_INITIATOR_MISMATCH_MESSAGE,
} from '@/lib/auth/oauth-flow-binding'
const log = createLogger('woocommerce/return')
// The state is a UUID we generated; anything else cannot match a row (and the
// column is typed uuid, which would error opaquely on a non-UUID filter).
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* GET /api/extensions/woocommerce/return
*
* Browser leg of the wc-auth handshake: WooCommerce redirects the merchant
* here with ?success=1|0&user_id=<our oauth_state>. The credentials arrive on
* the separate server-to-server callback (usually before this redirect, but
* ordering is not guaranteed), so on success this route only sends the user
* back to the import page; the panel polls /status until the row is active.
* ordering is not guaranteed); the panel polls /status until the row is
* active.
*
* This leg is the only point in the handshake where a browser session is
* present, so it is where the completion is bound to the user who started the
* flow: the callback POST has no cookies (the store calls it) and so leaves
* the oauth_state on the row for this route to verify against and consume.
* Without that binding a victim lured into approving a connect someone else
* started would have their store's orders flowing into that someone's books.
*/
export async function GET(request: Request) {
loadExtensions()
@@ -34,7 +49,7 @@ export async function GET(request: Request) {
const returnUrl = `${baseUrl}/import?mode=woocommerce`
if (success === '1') {
return NextResponse.redirect(`${returnUrl}&woocommerce_connected=true`)
return completeApproved(request, state, returnUrl)
}
// Denied (or malformed): close out the pending row so its state can never
@@ -60,3 +75,93 @@ export async function GET(request: Request) {
return NextResponse.redirect(`${returnUrl}&woocommerce_error=denied`)
}
/**
* The approved leg: find the row this state belongs to, require that the
* browser completing it is the initiator's, then either hand the row over
* (consume the state) or take back what the callback stored.
*/
async function completeApproved(
request: Request,
state: string | null,
returnUrl: string,
): Promise<Response> {
const connectedUrl = `${returnUrl}&woocommerce_connected=true`
if (!state || !UUID_RE.test(state)) {
// Nothing to bind against. The panel polls /status for the truth, and no
// row is finalized by this route on its own.
return NextResponse.redirect(connectedUrl)
}
const supabase = await createServiceClient()
const { data: row, error: findError } = await supabase
.from('woocommerce_connections')
.select('id, user_id, status')
.eq('oauth_state', state)
.in('status', ['pending', 'active'])
.single()
if (findError || !row) {
// Already consumed (a re-visit of the return URL), superseded, or unknown:
// there is nothing left to bind. The panel polls /status for the truth.
return NextResponse.redirect(connectedUrl)
}
const initiator = await requireFlowInitiator(request, row.user_id, {
flow: 'woocommerce.return',
})
if (!initiator.ok) {
if (initiator.reason === 'no_session') {
// Session expired mid-handshake: sign in and this route re-runs with
// the same state. The row is untouched (it still carries the state).
return initiator.response
}
// A different user completed it. The callback POST may already have
// activated the row with the store's keys (it usually lands before this
// redirect), so refusing means taking that back: keys wiped, state
// consumed, row parked in 'error' with the reason. A still-pending row is
// closed the same way so the late callback finds nothing to activate.
const { error: revokeError } = await supabase
.from('woocommerce_connections')
.update({
status: 'error',
error_message: FLOW_INITIATOR_MISMATCH_MESSAGE,
oauth_state: null,
consumer_key_encrypted: null,
consumer_secret_encrypted: null,
})
.eq('id', row.id)
.in('status', ['pending', 'active'])
if (revokeError) {
log.error('failed to revoke connection completed by a non-initiator', {
connectionId: row.id,
code: revokeError.code,
message: revokeError.message,
})
}
return NextResponse.redirect(`${returnUrl}&woocommerce_error=wrong_user`)
}
// Initiator confirmed. An active row has been fully handed over: consume the
// state so the token cannot be presented again. A pending row keeps it: the
// callback POST has not landed yet and still needs it to find the row.
if (row.status === 'active') {
const { error: consumeError } = await supabase
.from('woocommerce_connections')
.update({ oauth_state: null })
.eq('id', row.id)
.eq('status', 'active')
if (consumeError) {
log.warn('failed to consume oauth_state after handover', {
connectionId: row.id,
code: consumeError.code,
message: consumeError.message,
})
}
}
return NextResponse.redirect(connectedUrl)
}
@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import crypto from 'crypto'
/**
* /authorize with the REAL redirect-URI allowlist. The route tests in
* route.test.ts stub resolveRedirectUri; here only the service-role client is
* faked, so the tests prove the phishing fix end to end: a redirect URI that
* some unrelated account registered is refused for this user, a colleague's
* registration is accepted, and built-in Claude never touches the table.
*/
const mocks = vi.hoisted(() => ({
createClient: vi.fn(),
serviceClient: vi.fn(),
getActiveCompanyId: vi.fn(),
getBranding: vi.fn(),
createAuthCode: vi.fn<(...args: unknown[]) => string>(() => 'test-auth-code'),
}))
vi.mock('@/lib/auth/oauth-codes', () => ({
createAuthCode: (...args: unknown[]) => mocks.createAuthCode(...args),
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: () => mocks.createClient(),
}))
// The allowlist imports createServiceClientNoCookies from lib/auth/api-keys
// (relative path); the alias resolves to the same module, so this stub is
// what resolveRedirectUri constructs for its registration lookup.
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
return {
...actual,
createServiceClientNoCookies: () => mocks.serviceClient(),
}
})
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: (...args: unknown[]) => mocks.getActiveCompanyId(...args),
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => mocks.getBranding(),
}))
import { GET, POST } from '../route'
type Row = Record<string, unknown>
/**
* Minimal PostgREST-shaped fake over in-memory tables: eq/is filters are
* applied, everything else is a no-op, and the chain resolves to the filtered
* rows (all rows when awaited, first row via maybeSingle).
*/
function fakeServiceClient(tables: Record<string, Row[]>) {
const from = vi.fn((table: string) => {
const filters: [string, unknown][] = []
const run = () =>
(tables[table] ?? []).filter((row) =>
filters.every(([col, val]) => (val === null ? row[col] == null : row[col] === val)),
)
const chain: Record<string, unknown> = {}
for (const method of ['select', 'order', 'range', 'limit']) chain[method] = () => chain
chain.eq = (col: string, val: unknown) => {
filters.push([col, val])
return chain
}
chain.is = (col: string, val: unknown) => {
filters.push([col, val])
return chain
}
chain.maybeSingle = async () => ({ data: run()[0] ?? null, error: null })
chain.then = (resolve: (v: unknown) => void) => resolve({ data: run(), error: null })
return chain
})
return { from }
}
function userClient(userId: string, role = 'owner') {
const chainFor = (result: { data: unknown; error: unknown }) => {
const chain: Record<string, unknown> = {}
for (const method of ['select', 'eq', 'is', 'order', 'range', 'limit']) chain[method] = () => chain
chain.single = async () => result
chain.maybeSingle = async () => result
chain.then = (resolve: (v: unknown) => void) => resolve(result)
return chain
}
return {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: userId } }, error: null }),
mfa: {
getAuthenticatorAssuranceLevel: vi
.fn()
.mockResolvedValue({ data: { currentLevel: 'aal2', nextLevel: 'aal2' }, error: null }),
listFactors: vi.fn().mockResolvedValue({ data: { totp: [] }, error: null }),
},
},
from: vi.fn((table: string) =>
table === 'company_members'
? chainFor({ data: { role }, error: null })
: chainFor({ data: { company_name: 'Test AB' }, error: null }),
),
}
}
const DB = {
oauth_client_registrations: [
// A colleague of user-1 (both in company-1) registered this one.
{ id: 'reg-1', user_id: 'user-2', client_name: 'Byråns bot', redirect_uri: 'https://app.example.com/cb', revoked_at: null },
// An unrelated account on the same instance registered this one.
{ id: 'reg-2', user_id: 'user-9', client_name: 'Claude (Anthropic)', redirect_uri: 'https://claude-login.example/cb', revoked_at: null },
// user-1's own registration.
{ id: 'reg-3', user_id: 'user-1', client_name: 'Min egen app', redirect_uri: 'https://mine.example/cb', revoked_at: null },
],
company_members: [
{ id: 'm1', user_id: 'user-1', company_id: 'company-1', role: 'owner' },
{ id: 'm2', user_id: 'user-2', company_id: 'company-1', role: 'member' },
{ id: 'm3', user_id: 'user-9', company_id: 'company-9', role: 'owner' },
],
}
function authorizeUrl(redirectUri: string): string {
const url = new URL('http://localhost/api/mcp-oauth/authorize')
url.searchParams.set('response_type', 'code')
url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('code_challenge', 'abc')
url.searchParams.set('code_challenge_method', 'S256')
url.searchParams.set('state', 'xyz')
return url.toString()
}
function consentForm(): FormData {
const key = crypto.createHash('sha256').update('oauth-scope:test-service-key').digest()
const sig = crypto.createHmac('sha256', key).update('').digest('base64url')
const formData = new FormData()
formData.set('consent', 'allow')
formData.set('scope_binding', '')
formData.set('scope_binding_sig', sig)
formData.append('scopes', 'reports:read')
return formData
}
describe('/api/mcp-oauth/authorize with the real redirect-URI allowlist', () => {
let service: ReturnType<typeof fakeServiceClient>
beforeEach(() => {
vi.clearAllMocks()
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
service = fakeServiceClient(DB)
mocks.serviceClient.mockReturnValue(service)
mocks.createClient.mockResolvedValue(userClient('user-1'))
mocks.getActiveCompanyId.mockResolvedValue('company-1')
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
})
it('GET rejects a redirect URI registered by an unrelated user, even one named like Claude', async () => {
const response = await GET(new Request(authorizeUrl('https://claude-login.example/cb')))
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toBe('invalid_request')
expect(body.error_description).toBe('redirect_uri is not allowed')
})
it('POST rejects the same URI and mints no code', async () => {
const response = await POST(
new Request(authorizeUrl('https://claude-login.example/cb'), { method: 'POST', body: consentForm() }),
)
expect(response.status).toBe(400)
expect(mocks.createAuthCode).not.toHaveBeenCalled()
})
it('GET accepts a redirect URI registered by a colleague in a shared company and names it', async () => {
const response = await GET(new Request(authorizeUrl('https://app.example.com/cb')))
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('Byråns bot')
expect(html).toContain('Registrerad av en kollega')
expect(html).toContain('app.example.com')
expect(html).not.toContain('Verifierad')
})
it("GET accepts the consenting user's own registration", async () => {
const response = await GET(new Request(authorizeUrl('https://mine.example/cb')))
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('Min egen app')
expect(html).toContain('Registrerad av dig')
})
it('POST for a colleague registration issues a code to that URI', async () => {
const response = await POST(
new Request(authorizeUrl('https://app.example.com/cb'), { method: 'POST', body: consentForm() }),
)
expect(response.status).toBe(303)
const location = new URL(response.headers.get('location')!)
expect(location.origin).toBe('https://app.example.com')
expect(location.searchParams.get('code')).toBe('test-auth-code')
})
it('GET accepts the built-in Claude callback without consulting the registration table', async () => {
const response = await GET(new Request(authorizeUrl('https://claude.ai/api/mcp/auth_callback')))
expect(response.status).toBe(200)
expect(mocks.serviceClient).not.toHaveBeenCalled()
expect(service.from).not.toHaveBeenCalled()
const html = await response.text()
expect(html).toContain('Claude (Anthropic)')
expect(html).toContain('Verifierad')
})
})
@@ -1,24 +1,32 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
import type { RedirectUriResolution } from '@/lib/auth/oauth-allowlist'
const mocks = vi.hoisted(() => ({
createClient: vi.fn(),
isAllowedRedirectUri: vi.fn(),
resolveRedirectUri: vi.fn(),
getActiveCompanyId: vi.fn(),
getBranding: vi.fn(),
createAuthCode: vi.fn<(...args: unknown[]) => string>(() => 'test-auth-code'),
}))
vi.mock('@/lib/auth/oauth-codes', () => ({
createAuthCode: vi.fn(() => 'test-auth-code'),
createAuthCode: (...args: unknown[]) => mocks.createAuthCode(...args),
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: () => mocks.createClient(),
}))
vi.mock('@/lib/auth/oauth-allowlist', () => ({
isAllowedRedirectUri: (...args: unknown[]) => mocks.isAllowedRedirectUri(...args),
}))
// Only the redirect-URI resolution is replaced: the role cap helpers from the
// same module run for real so the tests exercise the actual ceiling logic.
vi.mock('@/lib/auth/oauth-allowlist', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/oauth-allowlist')>()
return {
...actual,
resolveRedirectUri: (...args: unknown[]) => mocks.resolveRedirectUri(...args),
}
})
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: (...args: unknown[]) => mocks.getActiveCompanyId(...args),
@@ -30,18 +38,50 @@ vi.mock('@/lib/branding/service', () => ({
import { GET, POST } from '../route'
const CLAUDE: RedirectUriResolution = { allowed: true, kind: 'built_in', provider: 'claude' }
const CHATGPT: RedirectUriResolution = { allowed: true, kind: 'built_in', provider: 'chatgpt' }
const REGISTERED: RedirectUriResolution = {
allowed: true,
kind: 'registered',
clientName: 'Byråns bokföringsbot',
registeredByConsentingUser: false,
}
function buildAuthorizeUrl(params: Record<string, string>): string {
const url = new URL('http://localhost/api/mcp-oauth/authorize')
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v))
return url.toString()
}
/**
* Chainable query stub: every builder method returns the chain, and the chain
* resolves to `result` whether awaited directly or via single()/maybeSingle().
*/
function tableChain(result: { data: unknown; error: unknown }) {
const chain: Record<string, unknown> = {}
for (const method of ['select', 'eq', 'is', 'in', 'order', 'range', 'limit']) {
chain[method] = vi.fn(() => chain)
}
chain.single = vi.fn().mockResolvedValue(result)
chain.maybeSingle = vi.fn().mockResolvedValue(result)
chain.then = (resolve: (v: unknown) => void) => resolve(result)
return chain
}
type Membership = { role: string | null } | { error: string }
function buildSupabase(
user: { id: string; email?: string } | null,
companyName = 'Test AB',
aal: { currentLevel: string; nextLevel: string } = { currentLevel: 'aal2', nextLevel: 'aal2' },
verifiedFactors: number = aal.nextLevel === 'aal2' ? 1 : 0,
membership: Membership = { role: 'owner' },
) {
const settingsResult = { data: { company_name: companyName }, error: null }
const membershipResult =
'error' in membership
? { data: null, error: { message: membership.error } }
: { data: membership.role === null ? null : { role: membership.role }, error: null }
return {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user }, error: null }),
@@ -58,25 +98,43 @@ function buildSupabase(
}),
},
},
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { company_name: companyName },
error: null,
}),
}),
}),
}),
from: vi.fn((table: string) =>
table === 'company_members' ? tableChain(membershipResult) : tableChain(settingsResult),
),
}
}
// Mirrors getScopeSigningKey/signScopeBinding in the route so a POST can
// present a scope binding that verifies against the test service key.
function signScope(scopeParam: string): string {
const key = crypto.createHash('sha256').update('oauth-scope:test-service-key').digest()
return crypto.createHmac('sha256', key).update(scopeParam).digest('base64url')
}
function consentForm(scopeParam: string, scopes: string[] = []): FormData {
const formData = new FormData()
formData.set('consent', 'allow')
formData.set('scope_binding', scopeParam)
formData.set('scope_binding_sig', signScope(scopeParam))
for (const s of scopes) formData.append('scopes', s)
return formData
}
function checkboxFor(html: string, scope: string): string | undefined {
return html.match(new RegExp(`<input[^>]*value="${scope}"[^>]*>`))?.[0]
}
function lastMintedPayload(): Record<string, unknown> {
const calls = mocks.createAuthCode.mock.calls as unknown[][]
return calls[calls.length - 1]![0] as Record<string, unknown>
}
describe('GET /api/mcp-oauth/authorize: CSP', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1' }))
mocks.isAllowedRedirectUri.mockResolvedValue(true)
mocks.resolveRedirectUri.mockResolvedValue(CLAUDE)
mocks.getActiveCompanyId.mockResolvedValue('company-1')
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
})
@@ -188,22 +246,12 @@ describe('GET /api/mcp-oauth/authorize: CSP', () => {
// Every row starts checked: the deliberate act is the visible Allow
// click, and unticking stays available per row inside the details fold.
const writeRow = html.match(/<input[^>]*value="transactions:write"[^>]*>/)?.[0]
expect(writeRow).toBeDefined()
expect(writeRow!).toContain('checked')
const approveRow = html.match(/<input[^>]*value="pending_operations:approve"[^>]*>/)?.[0]
expect(approveRow).toBeDefined()
expect(approveRow!).toContain('checked')
const bookkeepingRow = html.match(/<input[^>]*value="bookkeeping:write"[^>]*>/)?.[0]
expect(bookkeepingRow).toBeDefined()
expect(bookkeepingRow!).toContain('checked')
expect(checkboxFor(html, 'transactions:write')).toContain('checked')
expect(checkboxFor(html, 'pending_operations:approve')).toContain('checked')
expect(checkboxFor(html, 'bookkeeping:write')).toContain('checked')
// The :read counterpart is pre-checked too.
const readRow = html.match(/<input[^>]*value="transactions:read"[^>]*>/)?.[0]
expect(readRow).toBeDefined()
expect(readRow!).toContain('checked')
expect(checkboxFor(html, 'transactions:read')).toContain('checked')
})
it('renders only the requested scopes when the client passes them explicitly', async () => {
@@ -229,8 +277,8 @@ describe('GET /api/mcp-oauth/authorize: CSP', () => {
expect(html).not.toContain('value="bookkeeping:write"')
})
it('rejects disallowed redirect_uri before any CSP would be emitted', async () => {
mocks.isAllowedRedirectUri.mockResolvedValue(false)
it('rejects a redirect_uri the allowlist refuses for this user before any CSP would be emitted', async () => {
mocks.resolveRedirectUri.mockResolvedValue({ allowed: false })
const request = new Request(
buildAuthorizeUrl({
response_type: 'code',
@@ -246,6 +294,296 @@ describe('GET /api/mcp-oauth/authorize: CSP', () => {
// untrusted origin. A 400 here keeps the allowlist as the single source
// of truth for which origins can land at this endpoint.
})
it('binds the redirect_uri check to the consenting user on GET and POST', async () => {
// The allowlist can only tell a colleague's registration from a
// stranger's when it knows who is consenting. Both handlers must pass it.
const params = {
response_type: 'code',
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'mcp',
}
await GET(new Request(buildAuthorizeUrl(params)))
expect(mocks.resolveRedirectUri).toHaveBeenLastCalledWith(
'https://claude.ai/api/mcp/auth_callback',
undefined,
{ consentingUserId: 'user-1' },
)
await POST(new Request(buildAuthorizeUrl(params), { method: 'POST', body: consentForm('mcp') }))
expect(mocks.resolveRedirectUri).toHaveBeenLastCalledWith(
'https://claude.ai/api/mcp/auth_callback',
undefined,
{ consentingUserId: 'user-1' },
)
})
})
describe('client identity on the consent page', () => {
const params = {
response_type: 'code',
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'mcp',
}
beforeEach(() => {
vi.clearAllMocks()
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1' }))
mocks.getActiveCompanyId.mockResolvedValue('company-1')
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
})
it('names Claude as a verified client and shows the redirect host', async () => {
mocks.resolveRedirectUri.mockResolvedValue(CLAUDE)
const html = await (await GET(new Request(buildAuthorizeUrl(params)))).text()
expect(html).toContain('Claude (Anthropic)')
expect(html).toContain('Verifierad')
expect(html).toContain('Skickar dig vidare till')
expect(html).toContain('claude.ai')
// The generic "en extern applikation" wording is gone: the client is named.
expect(html).not.toContain('En extern applikation')
})
it('names ChatGPT for chatgpt.com callbacks', async () => {
mocks.resolveRedirectUri.mockResolvedValue(CHATGPT)
const html = await (
await GET(
new Request(
buildAuthorizeUrl({ ...params, redirect_uri: 'https://chatgpt.com/connector/oauth/abc' }),
),
)
).text()
expect(html).toContain('ChatGPT (OpenAI)')
expect(html).toContain('chatgpt.com')
})
it('shows client_name and redirect host for a DB-registered client, never marked verified', async () => {
mocks.resolveRedirectUri.mockResolvedValue(REGISTERED)
const html = await (
await GET(
new Request(buildAuthorizeUrl({ ...params, redirect_uri: 'https://app.example.com/cb' })),
)
).text()
expect(html).toContain('Byråns bokföringsbot')
expect(html).toContain('app.example.com')
expect(html).toContain('Registrerad av en kollega')
expect(html).not.toContain('Verifierad')
expect(html).not.toContain('Claude')
})
it('HTML-escapes a hostile client_name', async () => {
mocks.resolveRedirectUri.mockResolvedValue({
...REGISTERED,
clientName: '<img src=x onerror=alert(1)>Claude (Anthropic)',
registeredByConsentingUser: true,
})
const html = await (
await GET(
new Request(buildAuthorizeUrl({ ...params, redirect_uri: 'https://app.example.com/cb' })),
)
).text()
expect(html).not.toContain('<img src=x')
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;')
expect(html).toContain('Registrerad av dig')
})
})
describe('scope defaults for DB-registered clients', () => {
const params = {
response_type: 'code',
redirect_uri: 'https://app.example.com/cb',
code_challenge: 'abc',
code_challenge_method: 'S256',
}
beforeEach(() => {
vi.clearAllMocks()
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1' }))
mocks.resolveRedirectUri.mockResolvedValue(REGISTERED)
mocks.getActiveCompanyId.mockResolvedValue('company-1')
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
})
it('pre-checks only read scopes when a registered client sends no scope', async () => {
// The ceiling stays ALL_SCOPES so the user can still opt in, but a
// registration is just a URL a member typed into settings: writes and
// approval must be a deliberate tick, never a default.
const html = await (await GET(new Request(buildAuthorizeUrl(params)))).text()
expect(checkboxFor(html, 'transactions:read')).toContain('checked')
expect(checkboxFor(html, 'reports:read')).toContain('checked')
expect(checkboxFor(html, 'transactions:write')).toBeDefined()
expect(checkboxFor(html, 'transactions:write')).not.toContain('checked')
expect(checkboxFor(html, 'bookkeeping:write')).not.toContain('checked')
expect(checkboxFor(html, 'pending_operations:approve')).not.toContain('checked')
expect(checkboxFor(html, 'webhooks:manage')).not.toContain('checked')
expect(html).toContain('Endast läs förvalt')
expect(html).toContain('Endast läsbehörigheter är förvalda')
})
it('pre-checks write scopes only when the registered client explicitly requested them', async () => {
const html = await (
await GET(
new Request(
buildAuthorizeUrl({ ...params, scope: 'transactions:read transactions:write' }),
),
)
).text()
expect(checkboxFor(html, 'transactions:write')).toContain('checked')
expect(checkboxFor(html, 'transactions:read')).toContain('checked')
// Not requested: not even rendered.
expect(html).not.toContain('value="pending_operations:approve"')
})
it('states the segregation-of-duties rule when stage and approve scopes are both on offer', async () => {
const html = await (await GET(new Request(buildAuthorizeUrl(params)))).text()
expect(html).toContain('medgivande')
const readOnly = await (
await GET(new Request(buildAuthorizeUrl({ ...params, scope: 'transactions:read' })))
).text()
expect(readOnly).not.toContain('medgivande')
})
})
describe('role cap on consent', () => {
const params = {
response_type: 'code',
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'mcp',
state: 'xyz',
}
beforeEach(() => {
vi.clearAllMocks()
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
mocks.resolveRedirectUri.mockResolvedValue(CLAUDE)
mocks.getActiveCompanyId.mockResolvedValue('company-1')
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
})
it('viewer: GET offers read scopes only and says why', async () => {
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', undefined, undefined, { role: 'viewer' }),
)
const response = await GET(new Request(buildAuthorizeUrl(params)))
expect(response.status).toBe(200)
const html = await response.text()
expect(checkboxFor(html, 'transactions:read')).toContain('checked')
expect(html).not.toContain('value="transactions:write"')
expect(html).not.toContain('value="pending_operations:approve"')
expect(html).not.toContain('value="webhooks:manage"')
expect(html).toContain('läsare')
})
it('viewer: POST caps a forged write selection to read scopes and records the company', async () => {
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', undefined, undefined, { role: 'viewer' }),
)
const response = await POST(
new Request(buildAuthorizeUrl(params), {
method: 'POST',
body: consentForm('mcp', ['transactions:read', 'transactions:write', 'pending_operations:approve']),
}),
)
expect(response.status).toBe(303)
expect(new URL(response.headers.get('location')!).searchParams.get('code')).toBe('test-auth-code')
const payload = lastMintedPayload()
expect(payload.scopes).toEqual(['transactions:read'])
expect(payload.companyId).toBe('company-1')
expect(payload.userId).toBe('user-1')
})
it('viewer: a write-only client request is bounced with invalid_scope instead of a read grant it never asked for', async () => {
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', undefined, undefined, { role: 'viewer' }),
)
const response = await GET(
new Request(buildAuthorizeUrl({ ...params, scope: 'transactions:write' })),
)
expect(response.status).toBe(303)
const location = new URL(response.headers.get('location')!)
expect(location.origin).toBe('https://claude.ai')
expect(location.searchParams.get('error')).toBe('invalid_scope')
expect(location.searchParams.get('state')).toBe('xyz')
expect(location.searchParams.get('code')).toBeNull()
})
it('member: POST keeps requested write and approve scopes', async () => {
// Mirrors app/api/settings/api-keys: any writer role may hold approve;
// the stage+approve combination is acknowledged, not blocked.
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', undefined, undefined, { role: 'member' }),
)
const response = await POST(
new Request(buildAuthorizeUrl(params), {
method: 'POST',
body: consentForm('mcp', ['transactions:read', 'transactions:write', 'pending_operations:approve']),
}),
)
expect(response.status).toBe(303)
expect(lastMintedPayload().scopes).toEqual([
'transactions:read',
'transactions:write',
'pending_operations:approve',
])
})
it('no membership row: caps to read scopes rather than trusting the form', async () => {
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', undefined, undefined, { role: null }),
)
const response = await POST(
new Request(buildAuthorizeUrl(params), {
method: 'POST',
body: consentForm('mcp', ['reports:read', 'bookkeeping:write']),
}),
)
expect(response.status).toBe(303)
expect(lastMintedPayload().scopes).toEqual(['reports:read'])
})
it('GET fails closed with server_error when the role lookup errors', async () => {
// A transient error must neither widen the grant (treat as owner) nor
// silently downgrade a legitimate connection to read-only.
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', undefined, undefined, { error: 'boom' }),
)
const response = await GET(new Request(buildAuthorizeUrl(params)))
expect(response.status).toBe(500)
expect((await response.json()).error).toBe('server_error')
})
it('POST fails closed with server_error when the role lookup errors', async () => {
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', undefined, undefined, { error: 'boom' }),
)
const response = await POST(
new Request(buildAuthorizeUrl(params), { method: 'POST', body: consentForm('mcp', ['reports:read']) }),
)
expect(response.status).toBe(303)
const location = new URL(response.headers.get('location')!)
expect(location.searchParams.get('error')).toBe('server_error')
expect(location.searchParams.get('code')).toBeNull()
expect(mocks.createAuthCode).not.toHaveBeenCalled()
})
})
describe('MFA step-up on /api/mcp-oauth/authorize', () => {
@@ -267,7 +605,7 @@ describe('MFA step-up on /api/mcp-oauth/authorize', () => {
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
mocks.isAllowedRedirectUri.mockResolvedValue(true)
mocks.resolveRedirectUri.mockResolvedValue(CLAUDE)
mocks.getActiveCompanyId.mockResolvedValue('company-1')
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
})
@@ -407,15 +745,10 @@ describe('account with no company yet (issue #1814)', () => {
state: 'xyz',
}
function signScope(scopeParam: string): string {
const key = crypto.createHash('sha256').update('oauth-scope:test-service-key').digest()
return crypto.createHmac('sha256', key).update(scopeParam).digest('base64url')
}
beforeEach(() => {
vi.clearAllMocks()
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
mocks.isAllowedRedirectUri.mockResolvedValue(true)
mocks.resolveRedirectUri.mockResolvedValue(CLAUDE)
mocks.getActiveCompanyId.mockResolvedValue(null)
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
})
@@ -431,7 +764,7 @@ describe('account with no company yet (issue #1814)', () => {
expect(html).toContain('ny@example.se')
expect(html).toContain('inget företag')
expect(html).not.toContain('Test AB')
// No company to look up: company_settings is never queried.
// No company to look up: neither company_settings nor company_members is queried.
expect(supabase.from).not.toHaveBeenCalled()
})
@@ -441,30 +774,28 @@ describe('account with no company yet (issue #1814)', () => {
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
const html = await response.text()
const companiesWrite = html.match(/<input[^>]*value="companies:write"[^>]*>/)?.[0]
expect(companiesWrite).toBeDefined()
expect(companiesWrite!).toContain('checked')
const transactionsWrite = html.match(/<input[^>]*value="transactions:write"[^>]*>/)?.[0]
expect(transactionsWrite).toBeDefined()
expect(transactionsWrite!).toContain('checked')
expect(checkboxFor(html, 'companies:write')).toContain('checked')
expect(checkboxFor(html, 'transactions:write')).toContain('checked')
})
it('POST still issues an authorization code', async () => {
it('POST still issues an authorization code with no role cap and a null company', async () => {
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1', email: 'ny@example.se' }))
const formData = new FormData()
formData.set('consent', 'allow')
formData.set('scope_binding', 'mcp')
formData.set('scope_binding_sig', signScope('mcp'))
const response = await POST(
new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }),
new Request(buildAuthorizeUrl(authorizeParams), {
method: 'POST',
body: consentForm('mcp', ['companies:write', 'companies:read']),
}),
)
expect(response.status).toBe(303)
const location = new URL(response.headers.get('location')!)
expect(location.searchParams.get('code')).toBe('test-auth-code')
expect(location.searchParams.get('state')).toBe('xyz')
const payload = lastMintedPayload()
expect(payload.companyId).toBeNull()
expect(payload.scopes).toEqual(['companies:write', 'companies:read'])
})
})
@@ -478,19 +809,12 @@ describe('RFC 9207 iss parameter on authorization responses', () => {
state: 'xyz',
}
// Mirrors getScopeSigningKey/signScopeBinding in the route so the POST can
// present a scope binding that verifies against the test service key.
function signScope(scopeParam: string): string {
const key = crypto.createHash('sha256').update('oauth-scope:test-service-key').digest()
return crypto.createHmac('sha256', key).update(scopeParam).digest('base64url')
}
beforeEach(() => {
vi.clearAllMocks()
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test.example')
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1' }))
mocks.isAllowedRedirectUri.mockResolvedValue(true)
mocks.resolveRedirectUri.mockResolvedValue(CLAUDE)
mocks.getActiveCompanyId.mockResolvedValue('company-1')
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
})
@@ -500,13 +824,8 @@ describe('RFC 9207 iss parameter on authorization responses', () => {
})
it('includes iss alongside code and state on the success redirect', async () => {
const formData = new FormData()
formData.set('consent', 'allow')
formData.set('scope_binding', 'mcp')
formData.set('scope_binding_sig', signScope('mcp'))
const response = await POST(
new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }),
new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: consentForm('mcp') }),
)
expect(response.status).toBe(303)
+228 -53
View File
@@ -6,13 +6,19 @@ import { createAuthCode } from '@/lib/auth/oauth-codes'
import { shouldEnforceMfa } from '@/lib/auth/mfa'
import { getActiveCompanyId } from '@/lib/company/context'
import { getBranding } from '@/lib/branding/service'
import { isAllowedRedirectUri } from '@/lib/auth/oauth-allowlist'
import {
capScopesForRole,
lookupCompanyRole,
resolveRedirectUri,
type RedirectUriResolution,
} from '@/lib/auth/oauth-allowlist'
import { resolveDiscoveryBaseUrl } from '@/lib/api/v1/base-url'
import {
ALL_SCOPES,
API_KEY_SCOPES,
DEFAULT_OAUTH_SCOPES,
SCOPE_GROUPS,
findStageApproveConflict,
scopeKind,
validateScopes,
type ApiKeyScope,
@@ -173,9 +179,10 @@ function errorRedirect(request: Request, redirectUri: string, state: string | nu
export async function GET(request: Request) {
const url = new URL(request.url)
const redirectUri = url.searchParams.get('redirect_uri')
// state and code_challenge are carried through to the POST handler via
// the form action's url.search, so we don't read them here: they're only
// validated on POST.
// state is read only to echo it on error redirects issued from GET;
// code_challenge is carried through to the POST handler via the form
// action's url.search and validated there.
const state = url.searchParams.get('state')
const codeChallengeMethod = url.searchParams.get('code_challenge_method') || 'S256'
const responseType = url.searchParams.get('response_type')
const scopeParam = url.searchParams.get('scope')
@@ -222,9 +229,13 @@ export async function GET(request: Request) {
const mfaRedirect = await requireAal2(supabase, user, request)
if (mfaRedirect) return mfaRedirect
// Validate redirect_uri against allowlist (prevents open redirect). Passing
// the authenticated client makes the trust boundary explicit (SOC 2 CC6.1).
if (!(await isAllowedRedirectUri(redirectUri, supabase))) {
// Validate redirect_uri against the allowlist (prevents open redirect) and
// resolve who the client is. DB-registered URIs are bound to the consenting
// user: only their own or a colleague's registration counts, so a stranger
// cannot register a callback and phish consent from every account on the
// instance (SOC 2 CC6.1).
const resolution = await resolveRedirectUri(redirectUri, undefined, { consentingUserId: user.id })
if (!resolution.allowed) {
return NextResponse.json(
{ error: 'invalid_request', error_description: 'redirect_uri is not allowed' },
{ status: 400 }
@@ -238,6 +249,7 @@ export async function GET(request: Request) {
const companyId = await getActiveCompanyId(supabase, user.id)
let companyName: string | null = null
let role: string | null = null
if (companyId) {
const { data: settings } = await supabase
.from('company_settings')
@@ -245,15 +257,41 @@ export async function GET(request: Request) {
.eq('company_id', companyId)
.single()
companyName = settings?.company_name || user.email || null
// The user's role in the company shown on this page caps what the page
// may offer (viewer = read-only). A failed lookup is a hard stop, not a
// silent downgrade or widening.
const lookup = await lookupCompanyRole(supabase, user.id, companyId)
if (lookup.error) {
return NextResponse.json(
{ error: 'server_error', error_description: 'Could not resolve your role in the company' },
{ status: 500 }
)
}
role = lookup.role
}
const appNameLower = escapeHtml(getBranding().appName.toLowerCase())
// Client identity and the host the browser will be sent to after consent.
// Both are shown unconditionally so a look-alike registration cannot pass
// for Claude and the user always sees where the code is going.
const client = describeClient(resolution)
const redirectHost = new URL(redirectUri).host
const clientRowsHtml = `<div class="fact">
<span class="fact-label">Klient</span>
<span class="fact-value">${escapeHtml(client.name)} <span class="fact-tag${client.verified ? ' verified' : ''}">${escapeHtml(client.tag)}</span></span>
</div>
<div class="fact">
<span class="fact-label">Skickar dig vidare till</span>
<span class="fact-value fact-host">${escapeHtml(redirectHost)}</span>
</div>`
const accountRowHtml = companyName
? `<span class="account-label">Företag</span>
<span class="account-name">${escapeHtml(companyName)}</span>`
: `<span class="account-label">Konto</span>
<span class="account-name">${escapeHtml(user.email ?? '')}</span>`
? `<span class="fact-label">Företag</span>
<span class="fact-value">${escapeHtml(companyName)}</span>`
: `<span class="fact-label">Konto</span>
<span class="fact-value">${escapeHtml(user.email ?? '')}</span>`
const noCompanyNoteHtml = companyId
? ''
: `<p class="note">Du har inget företag i ${appNameLower} ännu. Du kan ansluta ändå: skapa företaget i appen så använder anslutningen det automatiskt, utan att du behöver ansluta på nytt.</p>`
@@ -271,25 +309,71 @@ export async function GET(request: Request) {
const scopeBindingValue = scopeParam ?? ''
const scopeBindingSignature = signScopeBinding(scopeBindingValue)
// Two-level model for the consent UI:
// Three inputs shape the consent UI:
//
// - Client requested specific scopes → ceiling = that set, pre-checked =
// that set (RFC 6749 §3.3 strict least-privilege).
// - Client passed no scope (or only the legacy `mcp` marker, Claude's
// connector today) → ceiling = ALL_SCOPES and pre-checked = ALL_SCOPES:
// one-click consent (founder decision 2026-08-26; the read-only default
// killed the agent flow with an insufficient-scope dead-end mid-chat).
// The mitigations that make full-by-default defensible: every write is
// STAGED for explicit approval before anything touches the ledger, the
// full scope list stays on the page (collapsed but expandable) with
// every row untickable, the warn line states the staging rule above the
// button, and the grant is revocable under Inställningar API-nycklar.
// RFC 6749 §3.3 lets the resource owner authorise the set presented;
// the consent is the click on a page that shows exactly that set.
const grantCeiling = new Set<ApiKeyScope>(parsed.scopes ?? ALL_SCOPES)
const preChecked = new Set<ApiKeyScope>(parsed.scopes ?? ALL_SCOPES)
// - Ceiling: the client's requested scopes (RFC 6749 §3.3 strict
// least-privilege), or ALL_SCOPES when it passed none (or only the
// legacy `mcp` marker, Claude's connector today), then capped to what
// the user's role in the selected company permits (viewer = read-only).
// Rows outside the ceiling are not rendered; the POST handler enforces
// the same bound server-side.
// - Pre-checked, built-in client (Claude, ChatGPT, localhost): the whole
// ceiling. One-click consent (founder decision 2026-08-26; the read-only
// default killed the agent flow with an insufficient-scope dead-end
// mid-chat). The mitigations that make full-by-default defensible: every
// write is STAGED for explicit approval before anything touches the
// ledger, the full scope list stays on the page (collapsed but
// expandable) with every row untickable, the warn line states the
// staging rule above the button, and the grant is revocable under
// Inställningar API-nycklar. RFC 6749 §3.3 lets the resource owner
// authorise the set presented; the consent is the click on a page that
// shows exactly that set.
// - Pre-checked, DB-registered client: only what it explicitly asked for,
// or the :read scopes when it asked for nothing. Write and approve
// scopes stay unticked until the user opts in: a registration is just a
// URL some member typed into settings, not a vetted integration.
const clientCeiling: ApiKeyScope[] = parsed.scopes ?? [...ALL_SCOPES]
const roleCapped = companyId ? capScopesForRole(clientCeiling, role) : clientCeiling
if (roleCapped.length === 0) {
return errorRedirect(
request,
redirectUri,
state,
'invalid_scope',
'None of the requested scopes are available to your role in this company'
)
}
const roleLimited = roleCapped.length < clientCeiling.length
const grantCeiling = new Set<ApiKeyScope>(roleCapped)
const preChecked = new Set<ApiKeyScope>(
resolution.kind === 'built_in' || parsed.scopes
? roleCapped
: roleCapped.filter((s) => scopeKind(s) === 'read')
)
const allPreChecked = preChecked.size === grantCeiling.size
const ceilingHasWrite = roleCapped.some((s) => scopeKind(s) === 'write')
const scopeCheckboxesHtml = renderScopeCheckboxes(preChecked, grantCeiling)
const ledeHtml = !ceilingHasWrite
? `${escapeHtml(client.name)} begär läsåtkomst till ditt ${appNameLower}-konto. Inga skrivbehörigheter ingår.`
: allPreChecked
? `${escapeHtml(client.name)} begär åtkomst till ditt ${appNameLower}-konto. Alla behörigheter är förvalda; varje skrivning kräver ändå ditt godkännande innan den bokförs.`
: `${escapeHtml(client.name)} begär åtkomst till ditt ${appNameLower}-konto. Endast läsbehörigheter är förvalda: skrivbehörigheter måste du själv välja nedan, och varje skrivning kräver ändå ditt godkännande innan den bokförs.`
const roleNoteHtml = roleLimited
? `<p class="note">Din roll i företaget är läsare, så bara läsbehörigheter kan ges här.</p>`
: ''
const summaryHintHtml = allPreChecked
? 'Alla förvalda &middot; visa och justera'
: 'Endast läs förvalt &middot; visa och justera'
// Segregation of duties: a key that can both stage and approve lets the
// agent commit bookkeeping without a human review in the app. Mirrors
// app/api/settings/api-keys, where the same combination needs an explicit
// acknowledgement: here the statement sits above the button and the token
// route records the consent click as that acknowledgement.
const sodNoteHtml = findStageApproveConflict(roleCapped)
? ` Ger du både skriv- och godkännandebehörighet kan klienten både förbereda och godkänna bokföring utan din granskning i ${appNameLower}; ditt godkännande här registreras som ett medgivande till det.`
: ''
// Render consent page
const html = `<!DOCTYPE html>
<html lang="sv">
@@ -388,31 +472,59 @@ export async function GET(request: Request) {
line-height: 1.55;
margin-bottom: 1.5rem;
}
.account {
.facts {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0 0.875rem;
margin-bottom: 1.75rem;
}
.fact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 0.875rem;
margin-bottom: 1.75rem;
padding: 0.625rem 0;
}
.account-label {
.fact + .fact { border-top: 1px solid var(--border); }
.fact-label {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-faint);
flex-shrink: 0;
}
.account-name {
.fact-value {
font-size: 0.875rem;
font-weight: 500;
color: var(--fg);
text-align: right;
word-break: break-word;
}
.fact-host {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.8125rem;
}
.fact-tag {
display: inline-block;
margin-left: 0.375rem;
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.0625rem 0.375rem;
border-radius: 4px;
background: var(--secondary);
color: var(--fg-muted);
border: 1px solid var(--border-strong);
vertical-align: middle;
}
.fact-tag.verified {
background: hsl(140 40% 94%);
color: hsl(150 45% 24%);
border-color: hsl(140 35% 78%);
}
.note {
font-size: 0.8125rem;
color: var(--fg-muted);
@@ -636,12 +748,16 @@ export async function GET(request: Request) {
<main class="card" role="main">
<div class="eyebrow">${appNameLower} · mcp</div>
<h1>Anslut MCP-klient</h1>
<p class="lede">En extern applikation begär åtkomst till ditt ${appNameLower}-konto. Alla behörigheter är förvalda; varje skrivning kräver ändå ditt godkännande innan den bokförs.</p>
<p class="lede">${ledeHtml}</p>
<div class="account">
${accountRowHtml}
<div class="facts">
${clientRowsHtml}
<div class="fact">
${accountRowHtml}
</div>
</div>
${noCompanyNoteHtml}
${roleNoteHtml}
<form method="POST" action="${escapeHtml(url.pathname + url.search)}" id="consent-form">
<input type="hidden" name="scope_binding" value="${escapeHtml(scopeBindingValue)}">
@@ -650,7 +766,7 @@ export async function GET(request: Request) {
<details class="scopes-details">
<summary>
<span class="scopes-title">Behörigheter</span>
<span class="scopes-summary-hint">Alla förvalda &middot; visa och justera</span>
<span class="scopes-summary-hint">${summaryHintHtml}</span>
<svg class="scopes-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M6 4l4 4-4 4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</summary>
<div class="scopes-header">
@@ -670,7 +786,7 @@ export async function GET(request: Request) {
<path d="M8 5v3.5" stroke-linecap="round"/>
<circle cx="8" cy="11" r="0.5" fill="currentColor" stroke="none"/>
</svg>
<span>Skrivbehörigheter låter agenten stagea verifikationer, fakturor och löner. Varje skrivoperation kräver ditt godkännande i ${appNameLower} innan den skrivs till databasen.</span>
<span>Skrivbehörigheter låter agenten stagea verifikationer, fakturor och löner. Varje skrivoperation kräver ditt godkännande i ${appNameLower} innan den skrivs till databasen.${sodNoteHtml}</span>
</div>
<div class="actions">
@@ -711,7 +827,7 @@ export async function GET(request: Request) {
// returns a 303 to the OAuth client's callback (e.g. claude.ai), and CSP
// form-action re-checks every hop in the redirect chain. With only 'self'
// the browser would block the post-consent redirect. The origin is safe
// to whitelist here because isAllowedRedirectUri() already gated it above.
// to whitelist here because resolveRedirectUri() already gated it above.
const redirectOrigin = new URL(redirectUri).origin
const csp = [
"default-src 'none'",
@@ -760,19 +876,16 @@ export async function POST(request: Request) {
const mfaRedirect = await requireAal2(supabase, user, request)
if (mfaRedirect) return mfaRedirect
// Pass the authenticated client so the lookup is bound to the same session
// that the consent display ran under (SOC 2 CC6.1).
if (!(await isAllowedRedirectUri(redirectUri, supabase))) {
// Same binding as GET: a DB-registered URI must be the consenting user's own
// or a colleague's registration (SOC 2 CC6.1).
const resolution = await resolveRedirectUri(redirectUri, undefined, { consentingUserId: user.id })
if (!resolution.allowed) {
return NextResponse.json(
{ error: 'invalid_request', error_description: 'redirect_uri is not allowed' },
{ status: 400 }
)
}
// No company check here: the auth code carries only the user id, and the
// token endpoint resolves (or leaves unbound) the company when it mints the
// key. An account without a company may consent (issue #1814).
// Parse form body
const formData = await request.formData()
const consent = formData.get('consent')
@@ -781,6 +894,27 @@ export async function POST(request: Request) {
return errorRedirect(request, redirectUri, state, 'access_denied', 'User denied the request')
}
// The company the consent page showed and the user's role in it. Null for
// an account without a company (issue #1814): consent still goes through
// uncapped and the token endpoint mints the key unbound. The role caps the
// grant below and is re-checked at /token against the same company, which
// travels in the code payload.
const companyId = await getActiveCompanyId(supabase, user.id)
let role: string | null = null
if (companyId) {
const lookup = await lookupCompanyRole(supabase, user.id, companyId)
if (lookup.error) {
return errorRedirect(
request,
redirectUri,
state,
'server_error',
'Could not resolve your role in the company'
)
}
role = lookup.role
}
// Verify the scope binding signed at consent display matches what was
// submitted with the form. This pins the form to the GET that minted it,
// so an attacker who tricks the user into submitting a crafted form can't
@@ -813,7 +947,7 @@ export async function POST(request: Request) {
return errorRedirect(request, redirectUri, state, 'invalid_scope', parsed.description)
}
// The user selects scopes via checkboxes on the consent page. Two upper
// The user selects scopes via checkboxes on the consent page. Three upper
// bounds apply server-side, regardless of what the form posts:
//
// 1. validateScopes drops any value that isn't in API_KEY_SCOPES: guards
@@ -830,14 +964,27 @@ export async function POST(request: Request) {
// resource owner's instructions"). The silent fallback when the
// user selects nothing remains DEFAULT_OAUTH_SCOPES (read-only),
// preserving GDPR Art. 25(2) data-protection-by-default.
// 3. The ceiling is capped to the user's role in the selected company: a
// viewer cannot hand an agent write scopes the viewer does not hold
// themselves, however the form was built.
const submittedScopes = formData.getAll('scopes').filter((s): s is string => typeof s === 'string')
const validated = validateScopes(submittedScopes)
const clientCeiling: ApiKeyScope[] = parsed.scopes ?? [...ALL_SCOPES]
const ceilingSet = new Set<ApiKeyScope>(clientCeiling)
const roleCapped = companyId ? capScopesForRole(clientCeiling, role) : clientCeiling
const ceilingSet = new Set<ApiKeyScope>(roleCapped)
const boundedToClient = (validated ?? []).filter(s => ceilingSet.has(s))
const grantedScopes: ApiKeyScope[] = boundedToClient.length > 0
? boundedToClient
: [...DEFAULT_OAUTH_SCOPES].filter(s => ceilingSet.has(s))
if (grantedScopes.length === 0) {
return errorRedirect(
request,
redirectUri,
state,
'invalid_scope',
'None of the requested scopes are available to your role in this company'
)
}
// Create auth code with userId (NO API key: that's created at /token after PKCE)
const code = createAuthCode({
@@ -845,6 +992,7 @@ export async function POST(request: Request) {
codeChallenge,
redirectUri,
scopes: grantedScopes,
companyId,
})
// Redirect to callback with the code
@@ -865,9 +1013,9 @@ export async function POST(request: Request) {
* Render the scope checkbox UI grouped by domain. Only scopes in `ceiling`
* are surfaced: scopes outside the ceiling are dropped from the consent UI
* so the user can't tick boxes that the POST handler would refuse anyway.
* The ceiling is either the client's `scope` querystring (when specified)
* or DEFAULT_OAUTH_SCOPES (when the client passed no scope), matching the
* server-side enforcement in the POST handler.
* The ceiling is the client's `scope` querystring (or ALL_SCOPES when it
* passed none) capped to the user's role, matching the server-side
* enforcement in the POST handler.
*/
function renderScopeCheckboxes(
preChecked: Set<ApiKeyScope>,
@@ -928,6 +1076,33 @@ function scopeRow(scope: ApiKeyScope, checked: boolean, kind: 'read' | 'write'):
`
}
/**
* Human-readable identity of the client behind an allowed redirect URI, for
* the consent page. Built-in patterns are named after the connector that owns
* the callback host (and marked verified, since only that vendor can receive
* the code there); DB registrations show the name the registering member
* typed in settings, tagged with who registered it, never as verified.
*/
function describeClient(
resolution: Exclude<RedirectUriResolution, { allowed: false }>,
): { name: string; tag: string; verified: boolean } {
if (resolution.kind === 'built_in') {
switch (resolution.provider) {
case 'claude':
return { name: 'Claude (Anthropic)', tag: 'Verifierad', verified: true }
case 'chatgpt':
return { name: 'ChatGPT (OpenAI)', tag: 'Verifierad', verified: true }
case 'local':
return { name: 'Lokal utveckling (localhost)', tag: 'Din egen dator', verified: false }
}
}
return {
name: resolution.clientName,
tag: resolution.registeredByConsentingUser ? 'Registrerad av dig' : 'Registrerad av en kollega',
verified: false,
}
}
/**
* Every interpolation into the consent-page template goes through this,
* including the form's own action attribute (url.pathname + url.search).
+5 -1
View File
@@ -15,10 +15,14 @@ import { truncateIp } from '@/lib/api/v1/with-api-v1'
*
* Security model:
* - Anonymous by design (RFC 7591 §3 allows it); the endpoint does NOT
* write to oauth_client_registrations: only owner/admin users can
* write to oauth_client_registrations: members with a writer role
* insert via /api/settings/oauth-clients. This endpoint just echoes
* a client_id back to callers whose redirect_uris are already on the
* allowlist.
* - With no user session there is nobody to bind a DB registration to, so
* any active registration passes here. That is harmless: a code is only
* ever minted at /authorize, which accepts a registered URI solely for
* the registering user and their colleagues (lib/auth/oauth-allowlist.ts).
* - Per-/24 sliding-window rate-limit prevents the endpoint being used
* as a high-rate oracle for enumerating registered URIs.
* - Error responses are uniform across "built-in", "DB-registered", and
+235 -77
View File
@@ -36,9 +36,32 @@ function formRequest(body: Record<string, string>) {
})
}
const codeExchange = {
grant_type: 'authorization_code',
code: 'ciphertext',
code_verifier: 'verifier',
redirect_uri: 'https://claude.ai/api/cb',
}
/**
* Query results in the order handleAuthorizationCodeGrant issues them:
* used-code insert, expired-code cleanup, (role lookup when a company is
* known), api_keys insert. The role step is skipped for companyless grants.
*/
function exchangeResults(role: { role: string } | null | 'skip' = { role: 'owner' }) {
const results: { data?: unknown; error?: unknown }[] = [
{ data: null, error: null }, // insert into oauth_used_codes
{ data: null, error: null }, // delete expired codes (best-effort)
]
if (role !== 'skip') results.push({ data: role, error: null }) // company_members role
results.push({ data: null, error: null }) // insert into api_keys
return results
}
describe('POST /api/mcp-oauth/token', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getActiveCompanyId.mockResolvedValue('company-1')
})
describe('grant_type validation', () => {
@@ -72,20 +95,9 @@ describe('POST /api/mcp-oauth/token', () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
mocks.supabaseFactory.mockReturnValue(supabase)
enqueueMany([
{ data: null, error: null }, // insert into oauth_used_codes
{ data: null, error: null }, // delete expired codes (best-effort)
{ data: null, error: null }, // insert into api_keys
])
enqueueMany(exchangeResults())
const res = await POST(
formRequest({
grant_type: 'authorization_code',
code: 'ciphertext',
code_verifier: 'verifier',
redirect_uri: 'https://claude.ai/api/cb',
})
)
const res = await POST(formRequest(codeExchange))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.access_token).toMatch(/^gnubok_sk_/)
@@ -97,11 +109,14 @@ describe('POST /api/mcp-oauth/token', () => {
it('mints an unbound key (company_id null) when the user has no company yet', async () => {
// Signup inside the OAuth popup (issue #1814): the account exists, the
// company does not. The key is stored unbound and validateApiKey binds
// it on the first call after the company is created.
// it on the first call after the company is created. No company means
// no role to cap against: the consented scopes go through as-is.
vi.mocked(decryptAuthCode).mockReturnValue({
userId: 'user-1',
codeChallenge: 'challenge',
redirectUri: 'https://claude.ai/api/cb',
scopes: ['companies:read', 'companies:write'],
companyId: null,
exp: Date.now() + 60_000,
})
vi.mocked(verifyPkce).mockReturnValue(true)
@@ -109,28 +124,19 @@ describe('POST /api/mcp-oauth/token', () => {
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
mocks.supabaseFactory.mockReturnValue(supabase)
enqueueMany([
{ data: null, error: null }, // insert into oauth_used_codes
{ data: null, error: null }, // delete expired codes (best-effort)
{ data: null, error: null }, // insert into api_keys
])
enqueueMany(exchangeResults('skip'))
const res = await POST(
formRequest({
grant_type: 'authorization_code',
code: 'ciphertext',
code_verifier: 'verifier',
redirect_uri: 'https://claude.ai/api/cb',
})
)
const res = await POST(formRequest(codeExchange))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.access_token).toMatch(/^gnubok_sk_/)
expect(body.scope).toBe('companies:read companies:write')
const inserted = findCall('api_keys', 'insert')?.[0] as Record<string, unknown>
expect(inserted).toBeDefined()
expect(inserted.user_id).toBe('user-1')
expect(inserted.company_id).toBeNull()
expect(findCall('company_members', 'select')).toBeUndefined()
})
it('rejects an already-used auth code (replay)', async () => {
@@ -146,14 +152,7 @@ describe('POST /api/mcp-oauth/token', () => {
mocks.supabaseFactory.mockReturnValue(supabase)
enqueue({ data: null, error: { message: 'unique violation' } })
const res = await POST(
formRequest({
grant_type: 'authorization_code',
code: 'ciphertext',
code_verifier: 'verifier',
redirect_uri: 'https://claude.ai/api/cb',
})
)
const res = await POST(formRequest(codeExchange))
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toBe('invalid_grant')
@@ -168,14 +167,7 @@ describe('POST /api/mcp-oauth/token', () => {
})
vi.mocked(verifyPkce).mockReturnValue(false)
const res = await POST(
formRequest({
grant_type: 'authorization_code',
code: 'ciphertext',
code_verifier: 'wrong',
redirect_uri: 'https://claude.ai/api/cb',
})
)
const res = await POST(formRequest({ ...codeExchange, code_verifier: 'wrong' }))
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toBe('invalid_grant')
@@ -183,6 +175,180 @@ describe('POST /api/mcp-oauth/token', () => {
})
})
describe('company binding and role cap', () => {
beforeEach(() => {
vi.mocked(verifyPkce).mockReturnValue(true)
})
it('binds the key to the company carried in the code instead of re-resolving the active company', async () => {
// The consent page showed company-7 and capped the grant to the user's
// role there; the key must land on that company, not on whatever the
// user switched to in the meantime.
vi.mocked(decryptAuthCode).mockReturnValue({
userId: 'user-1',
codeChallenge: 'challenge',
redirectUri: 'https://claude.ai/api/cb',
scopes: ['reports:read'],
companyId: 'company-7',
exp: Date.now() + 60_000,
})
const { supabase, enqueueMany, findCall, findCalls } = createQueuedMockSupabase()
mocks.supabaseFactory.mockReturnValue(supabase)
enqueueMany(exchangeResults({ role: 'member' }))
const res = await POST(formRequest(codeExchange))
expect(res.status).toBe(200)
expect(mocks.getActiveCompanyId).not.toHaveBeenCalled()
const inserted = findCall('api_keys', 'insert')?.[0] as Record<string, unknown>
expect(inserted.company_id).toBe('company-7')
// The role lookup ran against that same company.
const eqArgs = findCalls('company_members', 'eq')
expect(eqArgs).toContainEqual(['company_id', 'company-7'])
expect(eqArgs).toContainEqual(['user_id', 'user-1'])
})
it('viewer consent yields a read-only key even when the code carries write scopes', async () => {
vi.mocked(decryptAuthCode).mockReturnValue({
userId: 'user-1',
codeChallenge: 'challenge',
redirectUri: 'https://claude.ai/api/cb',
scopes: ['transactions:read', 'transactions:write', 'pending_operations:approve', 'reports:read'],
companyId: 'company-1',
exp: Date.now() + 60_000,
})
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
mocks.supabaseFactory.mockReturnValue(supabase)
enqueueMany(exchangeResults({ role: 'viewer' }))
const res = await POST(formRequest(codeExchange))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.scope.split(' ').sort()).toEqual(['reports:read', 'transactions:read'])
const inserted = findCall('api_keys', 'insert')?.[0] as Record<string, unknown>
expect(inserted.scopes).toEqual(['transactions:read', 'reports:read'])
expect(inserted.sod_acknowledged_at).toBeNull()
expect(inserted.sod_acknowledged_by).toBeNull()
})
it('viewer whose code carries only write scopes falls back to the read-only defaults', async () => {
vi.mocked(decryptAuthCode).mockReturnValue({
userId: 'user-1',
codeChallenge: 'challenge',
redirectUri: 'https://claude.ai/api/cb',
scopes: ['bookkeeping:write'],
companyId: 'company-1',
exp: Date.now() + 60_000,
})
const { supabase, enqueueMany } = createQueuedMockSupabase()
mocks.supabaseFactory.mockReturnValue(supabase)
enqueueMany(exchangeResults({ role: 'viewer' }))
const res = await POST(formRequest(codeExchange))
expect(res.status).toBe(200)
const granted = (await res.json()).scope.split(' ')
expect(granted).toContain('reports:read')
expect(granted).not.toContain('bookkeeping:write')
expect(granted.every((s: string) => s.endsWith(':read'))).toBe(true)
})
it('membership removed between consent and exchange caps to read-only', async () => {
vi.mocked(decryptAuthCode).mockReturnValue({
userId: 'user-1',
codeChallenge: 'challenge',
redirectUri: 'https://claude.ai/api/cb',
scopes: ['transactions:read', 'transactions:write'],
companyId: 'company-1',
exp: Date.now() + 60_000,
})
const { supabase, enqueueMany } = createQueuedMockSupabase()
mocks.supabaseFactory.mockReturnValue(supabase)
enqueueMany(exchangeResults(null))
const res = await POST(formRequest(codeExchange))
expect(res.status).toBe(200)
expect((await res.json()).scope).toBe('transactions:read')
})
it('records the segregation-of-duties acknowledgement when stage and approve are both granted', async () => {
// Mirrors app/api/settings/api-keys: the combination is allowed for a
// writer role but leaves a durable self-attestation on the key row.
vi.mocked(decryptAuthCode).mockReturnValue({
userId: 'user-1',
codeChallenge: 'challenge',
redirectUri: 'https://claude.ai/api/cb',
scopes: ['transactions:write', 'pending_operations:approve'],
companyId: 'company-1',
exp: Date.now() + 60_000,
})
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
mocks.supabaseFactory.mockReturnValue(supabase)
enqueueMany(exchangeResults({ role: 'member' }))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const res = await POST(formRequest(codeExchange))
warn.mockRestore()
expect(res.status).toBe(200)
const inserted = findCall('api_keys', 'insert')?.[0] as Record<string, unknown>
expect(inserted.scopes).toEqual(['transactions:write', 'pending_operations:approve'])
expect(typeof inserted.sod_acknowledged_at).toBe('string')
expect(inserted.sod_acknowledged_by).toBe('user-1')
})
it('records no acknowledgement for a non-conflicting grant', async () => {
vi.mocked(decryptAuthCode).mockReturnValue({
userId: 'user-1',
codeChallenge: 'challenge',
redirectUri: 'https://claude.ai/api/cb',
scopes: ['transactions:write', 'pending_operations:read'],
companyId: 'company-1',
exp: Date.now() + 60_000,
})
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
mocks.supabaseFactory.mockReturnValue(supabase)
enqueueMany(exchangeResults({ role: 'owner' }))
const res = await POST(formRequest(codeExchange))
expect(res.status).toBe(200)
const inserted = findCall('api_keys', 'insert')?.[0] as Record<string, unknown>
expect(inserted.sod_acknowledged_at).toBeNull()
})
it('returns 500 and mints no key when the role lookup fails', async () => {
vi.mocked(decryptAuthCode).mockReturnValue({
userId: 'user-1',
codeChallenge: 'challenge',
redirectUri: 'https://claude.ai/api/cb',
scopes: ['transactions:read'],
companyId: 'company-1',
exp: Date.now() + 60_000,
})
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
mocks.supabaseFactory.mockReturnValue(supabase)
enqueueMany([
{ data: null, error: null },
{ data: null, error: null },
{ data: null, error: { message: 'connection reset' } },
])
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const res = await POST(formRequest(codeExchange))
error.mockRestore()
expect(res.status).toBe(500)
expect((await res.json()).error).toBe('server_error')
expect(findCall('api_keys', 'insert')).toBeUndefined()
})
})
describe('refresh_token grant', () => {
it('rotates both tokens and returns a fresh access_token', async () => {
const { token: refreshToken } = generateRefreshToken()
@@ -317,20 +483,9 @@ describe('POST /api/mcp-oauth/token', () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
mocks.supabaseFactory.mockReturnValue(supabase)
enqueueMany([
{ data: null, error: null },
{ data: null, error: null },
{ data: null, error: null },
])
enqueueMany(exchangeResults())
const res = await POST(
formRequest({
grant_type: 'authorization_code',
code: 'ciphertext',
code_verifier: 'verifier',
redirect_uri: 'https://claude.ai/api/cb',
})
)
const res = await POST(formRequest(codeExchange))
expect(res.status).toBe(200)
const body = await res.json()
// DEFAULT_OAUTH_SCOPES is read-only by design. Write and approval scopes
@@ -366,25 +521,35 @@ describe('POST /api/mcp-oauth/token', () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
mocks.supabaseFactory.mockReturnValue(supabase)
enqueueMany([
{ data: null, error: null },
{ data: null, error: null },
{ data: null, error: null },
])
enqueueMany(exchangeResults())
const res = await POST(
formRequest({
grant_type: 'authorization_code',
code: 'ciphertext',
code_verifier: 'verifier',
redirect_uri: 'https://claude.ai/api/cb',
})
)
const res = await POST(formRequest(codeExchange))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.scope).toBe('transactions:read invoices:read')
})
it('keeps an owner grant with write scopes intact (no cap for writer roles)', async () => {
vi.mocked(decryptAuthCode).mockReturnValue({
userId: 'user-1',
codeChallenge: 'challenge',
redirectUri: 'https://claude.ai/api/cb',
scopes: ['transactions:read', 'transactions:write', 'bookkeeping:write'],
companyId: 'company-1',
exp: Date.now() + 60_000,
})
vi.mocked(verifyPkce).mockReturnValue(true)
const { supabase, enqueueMany } = createQueuedMockSupabase()
mocks.supabaseFactory.mockReturnValue(supabase)
enqueueMany(exchangeResults({ role: 'owner' }))
const res = await POST(formRequest(codeExchange))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.scope).toBe('transactions:read transactions:write bookkeeping:write')
})
it('rejects a code whose embedded scopes are all unknown', async () => {
// V9.2.1 defense-in-depth: even though /authorize already filters
// unknown scopes, the token endpoint must not silently mint a
@@ -406,14 +571,7 @@ describe('POST /api/mcp-oauth/token', () => {
{ data: null, error: null }, // delete expired codes
])
const res = await POST(
formRequest({
grant_type: 'authorization_code',
code: 'ciphertext',
code_verifier: 'verifier',
redirect_uri: 'https://claude.ai/api/cb',
})
)
const res = await POST(formRequest(codeExchange))
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toBe('invalid_grant')
+51 -4
View File
@@ -7,9 +7,11 @@ import {
hashRefreshToken,
createServiceClientNoCookies,
validateScopes,
findStageApproveConflict,
DEFAULT_OAUTH_SCOPES,
type ApiKeyScope,
} from '@/lib/auth/api-keys'
import { capScopesForRole, lookupCompanyRole } from '@/lib/auth/oauth-allowlist'
import { getActiveCompanyId } from '@/lib/company/context'
const ACCESS_TOKEN_TTL_SECONDS = 3600
@@ -126,10 +128,16 @@ async function handleAuthorizationCodeGrant(params: URLSearchParams) {
.lt('created_at', new Date(Date.now() - 10 * 60 * 1000).toISOString())
.then(() => {})
// null for an account that has no company yet (signed up from the OAuth
// popup, issue #1814). The key is minted unbound; validateApiKey binds it
// to the user's first company on the first call after one exists.
const companyId = await getActiveCompanyId(supabase, payload.userId)
// Bind the key to the company the consent page showed (carried in the code
// payload), so the role cap below is checked against the company the user
// actually consented for. Codes minted before the field existed, and
// companyless consents (signed up from the OAuth popup, issue #1814),
// resolve the active company here instead; null leaves the key unbound and
// validateApiKey binds it on the first call after a company exists.
const companyId =
typeof payload.companyId === 'string' && payload.companyId.length > 0
? payload.companyId
: await getActiveCompanyId(supabase, payload.userId)
const { key, hash, prefix } = generateApiKey()
const refresh = generateRefreshToken()
@@ -155,6 +163,30 @@ async function handleAuthorizationCodeGrant(params: URLSearchParams) {
grantedScopes = DEFAULT_OAUTH_SCOPES
}
// Re-apply the role cap /authorize computed, against the live membership:
// a viewer's key is read-only even if the code payload says otherwise, and
// a role demoted between consent and exchange is honoured. A failed lookup
// is a hard stop rather than a silent downgrade or widening.
if (companyId) {
const lookup = await lookupCompanyRole(supabase, payload.userId, companyId)
if (lookup.error) {
console.error('[mcp-oauth/token] role lookup failed', { message: lookup.error })
return NextResponse.json(
{ error: 'server_error', error_description: 'Failed to resolve company role' },
{ status: 500 }
)
}
const capped = capScopesForRole(grantedScopes, lookup.role)
grantedScopes = capped.length > 0 ? capped : capScopesForRole(DEFAULT_OAUTH_SCOPES, lookup.role)
}
// Segregation of duties, mirrored from app/api/settings/api-keys: a key
// that can both stage and approve is recorded as an acknowledged risk
// acceptance. The consent page states the rule above the Allow button, so
// the consent click is the self-attestation (ASVS V16.1.1 / SOC 2 CC6.1).
const conflictingScope = findStageApproveConflict(grantedScopes)
const sodAcknowledgedAt = conflictingScope ? new Date().toISOString() : null
const { error: insertError } = await supabase
.from('api_keys')
.insert({
@@ -165,6 +197,10 @@ async function handleAuthorizationCodeGrant(params: URLSearchParams) {
name: OAUTH_MCP_KEY_NAME,
scopes: grantedScopes,
refresh_token_hash: refresh.hash,
// Literal keys (null when no conflict): the no-phantom-columns scanner
// resolves object literals only, never spreads.
sod_acknowledged_at: sodAcknowledgedAt,
sod_acknowledged_by: sodAcknowledgedAt ? payload.userId : null,
})
if (insertError) {
@@ -181,6 +217,17 @@ async function handleAuthorizationCodeGrant(params: URLSearchParams) {
)
}
if (conflictingScope) {
// High-risk security event, same shape the manual create route logs.
console.warn('[mcp-oauth/token] api_key.sod_acknowledged', {
keyPrefix: prefix,
conflictingScope,
scopes: grantedScopes,
acknowledgedBy: payload.userId,
companyId,
})
}
return NextResponse.json({
access_token: key,
token_type: 'Bearer',
+106 -26
View File
@@ -19,13 +19,14 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
const logosBucket = {
list: vi.fn().mockResolvedValue({ data: [], error: null }),
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://cdn.example.com/logo.png' } }),
}
const serviceStorage = {
from: vi.fn().mockReturnValue({
list: vi.fn().mockResolvedValue({ data: [], error: null }),
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://cdn.example.com/logo.png' } }),
}),
from: vi.fn().mockReturnValue(logosBucket),
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
@@ -34,16 +35,27 @@ vi.mock('@/lib/supabase/server', () => ({
import { POST } from '../route'
function makeFormRequest(
size = 3,
type = 'image/png',
name = 'logo.png',
): Request {
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
const JPEG_MAGIC = [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]
// RIFF <size> WEBP
const WEBP_MAGIC = [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50]
const SVG_SOURCE = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.cookie)</script></svg>'
/** A buffer of `size` bytes that starts with `magic` (zero-padded). */
function withMagic(magic: number[], size = 64): Uint8Array<ArrayBuffer> {
const bytes = new Uint8Array(new ArrayBuffer(Math.max(size, magic.length)))
bytes.set(magic)
return bytes
}
function makeFormRequest(content: BlobPart, type = 'image/png', name = 'logo.png'): Request {
const fd = new FormData()
fd.append('file', new File([new Uint8Array(size)], name, { type }))
fd.append('file', new File([content], name, { type }))
return new Request('http://localhost/api/settings/logo', { method: 'POST', body: fd })
}
const params = { params: Promise.resolve({}) }
describe('POST /api/settings/logo', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -59,7 +71,7 @@ describe('POST /api/settings/logo', () => {
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(makeFormRequest(), { params: Promise.resolve({}) })
const response = await POST(makeFormRequest(withMagic(PNG_MAGIC)), params)
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
@@ -71,27 +83,70 @@ describe('POST /api/settings/logo', () => {
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const response = await POST(makeFormRequest(), { params: Promise.resolve({}) })
const response = await POST(makeFormRequest(withMagic(PNG_MAGIC)), params)
const { status } = await parseJsonResponse(response)
expect(status).toBe(403)
})
it('returns 400 for an unsupported file type', async () => {
it('returns 400 when no file is attached', async () => {
const fd = new FormData()
const response = await POST(
makeFormRequest(3, 'application/pdf', 'logo.pdf'),
{ params: Promise.resolve({}) },
new Request('http://localhost/api/settings/logo', { method: 'POST', body: fd }),
params,
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('returns 400 when the logo exceeds 10 MB', async () => {
it('returns 400 for an unsupported file type', async () => {
const response = await POST(makeFormRequest(new Uint8Array(3), 'application/pdf', 'logo.pdf'), params)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(logosBucket.upload).not.toHaveBeenCalled()
})
it('refuses SVG even when declared as image/svg+xml (public bucket, script-capable format)', async () => {
const response = await POST(makeFormRequest(SVG_SOURCE, 'image/svg+xml', 'logo.svg'), params)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Otillåten filtyp. Tillåtna: PNG, JPG, WebP.')
expect(logosBucket.upload).not.toHaveBeenCalled()
})
it('refuses SVG bytes smuggled under a declared image/png type', async () => {
const response = await POST(makeFormRequest(SVG_SOURCE, 'image/png', 'logo.png'), params)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('PNG, JPG, WebP')
expect(logosBucket.upload).not.toHaveBeenCalled()
})
it('refuses an HTML document declared as an image', async () => {
const response = await POST(
makeFormRequest(10 * 1024 * 1024 + 1),
{ params: Promise.resolve({}) },
makeFormRequest('<!doctype html><script>alert(1)</script>', 'image/jpeg', 'logo.jpg'),
params,
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(logosBucket.upload).not.toHaveBeenCalled()
})
it('refuses a PDF declared as an image', async () => {
const response = await POST(makeFormRequest('%PDF-1.4\n%%EOF', 'image/png', 'logo.png'), params)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(logosBucket.upload).not.toHaveBeenCalled()
})
it('returns 400 when the logo exceeds 10 MB', async () => {
const response = await POST(makeFormRequest(withMagic(PNG_MAGIC, 10 * 1024 * 1024 + 1)), params)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
@@ -101,22 +156,47 @@ describe('POST /api/settings/logo', () => {
it('accepts a logo larger than the previous 2 MB limit', async () => {
enqueue({ error: null }) // company_settings update
const response = await POST(
makeFormRequest(2 * 1024 * 1024 + 1),
{ params: Promise.resolve({}) },
)
const response = await POST(makeFormRequest(withMagic(PNG_MAGIC, 2 * 1024 * 1024 + 1)), params)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
it('uploads the logo and returns its public url on the happy path', async () => {
it('uploads a PNG under its sniffed type and returns the public url on the happy path', async () => {
enqueue({ error: null }) // company_settings update
const response = await POST(makeFormRequest(), { params: Promise.resolve({}) })
const response = await POST(makeFormRequest(withMagic(PNG_MAGIC)), params)
const { status, body } = await parseJsonResponse<{ data: { logo_url: string } }>(response)
expect(status).toBe(200)
expect(body.data.logo_url).toBe('https://cdn.example.com/logo.png')
expect(logosBucket.upload).toHaveBeenCalledTimes(1)
const [path, , options] = logosBucket.upload.mock.calls[0] as [string, Buffer, { contentType: string }]
expect(path).toMatch(/^company-1\/logo-\d+\.png$/)
expect(options.contentType).toBe('image/png')
})
it('stores the type the bytes prove, not the declared one (JPEG declared as PNG)', async () => {
enqueue({ error: null })
const response = await POST(makeFormRequest(withMagic(JPEG_MAGIC), 'image/png', 'logo.png'), params)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
const [path, , options] = logosBucket.upload.mock.calls[0] as [string, Buffer, { contentType: string }]
expect(path).toMatch(/\.jpg$/)
expect(options.contentType).toBe('image/jpeg')
})
it('accepts WebP by magic bytes', async () => {
enqueue({ error: null })
const response = await POST(makeFormRequest(withMagic(WEBP_MAGIC), 'image/webp', 'logo.webp'), params)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
const [path, , options] = logosBucket.upload.mock.calls[0] as [string, Buffer, { contentType: string }]
expect(path).toMatch(/\.webp$/)
expect(options.contentType).toBe('image/webp')
})
})
+20 -12
View File
@@ -3,8 +3,22 @@ import { createServiceClient } from '@/lib/supabase/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { LOGO_UPLOAD_MAX_BYTES, LOGO_UPLOAD_MAX_MB } from '@/lib/invoices/branding-constants'
import { detectFileMagic } from '@/lib/core/documents/document-service'
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
/**
* Raster formats only, decided by the file's magic bytes (detectFileMagic),
* never by the client-declared Content-Type. The logos bucket is PUBLIC and
* the object is served under the type stored here: an SVG (or an HTML file
* declared as an image) would be a script-capable document on a public URL,
* so SVG is not accepted at all and a declared type that disagrees with the
* bytes is ignored in favour of the bytes.
*/
const LOGO_TYPE_EXTENSIONS: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
}
const LOGO_TYPE_ERROR = 'Otillåten filtyp. Tillåtna: PNG, JPG, WebP.'
export const POST = withRouteContext(
'settings.logo.upload',
@@ -16,22 +30,16 @@ export const POST = withRouteContext(
return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 })
}
if (file.size > LOGO_UPLOAD_MAX_BYTES) {
return NextResponse.json({ error: `Filen är för stor (max ${LOGO_UPLOAD_MAX_MB} MB).` }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
const mimeToExt: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/svg+xml': 'svg',
'image/webp': 'webp',
const detectedType = detectFileMagic(new Uint8Array(buffer))
const ext = detectedType ? LOGO_TYPE_EXTENSIONS[detectedType] : undefined
if (!detectedType || !ext) {
return NextResponse.json({ error: LOGO_TYPE_ERROR }, { status: 400 })
}
const ext = mimeToExt[file.type] ?? 'png'
const storagePath = `${companyId}/logo-${Date.now()}.${ext}`
const serviceClient = createServiceClient()
@@ -49,7 +57,7 @@ export const POST = withRouteContext(
const { error: uploadError } = await serviceClient.storage
.from('logos')
.upload(storagePath, buffer, {
contentType: file.type,
contentType: detectedType,
upsert: true,
})
+107 -3
View File
@@ -1,7 +1,12 @@
import { createLogger } from '@/lib/logger'
import { createServiceClient } from '@/lib/supabase/server'
import { contentDisposition } from '@/lib/api/content-disposition'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import {
OPAQUE_DOCUMENT_CSP,
STORAGE_PROXY_ROUTE,
documentKeyFromProxyPath,
inlineSafeMimeType,
readBodyWithCap,
resolveUpstreamStorageUrl,
} from '@/lib/core/documents/storage-proxy'
@@ -17,6 +22,15 @@ import {
* lib/core/documents/storage-proxy.ts), and this handler forwards them to
* Storage unchanged.
*
* What the browser is told about the bytes is NOT taken from Storage. The
* object's Content-Type and Content-Disposition are whatever the uploader
* declared on PUT, and the proxy serves anonymous visitors on the app
* origin: relaying them would let an HTML or SVG upload run scripts with our
* origin's authority. Downloads are therefore served as opaque attachments
* (application/octet-stream + OPAQUE_DOCUMENT_CSP) unless the object is a
* document whose DB-validated mime type is natively inline-safe (PDF, raster
* images), in which case that type is served.
*
* Auth: deliberately NOT withRouteContext. The caller is a sandbox with no
* session, and the signed token in the query string is the credential:
* Storage validates it against the exact object path on every request, the
@@ -51,10 +65,10 @@ const REQUEST_HEADERS_FORWARDED = [
'if-modified-since',
] as const
// Deliberately without content-type and content-disposition: both are
// uploader-declared object metadata (see the module comment).
const RESPONSE_HEADERS_FORWARDED = [
'content-type',
'content-length',
'content-disposition',
'content-range',
'accept-ranges',
'cache-control',
@@ -84,6 +98,85 @@ function objectPathOf(request: Request): string {
return pathname.startsWith(prefix) ? pathname.slice(prefix.length) : ''
}
interface ServedDocument {
/** Canonical inline-safe type from the document row, or null when the row is missing or its type is not inline-safe. */
mimeType: string | null
fileName: string | null
}
/**
* Look up the document row behind a proxied download key. Runs only after
* Storage has accepted the signed token, so the caller has already proven
* access to exactly this object; the row adds nothing they could not read
* from the bytes. Every failure (no row, DB error, unsafe type) falls back
* to the opaque default rather than trusting anything else.
*/
async function lookupServedDocument(objectPath: string): Promise<ServedDocument> {
const key = documentKeyFromProxyPath(objectPath)
if (!key) return { mimeType: null, fileName: null }
try {
const { data, error } = await createServiceClient()
.from('document_attachments')
.select('mime_type, file_name')
.eq('storage_path', key)
.limit(1)
if (error) {
log.warn('storage proxy document lookup failed', { message: error.message })
return { mimeType: null, fileName: null }
}
const row = (data as { mime_type: string | null; file_name: string | null }[] | null)?.[0]
if (!row) return { mimeType: null, fileName: null }
return { mimeType: inlineSafeMimeType(row.mime_type), fileName: row.file_name }
} catch (error) {
log.warn('storage proxy document lookup threw', { message: (error as Error).message })
return { mimeType: null, fileName: null }
}
}
/** Last segment of the object key, decoded, as the filename of last resort. */
function fileNameFromObjectPath(objectPath: string): string {
const last = objectPath.split('/').pop() ?? ''
try {
return decodeURIComponent(last) || 'download'
} catch {
return last || 'download'
}
}
/**
* Decide Content-Type / Content-Disposition / CSP for a GET or HEAD from
* what the database says about the object, never from Storage's echo of
* the uploader's metadata. Storage's own `?download[=name]` convention is
* honoured for the disposition and filename so callers see the same
* behaviour they got from the raw signed URL.
*/
async function servedContentHeaders(
objectPath: string,
search: URLSearchParams,
upstreamOk: boolean,
): Promise<Record<string, string>> {
const served = upstreamOk
? await lookupServedDocument(objectPath)
: { mimeType: null, fileName: null }
const downloadParam = search.get('download')
const fileName = downloadParam || served.fileName || fileNameFromObjectPath(objectPath)
if (served.mimeType) {
return {
'Content-Type': served.mimeType,
'Content-Disposition': contentDisposition(
downloadParam === null ? 'inline' : 'attachment',
fileName,
),
}
}
return {
'Content-Type': 'application/octet-stream',
'Content-Disposition': contentDisposition('attachment', fileName),
'Content-Security-Policy': OPAQUE_DOCUMENT_CSP,
}
}
function rejected(reason: 'unsupported_path' | 'missing_token' | 'storage_unconfigured') {
const code =
reason === 'unsupported_path'
@@ -96,7 +189,8 @@ function rejected(reason: 'unsupported_path' | 'missing_token' | 'storage_unconf
async function proxy(request: Request, method: 'GET' | 'HEAD' | 'PUT'): Promise<Response> {
const url = new URL(request.url)
const resolved = resolveUpstreamStorageUrl(objectPathOf(request), url.searchParams)
const objectPath = objectPathOf(request)
const resolved = resolveUpstreamStorageUrl(objectPath, url.searchParams)
if (!resolved.ok) return rejected(resolved.reason)
const headers = new Headers()
@@ -144,6 +238,16 @@ async function proxy(request: Request, method: 'GET' | 'HEAD' | 'PUT'): Promise<
// Never let a served document be sniffed into something executable.
responseHeaders.set('X-Content-Type-Options', 'nosniff')
if (method === 'PUT') {
// Storage answers an upload with its own small JSON envelope ({ Key }),
// not object bytes, so its type is safe to relay.
const upstreamType = upstream.headers.get('content-type')
if (upstreamType) responseHeaders.set('Content-Type', upstreamType)
} else {
const served = await servedContentHeaders(objectPath, url.searchParams, upstream.ok)
for (const [key, value] of Object.entries(served)) responseHeaders.set(key, value)
}
return new Response(method === 'HEAD' ? null : upstream.body, {
status: upstream.status,
headers: responseHeaders,
+195 -5
View File
@@ -1,15 +1,34 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GET, HEAD, OPTIONS, PUT } from '../[...path]/route'
const SUPABASE = 'https://pwxtzglxptnnvjrpixpg.supabase.co'
const OPAQUE_CSP = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:"
const fetchMock = vi.fn()
// document_attachments lookup behind a proxied download: (table, columns,
// filter column, filter value) => { data, error }.
const documentLookup = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => ({
from: (table: string) => ({
select: (columns: string) => ({
eq: (column: string, value: string) => ({
limit: () => documentLookup(table, columns, column, value),
}),
}),
}),
}),
}))
import { GET, HEAD, OPTIONS, PUT } from '../[...path]/route'
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', SUPABASE)
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.accounted.se')
vi.stubGlobal('fetch', fetchMock)
fetchMock.mockReset()
documentLookup.mockReset()
documentLookup.mockResolvedValue({ data: [], error: null })
})
afterEach(() => {
@@ -25,6 +44,10 @@ function upstreamResponse(body: string | null, init: ResponseInit = {}) {
})
}
function pdfRow(fileName = 'kvitto.pdf', mimeType: string | null = 'application/pdf') {
return { data: [{ mime_type: mimeType, file_name: fileName }], error: null }
}
describe('/api/storage/[...path] same-origin Storage proxy', () => {
it('forwards a signed upload PUT (bytes, content-type, token) to our Storage host', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ Key: 'documents/x' }), { status: 200, headers: { 'content-type': 'application/json' } }))
@@ -38,6 +61,10 @@ describe('/api/storage/[...path] same-origin Storage proxy', () => {
expect(response.status).toBe(200)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
// Storage's own JSON envelope, not object bytes: relayed as-is.
expect(response.headers.get('content-type')).toBe('application/json')
expect(response.headers.get('content-security-policy')).toBeNull()
expect(documentLookup).not.toHaveBeenCalled()
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe(
@@ -50,16 +77,18 @@ describe('/api/storage/[...path] same-origin Storage proxy', () => {
expect(new TextDecoder().decode(init.body as ArrayBuffer)).toBe('%PDF-1.4 hello')
})
it('streams a signed download GET through and keeps the document headers', async () => {
it('streams a signed download GET and serves the DB-validated type when it is inline-safe', async () => {
fetchMock.mockResolvedValue(
upstreamResponse('%PDF-1.4 bytes', {
headers: {
'content-type': 'application/pdf',
'content-disposition': 'attachment; filename="kvitto.pdf"',
'content-type': 'text/html',
'content-disposition': 'inline; filename="evil.html"',
'set-cookie': 'leak=1',
'etag': '"abc"',
},
}),
)
documentLookup.mockResolvedValue(pdfRow('kvitto.pdf'))
const request = new Request(
'https://app.accounted.se/api/storage/sign/documents/co-1/user-1/kvitto.pdf?token=eyJ.sig',
)
@@ -68,15 +97,175 @@ describe('/api/storage/[...path] same-origin Storage proxy', () => {
expect(response.status).toBe(200)
expect(await response.text()).toBe('%PDF-1.4 bytes')
// The upstream (uploader-declared) type and disposition are ignored; the
// document row decides, and its filename is what the browser sees.
expect(response.headers.get('content-type')).toBe('application/pdf')
expect(response.headers.get('content-disposition')).toBe('attachment; filename="kvitto.pdf"')
expect(response.headers.get('content-disposition')).toBe(
`inline; filename="kvitto.pdf"; filename*=UTF-8''kvitto.pdf`,
)
expect(response.headers.get('content-security-policy')).toBeNull()
expect(response.headers.get('x-content-type-options')).toBe('nosniff')
expect(response.headers.get('etag')).toBe('"abc"')
expect(response.headers.get('set-cookie')).toBeNull()
expect(documentLookup).toHaveBeenCalledWith(
'document_attachments',
'mime_type, file_name',
'storage_path',
'co-1/user-1/kvitto.pdf',
)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe(`${SUPABASE}/storage/v1/object/sign/documents/co-1/user-1/kvitto.pdf?token=eyJ.sig`)
expect(init.method).toBe('GET')
})
it('looks the document up by its percent-decoded key and tolerates legacy type spelling', async () => {
fetchMock.mockResolvedValue(upstreamResponse('%PDF-1.4 bytes'))
documentLookup.mockResolvedValue(pdfRow('kvitto maj.pdf', 'Application/PDF; charset=binary'))
const response = await GET(
new Request('https://app.accounted.se/api/storage/sign/documents/co-1/user-1/kvitto%20maj.pdf?token=t'),
)
expect(response.headers.get('content-type')).toBe('application/pdf')
expect(documentLookup).toHaveBeenCalledWith(
'document_attachments',
'mime_type, file_name',
'storage_path',
'co-1/user-1/kvitto maj.pdf',
)
})
it("honours Storage's ?download convention for an inline-safe type", async () => {
fetchMock.mockResolvedValue(upstreamResponse('%PDF-1.4 bytes'))
documentLookup.mockResolvedValue(pdfRow('kvitto.pdf'))
const response = await GET(
new Request('https://app.accounted.se/api/storage/sign/documents/co-1/user-1/kvitto.pdf?token=t&download='),
)
expect(response.headers.get('content-type')).toBe('application/pdf')
expect(response.headers.get('content-disposition')).toContain('attachment; filename="kvitto.pdf"')
const named = await GET(
new Request('https://app.accounted.se/api/storage/sign/documents/co-1/user-1/kvitto.pdf?token=t&download=mars.pdf'),
)
expect(named.headers.get('content-disposition')).toContain('attachment; filename="mars.pdf"')
})
it('serves a document whose stored type is active content as an opaque attachment', async () => {
fetchMock.mockResolvedValue(
upstreamResponse('<script>alert(document.cookie)</script>', {
headers: { 'content-type': 'text/html', 'content-disposition': 'inline; filename="mail.html"' },
}),
)
documentLookup.mockResolvedValue(pdfRow('mail.html', 'text/html'))
const response = await GET(
new Request('https://app.accounted.se/api/storage/sign/documents/co-1/user-1/mail.html?token=t'),
)
expect(response.status).toBe(200)
expect(await response.text()).toBe('<script>alert(document.cookie)</script>')
expect(response.headers.get('content-type')).toBe('application/octet-stream')
expect(response.headers.get('content-disposition')).toBe(
`attachment; filename="mail.html"; filename*=UTF-8''mail.html`,
)
expect(response.headers.get('content-security-policy')).toBe(OPAQUE_CSP)
expect(response.headers.get('x-content-type-options')).toBe('nosniff')
})
it.each(['image/svg+xml', 'application/xml', 'application/xhtml+xml', 'application/json'])(
'never serves %s from the proxy with its own type',
async (mimeType) => {
fetchMock.mockResolvedValue(upstreamResponse('<x/>', { headers: { 'content-type': mimeType } }))
documentLookup.mockResolvedValue(pdfRow('underlag', mimeType))
const response = await GET(
new Request('https://app.accounted.se/api/storage/sign/documents/co-1/user-1/underlag?token=t'),
)
expect(response.headers.get('content-type')).toBe('application/octet-stream')
expect(response.headers.get('content-disposition')).toContain('attachment')
expect(response.headers.get('content-security-policy')).toBe(OPAQUE_CSP)
},
)
it('serves an object without a document row (audit-package zip) as an opaque attachment named after its key', async () => {
fetchMock.mockResolvedValue(
upstreamResponse('PK...', {
headers: { 'content-type': 'text/html', 'content-disposition': 'inline; filename="x.html"' },
}),
)
documentLookup.mockResolvedValue({ data: [], error: null })
const response = await GET(
new Request('https://app.accounted.se/api/storage/sign/documents/user-1/audit-packages/1700_audit%202026.zip?token=t'),
)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/octet-stream')
expect(response.headers.get('content-disposition')).toBe(
`attachment; filename="1700_audit 2026.zip"; filename*=UTF-8''1700_audit%202026.zip`,
)
expect(response.headers.get('content-security-policy')).toBe(OPAQUE_CSP)
})
it('fails closed to the opaque default when the document lookup errors or throws', async () => {
fetchMock.mockResolvedValue(upstreamResponse('%PDF-1.4 bytes'))
documentLookup.mockResolvedValue({ data: null, error: { message: 'db down' } })
const errored = await GET(
new Request('https://app.accounted.se/api/storage/sign/documents/co-1/user-1/kvitto.pdf?token=t'),
)
expect(errored.status).toBe(200)
expect(errored.headers.get('content-type')).toBe('application/octet-stream')
expect(errored.headers.get('content-security-policy')).toBe(OPAQUE_CSP)
fetchMock.mockResolvedValue(upstreamResponse('%PDF-1.4 bytes'))
documentLookup.mockRejectedValue(new Error('network'))
const thrown = await GET(
new Request('https://app.accounted.se/api/storage/sign/documents/co-1/user-1/kvitto.pdf?token=t'),
)
expect(thrown.status).toBe(200)
expect(thrown.headers.get('content-type')).toBe('application/octet-stream')
expect(thrown.headers.get('content-security-policy')).toBe(OPAQUE_CSP)
})
it('does not consult the database when Storage rejects the token', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ statusCode: '400', error: 'InvalidJWT' }), {
status: 400,
headers: { 'content-type': 'application/json' },
}),
)
const response = await GET(
new Request('https://app.accounted.se/api/storage/sign/documents/co-1/user-1/kvitto.pdf?token=bad'),
)
expect(response.status).toBe(400)
expect(documentLookup).not.toHaveBeenCalled()
expect(response.headers.get('content-type')).toBe('application/octet-stream')
expect(response.headers.get('content-security-policy')).toBe(OPAQUE_CSP)
})
it('answers HEAD without a body and with the same served headers as GET', async () => {
fetchMock.mockResolvedValue(upstreamResponse(null, { headers: { 'content-type': 'text/html', 'content-length': '14' } }))
documentLookup.mockResolvedValue(pdfRow('kvitto.pdf'))
const response = await HEAD(
new Request('https://app.accounted.se/api/storage/sign/documents/co-1/user-1/kvitto.pdf?token=t', { method: 'HEAD' }),
)
expect(response.status).toBe(200)
expect(response.body).toBeNull()
expect(response.headers.get('content-type')).toBe('application/pdf')
expect(response.headers.get('content-length')).toBe('14')
expect(response.headers.get('content-security-policy')).toBeNull()
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(init.method).toBe('HEAD')
})
it('answers HEAD without a body and relays the upstream status', async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 404, headers: { 'content-type': 'application/json' } }))
@@ -86,6 +275,7 @@ describe('/api/storage/[...path] same-origin Storage proxy', () => {
expect(response.status).toBe(404)
expect(response.body).toBeNull()
expect(documentLookup).not.toHaveBeenCalled()
})
it('refuses paths outside the signed documents-bucket allowlist without touching Storage', async () => {
+4 -1
View File
@@ -51,7 +51,9 @@ export interface BankIdResult {
tokenHash?: string
type?: string
isNewUser?: boolean
error?: 'no_account' | 'already_linked' | 'session_invalid' | 'service_unavailable'
error?: 'no_account' | 'already_linked' | 'session_invalid' | 'service_unavailable' | 'email_unconfirmed'
/** Server-provided Swedish explanation for errors that carry one (e.g. email_unconfirmed). */
message?: string
givenName?: string
surname?: string
/** Non-secret id that binds this tab to the shared server-held flow. */
@@ -304,6 +306,7 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
}
onCompleteRef.current({
error: errorCode,
message: typeof completeJson.message === 'string' ? completeJson.message : undefined,
givenName: completeJson.givenName,
surname: completeJson.surname,
})
+2 -2
View File
@@ -26,7 +26,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
const [isDragging, setIsDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp']
function validateAndUpload(file: File) {
if (!ALLOWED_TYPES.includes(file.type)) {
@@ -171,7 +171,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,image/webp"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={handleFileChange}
/>
@@ -11,7 +11,7 @@ import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSk
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp']
interface ByraBrand {
hasBrand: boolean
@@ -272,7 +272,7 @@ export function BrandSettingsContent() {
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,image/webp"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
@@ -0,0 +1,255 @@
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'
import { createMockRequest } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
/**
* Binds the OAuth callback to the user who STARTED the flow.
*
* oauth-callback-state.test.ts pins that the callback trusts nothing but the
* server-written state row. That row proves the callback belongs to a flow we
* started; it does not say who is finishing it. The authorize URL is
* shareable, so a victim lured into approving a Fortnox/Visma consent that an
* attacker started would have their provider account bound to the attacker's
* consent, and the attacker's next migration would import the victim's ledger.
*
* The state row now records the initiator (provider_otc.user_id, written by
* generateOtc from /connect) and the callback requires the completing
* browser's own session to be that user before the code is exchanged. The
* binding helper is the real one; only the cookie session behind it is faked.
*/
vi.mock('../lib/migration-orchestrator', () => ({
executeMigration: vi.fn().mockResolvedValue({}),
}))
vi.mock('../lib/provider-client', () => ({
createConsent: vi.fn(),
getConsent: vi.fn(),
listConsents: vi.fn(),
generateOtc: vi.fn(),
consumeOAuthState: vi.fn(),
getAuthUrl: vi.fn(),
exchangeAuthToken: vi.fn(),
submitProviderToken: vi.fn(),
acceptConsent: vi.fn(),
deleteConsent: vi.fn(),
resolveConsent: vi.fn(),
fetchCompanyInfoDirect: vi.fn(),
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
// Mirrors the real constructor and message so the callback's mapping from
// this error to the Swedish registry sentence is exercised for real.
ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {
constructor(
public readonly expectedOrgNumber: string,
public readonly actualOrgNumber: string,
public readonly actualCompanyName: string | null,
) {
super(
`Provider company mismatch: credentials open ${actualOrgNumber}, ` +
`but the target company is ${expectedOrgNumber}`,
)
}
},
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
const { mockCreateClient } = vi.hoisted(() => ({ mockCreateClient: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({
createClient: mockCreateClient,
createServiceClient: vi.fn(),
}))
import { arcimMigrationExtension } from '../index'
import {
consumeOAuthState,
exchangeAuthToken,
generateOtc,
getAuthUrl,
listConsents,
createConsent,
ProviderCompanyMismatchError,
} from '../lib/provider-client'
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
const findRoute = (method: string, path: string) =>
(arcimMigrationExtension.apiRoutes ?? []).find((r) => r.method === method && r.path === path)!
const callbackHandler = findRoute('GET', '/callback').handler as RouteHandler
const connectHandler = findRoute('POST', '/connect').handler as RouteHandler
const APP_URL = 'https://app.example.test'
const STATE_REJECTED = 'Ingen giltig migrationssession hittades'
/** The browser completing the callback is signed in as `userId` (or nobody). */
function useSession(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
error: null,
}),
},
})
}
function callbackRequest(params: Record<string, string>) {
return createMockRequest(`${APP_URL}/api/extensions/ext/arcim-migration/callback`, {
searchParams: params,
})
}
function connectCtx(userId = 'user-1'): ExtensionContext {
return {
supabase: { auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: userId } } }) } },
companyId: 'company-1',
userId,
} as unknown as ExtensionContext
}
describe('GET /callback: the completing session must be the initiator', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.stubEnv('FORTNOX_REDIRECT_URI', '')
vi.stubEnv('VISMA_REDIRECT_URI', '')
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
;(exchangeAuthToken as Mock).mockResolvedValue({ success: true, consentId: 'consent-1' })
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('finalises when the browser session belongs to the user who started the flow', async () => {
useSession('user-1')
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(exchangeAuthToken).toHaveBeenCalledTimes(1)
expect(exchangeAuthToken).toHaveBeenCalledWith(
'consent-1',
'fortnox',
'provider-auth-code',
`${APP_URL}/api/extensions/ext/arcim-migration/callback`,
)
expect(html).toContain('Anslutningen lyckades')
})
it('refuses a completion by a different signed-in user without exchanging the code', async () => {
// The victim (user-2) was lured into approving user-1's consent.
useSession('user-2')
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(html).toContain('Anslutningen misslyckades')
expect(html).toContain('annat användarkonto')
// The refused party learns nothing about the consent it tried to complete.
expect(html).not.toContain('consent-1')
// Error popups stay open (see oauth-callback-state.test.ts).
expect(html).not.toContain('window.close')
})
it('refuses a completion with no session at all and asks for a fresh connect', async () => {
useSession(null)
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
// The state is already spent (consumed atomically before the check), so
// the only way forward is a new connect, not a login-and-retry.
expect(consumeOAuthState).toHaveBeenCalledWith('otc')
expect(html).toContain('Logga in och starta om anslutningen')
expect(html).not.toContain('consent-1')
})
it('refuses a state row that records no initiator, with the generic state rejection', async () => {
// Rows minted before provider_otc.user_id existed: nobody to bind to.
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: null,
})
useSession('user-1')
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(mockCreateClient).not.toHaveBeenCalled()
expect(html).toContain(STATE_REJECTED)
})
it('reports valid tokens for the WRONG company in Swedish and never claims success', async () => {
useSession('user-1')
;(exchangeAuthToken as Mock).mockRejectedValue(
new ProviderCompanyMismatchError('5560160680', '5567037485', 'Annat Bolag AB'),
)
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(html).toContain('Anslutningen misslyckades')
expect(html).toContain('Uppgifterna gäller ett annat företag')
expect(html).not.toContain('Provider company mismatch')
expect(html).not.toContain('Anslutningen lyckades')
})
})
describe('POST /connect: the state row records who started the flow', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.stubEnv('FORTNOX_REDIRECT_URI', '')
vi.spyOn(console, 'error').mockImplementation(() => {})
;(generateOtc as Mock).mockResolvedValue({ code: 'otc-code-1' })
;(getAuthUrl as Mock).mockResolvedValue({ url: 'https://apps.fortnox.se/oauth-v1/auth?x=1' })
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('binds a first connect to the caller', async () => {
;(listConsents as Mock).mockResolvedValue([])
;(createConsent as Mock).mockResolvedValue({ id: 'consent-new' })
const res = await connectHandler(
createMockRequest(`${APP_URL}/api/extensions/ext/arcim-migration/connect`, {
method: 'POST',
body: { provider: 'fortnox' },
}),
connectCtx('user-1'),
)
expect(res.status).toBe(200)
expect(generateOtc).toHaveBeenCalledWith('consent-new', 'user-1')
})
it('binds a reconnect of an existing consent to the caller too', async () => {
;(listConsents as Mock).mockResolvedValue([{ id: 'consent-1', provider: 'fortnox', status: 1 }])
const res = await connectHandler(
createMockRequest(`${APP_URL}/api/extensions/ext/arcim-migration/connect`, {
method: 'POST',
body: { provider: 'fortnox', reconnect: true },
}),
connectCtx('user-1'),
)
expect(res.status).toBe(200)
expect(generateOtc).toHaveBeenCalledWith('consent-1', 'user-1')
})
})
@@ -42,6 +42,7 @@ vi.mock('../lib/provider-client', () => ({
resolveConsent: vi.fn(),
fetchCompanyInfoDirect: vi.fn(),
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {},
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
@@ -52,7 +53,17 @@ vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(),
}))
// The callback also binds the completing browser session to the user recorded
// on the state row. That check has its own tests
// (oauth-callback-initiator.test.ts); here it always passes so these tests
// stay about the state token itself.
vi.mock('@/lib/auth/oauth-flow-binding', () => ({
requireFlowInitiator: vi.fn(),
FLOW_INITIATOR_MISMATCH_MESSAGE: 'initiator mismatch',
}))
import { arcimMigrationExtension } from '../index'
import { requireFlowInitiator } from '@/lib/auth/oauth-flow-binding'
import {
consumeOAuthState,
exchangeAuthToken,
@@ -74,6 +85,12 @@ const findRoute = (method: string, path: string) =>
const callbackHandler = findRoute('GET', '/callback').handler as RouteHandler
const previewHandler = findRoute('GET', '/preview').handler as RouteHandler
// Every state row below was minted by 'user-1', and 'user-1' is the one
// completing the flow. Set per test, after each describe's clearAllMocks.
beforeEach(() => {
;(requireFlowInitiator as Mock).mockResolvedValue({ ok: true, userId: 'user-1' })
})
const APP_URL = 'https://app.example.test'
/** The exact string the callback shows for every state failure. */
@@ -143,7 +160,7 @@ describe('GET /callback: OAuth state binding', () => {
it('rejects a replayed state: the second callback with the same token fails', async () => {
// First delivery consumes the row, second finds nothing left to consume.
;(consumeOAuthState as Mock)
.mockResolvedValueOnce({ consentId: 'consent-1', provider: 'fortnox' })
.mockResolvedValueOnce({ consentId: 'consent-1', provider: 'fortnox', userId: 'user-1' })
.mockResolvedValueOnce(null)
const first = await callbackHandler(
@@ -165,6 +182,7 @@ describe('GET /callback: OAuth state binding', () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-owned-by-caller',
provider: 'visma',
userId: 'user-1',
})
// The token names a different consent and provider. It must be ignored:
@@ -228,6 +246,7 @@ describe('GET /callback: full-page fallback when there is no opener', () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
const res = await callbackHandler(
@@ -258,6 +277,7 @@ describe('GET /callback: full-page fallback when there is no opener', () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
const res = await callbackHandler(
@@ -372,6 +392,7 @@ describe('OAuth redirect_uri symmetry between authorize and exchange', () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-new',
provider: 'fortnox',
userId: 'user-1',
})
await callbackHandler(
@@ -421,6 +442,7 @@ describe('GET /callback: error popup stays open, success popup closes', () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
const res = await callbackHandler(
@@ -515,6 +537,7 @@ describe('GET /callback: the spent callback URL cannot come back', () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
const res = await callbackHandler(
@@ -0,0 +1,164 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
/**
* exchangeAuthToken() must refuse valid OAuth tokens that open the WRONG
* company.
*
* Bokio and WINT already had this guard in submitProviderToken: a token that
* works plus the wrong company imports a foreign legal entity's customers,
* suppliers and invoices into this ledger with no error at all. The OAuth
* providers (Fortnox, Visma) had nothing: whichever company the user (or
* whoever lured them) signed in to at the provider was bound to the consent.
*
* Rule under test: only a confident mismatch blocks. A missing org number on
* either side, or a failed company-information call, is not evidence and the
* exchange completes as before.
*/
vi.mock('@/lib/providers/oauth-config', () => ({
getOAuthConfig: vi.fn(() => ({
clientId: 'client-id',
clientSecret: 'client-secret',
redirectUri: 'https://app.example/api/extensions/ext/arcim-migration/callback',
})),
}))
vi.mock('@/lib/providers/fortnox/oauth', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/providers/fortnox/oauth')>()),
exchangeFortnoxCode: vi.fn(),
}))
vi.mock('@/lib/providers/provider-data-fetcher', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/providers/provider-data-fetcher')>()),
fetchCompanyInfoDirect: vi.fn(),
}))
let serviceClient: ReturnType<typeof createQueuedMockSupabase>
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceClient.supabase,
createClient: vi.fn(),
}))
import { exchangeAuthToken, ProviderCompanyMismatchError } from '../lib/provider-client'
import { exchangeFortnoxCode } from '@/lib/providers/fortnox/oauth'
import { fetchCompanyInfoDirect } from '@/lib/providers/provider-data-fetcher'
// Both pass normalizeOrgNumber's Luhn check: Spotify AB and Ericsson.
const TARGET_ORG = '5560160680'
const OTHER_ORG = '5567037485'
const TOKENS = { access_token: 'access-1', refresh_token: 'refresh-1', expires_in: 3600 }
/**
* Queue results in exchangeAuthToken's `from` order: provider_consents
* (company_id), companies (org_number), then the token upsert and the status
* update (whose results are not read).
*/
function useDb(consentCompanyId: string | null, targetOrgNumber: string | null) {
serviceClient = createQueuedMockSupabase()
serviceClient.enqueueMany([
{ data: consentCompanyId ? { company_id: consentCompanyId } : null },
{ data: { org_number: targetOrgNumber } },
])
return serviceClient
}
describe('exchangeAuthToken: provider company must match the consent company', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
;(exchangeFortnoxCode as Mock).mockResolvedValue(TOKENS)
})
it('refuses tokens for a different company and stores nothing', async () => {
const db = useDb('company-1', '556016-0680')
;(fetchCompanyInfoDirect as Mock).mockResolvedValue({
companyName: 'Annat Bolag AB',
organizationNumber: '556703-7485',
})
const error = await exchangeAuthToken('consent-1', 'fortnox', 'code', 'https://cb').catch((e) => e)
expect(error).toBeInstanceOf(ProviderCompanyMismatchError)
expect((error as ProviderCompanyMismatchError).expectedOrgNumber).toBe(TARGET_ORG)
expect((error as ProviderCompanyMismatchError).actualOrgNumber).toBe(OTHER_ORG)
expect((error as ProviderCompanyMismatchError).actualCompanyName).toBe('Annat Bolag AB')
// The one-shot code was exchanged (that is how we learned the company),
// but nothing about the result may land in the database.
expect(db.findCall('provider_consent_tokens', 'upsert')).toBeUndefined()
expect(db.findCall('provider_consents', 'update')).toBeUndefined()
})
it('checks the company the token opens, using the fresh access token', async () => {
useDb('company-1', TARGET_ORG)
;(fetchCompanyInfoDirect as Mock).mockResolvedValue({
companyName: 'Rätt Bolag AB',
organizationNumber: TARGET_ORG,
})
await exchangeAuthToken('consent-1', 'fortnox', 'code', 'https://cb')
expect(fetchCompanyInfoDirect).toHaveBeenCalledWith('fortnox', 'access-1')
})
it('stores the tokens and accepts the consent when the org numbers agree', async () => {
const db = useDb('company-1', '556016-0680')
;(fetchCompanyInfoDirect as Mock).mockResolvedValue({
companyName: 'Rätt Bolag AB',
organizationNumber: TARGET_ORG,
})
await expect(exchangeAuthToken('consent-1', 'fortnox', 'code', 'https://cb')).resolves.toEqual({
success: true,
consentId: 'consent-1',
})
const upsert = db.findCall('provider_consent_tokens', 'upsert')?.[0] as Record<string, unknown>
expect(upsert).toMatchObject({
consent_id: 'consent-1',
provider: 'fortnox',
access_token: 'access-1',
refresh_token: 'refresh-1',
})
expect(db.findCall('provider_consents', 'update')?.[0]).toEqual({ status: 1 })
})
it('does not block when the provider reports no org number', async () => {
const db = useDb('company-1', TARGET_ORG)
;(fetchCompanyInfoDirect as Mock).mockResolvedValue({ companyName: 'Namnlöst AB' })
await exchangeAuthToken('consent-1', 'fortnox', 'code', 'https://cb')
expect(db.findCall('provider_consent_tokens', 'upsert')).toBeDefined()
// Nothing to compare against: the target company is not even looked up.
expect(db.findCall('companies', 'select')).toBeUndefined()
})
it('does not block when the Accounted company has no org number', async () => {
const db = useDb('company-1', null)
;(fetchCompanyInfoDirect as Mock).mockResolvedValue({
companyName: 'Annat Bolag AB',
organizationNumber: OTHER_ORG,
})
await exchangeAuthToken('consent-1', 'fortnox', 'code', 'https://cb')
expect(db.findCall('provider_consent_tokens', 'upsert')).toBeDefined()
})
it('does not block when the company-information call fails', async () => {
// A scope the app registration lacks, or a provider hiccup, is not a
// verdict on identity. The tokens are valid (the exchange succeeded).
const db = useDb('company-1', TARGET_ORG)
;(fetchCompanyInfoDirect as Mock).mockRejectedValue(new Error('403 forbidden'))
await expect(exchangeAuthToken('consent-1', 'fortnox', 'code', 'https://cb')).resolves.toEqual({
success: true,
consentId: 'consent-1',
})
expect(db.findCall('provider_consent_tokens', 'upsert')).toBeDefined()
})
})
@@ -81,7 +81,7 @@ describe('consumeOAuthState', () => {
it('consumes the state row with one conditional UPDATE, not a read then a write', async () => {
const { calls } = useResults([
{ data: { consent_id: 'consent-1' } },
{ data: { consent_id: 'consent-1', user_id: 'user-1' } },
{ data: { provider: 'fortnox' } },
])
@@ -97,21 +97,22 @@ describe('consumeOAuthState', () => {
expect(findOp(otcCall, 'is', 'used_at')?.[1]).toEqual(['used_at', null])
// Expiry is enforced in the same statement, not in JavaScript afterwards.
expect(findOp(otcCall, 'gt', 'expires_at')).toBeDefined()
expect(findOp(otcCall, 'select', 'consent_id')).toBeDefined()
expect(findOp(otcCall, 'select', 'consent_id, user_id')).toBeDefined()
})
it('returns the consent and the provider read from the server-side rows', async () => {
useResults([{ data: { consent_id: 'consent-1' } }, { data: { provider: 'visma' } }])
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: { provider: 'visma' } }])
await expect(consumeOAuthState('state-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'visma',
userId: 'user-1',
})
})
it('reads the provider from provider_consents, never from the caller', async () => {
const { calls } = useResults([
{ data: { consent_id: 'consent-1' } },
{ data: { consent_id: 'consent-1', user_id: 'user-1' } },
{ data: { provider: 'fortnox' } },
])
@@ -136,10 +137,11 @@ describe('consumeOAuthState', () => {
})
it('returns null on replay: the second consume of the same token loses', async () => {
useResults([{ data: { consent_id: 'consent-1' } }, { data: { provider: 'fortnox' } }])
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: { provider: 'fortnox' } }])
await expect(consumeOAuthState('one-time-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
// Replay: used_at is now set, so `is('used_at', null)` matches nothing.
@@ -147,8 +149,20 @@ describe('consumeOAuthState', () => {
await expect(consumeOAuthState('one-time-token')).resolves.toBeNull()
})
it('returns userId null for a row minted before the initiator column existed', async () => {
// The callback refuses these (nobody to bind the completion to); this
// function only has to report the absence honestly, never invent a user.
useResults([{ data: { consent_id: 'consent-1', user_id: null } }, { data: { provider: 'fortnox' } }])
await expect(consumeOAuthState('state-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'fortnox',
userId: null,
})
})
it('returns null when the consent behind a valid token is gone', async () => {
useResults([{ data: { consent_id: 'consent-1' } }, { data: null }])
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: null }])
await expect(consumeOAuthState('state-token')).resolves.toBeNull()
})
@@ -172,12 +186,15 @@ describe('generateOtc', () => {
const { calls } = useResults([{ data: null }])
const before = Date.now()
const { expiresAt } = await generateOtc('consent-1')
const { expiresAt } = await generateOtc('consent-1', 'user-1')
const after = Date.now()
expect(calls[0].table).toBe('provider_otc')
const inserted = findOp(calls[0], 'insert')?.[1][0] as { consent_id: string; expires_at: string }
const inserted = findOp(calls[0], 'insert')?.[1][0] as { consent_id: string; user_id: string; expires_at: string }
expect(inserted.consent_id).toBe('consent-1')
// The initiator travels with the row: the callback binds the completing
// session to it, so it must be written here and nowhere else.
expect(inserted.user_id).toBe('user-1')
const tenMinutes = 10 * 60 * 1000
const expiry = new Date(expiresAt).getTime()
+65 -7
View File
@@ -38,6 +38,11 @@ import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
import type { ProviderName } from '@/lib/providers/types'
import { FORTNOX_DOCUMENT_SCOPES_APPROVED } from '@/lib/providers/fortnox/oauth'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import {
requireFlowInitiator,
FLOW_INITIATOR_MISMATCH_MESSAGE,
} from '@/lib/auth/oauth-flow-binding'
import { classifyProviderError } from '@/lib/providers/with-provider-call'
import { getProviderResourceForbiddenMessage } from '@/lib/errors/get-error-message'
import { FortnoxApiError, fortnoxErrorMessage } from '@/lib/providers/fortnox/client'
@@ -54,6 +59,15 @@ const moduleLog = createLogger('extensions/arcim-migration')
const STATE_REJECTED_MESSAGE =
'Ingen giltig migrationssession hittades. Starta om anslutningen.'
/**
* A valid state reached the callback but the browser carries no session. The
* state is already spent by then (consumeOAuthState is atomic and runs first),
* so unlike the bank/Stripe callbacks there is nothing to resume after a
* login: the user starts the connect again from the wizard.
*/
const SESSION_MISSING_MESSAGE =
'Ingen inloggad session hittades i det här fönstret. Logga in och starta om anslutningen.'
/**
* Map known OAuth error codes from providers (Fortnox, Visma) to actionable
* Swedish guidance. Falls back to the raw provider message so we never hide
@@ -126,12 +140,14 @@ function resolveArcimCallbackUrl(provider: ArcimProvider | ProviderName): string
async function buildArcimOAuthUrl(
consentId: string,
provider: ArcimProvider,
initiatedByUserId: string,
options?: { documentScopes?: boolean },
): Promise<string> {
// Server-side state row: consent id, provider (via the consent), expiry and a
// consumed marker all live in provider_otc. The `state` handed to the provider
// is that row's opaque random primary key, nothing more.
const otc = await generateOtc(consentId)
// Server-side state row: consent id, provider (via the consent), the user who
// started the flow, expiry and a consumed marker all live in provider_otc.
// The `state` handed to the provider is that row's opaque random primary
// key, nothing more.
const otc = await generateOtc(consentId, initiatedByUserId)
const callbackUrl = resolveArcimCallbackUrl(provider)
@@ -346,7 +362,7 @@ export const arcimMigrationExtension: Extension = {
await ctx.settings.set('provider', provider)
}
if (providerInfo.authType === 'oauth') {
const authUrl = await buildArcimOAuthUrl(stale.id, provider, {
const authUrl = await buildArcimOAuthUrl(stale.id, provider, user.id, {
documentScopes: documentScopes === true,
})
return NextResponse.json({
@@ -431,7 +447,7 @@ export const arcimMigrationExtension: Extension = {
}
if (providerInfo.authType === 'oauth') {
const authUrl = await buildArcimOAuthUrl(consent.id, provider)
const authUrl = await buildArcimOAuthUrl(consent.id, provider, user.id)
return NextResponse.json({
consentId: consent.id,
@@ -674,7 +690,39 @@ export const arcimMigrationExtension: Extension = {
return respondWithError(STATE_REJECTED_MESSAGE)
}
const { consentId, provider } = resolvedState
const { consentId, provider, userId: initiatedByUserId } = resolvedState
// The state proves this callback belongs to a flow WE started; it
// says nothing about who is finishing it. Before the code is
// exchanged, the completing browser's own session must belong to the
// user recorded on the state row at connect time. Otherwise a victim
// lured into approving a consent someone else started has their
// Fortnox/Visma account bound to that someone's consent, and the
// next migration imports the victim's ledger into a stranger's
// company. Checked before callbackConsentId is set: a refused
// completion must not hand the consent id to whoever is refused.
if (!initiatedByUserId) {
// Row minted before provider_otc.user_id existed (or written
// outside generateOtc): nobody to bind to, so nobody may finish it.
// Such rows expire within 10 minutes of the deploy.
log.error('OAuth callback state carries no initiator; refusing', { consentId })
return respondWithError(STATE_REJECTED_MESSAGE)
}
const initiator = await requireFlowInitiator(request, initiatedByUserId, {
flow: 'arcim-migration.callback',
})
if (!initiator.ok) {
log.error('OAuth callback refused: completing session is not the initiator', {
consentId,
reason: initiator.reason,
})
return respondWithError(
initiator.reason === 'no_session'
? SESSION_MISSING_MESSAGE
: FLOW_INITIATOR_MISMATCH_MESSAGE,
)
}
callbackConsentId = consentId
// Must match the redirect_uri the authorization request was built
@@ -711,6 +759,16 @@ export const arcimMigrationExtension: Extension = {
})
} catch (error) {
log.error('OAuth callback exchange failed', error)
// Valid tokens for the WRONG company: exchangeAuthToken stored
// nothing. Show the user-facing sentence from the error registry
// (the same one /submit-token answers with for Bokio/WINT) instead
// of the English diagnostic on the error object.
if (error instanceof ProviderCompanyMismatchError) {
return respondWithError(
getErrorEntry('PROVIDER_COMPANY_MISMATCH')?.message_sv ?? error.message,
callbackConsentId,
)
}
const reason = error instanceof Error ? error.message : 'Okänt fel vid tokenutbyte.'
return respondWithError(reason, callbackConsentId)
}
@@ -28,8 +28,13 @@ import {
import { WintClient, WintApiError } from '@/lib/providers/wint/client'
import { loginWint, WintLoginRejectedError } from '@/lib/providers/wint/oauth'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { fetchCompanyInfoDirect } from '@/lib/providers/provider-data-fetcher'
import type { CompanyInformationDto } from '@/lib/providers/dto'
import { createLogger } from '@/lib/logger'
import type { ConsentRecord, OtcResponse } from '../types'
const log = createLogger('extensions/arcim-migration/provider-client')
// Singleton (holds the rate limiter): used to validate BL User-Keys at submit
const bjornLundenClient = new BjornLundenClient()
@@ -235,9 +240,15 @@ export async function deleteConsent(consentId: string): Promise<void> {
* to the provider's login page and back. OAuth guidance puts state/OTC
* lifetimes at 5-10 minutes; every extra minute widens the window in which a
* leaked or phished state can still be consumed.
*
* `initiatedByUserId` is the user who clicked connect. The row remembers it so
* the callback can insist that the SAME user's browser session completes the
* flow: the state proves the callback belongs to a flow we started, not that
* the person finishing it is the person who started it.
*/
export async function generateOtc(
consentId: string,
initiatedByUserId: string,
expiresInMinutes: number = 10,
): Promise<OtcResponse> {
const supabase = createServiceClient()
@@ -250,6 +261,7 @@ export async function generateOtc(
.insert({
code,
consent_id: consentId,
user_id: initiatedByUserId,
expires_at: expiresAt,
})
@@ -275,10 +287,14 @@ export async function generateOtc(
* Returns null for every failure mode: unknown/forged state, expired state,
* already-consumed state, deleted consent. Callers must not distinguish them,
* that distinction is exactly the oracle this function exists to remove.
*
* `userId` is the initiator recorded at generateOtc time (null only for rows
* minted before provider_otc.user_id existed). The callback compares it to the
* completing browser's session before exchanging the code.
*/
export async function consumeOAuthState(
state: string,
): Promise<{ consentId: string; provider: ProviderName } | null> {
): Promise<{ consentId: string; provider: ProviderName; userId: string | null } | null> {
const supabase = createServiceClient()
const now = new Date().toISOString()
@@ -288,7 +304,7 @@ export async function consumeOAuthState(
.eq('code', state)
.is('used_at', null)
.gt('expires_at', now)
.select('consent_id')
.select('consent_id, user_id')
.maybeSingle()
if (error || !consumed?.consent_id) {
@@ -308,6 +324,7 @@ export async function consumeOAuthState(
return {
consentId: consumed.consent_id as string,
provider: consent.provider as ProviderName,
userId: typeof consumed.user_id === 'string' ? consumed.user_id : null,
}
}
@@ -369,6 +386,13 @@ export async function exchangeAuthToken(
const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
// The code is spent and the tokens are valid, but nothing so far ties the
// provider account they open to the Accounted company this consent belongs
// to: the user (or someone who lured them) may have signed in to a different
// Fortnox/Visma company. Same guard as Bokio/WINT in submitProviderToken:
// refuse BEFORE anything is stored, so a foreign ledger never gets imported.
await assertProviderCompanyMatchesConsent(supabase, consentId, provider, tokenResponse.access_token)
// Store tokens
await supabase
.from('provider_consent_tokens')
@@ -389,6 +413,61 @@ export async function exchangeAuthToken(
return { success: true, consentId }
}
/**
* Compare the org number of the company the freshly issued token opens with
* the org number of the Accounted company that owns the consent.
*
* Only a confident mismatch blocks (throws ProviderCompanyMismatchError). A
* missing org number on either side is not evidence of anything: Accounted
* allows companies without one, a provider response can omit it, and the
* company-information call itself can fail for reasons unrelated to identity
* (scope not granted on this app registration, provider hiccup). Those cases
* fall through and the connect completes exactly as before.
*/
async function assertProviderCompanyMatchesConsent(
supabase: ReturnType<typeof createServiceClient>,
consentId: string,
provider: ProviderName,
accessToken: string,
): Promise<void> {
let info: CompanyInformationDto | null
try {
info = await fetchCompanyInfoDirect(provider, accessToken)
} catch (error) {
log.warn('provider company information unavailable after OAuth exchange; org-number check skipped', {
provider,
consentId,
reason: error instanceof Error ? error.message : 'unknown',
})
return
}
const providerOrgNumber = normalizeOrgNumber(info?.organizationNumber)
if (!providerOrgNumber) return
const { data: consent } = await supabase
.from('provider_consents')
.select('company_id')
.eq('id', consentId)
.maybeSingle()
if (!consent?.company_id) return
const { data: targetCompany } = await supabase
.from('companies')
.select('org_number')
.eq('id', consent.company_id as string)
.maybeSingle()
const targetOrgNumber = normalizeOrgNumber(targetCompany?.org_number)
if (targetOrgNumber && providerOrgNumber !== targetOrgNumber) {
throw new ProviderCompanyMismatchError(
targetOrgNumber,
providerOrgNumber,
info?.companyName?.trim() || null,
)
}
}
export async function submitProviderToken(
consentId: string,
provider: ProviderName,
@@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'
/**
* The Gmail OAuth callback must be completed by the user who started it.
*
* The signed state carries userId + companyId and proves the flow was started
* by us for that user. It does not prove that the browser now finishing it is
* that user: Google's authorize URL is shareable, so a victim lured into
* approving a consent someone else started would have THEIR mailbox saved
* (with the service client, no RLS) under the initiator's company. The
* callback now binds the completion to the initiator's own cookie session
* before the code is exchanged. The binding helper is the real one; only the
* session behind it is faked.
*/
vi.mock('@/lib/mail-search/service', () => ({ registerMailSearchService: vi.fn() }))
vi.mock('../lib/search-service', () => ({ GmailSearchService: class GmailSearchService {} }))
vi.mock('@/lib/auth/api-keys', () => ({ createServiceClientNoCookies: vi.fn(() => ({})) }))
vi.mock('../lib/google-oauth', () => ({
buildAuthorizationUrl: vi.fn(),
exchangeCodeForTokens: vi.fn(),
getGoogleOAuthEnv: vi.fn(() => ({})),
isGoogleMailConfigured: vi.fn(() => true),
}))
vi.mock('../lib/connections', () => ({
disconnect: vi.fn(),
listConnections: vi.fn(),
saveConnection: vi.fn(),
}))
const { mockCreateClient } = vi.hoisted(() => ({ mockCreateClient: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({
createClient: mockCreateClient,
createServiceClient: vi.fn(),
}))
import { mailExtension } from '../index'
import { createOAuthState } from '../lib/crypto'
import { exchangeCodeForTokens } from '../lib/google-oauth'
import { saveConnection } from '../lib/connections'
const APP_URL = 'https://app.example'
const CALLBACK_PATH = '/api/extensions/ext/mail/oauth/callback'
const callbackRoute = () =>
mailExtension.apiRoutes!.find((r) => r.method === 'GET' && r.path === '/oauth/callback')!
/** The browser completing the callback is signed in as `userId` (or nobody). */
function useSession(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
error: null,
}),
},
})
}
function callbackRequest(state: string) {
const url = new URL(`${APP_URL}${CALLBACK_PATH}`)
url.searchParams.set('code', 'google-code')
url.searchParams.set('state', state)
return new Request(url.toString())
}
describe('mail GET /oauth/callback: the completing session must be the initiator', () => {
let state: string
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
// 32 bytes of hex so createOAuthState/verifyOAuthState use a real key.
vi.stubEnv('MAIL_TOKEN_ENCRYPTION_KEY', '00'.repeat(32))
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
state = createOAuthState('user-1', 'company-1')
;(exchangeCodeForTokens as Mock).mockResolvedValue({
refreshToken: 'refresh-1',
accessToken: 'access-1',
expiresAt: '2030-01-01T00:00:00Z',
email: 'ekonomi@example.se',
scopes: 'gmail.readonly',
})
;(saveConnection as Mock).mockResolvedValue(undefined)
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('saves the grant for the state user when the session is that user', async () => {
useSession('user-1')
const res = await callbackRoute().handler(callbackRequest(state))
expect(res.status).toBe(307)
expect(res.headers.get('location')).toBe(`${APP_URL}/settings/mail?mail=connected`)
expect(exchangeCodeForTokens).toHaveBeenCalledTimes(1)
expect(saveConnection).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: 'company-1',
userId: 'user-1',
provider: 'gmail',
emailAddress: 'ekonomi@example.se',
}),
)
})
it('refuses a completion by a different signed-in user: no exchange, no save', async () => {
// The victim (user-2) was lured into approving user-1's consent.
useSession('user-2')
const res = await callbackRoute().handler(callbackRequest(state))
expect(res.status).toBe(307)
expect(res.headers.get('location')).toBe(`${APP_URL}/settings/mail?mail=mismatch`)
expect(exchangeCodeForTokens).not.toHaveBeenCalled()
expect(saveConnection).not.toHaveBeenCalled()
})
it('sends a session-less completion to login with the callback as next, saving nothing', async () => {
useSession(null)
const res = await callbackRoute().handler(callbackRequest(state))
expect(res.status).toBe(307)
const location = new URL(res.headers.get('location') as string)
expect(location.origin).toBe(APP_URL)
expect(location.pathname).toBe('/login')
// Same-origin relative path + query: the only form the login page's
// safeReturnTo accepts. Signing in re-runs the callback with the same
// code and (still unexpired) state.
const next = location.searchParams.get('next') as string
expect(next.startsWith(`${CALLBACK_PATH}?`)).toBe(true)
expect(new URL(next, APP_URL).searchParams.get('state')).toBe(state)
expect(exchangeCodeForTokens).not.toHaveBeenCalled()
expect(saveConnection).not.toHaveBeenCalled()
})
it('still rejects a forged or expired state before ever reading the session', async () => {
useSession('user-1')
const res = await callbackRoute().handler(callbackRequest('not-a-real-state'))
expect(res.headers.get('location')).toBe(`${APP_URL}/settings/mail?mail=expired`)
expect(mockCreateClient).not.toHaveBeenCalled()
expect(saveConnection).not.toHaveBeenCalled()
})
})
+18
View File
@@ -12,6 +12,7 @@ import {
} from './lib/google-oauth'
import { disconnect, listConnections, saveConnection } from './lib/connections'
import { resolveCallbackOrigin } from './lib/callback-origin'
import { requireFlowInitiator } from '@/lib/auth/oauth-flow-binding'
// Registered as soon as the extension loads, so the receipt hunt can search
// mail without core ever importing from @/extensions.
@@ -78,6 +79,23 @@ export const mailExtension: Extension = {
const verified = verifyOAuthState(state)
if (!verified) return NextResponse.redirect(`${settingsUrl}?mail=expired`)
// The signed state proves the flow was started by verified.userId for
// verified.companyId; it does not prove that the browser now finishing
// it is that user. The grant is written for the state's user and
// company with the service client, so without this check a victim
// lured into approving a Google consent someone else started would
// have THEIR mailbox attached to that someone's company. Checked
// before the code exchange so a refused flow burns nothing.
const initiator = await requireFlowInitiator(request, verified.userId, {
flow: 'mail.oauth-callback',
})
if (!initiator.ok) {
// No session: sign in and the callback re-runs with the same code
// and state (the state is stateless and still within its TTL).
if (initiator.reason === 'no_session') return initiator.response
return NextResponse.redirect(`${settingsUrl}?mail=mismatch`)
}
try {
const origin = resolveCallbackOrigin(url.origin)
const env = getGoogleOAuthEnv(origin)
@@ -1,10 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ALL_SCOPES } from '@/lib/auth/scope-catalog'
import {
addCompanyToNextHint,
addCompanyToTopLevelNext,
assertMcpCompanyWriteAccess,
extractRequestedCompany,
isCompanyDependentTool,
isTenantWriteScope,
projectToolInputSchema,
resolveMcpCompanyContext,
} from '../company-routing'
@@ -248,6 +250,47 @@ describe('MCP company routing', () => {
expect(() => assertMcpCompanyWriteAccess(context, 'webhooks:manage')).toThrow(
expect.objectContaining({ code: 'FORBIDDEN' })
)
expect(() => assertMcpCompanyWriteAccess(context, 'reconciliation:signoff')).toThrow(
expect.objectContaining({ code: 'FORBIDDEN' })
)
})
it('names the read-only role and the refused scope in the viewer refusal', () => {
const context = { companyId: OTHER_COMPANY_ID, role: 'viewer' as const, isDefault: false }
expect(() => assertMcpCompanyWriteAccess(context, 'bookkeeping:write')).toThrow(
expect.objectContaining({
code: 'FORBIDDEN',
message: expect.stringMatching(/read-only \(viewer\).*"bookkeeping:write"/),
})
)
})
it.each(['owner', 'admin', 'member'] as const)('lets a %s through on every scope', (role) => {
const context = { companyId: OTHER_COMPANY_ID, role, isDefault: false }
for (const scope of ALL_SCOPES) {
expect(() => assertMcpCompanyWriteAccess(context, scope)).not.toThrow()
}
})
it('classifies every non-:read scope in the catalogue as a tenant write', () => {
// Derived from scopeKind rather than an allowlist of suffixes, so a scope
// added with a new suffix is viewer-gated by default. Pin the split.
const writes = ALL_SCOPES.filter((scope) => isTenantWriteScope(scope))
const reads = ALL_SCOPES.filter((scope) => !isTenantWriteScope(scope))
expect(reads.length).toBeGreaterThan(0)
expect(reads.every((scope) => scope.endsWith(':read'))).toBe(true)
expect(writes.every((scope) => !scope.endsWith(':read'))).toBe(true)
expect(writes).toEqual(
expect.arrayContaining([
'invoices:write',
'pending_operations:approve',
'webhooks:manage',
'reconciliation:signoff',
])
)
expect(isTenantWriteScope(undefined)).toBe(false)
})
it('keeps company context in follow-up tool hints', () => {
@@ -0,0 +1,192 @@
/**
* Read-only role gate on the MCP tools/call path.
*
* The MCP surface runs as the service role, so RLS never sees the caller's
* company role. The dispatcher (server.ts, tools/call) therefore calls
* assertMcpCompanyWriteAccess() right after resolveMcpCompanyContext() for
* every company-scoped call. A `viewer` membership must be refused on every
* tool that requires a non-:read scope, even when the KEY carries that scope
* (effective permission = key scopes intersected with the user's role), and
* must pass unchanged on read tools. The bridge tool (gnubok_call_tool) is
* rewritten to the inner tool before routing, so the same gate applies there.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventBus } from '@/lib/events/bus'
// Everything the hoisted vi.mock factories reference must itself be hoisted.
const mocks = vi.hoisted(() => ({
companyId: '11111111-1111-4111-8111-111111111111',
role: 'viewer' as string,
/** Every table the dispatcher or a tool touched, in order. */
tables: [] as string[],
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
// Generic query chain: every builder method returns the chain, awaiting it
// yields an empty result. Enough for read tools that tolerate `data: null`.
const emptyChain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
}
return () => emptyChain
},
},
)
const membershipChain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({ data: { company_id: mocks.companyId, role: mocks.role }, error: null })
}
return () => membershipChain
},
},
)
return {
...actual,
extractBearerToken: vi.fn().mockReturnValue('test-token'),
// A LIVE key holding both a write and a read scope, so the scope gate
// passes and the role gate is what decides.
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-viewer',
companyId: mocks.companyId,
scopes: ['customers:write', 'reports:read'],
apiKeyId: 'key-1',
apiKeyName: 'Viewer Key',
mode: 'live',
}),
createServiceClientNoCookies: vi.fn(() => ({
from: (table: string) => {
mocks.tables.push(table)
return table === 'company_members' ? membershipChain : emptyChain
},
rpc: () => emptyChain,
})),
}
})
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
})
// Seat gate entitled: a viewer is a non-owner, so the gate would otherwise
// read companies + capability_grants through the empty chain above.
vi.mock('@/lib/entitlements/multi-user', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/multi-user')>()
return {
...actual,
getMultiUserState: vi.fn().mockResolvedValue({ state: 'entitled', graceEndsAt: null }),
}
})
import { handleMcpRequest } from '../server'
function mcpToolCall(name: string, args: Record<string, unknown> = {}): Request {
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name, arguments: args },
}),
})
}
async function parsedToolResult(
response: Response,
): Promise<{ isError: boolean; payload: Record<string, unknown> }> {
const json = await response.json()
const result = json.result as { isError?: boolean; content: { text: string }[] }
return { isError: result.isError === true, payload: JSON.parse(result.content[0].text) }
}
describe('MCP read-only role gate (viewer)', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mocks.role = 'viewer'
mocks.tables.length = 0
})
it('refuses a viewer on a write tool before execute(), even though the key holds the scope', async () => {
const result = await parsedToolResult(
await handleMcpRequest(
mcpToolCall('gnubok_create_customer', { name: 'Kund AB', customer_type: 'company' }),
),
)
expect(result.isError).toBe(true)
const error = result.payload.error as Record<string, unknown>
expect(error.code).toBe('FORBIDDEN')
expect(String(error.message_en)).toMatch(/read-only \(viewer\)/)
expect(String(error.message_en)).toContain('"customers:write"')
// Only the membership lookup ran: nothing tenant-shaped was touched.
expect(mocks.tables).toEqual(['company_members'])
})
it('routes a viewer read through the gnubok_call_tool bridge unchanged', async () => {
// The bridge is read-only by design (call-tool-bridge.test.ts): writes
// must be named directly, so the role gate has nothing to add there. What
// matters is that a bridged read still resolves the company and runs.
const result = await parsedToolResult(
await handleMcpRequest(
mcpToolCall('gnubok_call_tool', { tool: 'gnubok_list_fiscal_periods', arguments: {} }),
),
)
expect(result.isError).toBe(false)
expect(result.payload).toMatchObject({ periods: [], count: 0 })
expect(mocks.tables[0]).toBe('company_members')
expect(mocks.tables).toContain('fiscal_periods')
})
it('lets a viewer run a read tool unchanged', async () => {
const result = await parsedToolResult(
await handleMcpRequest(mcpToolCall('gnubok_list_fiscal_periods')),
)
expect(result.isError).toBe(false)
expect(result.payload).toMatchObject({ periods: [], count: 0 })
expect(mocks.tables).toContain('fiscal_periods')
})
it('lets a member through the role gate on the same write tool', async () => {
mocks.role = 'member'
const telemetry = new Promise<{ errorKind: string | null }>((resolve) => {
const off = eventBus.on('mcp.tool_called', (payload) => {
off()
resolve(payload as unknown as { errorKind: string | null })
})
})
const result = await parsedToolResult(
await handleMcpRequest(
mcpToolCall('gnubok_create_customer', { name: 'Kund AB', customer_type: 'swedish_business' }),
),
)
// Whatever the empty-chain stub makes execute() return, the role gate did
// not fire: the refusal kind is not an access denial and the message is
// not the read-only one.
expect((await telemetry).errorKind).not.toBe('company_access_denied')
if (result.isError) {
const error = result.payload.error as Record<string, unknown>
expect(error.code).not.toBe('FORBIDDEN')
expect(String(error.message_en)).not.toMatch(/read-only \(viewer\)/)
}
})
})
@@ -1,5 +1,8 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ApiKeyScope } from '@/lib/auth/api-keys'
// Pure data module (no server imports): the same classifier the settings UI
// and the v1 REST wrapper use, so "what counts as a write" has one owner.
import { scopeKind } from '@/lib/auth/scope-catalog'
import { getMultiUserState, isMembershipDormant } from '@/lib/entitlements/multi-user'
import type { CompanyRole } from '@/types'
@@ -75,16 +78,22 @@ export function isCompanyDependentTool(toolName: string): boolean {
return !COMPANY_INDEPENDENT_TOOLS.has(toolName)
}
/**
* Does a tool that requires `scope` change tenant state?
*
* Every scope that is not `:read` counts: `:write`, `:approve`, `:manage` and
* `:signoff` today (sign-off attests on the company's behalf, a write for the
* role guard even though the scope is deliberately not `:write`), and any
* suffix added later. Deriving from `scopeKind` instead of an allowlist of
* suffixes means a new elevated scope is write-gated for viewers by default
* rather than silently open until someone remembers this function.
*
* `undefined` (a tool absent from TOOL_SCOPE_MAP) is not a tenant write: the
* unscoped tools are discovery, skills and the feedback channel, and
* strict-schemas.test.ts pins that every write-annotated tool has a scope.
*/
export function isTenantWriteScope(scope: ApiKeyScope | undefined): boolean {
return (
scope?.endsWith(':write') === true ||
scope?.endsWith(':approve') === true ||
scope?.endsWith(':manage') === true ||
// Sign-off attests on the company's behalf: a write for the role guard
// even though the scope is deliberately not `:write` (linking rows must
// not imply the right to attest).
scope?.endsWith(':signoff') === true
)
return scope !== undefined && scopeKind(scope) === 'write'
}
export function projectToolInputSchema(tool: ToolSchemaSource): Record<string, unknown> {
@@ -166,12 +175,27 @@ export async function resolveMcpCompanyContext(args: {
}
}
/**
* Read-only role gate for the MCP tools/call path.
*
* Called by the dispatcher (server.ts, tools/call) right after
* `resolveMcpCompanyContext` for every company-scoped call, including calls
* routed through the gnubok_call_tool bridge, and before execute(). The MCP
* surface runs as the service role, so RLS never sees the viewer: this is
* the only place the role is enforced for API-key callers. Read tools pass
* unchanged; a viewer's key with write scopes is still refused, because the
* key's scopes bound what the key MAY do and the role bounds what the user
* may do, and the effective permission is the intersection.
*/
export function assertMcpCompanyWriteAccess(
context: McpCompanyContext,
scope: ApiKeyScope | undefined
): void {
if (context.role === 'viewer' && isTenantWriteScope(scope)) {
throw codedError('FORBIDDEN', 'Write permission required for this company')
throw codedError(
'FORBIDDEN',
`This company membership is read-only (viewer): tools that require the "${scope}" scope change company data and are refused. Use read tools only, or ask a company owner or admin to change the role.`
)
}
}
@@ -8,10 +8,24 @@ import {
ShopifyApiError,
SHOPIFY_API_VERSION,
SHOPIFY_PAGE_SIZE,
INVALID_SHOP_DOMAIN_CODE,
UNSAFE_SHOP_URL_CODE,
type ShopifyCredentials,
type ShopifySession,
} from '../lib/api-client'
// The SSRF guard resolves DNS. Stub the validator (same seam the webhook
// dispatcher tests use) so tests are deterministic and offline; a dedicated
// test below flips it to a private-address verdict.
const guard = vi.hoisted(() => ({ validateUrl: vi.fn() }))
vi.mock('@/lib/webhooks/url-guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/webhooks/url-guard')>()
return {
...actual,
validateWebhookUrl: (...args: unknown[]) => guard.validateUrl(...args),
}
})
const CREDS: ShopifyCredentials = {
shopDomain: 'minbutik.myshopify.com',
clientId: 'client-id',
@@ -34,6 +48,12 @@ const fetchMock = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', fetchMock)
guard.validateUrl.mockReset()
guard.validateUrl.mockImplementation(async (rawUrl: string) => ({
ok: true,
hostname: new URL(rawUrl).hostname,
resolvedAddresses: ['203.0.113.10'],
}))
})
afterEach(() => {
@@ -89,6 +109,9 @@ describe('exchangeAccessToken', () => {
client_secret: 'client-secret',
grant_type: 'client_credentials',
})
// Guarded transport: no redirect following, DNS check ran for this URL.
expect((init as RequestInit).redirect).toBe('manual')
expect(guard.validateUrl).toHaveBeenCalledWith(url, undefined)
})
it('classifies a non-retryable 4xx as revoked credentials', async () => {
@@ -111,6 +134,33 @@ describe('exchangeAccessToken', () => {
expect((error as ShopifyApiError).status).toBe(429)
expect(isRevokedCredentialsError(error)).toBe(false)
}, 15_000)
it('refuses a stored shop_domain that is not a myshopify.com host (edited outside the app) before any fetch', async () => {
for (const shopDomain of ['minbutik.se', '10.0.0.5', 'https://evil.example.com/x.myshopify.com', '']) {
const error = await exchangeAccessToken({ ...CREDS, shopDomain }).catch((e) => e)
expect(error).toBeInstanceOf(ShopifyApiError)
expect((error as ShopifyApiError).code).toBe(INVALID_SHOP_DOMAIN_CODE)
expect((error as ShopifyApiError).status).toBe(0)
// Not a credentials problem: the row is malformed, not revoked upstream.
expect(isRevokedCredentialsError(error)).toBe(false)
}
expect(fetchMock).not.toHaveBeenCalled()
})
it('refuses a myshopify.com host that resolves to a private address, without retrying', async () => {
guard.validateUrl.mockResolvedValue({
ok: false,
reason: 'private_address',
detail: 'Resolved address 10.1.2.3 for minbutik.myshopify.com is not publicly routable (private_address).',
})
const error = await exchangeAccessToken(CREDS).catch((e) => e)
expect(error).toBeInstanceOf(ShopifyApiError)
expect((error as ShopifyApiError).code).toBe(UNSAFE_SHOP_URL_CODE)
expect(fetchMock).not.toHaveBeenCalled()
expect(guard.validateUrl).toHaveBeenCalledTimes(1)
})
})
describe('shopifyGraphQL', () => {
@@ -126,6 +176,7 @@ describe('shopifyGraphQL', () => {
expect((init as RequestInit).headers).toMatchObject({
'X-Shopify-Access-Token': 'token-1',
})
expect((init as RequestInit).redirect).toBe('manual')
})
it('retries a THROTTLED response before surfacing data', async () => {
@@ -154,6 +205,29 @@ describe('shopifyGraphQL', () => {
expect(isRevokedCredentialsError(error)).toBe(true)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('treats a redirect as a failure, not a hop, and does not retry it', async () => {
fetchMock.mockResolvedValueOnce(
new Response(null, { status: 307, headers: { Location: 'http://10.0.0.1/' } }),
)
const error = await shopifyGraphQL(SESSION, 'query { ok }').catch((e) => e)
expect(error).toBeInstanceOf(ShopifyApiError)
expect((error as ShopifyApiError).code).toBe(UNSAFE_SHOP_URL_CODE)
expect(isRevokedCredentialsError(error)).toBe(false)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('refuses a session whose shop domain is not a myshopify.com host before any fetch', async () => {
const error = await shopifyGraphQL(
{ ...SESSION, shopDomain: 'internal.example' },
'query { ok }',
).catch((e) => e)
expect((error as ShopifyApiError).code).toBe(INVALID_SHOP_DOMAIN_CODE)
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe('listOrdersPage', () => {
+62 -3
View File
@@ -1,8 +1,16 @@
import { isUnsafeUrlError, safeFetch } from '@/lib/http/safe-fetch'
import type { ShopifyOrder, ShopifyShopInfo } from '../types'
/**
* Minimal Shopify GraphQL Admin API client for the order feed.
*
* The shop domain is tenant input that the server connects to, and members
* can write `shopify_connections.shop_domain` directly through PostgREST
* (bypassing the connect route's normalisation), so every request here
* re-normalises the stored domain to `<handle>.myshopify.com` and goes
* through `safeFetch`: public addresses only, checked at request time, and no
* redirects followed.
*
* Auth is the client credentials grant: the merchant creates a custom app in
* their own Shopify Dev Dashboard (the admin-created custom apps with
* revealable shpat_ tokens were discontinued 2026-01-01) and pastes the app's
@@ -91,12 +99,56 @@ export function normalizeShopDomain(input: string): string | null {
return /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(value) ? value : null
}
/** Error code on a ShopifyApiError when the stored shop domain fails re-normalisation. */
export const INVALID_SHOP_DOMAIN_CODE = 'accounted_invalid_shop_domain'
/** Error code on a ShopifyApiError when the SSRF guard refused to connect. */
export const UNSAFE_SHOP_URL_CODE = 'accounted_unsafe_shop_url'
/**
* Re-run the connect-time normalisation on the STORED shop domain at use
* time and return the https origin to call. The connect route normalises what
* the user typed, but a member can PATCH `shop_domain` straight into the row
* through PostgREST, so the database value is not trusted to still be a
* myshopify.com host. Anything else is refused with a clear, non-retryable
* error instead of fetched.
*/
function shopOriginOf(shopDomain: string): string {
const normalized = normalizeShopDomain(shopDomain)
if (!normalized) {
throw new ShopifyApiError(
`Shopify shop domain is not a myshopify.com domain (${shopDomain}); reconnect the store`,
0,
INVALID_SHOP_DOMAIN_CODE,
)
}
return `https://${normalized}`
}
/**
* Map a failure from postJson to the error the retry loop should see. Guard
* refusals (private address, redirect) are terminal: retrying the same URL
* cannot succeed and must not spend the backoff budget.
*/
function asTerminalGuardError(err: unknown): ShopifyApiError | null {
if (err instanceof ShopifyApiError) return err
if (isUnsafeUrlError(err)) {
return new ShopifyApiError(
`Shopify host refused by outbound URL guard: ${err.detail}`,
0,
UNSAFE_SHOP_URL_CODE,
)
}
return null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function postJson(url: string, body: unknown, headers: Record<string, string>) {
return fetch(url, {
// safeFetch: public address only (checked now, not at connect time), no
// redirects. A 3xx from the host is a failure, never a hop.
return safeFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
body: JSON.stringify(body),
@@ -112,12 +164,14 @@ async function postJson(url: string, body: unknown, headers: Record<string, stri
* the normal backoff schedule.
*/
export async function exchangeAccessToken(creds: ShopifyCredentials): Promise<string> {
// Throws (non-retryable) when the stored domain is not a myshopify.com host.
const url = `${shopOriginOf(creds.shopDomain)}/admin/oauth/access_token`
let lastError: unknown
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
let response: Response
try {
response = await postJson(
`https://${creds.shopDomain}/admin/oauth/access_token`,
url,
{
client_id: creds.clientId,
client_secret: creds.clientSecret,
@@ -126,6 +180,8 @@ export async function exchangeAccessToken(creds: ShopifyCredentials): Promise<st
{},
)
} catch (err) {
const terminal = asTerminalGuardError(err)
if (terminal) throw terminal
lastError = new ShopifyApiError(
`Shopify token exchange failed: ${err instanceof Error ? err.message : String(err)}`,
0,
@@ -203,7 +259,8 @@ export async function shopifyGraphQL<T>(
query: string,
variables: Record<string, unknown> = {},
): Promise<T> {
const url = `https://${session.shopDomain}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`
// Throws (non-retryable) when the stored domain is not a myshopify.com host.
const url = `${shopOriginOf(session.shopDomain)}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`
let lastError: unknown
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
let response: Response
@@ -212,6 +269,8 @@ export async function shopifyGraphQL<T>(
'X-Shopify-Access-Token': session.accessToken,
})
} catch (err) {
const terminal = asTerminalGuardError(err)
if (terminal) throw terminal
lastError = new ShopifyApiError(
`Shopify request failed: ${err instanceof Error ? err.message : String(err)}`,
0,
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// after() must be observable: the callback hands it the eager refresh
// promise so the serverless function stays alive past the response.
@@ -132,6 +132,12 @@ function callbackRequest(params: string) {
describe('skatteverket OAuth callback', () => {
beforeEach(() => {
vi.clearAllMocks()
// Hosted shape: the OAuth redirect_uri is pinned to a different host than
// the app (app.gnubok.se vs app.accounted.se), so the callback cannot
// expect the app's session cookies. The same-origin tests below override.
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example')
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'https://oauth.example')
vi.spyOn(console, 'warn').mockImplementation(() => {})
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
@@ -146,6 +152,11 @@ describe('skatteverket OAuth callback', () => {
})
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('responds with the success page WITHOUT awaiting the post-connect refresh', async () => {
// A refresh that never settles: if the handler regressed to awaiting it,
// this test would hang into the vitest timeout instead of passing.
@@ -176,9 +187,10 @@ 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 stored oauth_user_id resolved the user. The cookie session is
// consulted only to catch a DIFFERENT signed-in user; on the pinned OAuth
// host it is empty, and that must not block the flow.
expect(mockCreateClient).toHaveBeenCalledTimes(1)
// 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
@@ -334,6 +346,83 @@ describe('skatteverket OAuth callback', () => {
expect(mockExchange).not.toHaveBeenCalled()
expect(mockStoreTokens).not.toHaveBeenCalled()
})
// The state row names who started the flow; the tokens are stored for that
// user with the service client. The browser finishing the flow must be that
// user whenever a session can be read at all.
describe('binding the completion to the initiator', () => {
it('finalises when the signed-in user is the one who started the flow', async () => {
mockCreateClient.mockResolvedValue(makeCookieClient('user-1') as any)
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 } as any)
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
expect(await response.text()).toContain('skatteverket-oauth-success')
expect(mockExchange).toHaveBeenCalledTimes(1)
expect(mockStoreTokens).toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.objectContaining({ access_token: 'at' }),
'company-1',
)
})
it('refuses a completion by a different signed-in user, on any host', async () => {
// The victim (user-2) was lured into approving user-1's consent.
mockCreateClient.mockResolvedValue(makeCookieClient('user-2') 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(html).toContain('annat användarkonto')
// Refused before the exchange: the one-shot code is not burned and no
// token is written under the initiator's id.
expect(mockExchange).not.toHaveBeenCalled()
expect(mockStoreTokens).not.toHaveBeenCalled()
expect(mockRefresh).not.toHaveBeenCalled()
})
it('sends a session-less completion to login when the OAuth host IS the app host', async () => {
// Self-hosted shape (or the hosted pin removed): the initiator's cookies
// do arrive here, so an empty session means someone else is finishing it.
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'https://app.example')
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(307)
const location = new URL(response.headers.get('location') as string)
expect(location.origin).toBe('https://app.example')
expect(location.pathname).toBe('/login')
// The state row is untouched until the exchange, so signing in and
// re-running this exact callback completes the flow for its initiator.
expect(location.searchParams.get('next')).toBe(
`/api/extensions/ext/skatteverket/callback?code=abc&state=${STATE}`,
)
expect(mockExchange).not.toHaveBeenCalled()
expect(mockStoreTokens).not.toHaveBeenCalled()
})
it('keeps tolerating a missing session on the pinned OAuth host (no cookies can arrive)', async () => {
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 } as any)
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
expect(await response.text()).toContain('skatteverket-oauth-success')
expect(mockExchange).toHaveBeenCalledTimes(1)
})
})
})
// Connector branch: a self-hosted instance's SKV consent, started through the
+41 -2
View File
@@ -14,6 +14,10 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth'
import { skatteverketConnectorMode, startConnectorAuthorization } from './lib/connector-mode'
import { isConnectorState, verifyConnectorState } from '@/lib/connect/hosted/state'
import {
requireFlowInitiator,
FLOW_INITIATOR_MISMATCH_MESSAGE,
} from '@/lib/auth/oauth-flow-binding'
import { storeTokens, getTokens, deleteTokens, getTokenHealth } from './lib/token-store'
import { skvRequest, skvRequestWithAuth, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client'
import { writeSkatteverketAudit } from './lib/audit'
@@ -558,8 +562,43 @@ export const skatteverketExtension: Extension = {
// 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 storedUserId = await readSetting('oauth_user_id')
let userId = storedUserId
if (storedUserId) {
// The state row names the user who started the flow; the tokens
// below are stored for that user with the service client. Bind the
// completion to that user's own session so a victim lured into
// approving a Skatteverket consent someone else started cannot have
// their BankID-authorised access stored under that someone.
//
// Hosted, this callback is served on the pinned OAuth host
// (getSkvOauthBaseUrl, app.gnubok.se) where the app's session
// cookies never arrive: a missing session proves nothing there and
// the single-use state + membership check stay the guard. Where the
// OAuth host IS the app host (self-hosted, or the pin removed) the
// initiator's cookies do arrive, so no session means the initiator
// is not the one finishing the flow. A session for a DIFFERENT user
// is refused on every host.
const initiator = await requireFlowInitiator(request, storedUserId, {
flow: 'skatteverket.callback',
})
if (!initiator.ok) {
const sessionExpected =
new URL(getSkvOauthBaseUrl()).origin === new URL(appUrl).origin
if (initiator.reason === 'mismatch') {
return respondWithError(
FLOW_INITIATOR_MISMATCH_MESSAGE,
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent(FLOW_INITIATOR_MISMATCH_MESSAGE)}`,
)
}
if (sessionExpected) {
// The state row is untouched until the exchange, so signing in
// and re-running this callback (the helper's /login?next=...)
// completes the flow for its initiator.
return initiator.response
}
}
} else {
const cookieClient = await createClient()
const { data: { user } } = await cookieClient.auth.getUser()
userId = user?.id ?? null
@@ -15,6 +15,21 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
// The confirmation mail (generateLink + platform email service + brand
// lookup) is covered in bankid-confirmation-mail.test.ts; here it is a seam
// so the route tests can assert WHEN it is sent and to WHOM.
vi.mock('../lib/bankid-confirmation-mail', () => ({
sendBankIdSignupConfirmation: vi.fn(),
}))
// The invite-only brand gate reads the brands table for any non-empty host;
// it has its own tests. Open here so a forwarded host can be asserted on the
// mail without a database.
vi.mock('@/lib/auth/brand-signup-gate', () => ({
evaluateBrandSignupGate: vi.fn().mockResolvedValue({ allowed: true }),
readInviteTokenFromCookieHeader: vi.fn().mockReturnValue(null),
}))
import {
cancelBankIdSession,
collectBankIdResult,
@@ -23,6 +38,7 @@ import {
fetchEnrichmentData,
startBankIdAuth,
} from '../lib/bankid-client'
import { sendBankIdSignupConfirmation } from '../lib/bankid-confirmation-mail'
import { createServiceClient } from '@/lib/supabase/server'
import { ticExtension } from '../index'
import {
@@ -141,9 +157,13 @@ function mockServiceClient(
return { admin, client }
}
/** A verified identity row, as every pre-2026-09 row is after the backfill. */
const VERIFIED_AT = '2026-01-01T00:00:00Z'
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('BANKID_ENCRYPTION_KEY', TEST_KEY)
vi.mocked(sendBankIdSignupConfirmation).mockResolvedValue({ ok: true })
})
afterEach(() => {
@@ -213,37 +233,71 @@ describe('POST /bankid/complete', () => {
})
})
describe('signup mode: happy path', () => {
it('creates a new user, marks bankid_linked, and returns the magic link tokenHash', async () => {
describe('signup mode: happy path (account pre-hijacking fix, audit 2026-09)', () => {
it('creates an UNCONFIRMED user with a PENDING identity, mails the typed address, and returns no session', async () => {
// The address came from the request body and nothing proved it belongs
// to the BankID holder. The old flow confirmed it, granted the MFA
// exemption and handed the browser a magic link; the real owner of the
// address could later adopt the account while the BankID kept a login.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
const { admin, client } = mockServiceClient([
{ data: null }, // pnr lookup → not linked
{ error: null }, // bankid_identities insert OK
])
const insertSpy = vi.fn().mockResolvedValue({ error: null })
const origFrom = client.from as unknown as ReturnType<typeof vi.fn>
const queuedFrom = origFrom.getMockImplementation() as (table: string) => unknown
let identityCalls = 0
origFrom.mockImplementation((table: string) => {
if (table === 'bankid_identities' && ++identityCalls === 2) {
return { insert: insertSpy }
}
return queuedFrom(table)
})
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'fresh@example.com' },
headers: { ...(await flowCookie('signup')), 'x-forwarded-host': 'app.gnubok.se', 'x-forwarded-proto': 'https' },
body: { email: 'Fresh@Example.com ' },
})
const response = await findCompleteHandler()(req)
const raw = await response.clone().text()
const { status, body } = await parseJsonResponse<{
data?: { tokenHash?: string; type?: string; isNewUser?: boolean }
}>(await findCompleteHandler()(req))
data?: { status?: string; email?: string; tokenHash?: string }
}>(response)
expect(status).toBe(200)
expect(body.data?.tokenHash).toBe('magic-token-hash')
expect(body.data?.type).toBe('magiclink')
expect(body.data?.isNewUser).toBe(true)
// Same shape as POST /api/auth/signup: the register page shows its
// "check your inbox" screen. Nothing here signs anyone in.
expect(body.data).toEqual({ status: 'confirmation_sent', email: 'fresh@example.com' })
expect(raw).not.toContain('magic-token-hash')
expect(raw).not.toContain('tokenHash')
// The route itself mints no link; only the mail helper does, server-side.
expect(admin.generateLink).not.toHaveBeenCalled()
expect(admin.createUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'fresh@example.com', email_confirm: true })
expect.objectContaining({ email: 'fresh@example.com', email_confirm: false })
)
// Pending, not linked: bankid_linked (the MFA exemption) is granted by
// /auth/callback once the mail is clicked.
expect(admin.updateUserById).toHaveBeenCalledWith(
'new-user-uuid',
expect.objectContaining({
app_metadata: { bankid_linked: true, has_password: false },
app_metadata: { bankid_pending: true, has_password: false },
})
)
expect(insertSpy).toHaveBeenCalledWith(
expect.objectContaining({ user_id: 'new-user-uuid', email_verified_at: null })
)
expect(insertSpy.mock.calls[0][0]).not.toHaveProperty('bankid_linked')
expect(sendBankIdSignupConfirmation).toHaveBeenCalledTimes(1)
expect(sendBankIdSignupConfirmation).toHaveBeenCalledWith({
supabase: client,
email: 'fresh@example.com',
host: 'app.gnubok.se',
proto: 'https',
})
expect(admin.deleteUser).not.toHaveBeenCalled()
})
})
@@ -251,7 +305,7 @@ describe('POST /bankid/complete', () => {
it('returns 409 already_linked before email lookup', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin, client } = mockServiceClient([
{ data: { user_id: 'some-other-user' } }, // pnr lookup → LINKED
{ data: { user_id: 'some-other-user', email_verified_at: VERIFIED_AT } }, // pnr lookup → LINKED
])
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
@@ -300,28 +354,32 @@ describe('POST /bankid/complete', () => {
expect(admin.generateLink).not.toHaveBeenCalled()
})
it('deletes the created user when generateLink fails', async () => {
it('deletes the created user when the confirmation mail cannot be sent', async () => {
// An account whose address will never receive its confirmation link is
// unusable AND blocks the address; roll it back so a retry starts clean.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: null }, // pnr lookup → not linked
{ error: null }, // identity insert OK
])
admin.generateLink.mockResolvedValueOnce({
data: null,
error: { message: 'link boom' },
} as never)
vi.mocked(sendBankIdSignupConfirmation).mockResolvedValueOnce({
ok: false,
step: 'send',
message: 'Email service not configured',
})
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'fresh@example.com' },
})
const { status, body } = await parseJsonResponse<{ error?: string }>(
const { status, body } = await parseJsonResponse<{ error?: string; message?: string }>(
await findCompleteHandler()(req)
)
expect(status).toBe(500)
expect(body.error).toBe('internal_error')
expect(body.message).toBe('Kunde inte skicka bekräftelsemailet. Försök igen.')
expect(admin.deleteUser).toHaveBeenCalledWith('new-user-uuid')
})
@@ -388,6 +446,323 @@ describe('POST /bankid/complete', () => {
expect(body.error).toBe('no_account')
expect(admin.generateLink).not.toHaveBeenCalled()
})
it('answers 503 (not no_account) when the identity lookup itself fails', async () => {
// A schema behind the code or a lost connection must not send every
// returning BankID user to signup, nor create anything.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: null, error: { code: '42703', message: 'column "email_verified_at" does not exist' } },
])
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
)
)
expect(status).toBe(503)
expect(body.error).toBe('service_unavailable')
expect(admin.generateLink).not.toHaveBeenCalled()
expect(admin.createUser).not.toHaveBeenCalled()
})
it('treats PGRST116 (no row) as not linked', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
mockServiceClient([
{ data: null, error: { code: 'PGRST116', message: 'JSON object requested, multiple (or no) rows returned' } },
])
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
)
)
expect(status).toBe(404)
expect(body.error).toBe('no_account')
})
it('signs a VERIFIED identity in with a magic link, as before', async () => {
// Every identity that existed before the pending column was added is
// backfilled as verified; their login must be byte-identical.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'existing-user', email_verified_at: VERIFIED_AT } },
])
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
const { status, body } = await parseJsonResponse<{
data?: { tokenHash?: string; type?: string; isNewUser?: boolean }
}>(await findCompleteHandler()(req))
expect(status).toBe(200)
expect(body.data).toEqual({ tokenHash: 'magic-token-hash', type: 'magiclink', isNewUser: false })
expect(admin.generateLink).toHaveBeenCalledWith({
type: 'magiclink',
email: 'existing@example.com',
})
expect(sendBankIdSignupConfirmation).not.toHaveBeenCalled()
})
})
describe('pending identities (address never proven, audit 2026-09)', () => {
/** The auth user a BankID signup leaves behind until the mail is clicked. */
const pendingShell = {
id: 'pending-user',
email: 'typed@example.com',
email_confirmed_at: undefined,
identities: [{ provider: 'email' }],
app_metadata: { bankid_pending: true, has_password: false },
}
function clearedFlow(response: Response): boolean {
return response.headers
.getSetCookie()
.some((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`) && /Max-Age=0/i.test(c))
}
it('login: refuses a pending identity, re-sends the confirmation mail, and mints nothing', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin, client } = mockServiceClient([
{ data: { user_id: 'pending-user', email_verified_at: null } },
])
admin.getUserById.mockResolvedValue({ data: { user: pendingShell }, error: null } as never)
const response = await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: { ...(await flowCookie('login')), 'x-forwarded-host': 'app.gnubok.se' },
})
)
const raw = await response.clone().text()
const { status, body } = await parseJsonResponse<{ error?: string; message?: string }>(response)
expect(status).toBe(403)
expect(body.error).toBe('email_unconfirmed')
expect(body.message).toMatch(/^Bekräfta din e-postadress först/)
expect(raw).not.toContain('tokenHash')
expect(admin.generateLink).not.toHaveBeenCalled()
expect(sendBankIdSignupConfirmation).toHaveBeenCalledWith(
expect.objectContaining({ email: 'typed@example.com', host: 'app.gnubok.se' })
)
// The flag was already set, so nothing to heal; the row stays for the click.
expect(admin.updateUserById).not.toHaveBeenCalled()
expect(vi.mocked(client.from).mock.calls.map((c) => c[0])).toEqual(['bankid_identities'])
// Terminal for this identification: the flow is spent.
expect(clearedFlow(response)).toBe(true)
})
it('login: heals a missing bankid_pending flag before re-sending (rows from the old flow)', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'legacy-user', email_verified_at: null } },
])
admin.getUserById.mockResolvedValue({
data: {
user: {
...pendingShell,
id: 'legacy-user',
email_confirmed_at: '2026-09-02T08:00:00Z',
app_metadata: { bankid_linked: true, has_password: false },
},
},
error: null,
} as never)
const { status } = await parseJsonResponse(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
)
)
expect(status).toBe(403)
// /auth/callback only promotes when the flag is present.
expect(admin.updateUserById).toHaveBeenCalledWith('legacy-user', {
app_metadata: { bankid_linked: true, has_password: false, bankid_pending: true },
})
expect(sendBankIdSignupConfirmation).toHaveBeenCalledTimes(1)
})
it('login: revokes the pending link and answers no_account once the address owner adopted the account', async () => {
// The victim signed in with Google (or set a password): the account is
// theirs. The BankID holder who typed their address gets no login into
// it, now or after any later confirmation click.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin, client } = mockServiceClient([
{ data: { user_id: 'victim-user', email_verified_at: null } }, // pnr lookup → pending
{ error: null }, // bankid_identities delete OK
])
admin.getUserById.mockResolvedValue({
data: {
user: {
...pendingShell,
id: 'victim-user',
email: 'victim@example.com',
identities: [{ provider: 'email' }, { provider: 'google' }],
},
},
error: null,
} as never)
const { status, body } = await parseJsonResponse<{ error?: string; givenName?: string }>(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
)
)
expect(status).toBe(404)
expect(body.error).toBe('no_account')
expect(body.givenName).toBe('Anna')
expect(admin.generateLink).not.toHaveBeenCalled()
expect(sendBankIdSignupConfirmation).not.toHaveBeenCalled()
// Row deleted, flag dropped, no MFA exemption granted.
expect(vi.mocked(client.from).mock.calls.map((c) => c[0])).toEqual([
'bankid_identities',
'bankid_identities',
])
expect(admin.updateUserById).toHaveBeenCalledWith('victim-user', {
app_metadata: { bankid_pending: null, has_password: false },
})
expect(admin.deleteUser).not.toHaveBeenCalled()
})
it('login: a pending identity whose user set a password is treated as adopted', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'victim-user', email_verified_at: null } },
{ error: null },
])
admin.getUserById.mockResolvedValue({
data: {
user: { ...pendingShell, id: 'victim-user', app_metadata: { bankid_pending: true, has_password: true } },
},
error: null,
} as never)
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
)
)
expect(status).toBe(404)
expect(body.error).toBe('no_account')
expect(sendBankIdSignupConfirmation).not.toHaveBeenCalled()
})
it('signup: replaces a stale unadopted pending account from the same BankID (typo, lost mail)', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'stale-user', email_verified_at: null } }, // pnr lookup → pending
{ error: null }, // new bankid_identities insert OK
])
admin.getUserById.mockResolvedValue({
data: { user: { ...pendingShell, id: 'stale-user', email: 'typo@exmaple.com' } },
error: null,
} as never)
const { status, body } = await parseJsonResponse<{ data?: { status?: string } }>(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'correct@example.com' },
})
)
)
expect(status).toBe(200)
expect(body.data?.status).toBe('confirmation_sent')
// The unconfirmed shell (cascades its identity row) goes; a fresh one is made.
expect(admin.deleteUser).toHaveBeenCalledTimes(1)
expect(admin.deleteUser).toHaveBeenCalledWith('stale-user')
expect(admin.createUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'correct@example.com', email_confirm: false })
)
expect(sendBankIdSignupConfirmation).toHaveBeenCalledWith(
expect.objectContaining({ email: 'correct@example.com' })
)
})
it('signup: never deletes an adopted account; only the pending link is removed before the new signup', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin, client } = mockServiceClient([
{ data: { user_id: 'victim-user', email_verified_at: null } }, // pnr lookup → pending
{ error: null }, // pending row delete OK
{ error: null }, // new bankid_identities insert OK
])
admin.getUserById.mockResolvedValue({
data: {
user: {
...pendingShell,
id: 'victim-user',
email_confirmed_at: '2026-09-01T00:00:00Z',
identities: [{ provider: 'email' }, { provider: 'google' }],
},
},
error: null,
} as never)
const { status } = await parseJsonResponse(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'mine@example.com' },
})
)
)
expect(status).toBe(200)
expect(admin.deleteUser).not.toHaveBeenCalled()
expect(vi.mocked(client.from).mock.calls.filter((c) => c[0] === 'bankid_identities')).toHaveLength(3)
expect(admin.updateUserById).toHaveBeenCalledWith('victim-user', {
app_metadata: { bankid_pending: null, has_password: false },
})
expect(admin.createUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'mine@example.com' })
)
})
it('signup: fails closed (500, flow kept) when the stale pending link cannot be cleared', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'stale-user', email_verified_at: null } },
])
admin.getUserById.mockResolvedValue({ data: { user: { ...pendingShell, id: 'stale-user' } }, error: null } as never)
admin.deleteUser.mockResolvedValueOnce({ data: null, error: { message: 'boom' } } as never)
const response = await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'correct@example.com' },
})
)
expect(response.status).toBe(500)
expect(admin.createUser).not.toHaveBeenCalled()
expect(clearedFlow(response)).toBe(false)
})
})
describe('enrichment: SPAR + CompanyRoles', () => {
@@ -457,11 +832,11 @@ describe('POST /bankid/complete', () => {
body: { email: 'fresh@example.com' },
})
const { status, body } = await parseJsonResponse<{
data?: { tokenHash?: string; isNewUser?: boolean }
data?: { status?: string }
}>(await findCompleteHandler()(req))
expect(status).toBe(200)
expect(body.data?.isNewUser).toBe(true)
expect(body.data?.status).toBe('confirmation_sent')
expect(vi.mocked(requestEnrichment)).toHaveBeenCalledWith(
'test-session',
['SPAR', 'CompanyRoles']
@@ -586,9 +961,9 @@ describe('POST /bankid/complete', () => {
expect(collectBankIdResult).not.toHaveBeenCalled()
})
it('spends the flow on success, so a second tab cannot mint a rival magic link', async () => {
it('spends the flow on success, so a second tab cannot send a rival confirmation mail', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
mockServiceClient([
{ data: null }, // pnr lookup → not linked
{ error: null }, // bankid_identities insert OK
])
@@ -602,9 +977,9 @@ describe('POST /bankid/complete', () => {
)
expect(response.status).toBe(200)
expect(admin.generateLink).toHaveBeenCalled()
expect(sendBankIdSignupConfirmation).toHaveBeenCalledTimes(1)
// Without this, two tabs that both saw 'complete' would each mint a
// magic link and the second would invalidate the first.
// link and the second would invalidate the first.
expect(clearedFlow(response)).toBe(true)
})
@@ -615,7 +990,7 @@ describe('POST /bankid/complete', () => {
// magic link that would invalidate the first tab's.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient(
[{ data: { user_id: 'existing-user' } }], // pnr is linked
[{ data: { user_id: 'existing-user', email_verified_at: VERIFIED_AT } }], // pnr is linked
{ error: { code: '23505', message: 'duplicate key' } },
)
@@ -662,7 +1037,7 @@ describe('POST /bankid/complete', () => {
// authenticate again, so an unreachable table must not be waved through.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient(
[{ data: { user_id: 'existing-user' } }],
[{ data: { user_id: 'existing-user', email_verified_at: VERIFIED_AT } }],
{ error: { code: '42P01', message: 'relation does not exist' } },
)
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
const sendEmailMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ sendEmail: sendEmailMock, isConfigured: () => true }),
}))
const resolveBrandByHostMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandByHost: resolveBrandByHostMock,
// Imported by lib/email/brand-sender (not called on this path).
resolveBrandForCompany: vi.fn(),
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appName: 'Accounted', appUrl: 'https://app.gnubok.se' }),
}))
import {
buildConfirmationUrl,
sendBankIdSignupConfirmation,
} from '../lib/bankid-confirmation-mail'
function serviceClient(generateLinkResult: unknown) {
const generateLink = vi.fn().mockResolvedValue(generateLinkResult)
return {
generateLink,
supabase: { auth: { admin: { generateLink } } } as unknown as SupabaseClient,
}
}
const LINK_OK = { data: { properties: { hashed_token: 'hashed-123' } }, error: null }
beforeEach(() => {
vi.clearAllMocks()
resolveBrandByHostMock.mockResolvedValue(null)
sendEmailMock.mockResolvedValue({ success: true, messageId: 'm-1' })
})
describe('buildConfirmationUrl', () => {
it('lands on the originating host with the token_hash + magiclink verify pattern', () => {
expect(buildConfirmationUrl('app.siffra.se', 'https', 'tok')).toBe(
'https://app.siffra.se/auth/callback?token_hash=tok&type=magiclink',
)
})
it('defaults to https when the proxy did not forward a protocol', () => {
expect(buildConfirmationUrl('app.siffra.se', null, 'tok')).toMatch(/^https:\/\/app\.siffra\.se\//)
})
it('falls back to the canonical app URL without a host', () => {
expect(buildConfirmationUrl('', undefined, 'tok')).toBe(
'https://app.gnubok.se/auth/callback?token_hash=tok&type=magiclink',
)
})
})
describe('sendBankIdSignupConfirmation', () => {
it('mints a magic link server-side and mails it to the typed address, never returning the token', async () => {
const { supabase, generateLink } = serviceClient(LINK_OK)
const result = await sendBankIdSignupConfirmation({
supabase,
email: 'fresh@example.com',
host: 'app.gnubok.se',
proto: 'https',
})
expect(result).toEqual({ ok: true })
expect(generateLink).toHaveBeenCalledWith({ type: 'magiclink', email: 'fresh@example.com' })
expect(sendEmailMock).toHaveBeenCalledTimes(1)
const mail = sendEmailMock.mock.calls[0][0]
expect(mail.to).toBe('fresh@example.com')
expect(mail.subject).toBe('Bekräfta din e-postadress')
expect(mail.text).toContain(
'https://app.gnubok.se/auth/callback?token_hash=hashed-123&type=magiclink',
)
expect(mail.text).toContain('BankID')
// Platform sender: no brand on the canonical host.
expect(mail.fromName).toBeUndefined()
expect(mail.fromAddress).toBeUndefined()
})
it('sends in the brand of the requesting host', async () => {
resolveBrandByHostMock.mockResolvedValue({
appName: 'Siffra',
domain: 'app.siffra.se',
supportEmail: 'support@siffra.se',
authEmailFrom: 'noreply@post.siffra.se',
senderDomainStatus: 'verified',
})
const { supabase } = serviceClient(LINK_OK)
await sendBankIdSignupConfirmation({
supabase,
email: 'fresh@example.com',
host: 'app.siffra.se',
proto: 'https',
})
expect(resolveBrandByHostMock).toHaveBeenCalledWith('app.siffra.se')
const mail = sendEmailMock.mock.calls[0][0]
expect(mail.fromName).toBe('Siffra')
expect(mail.fromAddress).toBe('noreply@post.siffra.se')
expect(mail.replyTo).toBe('support@siffra.se')
expect(mail.text).toContain('https://app.siffra.se/auth/callback?token_hash=hashed-123')
expect(mail.html).not.toMatch(/accounted/i)
})
it('reports a generateLink failure without sending anything', async () => {
const { supabase } = serviceClient({ data: null, error: { message: 'link boom', code: 'x' } })
const result = await sendBankIdSignupConfirmation({
supabase,
email: 'fresh@example.com',
host: '',
})
expect(result).toEqual({ ok: false, step: 'generate_link', message: 'link boom' })
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('reports a send failure so the caller can roll the signup back', async () => {
sendEmailMock.mockResolvedValue({ success: false, error: 'Email service not configured' })
const { supabase } = serviceClient(LINK_OK)
const result = await sendBankIdSignupConfirmation({
supabase,
email: 'fresh@example.com',
host: '',
})
expect(result).toEqual({ ok: false, step: 'send', message: 'Email service not configured' })
})
})
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
hasForeignCredential,
isUnadoptedPendingAccount,
revokePendingIdentity,
} from '../lib/bankid-pending'
function identity(provider: string) {
return { provider } as unknown as NonNullable<
Parameters<typeof hasForeignCredential>[0]['identities']
>[number]
}
describe('hasForeignCredential', () => {
it('is false for the shell a BankID signup makes (email identity, no password)', () => {
expect(
hasForeignCredential({
identities: [identity('email')],
app_metadata: { bankid_pending: true, has_password: false },
email_confirmed_at: undefined,
}),
).toBe(false)
})
it('is false when identities are absent altogether', () => {
expect(hasForeignCredential({ app_metadata: {} })).toBe(false)
})
it('is true once a Google identity is attached', () => {
expect(
hasForeignCredential({
identities: [identity('email'), identity('google')],
app_metadata: { bankid_pending: true },
}),
).toBe(true)
})
it('is true once the user has set a password themselves', () => {
expect(
hasForeignCredential({
identities: [identity('email')],
app_metadata: { bankid_pending: true, has_password: true },
}),
).toBe(true)
})
})
describe('isUnadoptedPendingAccount', () => {
it('is true only while the address is unconfirmed and no foreign credential exists', () => {
expect(
isUnadoptedPendingAccount({
identities: [identity('email')],
app_metadata: { bankid_pending: true, has_password: false },
email_confirmed_at: undefined,
}),
).toBe(true)
})
it('is false once the address was confirmed by any means', () => {
// A confirmed account is never deleted on re-signup, even without a
// foreign credential: the old flow admin-confirmed addresses it never
// proved, and those accounts may have data.
expect(
isUnadoptedPendingAccount({
identities: [identity('email')],
app_metadata: { bankid_linked: true },
email_confirmed_at: '2026-08-01T00:00:00Z',
}),
).toBe(false)
})
it('is false when a foreign credential exists even if unconfirmed', () => {
expect(
isUnadoptedPendingAccount({
identities: [identity('email')],
app_metadata: { has_password: true },
email_confirmed_at: undefined,
}),
).toBe(false)
})
})
describe('revokePendingIdentity', () => {
const updateUserById = vi.fn()
const calls: Array<{ method: string; args: unknown[] }> = []
let deleteResult: { error: { code?: string; message: string } | null }
function chain(): unknown {
return new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(deleteResult)
}
return (...args: unknown[]) => {
calls.push({ method: String(prop), args })
return chain()
}
},
},
)
}
const supabase = {
from: vi.fn(() => chain()),
auth: { admin: { updateUserById } },
} as unknown as SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
calls.length = 0
deleteResult = { error: null }
updateUserById.mockResolvedValue({ data: {}, error: null })
})
it('deletes only unverified rows for the user and drops bankid_pending, keeping the rest of app_metadata', async () => {
const ok = await revokePendingIdentity(supabase, 'user-1', {
bankid_pending: true,
has_password: true,
provider: 'email',
})
expect(ok).toBe(true)
expect(supabase.from).toHaveBeenCalledWith('bankid_identities')
expect(calls.map((c) => c.method)).toEqual(['delete', 'eq', 'is'])
expect(calls[1].args).toEqual(['user_id', 'user-1'])
expect(calls[2].args).toEqual(['email_verified_at', null])
expect(updateUserById).toHaveBeenCalledWith('user-1', {
app_metadata: { bankid_pending: null, has_password: true, provider: 'email' },
})
// Never grants the MFA exemption on the way out.
const written = updateUserById.mock.calls[0][1].app_metadata as Record<string, unknown>
expect(written.bankid_linked).toBeUndefined()
})
it('returns false and leaves app_metadata alone when the delete fails', async () => {
deleteResult = { error: { code: 'XX000', message: 'boom' } }
const ok = await revokePendingIdentity(supabase, 'user-1', { bankid_pending: true })
expect(ok).toBe(false)
expect(updateUserById).not.toHaveBeenCalled()
})
it('tolerates missing prior metadata', async () => {
const ok = await revokePendingIdentity(supabase, 'user-1', undefined)
expect(ok).toBe(true)
expect(updateUserById).toHaveBeenCalledWith('user-1', {
app_metadata: { bankid_pending: null },
})
})
})
+198 -43
View File
@@ -35,6 +35,12 @@ import {
setBankIdFlowCookies,
} from './lib/bankid-flow-cookie'
import { lookupCompanyByOrgNumber, registrationDateToMs } from './lib/lookup'
import {
hasForeignCredential,
isUnadoptedPendingAccount,
revokePendingIdentity,
} from './lib/bankid-pending'
import { sendBankIdSignupConfirmation } from './lib/bankid-confirmation-mail'
import { hashPersonalNumber, encryptPersonalNumberForStorage } from '@/lib/auth/bankid'
import {
evaluateBrandSignupGate,
@@ -43,11 +49,109 @@ import {
import { requireAuth } from '@/lib/auth/require-auth'
import { createServiceClient } from '@/lib/supabase/server'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { SupabaseClient, User } from '@supabase/supabase-js'
import crypto from 'crypto'
const log = createLogger('tic/bankid')
const SIGNUP_FAILED = { error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' }
/** Host the browser is on, as the proxy forwarded it. '' when unknown. */
function forwardedHost(request: Request): string {
return request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? ''
}
/**
* Login attempt by a BankID identity whose address was never proven
* (`email_verified_at IS NULL`, see lib/bankid-pending.ts). Never mints a
* session. Two outcomes:
*
* - The account has since been adopted through another credential (the real
* owner of the address set a password or signed in with Google): the
* pending link is revoked so it can never be promoted, and the BankID
* holder is told there is no account, which for them is now true.
* - Otherwise the account is still the unconfirmed shell the signup made:
* the confirmation mail is re-sent (best effort; every attempt costs a
* BankID identification, which bounds the volume) and the caller is asked
* to confirm first. The message surfaces in the login page, so Swedish.
*/
async function refusePendingLogin(
supabase: SupabaseClient,
userId: string,
user: User | null | undefined,
names: { givenName?: string; surname?: string },
request: Request,
): Promise<NextResponse> {
if (!user || hasForeignCredential(user)) {
if (user) {
log.warn('pending bankid identity revoked at login: account adopted by another credential', {
userId,
})
}
await revokePendingIdentity(supabase, userId, user?.app_metadata)
return NextResponse.json({ error: 'no_account', ...names }, { status: 404 })
}
if (user.email) {
// /auth/callback only looks for a pending identity when the flag is set.
// Rows created by the old flow (before the column existed) have the flag
// missing; heal it here so the re-sent mail can actually promote them.
if (user.app_metadata?.bankid_pending !== true) {
await supabase.auth.admin.updateUserById(userId, {
app_metadata: { ...(user.app_metadata ?? {}), bankid_pending: true },
})
}
const sent = await sendBankIdSignupConfirmation({
supabase,
email: user.email,
host: forwardedHost(request),
proto: request.headers.get('x-forwarded-proto'),
})
if (!sent.ok) {
log.warn('could not re-send bankid confirmation mail', { userId, step: sent.step })
}
}
return NextResponse.json(
{
error: 'email_unconfirmed',
message:
'Bekräfta din e-postadress först. Vi har skickat ett nytt bekräftelsemail till adressen du angav när kontot skapades.',
},
{ status: 403 }
)
}
/**
* The same BankID signs up again while an earlier signup is still pending
* (typo'd address, lost mail). Clear the stale link so this attempt can start
* over with the address typed now. The stale account is deleted outright only
* while it is still the unconfirmed shell the signup made (bankid_identities
* cascades); if somebody else has adopted it in the meantime, only the BankID
* link is removed and their account is left alone. False = could not clear.
*/
async function clearStalePendingSignup(supabase: SupabaseClient, userId: string): Promise<boolean> {
const { data } = await supabase.auth.admin.getUserById(userId)
const user = data?.user
if (user && isUnadoptedPendingAccount(user)) {
const { error } = await supabase.auth.admin.deleteUser(userId)
if (error) {
log.error('could not delete stale pending bankid signup', { userId, message: error.message })
return false
}
log.info('stale pending bankid signup deleted for re-signup', { userId })
return true
}
if (user) {
log.warn('pending bankid identity revoked at re-signup: account adopted by another credential', {
userId,
})
}
return revokePendingIdentity(supabase, userId, user?.app_metadata)
}
/**
* Claim a BankID session, atomically and exactly once.
*
@@ -1033,13 +1137,31 @@ export const ticExtension: Extension = {
const pnrHash = hashPersonalNumber(personalNumber)
const supabase = createServiceClient()
// Look up existing BankID identity
const { data: existing } = await supabase
// Look up existing BankID identity. email_verified_at NULL means the
// address on the account was never proven (pending signup): such an
// identity signs nobody in and is not "linked" for signup purposes.
const { data: existing, error: lookupError } = await supabase
.from('bankid_identities')
.select('user_id')
.select('user_id, email_verified_at')
.eq('personal_number_hash', pnrHash)
.single()
// PGRST116 is .single() finding no row, which is the normal "not
// linked" answer. Anything else (schema behind the code, connection
// lost) must not read as "no account": that would send every
// returning BankID user to signup. Not settled, so the completed
// identification survives a retry.
if (lookupError && lookupError.code !== 'PGRST116') {
log.error('bankid_identities lookup failed', {
code: lookupError.code,
message: lookupError.message,
})
return NextResponse.json(
{ error: 'service_unavailable', message: 'Tillfälligt fel. Försök igen om en stund.' },
{ status: 503 }
)
}
if (mode === 'login') {
if (!existing) {
// Terminal for a login flow: the user is sent to signup, which
@@ -1051,8 +1173,23 @@ export const ticExtension: Extension = {
}, { status: 404 }))
}
// Returning user: generate magic link
// Returning user. Load the account before deciding anything: a
// pending identity is refused (never a magic link), see
// refusePendingLogin.
const { data: userData } = await supabase.auth.admin.getUserById(existing.user_id)
if (existing.email_verified_at === null) {
return settle(
await refusePendingLogin(
supabase,
existing.user_id,
userData?.user,
{ givenName, surname },
request,
)
)
}
if (!userData?.user?.email) {
// Data problem, not a transient one: an identity with no user
// email will never complete. settle() so it is not re-offered as
@@ -1105,7 +1242,7 @@ export const ticExtension: Extension = {
}
// mode === 'signup'
if (existing) {
if (existing && existing.email_verified_at !== null) {
// Terminal: this BankID already has an account, so the answer is
// to sign in, not to retry this session.
return settle(NextResponse.json(
@@ -1114,6 +1251,17 @@ export const ticExtension: Extension = {
))
}
if (existing) {
// Pending identity from an earlier signup by this same BankID.
// Not settled: the identification is still good, only the stale
// link is in the way. A failure here is transient by nature.
if (!await clearStalePendingSignup(supabase, existing.user_id)) {
return NextResponse.json(SIGNUP_FAILED, { status: 500 })
}
}
const host = forwardedHost(request)
// Invite-only brand domain gate (founder decision 2026-08-27):
// same rule POST /api/auth/signup enforces on the email path. Runs
// AFTER the existing-identity check so a returning user's login is
@@ -1121,10 +1269,7 @@ export const ticExtension: Extension = {
// deliberately NOT settled, so the visitor keeps the completed
// BankID identification if the byrå allowlists them mid-flow.
const gateResult = await evaluateBrandSignupGate({
host:
request.headers.get('x-forwarded-host') ??
request.headers.get('host') ??
'',
host,
email: trimmedEmail!,
inviteToken: readInviteTokenFromCookieHeader(request.headers.get('cookie')),
})
@@ -1154,10 +1299,18 @@ export const ticExtension: Extension = {
// The profile mirror can lack the address while the auth row still
// holds it (anonymize_user_account scrubs profiles.email but keeps the
// auth tombstone), which used to fall through to a dead-end 500 here.
//
// email_confirm: false. The address came from the request body and
// nothing has proven it belongs to the person holding the BankID.
// Confirming it here let anyone open an account on a stranger's
// address and keep a BankID login into it after the stranger adopted
// it (account pre-hijacking, security audit 2026-09). The address is
// confirmed by the mail sent below, and only then does the identity
// count (see lib/bankid-pending.ts).
const randomPassword = crypto.randomBytes(32).toString('base64url')
const { data: newUser, error: createError } = await supabase.auth.admin.createUser({
email: trimmedEmail!,
email_confirm: true,
email_confirm: false,
password: randomPassword,
user_metadata: { full_name: name },
})
@@ -1192,10 +1345,7 @@ export const ticExtension: Extension = {
code: createError?.code,
message: createError?.message,
})
return NextResponse.json(
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
{ status: 500 }
)
return NextResponse.json(SIGNUP_FAILED, { status: 500 })
}
const userId = newUser.user.id
@@ -1216,24 +1366,22 @@ export const ticExtension: Extension = {
}
}
// Mark user as BankID-linked (skips TOTP MFA) and record that they
// do not have a password yet: the BankID signup gave them a random
// server-side password they will never see. This flag gates MFA
// enrollment (see lib/auth/has-password.ts).
// Mark the BankID link as PENDING, not linked: bankid_linked (which
// skips TOTP MFA, lib/auth/mfa.ts) is set by /auth/callback once the
// confirmation mail is clicked. has_password: false records that the
// BankID signup gave them a random server-side password they will
// never see; it gates MFA enrollment (see lib/auth/has-password.ts).
const { error: metaError } = await supabase.auth.admin.updateUserById(userId, {
app_metadata: { bankid_linked: true, has_password: false },
app_metadata: { bankid_pending: true, has_password: false },
})
if (metaError) {
log.error('signup app_metadata update failed', { message: metaError.message, code: metaError.code })
await rollbackSignup('app_metadata update')
return NextResponse.json(
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
{ status: 500 }
)
return NextResponse.json(SIGNUP_FAILED, { status: 500 })
}
// Store BankID identity
// Store BankID identity, unverified until the mail is clicked.
const { error: insertError } = await supabase
.from('bankid_identities')
.insert({
@@ -1242,20 +1390,18 @@ export const ticExtension: Extension = {
personal_number_enc: encryptPersonalNumberForStorage(personalNumber),
given_name: givenName,
surname,
email_verified_at: null,
})
if (insertError) {
log.error('insert bankid_identities failed', { message: insertError.message, code: insertError.code })
await rollbackSignup('bankid_identities insert')
return NextResponse.json(
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
{ status: 500 }
)
return NextResponse.json(SIGNUP_FAILED, { status: 500 })
}
// Claim the session before minting. Placed after createUser so the
// Claim the session before mailing. Placed after createUser so the
// recoverable account_exists path above leaves the flow reusable,
// and before generateLink so two tabs cannot both mint.
// and before the mail so two tabs cannot both send one.
if (!await consumeBankIdSession(supabase, sessionId)) {
await rollbackSignup('session already consumed')
return settle(NextResponse.json(
@@ -1264,20 +1410,24 @@ export const ticExtension: Extension = {
))
}
// Generate magic link for session
const { data: link, error: linkError } = await supabase.auth.admin.generateLink({
type: 'magiclink',
// Mail the confirmation link to the typed address. The token never
// reaches the browser: whoever reads that inbox proves the address,
// and /auth/callback promotes the identity when they click.
const sent = await sendBankIdSignupConfirmation({
supabase,
email: trimmedEmail!,
host,
proto: request.headers.get('x-forwarded-proto'),
})
if (linkError || !link?.properties?.hashed_token) {
log.error('generateLink failed for signup', { message: linkError?.message, code: linkError?.code })
await rollbackSignup('generateLink')
if (!sent.ok) {
log.error('bankid signup confirmation mail failed', { step: sent.step, message: sent.message })
await rollbackSignup(`confirmation mail (${sent.step})`)
// The session was already consumed above, so this flow cannot be
// retried; settle() clears it rather than leaving a spent,
// rolled-back flow to be re-offered as resumable.
return settle(NextResponse.json(
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
{ error: 'internal_error', message: 'Kunde inte skicka bekräftelsemailet. Försök igen.' },
{ status: 500 }
))
}
@@ -1285,12 +1435,13 @@ export const ticExtension: Extension = {
// Enrichment (CompanyRoles): pre-fills /select-company picker.
await fetchAndStoreEnrichment(sessionId, userId, supabase)
// settle(): account created and magic link minted. The flow is spent.
// settle(): account created and the mail is out. The flow is spent.
// Same shape as POST /api/auth/signup, so the register page can show
// its existing "check your inbox" screen. No session, no token.
return settle(NextResponse.json({
data: {
tokenHash: link.properties.hashed_token,
type: 'magiclink',
isNewUser: true,
status: 'confirmation_sent',
email: trimmedEmail!,
},
}))
} catch (error) {
@@ -1444,7 +1595,10 @@ export const ticExtension: Extension = {
))
}
// Link BankID to current user
// Link BankID to current user. The caller is authenticated, so the
// address on the account is already theirs: verified from the start
// (the column has no default; NULL would make this a pending link
// that refuses to sign in).
const { error: insertError } = await supabase
.from('bankid_identities')
.insert({
@@ -1453,6 +1607,7 @@ export const ticExtension: Extension = {
personal_number_enc: encryptPersonalNumberForStorage(personalNumber),
given_name: givenName,
surname,
email_verified_at: new Date().toISOString(),
})
if (insertError) {
@@ -0,0 +1,99 @@
/**
* The confirmation mail a BankID signup sends to the typed address.
*
* The BankID signup used to mint a magic link and hand it straight back to the
* browser, which proved nothing about the address. Now the link travels only
* by mail, through the same token_hash + /auth/callback pattern the Supabase
* Send Email hook uses (app/api/auth/email-hook/route.ts), branded per the
* requesting host like every other auth mail. The browser never sees the
* token.
*
* `magiclink` rather than `signup` as the link type: GoTrue refuses a signup
* link for an already-confirmed address, and this mail is also re-sent when a
* pending identity tries to log in, which includes accounts created by the
* old flow (confirmed by admin, address never proven). Verifying a magic link
* confirms an unconfirmed address as a side effect, so both cases converge.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { buildAuthEmail } from '@/lib/email/auth-templates'
import { getSenderForBrand } from '@/lib/email/brand-sender'
import { getBranding } from '@/lib/branding/service'
import { resolveBrandByHost } from '@/lib/branding/resolve'
import { createLogger } from '@/lib/logger'
const log = createLogger('tic/bankid-confirmation-mail')
export interface SendBankIdConfirmationInput {
/** Service-role client (auth.admin.generateLink). */
supabase: SupabaseClient
/** Normalised (trimmed, lower-cased) recipient address. */
email: string
/** Forwarded host of the request, '' when unknown. */
host: string
/** Forwarded protocol of the request; defaults to https. */
proto?: string | null
}
export type SendBankIdConfirmationResult =
| { ok: true }
| { ok: false; step: 'generate_link' | 'send'; message?: string }
/**
* Confirmation links must land on the ORIGINATING host (the brand mail
* resolves its brand from it), mirroring POST /api/auth/signup. With no host
* (direct invocation, tests) the canonical app URL is used.
*/
export function buildConfirmationUrl(
host: string,
proto: string | null | undefined,
tokenHash: string,
): string {
const base = host ? `${proto || 'https'}://${host}` : getBranding().appUrl
const url = new URL('/auth/callback', base)
url.searchParams.set('token_hash', tokenHash)
url.searchParams.set('type', 'magiclink')
return url.toString()
}
export async function sendBankIdSignupConfirmation(
input: SendBankIdConfirmationInput,
): Promise<SendBankIdConfirmationResult> {
const { data: link, error: linkError } = await input.supabase.auth.admin.generateLink({
type: 'magiclink',
email: input.email,
})
if (linkError || !link?.properties?.hashed_token) {
log.error('generateLink failed for bankid confirmation mail', {
code: linkError?.code,
message: linkError?.message,
})
return { ok: false, step: 'generate_link', message: linkError?.message }
}
const brand = input.host ? await resolveBrandByHost(input.host) : null
const sender = getSenderForBrand(brand)
const appName = brand?.appName ?? getBranding().appName
const mail = buildAuthEmail({
actionType: 'bankid_signup',
appName,
actionUrl: buildConfirmationUrl(input.host, input.proto, link.properties.hashed_token),
})
const result = await getEmailService().sendEmail({
to: input.email,
subject: mail.subject,
html: mail.html,
text: mail.text,
fromName: sender.fromName ?? undefined,
fromAddress: sender.fromAddress ?? undefined,
replyTo: sender.replyTo ?? undefined,
})
if (!result.success) {
log.error('bankid confirmation mail send failed', new Error(result.error ?? 'unknown'))
return { ok: false, step: 'send', message: result.error }
}
return { ok: true }
}
@@ -0,0 +1,90 @@
/**
* Pending BankID identities (security audit 2026-09, account pre-hijacking).
*
* A BankID signup creates the auth user for whatever address the caller typed.
* Until that address is proven (the confirmation mail is clicked and
* /auth/callback promotes the row), the `bankid_identities` row carries
* `email_verified_at IS NULL` and the auth user carries
* `app_metadata.bankid_pending: true` instead of `bankid_linked: true`. A
* pending identity must never sign anyone in, and it must never be promoted
* once the account has been adopted through another credential (the real
* owner of the address set a password or signed in with Google).
*
* The promotion side lives in app/(auth)/auth/callback/route.ts (core, so it
* cannot import this module); `hasForeignCredential` there mirrors the one
* here on purpose. Keep the two in step.
*/
import type { SupabaseClient, User } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('tic/bankid-pending')
/** The subset of the auth user the adoption checks read. */
export type AdoptionUser = Pick<User, 'identities' | 'app_metadata' | 'email_confirmed_at'>
/**
* True when the account has a credential the BankID signup never created:
* a non-email identity (Google) or a password the user set themselves
* (`has_password: true`, written only by POST /api/account/password). Either
* one means somebody proved ownership of the address by other means, and the
* pending BankID identity must be revoked rather than promoted.
*/
export function hasForeignCredential(user: AdoptionUser): boolean {
const identities = user.identities ?? []
if (identities.some((identity) => identity.provider !== 'email')) return true
return user.app_metadata?.has_password === true
}
/**
* True when the pending account is still exactly what the BankID signup made:
* address unconfirmed, no foreign credential. Only such an account may be
* deleted outright when the same BankID signs up again (typo'd address, lost
* mail): nobody else has a stake in it yet.
*/
export function isUnadoptedPendingAccount(user: AdoptionUser): boolean {
return !user.email_confirmed_at && !hasForeignCredential(user)
}
/**
* Remove the pending BankID link from an account that somebody else now owns:
* delete the unverified identity row(s) and drop `bankid_pending` from
* app_metadata. `bankid_linked` is untouched (a pending signup never set it).
* Read-merge-write on app_metadata, like every other writer in this
* extension. Returns false when the row delete failed; the flag update is
* best-effort.
*/
export async function revokePendingIdentity(
supabase: SupabaseClient,
userId: string,
priorAppMetadata: Record<string, unknown> | undefined,
): Promise<boolean> {
const { error: deleteError } = await supabase
.from('bankid_identities')
.delete()
.eq('user_id', userId)
.is('email_verified_at', null)
if (deleteError) {
log.error('could not revoke pending bankid identity', {
userId,
code: deleteError.code,
message: deleteError.message,
})
return false
}
const { error: metaError } = await supabase.auth.admin.updateUserById(userId, {
// null removes the key under GoTrue's merge semantics and is falsy if the
// metadata is ever replaced wholesale instead.
app_metadata: { ...(priorAppMetadata ?? {}), bankid_pending: null },
})
if (metaError) {
log.error('could not clear bankid_pending after revoking identity', {
userId,
code: metaError.code,
message: metaError.message,
})
}
return true
}
@@ -1,5 +1,25 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { listOrderRefunds, type WooCredentials } from '../lib/api-client'
import {
INVALID_STORE_URL_CODE,
UNSAFE_STORE_URL_CODE,
listOrderRefunds,
testConnectionAndFetchStoreInfo,
wcGet,
WooCommerceApiError,
type WooCredentials,
} from '../lib/api-client'
// The SSRF guard resolves DNS. Stub the validator (same seam the webhook
// dispatcher tests use) so tests are deterministic and offline; a dedicated
// test below flips it to a private-address verdict.
const guard = vi.hoisted(() => ({ validateUrl: vi.fn() }))
vi.mock('@/lib/webhooks/url-guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/webhooks/url-guard')>()
return {
...actual,
validateWebhookUrl: (...args: unknown[]) => guard.validateUrl(...args),
}
})
const CREDS: WooCredentials = {
storeUrl: 'https://shop.example.se',
@@ -28,12 +48,114 @@ const fetchMock = vi.fn()
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
guard.validateUrl.mockReset()
guard.validateUrl.mockImplementation(async (rawUrl: string) => ({
ok: true,
hostname: new URL(rawUrl).hostname,
resolvedAddresses: ['203.0.113.10'],
}))
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('wcGet: outbound URL guard', () => {
it('happy path: Basic auth, no redirect following, and the DNS check runs per request', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 1 }]))
await expect(wcGet(CREDS, '/orders', { per_page: '1' })).resolves.toEqual([{ id: 1 }])
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://shop.example.se/wp-json/wc/v3/orders?per_page=1')
expect(init.redirect).toBe('manual')
expect((init.headers as Record<string, string>).Authorization).toMatch(/^Basic /)
expect(init.signal).toBeInstanceOf(AbortSignal)
expect(guard.validateUrl).toHaveBeenCalledWith(url, undefined)
})
it('refuses a stored store_url that no longer normalises (edited outside the app) before any fetch', async () => {
for (const storeUrl of ['http://shop.example.se', 'https://10.0.0.5', 'https://localhost:8080', 'not a url']) {
const error = await wcGet({ ...CREDS, storeUrl }, '/orders').catch((e) => e)
expect(error).toBeInstanceOf(WooCommerceApiError)
expect((error as WooCommerceApiError).wooCode).toBe(INVALID_STORE_URL_CODE)
expect((error as WooCommerceApiError).status).toBe(0)
expect((error as WooCommerceApiError).message).toMatch(/reconnect the store/)
}
expect(fetchMock).not.toHaveBeenCalled()
expect(guard.validateUrl).not.toHaveBeenCalled()
})
it('canonicalises a cosmetically different stored URL instead of refusing it', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse([]))
await wcGet({ ...CREDS, storeUrl: 'https://Shop.Example.se/' }, '/orders')
expect(fetchMock.mock.calls[0][0]).toBe('https://shop.example.se/wp-json/wc/v3/orders')
})
it('refuses a public hostname that resolves to a private address, without retrying', async () => {
guard.validateUrl.mockResolvedValue({
ok: false,
reason: 'private_address',
detail: 'Resolved address 10.1.2.3 for shop.example.se is not publicly routable (private_address).',
})
const error = await wcGet(CREDS, '/orders').catch((e) => e)
expect(error).toBeInstanceOf(WooCommerceApiError)
expect((error as WooCommerceApiError).wooCode).toBe(UNSAFE_STORE_URL_CODE)
expect((error as WooCommerceApiError).message).toMatch(/10\.1\.2\.3/)
// Critically: no socket was opened and the backoff schedule was not spent.
expect(fetchMock).not.toHaveBeenCalled()
expect(guard.validateUrl).toHaveBeenCalledTimes(1)
})
it('treats a redirect from the store as a failure, not a hop, and does not retry it', async () => {
fetchMock.mockResolvedValueOnce(
new Response(null, { status: 302, headers: { Location: 'http://169.254.169.254/latest/' } }),
)
const error = await wcGet(CREDS, '/orders').catch((e) => e)
expect(error).toBeInstanceOf(WooCommerceApiError)
expect((error as WooCommerceApiError).wooCode).toBe(UNSAFE_STORE_URL_CODE)
expect((error as WooCommerceApiError).message).toMatch(/redirects are never followed/)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('still falls back to query-string credentials on a 401 (header-stripping hosts)', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({ code: 'woocommerce_rest_cannot_view' }), { status: 401 }))
.mockResolvedValueOnce(jsonResponse([{ id: 7 }]))
await expect(wcGet(CREDS, '/orders')).resolves.toEqual([{ id: 7 }])
const secondUrl = new URL(fetchMock.mock.calls[1][0] as string)
expect(secondUrl.searchParams.get('consumer_key')).toBe('ck_test')
expect(secondUrl.searchParams.get('consumer_secret')).toBe('cs_test')
expect((fetchMock.mock.calls[1][1] as RequestInit).redirect).toBe('manual')
})
})
describe('testConnectionAndFetchStoreInfo', () => {
it('routes the public /wp-json/ title probe through the guard too', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([{ id: 1 }])) // /orders probe
.mockResolvedValueOnce(jsonResponse([])) // /settings/general
.mockResolvedValueOnce(jsonResponse({})) // /system_status
.mockResolvedValueOnce(jsonResponse({ name: 'Butiken' })) // /wp-json/
const info = await testConnectionAndFetchStoreInfo(CREDS)
expect(info.name).toBe('Butiken')
const [url, init] = fetchMock.mock.calls[3] as [string, RequestInit]
expect(url).toBe('https://shop.example.se/wp-json/')
expect(init.redirect).toBe('manual')
expect(guard.validateUrl).toHaveBeenCalledTimes(4)
})
})
describe('listOrderRefunds', () => {
it('terminates on an empty page, not a short one (hosts may cap per_page)', async () => {
fetchMock
@@ -107,7 +107,10 @@ export default function WooCommerceSettingsPanel() {
if (connected) {
toast({ title: t('connected_toast_title'), description: t('connected_toast_description') })
} else if (error) {
const message = error === 'denied' ? t('error_denied') : t('error_generic')
const message =
error === 'denied' ? t('error_denied')
: error === 'wrong_user' ? t('error_wrong_user')
: t('error_generic')
toast({ title: t('connect_failed_title'), description: message, variant: 'destructive' })
}
router.replace('/import?mode=woocommerce')
@@ -1,3 +1,4 @@
import { isUnsafeUrlError, safeFetch } from '@/lib/http/safe-fetch'
import type { WooOrder, WooRefund, WooStoreInfo } from '../types'
/**
@@ -9,6 +10,14 @@ import type { WooOrder, WooRefund, WooStoreInfo } from '../types'
* query-string credential fallback; that fallback is why plain-http stores
* are refused outright (keys in a cleartext URL are a credentials leak).
*
* The store URL is tenant input that the server connects to, and members can
* write `woocommerce_connections.store_url` directly through PostgREST
* (bypassing the connect route's normalisation), so every request here
* re-normalises the stored URL and goes through `safeFetch`: public
* addresses only, checked at request time, and no redirects followed. The
* nightly cron runs this under the service role, which is exactly the
* network position an SSRF would want.
*
* Typical WooCommerce hosts are slow shared PHP boxes: requests run
* sequentially, pages are capped at 100 rows, and 429/5xx responses get a
* short exponential backoff before the error is surfaced.
@@ -53,9 +62,10 @@ export function isRevokedCredentialsError(error: unknown): boolean {
/**
* Hostnames the server must never fetch: the store URL is user input that we
* probe server-side, so loopback/link-local/private ranges and internal
* naming conventions are refused outright (SSRF guard). Hostname-level only:
* a public DNS name resolving to a private address is not caught here, which
* matches the app's other outbound-URL surfaces.
* naming conventions are refused outright (SSRF guard). Hostname-level only,
* and cheap enough to run synchronously at connect time; a public DNS name
* resolving to a private address is caught later by `safeFetch`, which
* resolves and classifies every A/AAAA record at request time.
*/
function isDisallowedHost(hostname: string): boolean {
const h = hostname.toLowerCase()
@@ -99,6 +109,30 @@ export function normalizeStoreUrl(input: string): string | null {
return `https://${url.host.toLowerCase()}${path}`
}
/** Error code on a WooCommerceApiError when the stored store URL fails re-normalisation. */
export const INVALID_STORE_URL_CODE = 'accounted_invalid_store_url'
/** Error code on a WooCommerceApiError when the SSRF guard refused to connect. */
export const UNSAFE_STORE_URL_CODE = 'accounted_unsafe_store_url'
/**
* Re-run the connect-time normalisation on the STORED store URL at use time.
* The connect route normalises what the user typed, but a member can PATCH
* `store_url` straight into the row through PostgREST, so the value in the
* database is not trusted to still be an https public-host origin. A row that
* fails here is refused with a clear, non-retryable error instead of fetched.
*/
function storeOriginOf(creds: WooCredentials): string {
const normalized = normalizeStoreUrl(creds.storeUrl)
if (!normalized) {
throw new WooCommerceApiError(
`WooCommerce store URL is not a valid https store address (${creds.storeUrl}); reconnect the store`,
0,
INVALID_STORE_URL_CODE,
)
}
return normalized
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -109,7 +143,7 @@ function buildUrl(
params: Record<string, string>,
credentialsInQuery: boolean,
): string {
const url = new URL(`${creds.storeUrl}/wp-json/wc/v3${path}`)
const url = new URL(`${storeOriginOf(creds)}/wp-json/wc/v3${path}`)
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value)
if (credentialsInQuery) {
url.searchParams.set('consumer_key', creds.consumerKey)
@@ -129,12 +163,31 @@ async function requestOnce(
const basic = Buffer.from(`${creds.consumerKey}:${creds.consumerSecret}`).toString('base64')
headers.Authorization = `Basic ${basic}`
}
return fetch(buildUrl(creds, path, params, credentialsInQuery), {
// safeFetch: public address only (checked now, not at connect time), no
// redirects. A 3xx from the store is a failure, never a hop.
return safeFetch(buildUrl(creds, path, params, credentialsInQuery), {
headers,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
}
/**
* Map a failure from requestOnce to the error the retry loop should see.
* Guard refusals (bad stored URL, private address, redirect) are terminal:
* retrying the same URL cannot succeed and must not spend the backoff budget.
*/
function asTerminalGuardError(err: unknown): WooCommerceApiError | null {
if (err instanceof WooCommerceApiError) return err
if (isUnsafeUrlError(err)) {
return new WooCommerceApiError(
`WooCommerce store refused by outbound URL guard: ${err.detail}`,
0,
UNSAFE_STORE_URL_CODE,
)
}
return null
}
async function parseError(response: Response): Promise<WooCommerceApiError> {
let wooCode: string | null = null
let detail = ''
@@ -168,6 +221,8 @@ export async function wcGet<T>(
try {
response = await requestOnce(creds, path, params, credentialsInQuery)
} catch (err) {
const terminal = asTerminalGuardError(err)
if (terminal) throw terminal
// Network/timeout errors: retry on the same backoff schedule.
lastError = new WooCommerceApiError(
`WooCommerce request failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -303,7 +358,7 @@ export async function testConnectionAndFetchStoreInfo(
try {
// The WP REST index is public and carries the site title.
const response = await fetch(`${creds.storeUrl}/wp-json/`, {
const response = await safeFetch(`${storeOriginOf(creds)}/wp-json/`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
+198
View File
@@ -757,3 +757,201 @@ describe('truncateIp: privacy-preserving IP logging', () => {
// Suppress unused-import warning: we re-export to keep the type chain visible.
void mockStoreIdempotency
describe('withApiV1: read-only role gate (viewer)', () => {
// The v1 surface runs as the service role, so RLS never sees the viewer.
// This wrapper is the only place the read-only role is enforced for API
// keys; the DB triggers that block viewer writes apply to cookie sessions.
function viewerKey(scopes: string[]) {
mockValidate.mockResolvedValue({
userId: 'user-viewer',
companyId: 'company-1',
scopes,
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'viewer' }))
}
it('refuses a viewer key on POST journal-entries with 403 ROLE_READ_ONLY before the handler runs', async () => {
viewerKey(['bookkeeping:write'])
const handlerSpy = vi.fn(async (_req: Request, ctx: { requestId: string }) =>
ok({ id: 'je-1' }, { requestId: ctx.requestId }),
)
// No requireScope override: the real catalogue entry for the route is what
// classifies the request ('POST .../journal-entries' -> bookkeeping:write).
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
'journal-entries.create',
handlerSpy,
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', {
method: 'POST',
headers: { Authorization: 'Bearer gnubok_sk_x', 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'x', lines: [] }),
}),
companyParams('company-1'),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error.code).toBe('FORBIDDEN')
expect(body.error.details.code).toBe('ROLE_READ_ONLY')
expect(body.error.details.role).toBe('viewer')
expect(body.error.details.required_scope).toBe('bookkeeping:write')
// Swedish user-facing message from the registry entry.
expect(body.error.message).toBe('Du har inte behörighet att utföra denna åtgärd.')
expect(body.error.request_id).toMatch(/^req_/)
expect(handlerSpy).not.toHaveBeenCalled()
// Refused before the seat gate: no extra read for a refused write.
expect(getMultiUserStateMock).not.toHaveBeenCalled()
})
it('refuses a viewer even when the write is a dry-run (nothing to simulate for a read-only role)', async () => {
viewerKey(['invoices:write'])
const handlerSpy = vi.fn(async (_req: Request, ctx: { requestId: string }) =>
ok({ ok: true }, { requestId: ctx.requestId }),
)
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
'invoices.create',
handlerSpy,
{ requireScope: 'invoices:write' },
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/invoices?dry_run=true', {
method: 'POST',
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
companyParams('company-1'),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error.details.code).toBe('ROLE_READ_ONLY')
expect(handlerSpy).not.toHaveBeenCalled()
})
it('refuses a viewer on a GET whose scope is an elevated grant (webhooks:manage)', async () => {
viewerKey(['webhooks:manage'])
const handlerSpy = vi.fn(async (_req: Request, ctx: { requestId: string }) =>
ok({ webhooks: [] }, { requestId: ctx.requestId }),
)
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
'webhooks.list',
handlerSpy,
{ requireScope: 'webhooks:manage' },
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/webhooks', {
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
companyParams('company-1'),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error.details.code).toBe('ROLE_READ_ONLY')
expect(body.error.details.required_scope).toBe('webhooks:manage')
expect(handlerSpy).not.toHaveBeenCalled()
})
it('leaves a viewer GET on a :read scope untouched (200, seat gate still consulted)', async () => {
viewerKey(['reports:read'])
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
'journal-entries.list',
async (_req, ctx) => ok({ entries: [], companyId: ctx.companyId }, { requestId: ctx.requestId }),
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', {
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
companyParams('company-1'),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.companyId).toBe('company-1')
// A viewer is a non-owner: the dormant-seat logic still runs for reads.
expect(getMultiUserStateMock).toHaveBeenCalledTimes(1)
})
it('still applies the seat gate to a viewer read in a frozen company', async () => {
viewerKey(['reports:read'])
getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null })
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
'journal-entries.list',
async (_req, ctx) => ok({ entries: [] }, { requestId: ctx.requestId }),
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', {
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
companyParams('company-1'),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error.details.capability).toBe('multi_user')
expect(body.error.details.code).toBeUndefined()
})
it.each(['member', 'admin', 'owner'])('lets a %s key through the role gate on POST journal-entries', async (role) => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['bookkeeping:write'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role }))
const handlerSpy = vi.fn(async (_req: Request, ctx: { requestId: string }) =>
NextResponse.json({ data: { id: 'je-1', requestId: ctx.requestId } }, { status: 201 }),
)
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
'journal-entries.create',
handlerSpy,
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', {
method: 'POST',
headers: { Authorization: 'Bearer gnubok_sk_x', 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'x', lines: [] }),
}),
companyParams('company-1'),
)
expect(res.status).toBe(201)
expect(handlerSpy).toHaveBeenCalledTimes(1)
})
it('keeps the non-member answer unchanged: 404, no role information leaks', async () => {
mockValidate.mockResolvedValue({
userId: 'user-outsider',
companyId: 'company-2',
scopes: ['bookkeeping:write'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub(null))
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
'journal-entries.create',
async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }),
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', {
method: 'POST',
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
companyParams('company-1'),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
expect(body.error.details.role).toBeUndefined()
})
})
+53 -3
View File
@@ -13,7 +13,9 @@
* the token when one is supplied.
* 4. When the URL contains `companyId`, verifies the API key's user has
* access to that company via `company_members`. Multi-company keys are
* supported transparently: the URL is the source of truth.
* supported transparently: the URL is the source of truth. A `viewer`
* (read-only) membership is refused for every write: mutating method
* or non-`:read` scope (FORBIDDEN, details.code ROLE_READ_ONLY).
* 5. Resolves the dry-run flag (`?dry_run=true` query OR `X-Dry-Run` header).
* 6. Resolves `Idempotency-Key` (header) and replays cached responses. The
* dry-run flag is part of the cache identity and dry-run responses are
@@ -46,6 +48,7 @@ import {
extractBearerToken,
hasScope,
RATE_LIMIT_RETRY_AFTER_SECONDS,
scopeKind,
validateApiKey,
} from '@/lib/auth/api-keys'
import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
@@ -78,6 +81,13 @@ const DRY_RUN_HEADER = 'X-Dry-Run'
// idempotency replay, requireIdempotencyKey enforcement), and omitting PUT
// would let test keys write through PUT routes for real.
const REQUIRES_IDEMPOTENCY = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
// RFC 9110 safe methods. The read-only role gate treats EVERYTHING else as a
// write: a superset of REQUIRES_IDEMPOTENCY, so an exotic method can never
// slip a viewer past the gate. Deliberately separate from REQUIRES_IDEMPOTENCY,
// whose semantics (replay, dry-run forcing) must not widen as a side effect.
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
/** The read-only company role (see `CompanyRole` in `@/types`). */
const READ_ONLY_ROLE = 'viewer'
export interface ApiV1Context {
/** Stable id for this HTTP request: appears in logs, error envelope, X-Request-Id. */
@@ -414,14 +424,54 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
})
}
const membershipRole = (membership as { role?: string }).role
// Read-only role gate. Cookie routes enforce the viewer role through
// withRouteContext({ requireWrite }) and the DB enforces it through
// RLS + triggers for cookie sessions, but this surface runs as the
// service role: nothing below this line would stop a viewer's key from
// posting. A request is a write when EITHER its method is unsafe
// (catches action verbs whatever their scope) OR its scope is an
// elevated grant (catches reads-by-method that still manage tenant
// state, e.g. GET /webhooks on webhooks:manage). Dry-run and test
// keys are refused too: a viewer has no write to simulate.
//
// Placed AFTER the membership 404 so a non-member sees exactly what it
// saw before (no new company-existence signal), and BEFORE the seat
// gate so a refused write costs no extra read.
if (
membershipRole === READ_ONLY_ROLE &&
(!SAFE_METHODS.has(request.method) || scopeKind(requiredScope) === 'write')
) {
userLog.warn('read-only membership refused write request', {
companyId,
method: request.method,
requiredScope,
...forensic,
})
return await v1ErrorResponseFromCode('FORBIDDEN', userLog, {
requestId,
status: 403,
reason: 'role_read_only',
details: {
code: 'ROLE_READ_ONLY',
companyId,
role: READ_ONLY_ROLE,
required_scope: requiredScope,
message:
'This company membership is read-only (viewer): write requests are refused. Ask a company owner or admin to change the role.',
},
})
}
// Multi-user seat gate: the API-key surface is a chokepoint like the
// cookie routes and MCP. A non-owner membership in a frozen company
// (multi_user lapsed past its 20-day grace) is refused here so an old
// key cannot keep working the books after the freeze. Owners pass
// without the extra read; the service client sees team-scoped grants.
if ((membership as { role?: string }).role !== 'owner') {
if (membershipRole !== 'owner') {
const access = await getMultiUserState(supabase, companyId)
if (isMembershipDormant((membership as { role: string }).role, access.state)) {
if (isMembershipDormant(membershipRole as string, access.state)) {
userLog.warn('multi-user seat gate refused frozen membership', { companyId, ...forensic })
return await v1ErrorResponseFromCode('FORBIDDEN', userLog, {
requestId,
+236 -29
View File
@@ -1,5 +1,13 @@
import { describe, it, expect, vi } from 'vitest'
import { isBuiltInRedirectUri, isAllowedRedirectUri } from '../oauth-allowlist'
import { describe, it, expect, vi, afterEach } from 'vitest'
import {
builtInRedirectProvider,
capScopesForRole,
isAllowedRedirectUri,
isBuiltInRedirectUri,
lookupCompanyRole,
resolveRedirectUri,
} from '../oauth-allowlist'
import { ALL_SCOPES, type ApiKeyScope } from '../scope-catalog'
import type { SupabaseClient } from '@supabase/supabase-js'
describe('isBuiltInRedirectUri', () => {
@@ -23,45 +31,244 @@ describe('isBuiltInRedirectUri', () => {
})
})
function makeFakeSupabase(rows: Array<{ id: string }>): SupabaseClient {
// Chainable thenable that resolves to { data, error } when awaited via
// .maybeSingle(). Matches the shape isAllowedRedirectUri actually invokes.
const chain = {
from() { return chain },
select() { return chain },
eq() { return chain },
is() { return chain },
limit() { return chain },
async maybeSingle() {
return { data: rows[0] ?? null, error: null }
},
}
return chain as unknown as SupabaseClient
describe('builtInRedirectProvider', () => {
it.each([
['https://claude.ai/api/oauth/callback', 'claude'],
['https://claude.com/api/oauth/callback', 'claude'],
['https://chatgpt.com/connector/oauth/abc123', 'chatgpt'],
['https://chatgpt.com/connector_platform_oauth_redirect', 'chatgpt'],
['http://localhost:3000/cb', 'local'],
['http://127.0.0.1:8080/cb', 'local'],
['https://claude-login.example/cb', null],
['', null],
])('maps %s to %s', (uri, expected) => {
expect(builtInRedirectProvider(uri)).toBe(expected)
})
})
type Row = Record<string, unknown>
/**
* Minimal PostgREST-shaped fake over in-memory tables: eq/is filters are
* applied, everything else is a no-op, and the chain resolves to the filtered
* rows (all rows when awaited, first row via maybeSingle). `failTable` makes
* every query against that table return a DB error.
*/
function fakeClient(tables: Record<string, Row[]>, failTable?: string) {
const from = vi.fn((table: string) => {
const filters: [string, unknown][] = []
const run = () =>
(tables[table] ?? []).filter((row) =>
filters.every(([col, val]) => (val === null ? row[col] == null : row[col] === val)),
)
const result = () =>
table === failTable
? { data: null, error: { message: 'db down' } }
: { data: run(), error: null }
const chain: Record<string, unknown> = {}
for (const method of ['select', 'order', 'range', 'limit']) chain[method] = () => chain
chain.eq = (col: string, val: unknown) => {
filters.push([col, val])
return chain
}
chain.is = (col: string, val: unknown) => {
filters.push([col, val])
return chain
}
chain.maybeSingle = async () => {
const r = result()
return { data: Array.isArray(r.data) ? (r.data[0] ?? null) : null, error: r.error }
}
chain.then = (resolve: (v: unknown) => void) => resolve(result())
return chain
})
return { from } as unknown as SupabaseClient & { from: ReturnType<typeof vi.fn> }
}
describe('isAllowedRedirectUri', () => {
it('short-circuits to true for built-in patterns without touching the DB', async () => {
const REGISTRATIONS: Row[] = [
{ id: 'reg-1', user_id: 'user-2', client_name: 'Byråns bot', redirect_uri: 'https://app.example.com/cb', revoked_at: null },
{ id: 'reg-2', user_id: 'user-9', client_name: 'Evil', redirect_uri: 'https://evil.example/cb', revoked_at: null },
{ id: 'reg-3', user_id: 'user-1', client_name: 'Min egen app', redirect_uri: 'https://mine.example/cb', revoked_at: null },
{ id: 'reg-4', user_id: 'user-1', client_name: 'Gammal app', redirect_uri: 'https://old.example/cb', revoked_at: '2026-01-01T00:00:00Z' },
]
const MEMBERSHIPS: Row[] = [
{ id: 'm1', user_id: 'user-1', company_id: 'company-1', role: 'owner' },
{ id: 'm2', user_id: 'user-2', company_id: 'company-1', role: 'member' },
{ id: 'm3', user_id: 'user-2', company_id: 'company-2', role: 'owner' },
{ id: 'm4', user_id: 'user-9', company_id: 'company-9', role: 'owner' },
]
const DB = { oauth_client_registrations: REGISTRATIONS, company_members: MEMBERSHIPS }
describe('resolveRedirectUri', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('short-circuits to the built-in provider without touching the DB', async () => {
const sb = {
from: vi.fn(() => {
throw new Error('should not be called')
}),
} as unknown as SupabaseClient
expect(await isAllowedRedirectUri('https://claude.ai/api/cb', sb)).toBe(true)
expect(await isAllowedRedirectUri('http://localhost:3000/cb', sb)).toBe(true)
expect(await resolveRedirectUri('https://claude.ai/api/cb', sb, { consentingUserId: 'user-1' })).toEqual({
allowed: true,
kind: 'built_in',
provider: 'claude',
})
expect(await resolveRedirectUri('http://localhost:3000/cb', sb)).toEqual({
allowed: true,
kind: 'built_in',
provider: 'local',
})
})
it('returns true when the DB has a registration for the URI', async () => {
const sb = makeFakeSupabase([{ id: 'reg-1' }])
expect(await isAllowedRedirectUri('https://myapp.example.com/cb', sb)).toBe(true)
it("accepts the consenting user's own registration", async () => {
const result = await resolveRedirectUri('https://mine.example/cb', fakeClient(DB), {
consentingUserId: 'user-1',
})
expect(result).toEqual({
allowed: true,
kind: 'registered',
clientName: 'Min egen app',
registeredByConsentingUser: true,
})
})
it('returns false when no registration exists', async () => {
const sb = makeFakeSupabase([])
expect(await isAllowedRedirectUri('https://evil.com/cb', sb)).toBe(false)
it('accepts a registration by a colleague who shares a company', async () => {
const result = await resolveRedirectUri('https://app.example.com/cb', fakeClient(DB), {
consentingUserId: 'user-1',
})
expect(result).toEqual({
allowed: true,
kind: 'registered',
clientName: 'Byråns bot',
registeredByConsentingUser: false,
})
})
it('returns false for empty / non-string inputs', async () => {
expect(await isAllowedRedirectUri('')).toBe(false)
expect(await isAllowedRedirectUri(undefined as unknown as string)).toBe(false)
it('rejects a registration by an unrelated user', async () => {
// user-9 is a real member of the instance, just not of any company user-1
// belongs to: their registration must not be a valid target for user-1.
const result = await resolveRedirectUri('https://evil.example/cb', fakeClient(DB), {
consentingUserId: 'user-1',
})
expect(result).toEqual({ allowed: false })
})
it('accepts any active registration when no consenting user is given (anonymous /register)', async () => {
const result = await resolveRedirectUri('https://evil.example/cb', fakeClient(DB))
expect(result).toEqual({
allowed: true,
kind: 'registered',
clientName: 'Evil',
registeredByConsentingUser: false,
})
})
it('rejects a revoked registration, even the users own', async () => {
const result = await resolveRedirectUri('https://old.example/cb', fakeClient(DB), {
consentingUserId: 'user-1',
})
expect(result).toEqual({ allowed: false })
})
it('rejects an unknown URI', async () => {
expect(await resolveRedirectUri('https://nowhere.example/cb', fakeClient(DB))).toEqual({ allowed: false })
})
it('fails closed when the registration lookup errors', async () => {
const result = await resolveRedirectUri(
'https://mine.example/cb',
fakeClient(DB, 'oauth_client_registrations'),
{ consentingUserId: 'user-1' },
)
expect(result).toEqual({ allowed: false })
})
it('fails closed when the shared-company check errors', async () => {
const result = await resolveRedirectUri('https://app.example.com/cb', fakeClient(DB, 'company_members'), {
consentingUserId: 'user-1',
})
expect(result).toEqual({ allowed: false })
})
it('fails closed when no client is given and none can be constructed', async () => {
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '')
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', '')
expect(await resolveRedirectUri('https://mine.example/cb', undefined, { consentingUserId: 'user-1' })).toEqual({
allowed: false,
})
})
it('returns not allowed for empty / non-string inputs', async () => {
expect(await resolveRedirectUri('')).toEqual({ allowed: false })
expect(await resolveRedirectUri(undefined as unknown as string)).toEqual({ allowed: false })
})
})
describe('isAllowedRedirectUri', () => {
it('is the boolean view of resolveRedirectUri', async () => {
expect(await isAllowedRedirectUri('https://claude.ai/api/cb')).toBe(true)
expect(await isAllowedRedirectUri('https://mine.example/cb', fakeClient(DB), { consentingUserId: 'user-1' })).toBe(true)
expect(await isAllowedRedirectUri('https://evil.example/cb', fakeClient(DB), { consentingUserId: 'user-1' })).toBe(false)
expect(await isAllowedRedirectUri('https://evil.example/cb', fakeClient(DB))).toBe(true)
expect(await isAllowedRedirectUri('', fakeClient(DB))).toBe(false)
})
})
describe('capScopesForRole', () => {
const mixed: ApiKeyScope[] = [
'transactions:read',
'transactions:write',
'pending_operations:approve',
'webhooks:manage',
'reconciliation:signoff',
'reports:read',
]
it('keeps every scope for owner, admin and member', () => {
for (const role of ['owner', 'admin', 'member']) {
expect(capScopesForRole(mixed, role)).toEqual(mixed)
}
})
it('caps a viewer to :read scopes only', () => {
expect(capScopesForRole(mixed, 'viewer')).toEqual(['transactions:read', 'reports:read'])
})
it('caps an unknown role and a missing membership to :read scopes', () => {
expect(capScopesForRole(mixed, 'superuser')).toEqual(['transactions:read', 'reports:read'])
expect(capScopesForRole(mixed, null)).toEqual(['transactions:read', 'reports:read'])
})
it('leaves no write-kind scope in a capped set for the full catalogue', () => {
const capped = capScopesForRole(ALL_SCOPES, 'viewer')
expect(capped.length).toBeGreaterThan(0)
expect(capped.every((s) => s.endsWith(':read'))).toBe(true)
expect(capped).not.toContain('pending_operations:approve')
})
it('returns a copy, never the callers array', () => {
const result = capScopesForRole(mixed, 'owner')
expect(result).not.toBe(mixed)
})
})
describe('lookupCompanyRole', () => {
it('returns the role for an existing membership', async () => {
expect(await lookupCompanyRole(fakeClient(DB), 'user-2', 'company-1')).toEqual({ role: 'member', error: null })
expect(await lookupCompanyRole(fakeClient(DB), 'user-2', 'company-2')).toEqual({ role: 'owner', error: null })
})
it('returns a null role when no membership row exists', async () => {
expect(await lookupCompanyRole(fakeClient(DB), 'user-9', 'company-1')).toEqual({ role: null, error: null })
})
it('surfaces a query error instead of guessing', async () => {
const result = await lookupCompanyRole(fakeClient(DB, 'company_members'), 'user-1', 'company-1')
expect(result.role).toBeNull()
expect(result.error).toBe('db down')
})
})
@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
import { createClient } from '@/lib/supabase/server'
import {
requireFlowInitiator,
buildLoginRedirect,
redactUserId,
FLOW_INITIATOR_MISMATCH_MESSAGE,
} from '../oauth-flow-binding'
const CALLBACK_URL =
'https://app.example.se/api/extensions/stripe/callback?code=ac_123&state=state-1'
function mockSession(getUser: ReturnType<typeof vi.fn>) {
vi.mocked(createClient).mockResolvedValue({ auth: { getUser } } as never)
return getUser
}
function sessionWith(userId: string | null, error: unknown = null) {
return mockSession(
vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
error,
}),
)
}
describe('requireFlowInitiator', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.se')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('passes when the cookie session belongs to the initiator', async () => {
const getUser = sessionWith('user-1')
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1')
expect(result).toEqual({ ok: true, userId: 'user-1' })
expect(getUser).toHaveBeenCalledTimes(1)
})
it('refuses with a 403 envelope when a different user completes the flow', async () => {
sessionWith('user-2')
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1', {
flow: 'stripe.callback',
})
expect(result.ok).toBe(false)
if (result.ok) throw new Error('unreachable')
expect(result.reason).toBe('mismatch')
if (result.reason !== 'mismatch') throw new Error('unreachable')
expect(result.sessionUserId).toBe('user-2')
expect(result.response.status).toBe(403)
const body = await result.response.json()
expect(body.error.code).toBe('OAUTH_FLOW_INITIATOR_MISMATCH')
expect(body.error.message).toBe(FLOW_INITIATOR_MISMATCH_MESSAGE)
expect(typeof body.error.message_en).toBe('string')
})
it('sends an anonymous browser to /login with the callback URL as next', async () => {
sessionWith(null)
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1')
expect(result.ok).toBe(false)
if (result.ok) throw new Error('unreachable')
expect(result.reason).toBe('no_session')
expect(result.response.status).toBe(307)
const location = new URL(result.response.headers.get('location') ?? '')
expect(location.origin).toBe('https://app.example.se')
expect(location.pathname).toBe('/login')
// Same-origin relative path + query, the only shape the login page's
// safeReturnTo accepts, so signing in resumes the very same callback.
expect(location.searchParams.get('next')).toBe(
'/api/extensions/stripe/callback?code=ac_123&state=state-1',
)
})
it('treats a getUser error as no session (fail closed)', async () => {
sessionWith(null, { message: 'invalid JWT' })
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1')
expect(result.ok).toBe(false)
if (result.ok) throw new Error('unreachable')
expect(result.reason).toBe('no_session')
})
it('treats a thrown client error as no session instead of finalizing on a guess', async () => {
vi.mocked(createClient).mockRejectedValue(new Error('cookies() outside request scope'))
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1')
expect(result.ok).toBe(false)
if (result.ok) throw new Error('unreachable')
expect(result.reason).toBe('no_session')
expect(result.response.headers.get('location')).toContain('/login?next=')
})
it('never passes on an empty expected id even when someone is signed in', async () => {
sessionWith('user-2')
const result = await requireFlowInitiator(new Request(CALLBACK_URL), '')
expect(result.ok).toBe(false)
})
})
describe('buildLoginRedirect', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('falls back to the request origin when NEXT_PUBLIC_APP_URL is unset', () => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', '')
const response = buildLoginRedirect(
new Request('http://localhost:3000/api/extensions/woocommerce/return?success=1&user_id=abc'),
)
expect(response.headers.get('location')).toBe(
'http://localhost:3000/login?next=' +
encodeURIComponent('/api/extensions/woocommerce/return?success=1&user_id=abc'),
)
})
})
describe('redactUserId', () => {
it('keeps only a correlation prefix of a uuid', () => {
expect(redactUserId('123e4567-e89b-12d3-a456-426614174000')).toBe('123e4567...')
})
it('handles short and missing ids', () => {
expect(redactUserId('user-1')).toBe('user-1')
expect(redactUserId(null)).toBe('(none)')
expect(redactUserId(undefined)).toBe('(none)')
})
})
+155
View File
@@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
logError: vi.fn(),
logWarn: vi.fn(),
logInfo: vi.fn(),
redisCtor: vi.fn(),
ratelimitCtor: vi.fn(),
limit: vi.fn(),
}))
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
error: mocks.logError,
warn: mocks.logWarn,
info: mocks.logInfo,
child: () => ({ error: mocks.logError, warn: mocks.logWarn, info: mocks.logInfo }),
}),
}))
vi.mock('@upstash/redis', () => ({
Redis: class Redis {
constructor(cfg: unknown) {
mocks.redisCtor(cfg)
}
},
}))
vi.mock('@upstash/ratelimit', () => ({
Ratelimit: class Ratelimit {
static slidingWindow = vi.fn((max: number, window: string) => ({ max, window }))
limit = mocks.limit
constructor(cfg: unknown) {
mocks.ratelimitCtor(cfg)
}
},
}))
// The "report once per process" flag is module state, so every test gets a
// fresh module instance instead of a test-only reset export.
async function loadModule() {
vi.resetModules()
return import('@/lib/auth/rate-limit-http')
}
const OPTS = { prefix: 'test', identifier: 'ip:1', maxRequests: 5, windowMs: 60_000 }
describe('checkRateLimit', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('UPSTASH_REDIS_REST_URL', '')
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', '')
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
vi.stubEnv('NODE_ENV', 'production')
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('Redis not configured', () => {
it('fails open on a hosted production deployment but logs one error with the alert flag', async () => {
const { checkRateLimit } = await loadModule()
expect(await checkRateLimit(OPTS)).toEqual({ ok: true })
expect(await checkRateLimit({ ...OPTS, identifier: 'ip:2' })).toEqual({ ok: true })
expect(await checkRateLimit({ ...OPTS, prefix: 'other' })).toEqual({ ok: true })
expect(mocks.logError).toHaveBeenCalledTimes(1)
const [message, ctx] = mocks.logError.mock.calls[0]
expect(message).toMatch(/UPSTASH_REDIS_REST_URL/)
expect(message).toMatch(/failing open/)
expect(ctx).toMatchObject({ alert: true })
expect(mocks.redisCtor).not.toHaveBeenCalled()
})
it('stays quiet on a self-hosted deployment (Redis is optional there)', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const { checkRateLimit } = await loadModule()
expect(await checkRateLimit(OPTS)).toEqual({ ok: true })
expect(mocks.logError).not.toHaveBeenCalled()
})
it('stays quiet outside production (local dev and tests are not deployments)', async () => {
vi.stubEnv('NODE_ENV', 'development')
const { checkRateLimit } = await loadModule()
expect(await checkRateLimit(OPTS)).toEqual({ ok: true })
expect(mocks.logError).not.toHaveBeenCalled()
})
it('reports the limiter as not configured', async () => {
const { isRateLimiterConfigured } = await loadModule()
expect(isRateLimiterConfigured()).toBe(false)
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.example')
expect(isRateLimiterConfigured()).toBe(false)
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'tok')
expect(isRateLimiterConfigured()).toBe(true)
})
})
describe('Redis configured', () => {
beforeEach(() => {
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.example')
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'tok')
})
it('allows the request when the sliding window has room and logs nothing', async () => {
mocks.limit.mockResolvedValue({ success: true, reset: Date.now() + 1000, limit: 5, remaining: 4 })
const { checkRateLimit } = await loadModule()
expect(await checkRateLimit(OPTS)).toEqual({ ok: true })
expect(mocks.limit).toHaveBeenCalledWith('ip:1')
expect(mocks.redisCtor).toHaveBeenCalledWith({ url: 'https://redis.example', token: 'tok' })
expect(mocks.ratelimitCtor).toHaveBeenCalledWith(
expect.objectContaining({ prefix: 'test', analytics: false }),
)
expect(mocks.logError).not.toHaveBeenCalled()
})
it('returns a Swedish 429 with Retry-After and X-RateLimit headers when blocked', async () => {
const reset = Date.now() + 30_000
mocks.limit.mockResolvedValue({ success: false, reset, limit: 5, remaining: 0 })
const { checkRateLimit } = await loadModule()
const result = await checkRateLimit(OPTS)
expect(result.ok).toBe(false)
expect(result.response?.status).toBe(429)
expect(await result.response?.json()).toEqual({
error: 'För många förfrågningar. Försök igen om en stund.',
})
const retryAfter = Number(result.response?.headers.get('Retry-After'))
expect(retryAfter).toBeGreaterThanOrEqual(29)
expect(retryAfter).toBeLessThanOrEqual(31)
expect(result.response?.headers.get('X-RateLimit-Limit')).toBe('5')
expect(result.response?.headers.get('X-RateLimit-Remaining')).toBe('0')
expect(result.response?.headers.get('X-RateLimit-Reset')).toBe(String(Math.ceil(reset / 1000)))
})
it('reuses one limiter per prefix/limit/window triple', async () => {
mocks.limit.mockResolvedValue({ success: true, reset: 0, limit: 5, remaining: 4 })
const { checkRateLimit } = await loadModule()
await checkRateLimit(OPTS)
await checkRateLimit({ ...OPTS, identifier: 'ip:9' })
await checkRateLimit({ ...OPTS, maxRequests: 50 })
expect(mocks.ratelimitCtor).toHaveBeenCalledTimes(2)
expect(mocks.redisCtor).toHaveBeenCalledTimes(1)
})
})
})
+190 -43
View File
@@ -31,6 +31,22 @@ const MOCK_USER = {
created_at: '2026-01-01T00:00:00Z',
}
const VERIFIED_TOTP = { id: 'f1', status: 'verified', factor_type: 'totp' }
const UNVERIFIED_TOTP = { id: 'f2', status: 'unverified', factor_type: 'totp' }
/** listFactors() payload: `all` carries everything, the typed arrays only verified ones. */
function factorList(...factors: Array<typeof VERIFIED_TOTP>) {
return {
data: {
all: factors,
totp: factors.filter((f) => f.status === 'verified'),
phone: [],
webauthn: [],
},
error: null,
}
}
type MockAuth = Record<string, unknown>
function useSupabase(auth: MockAuth) {
@@ -39,6 +55,11 @@ function useSupabase(auth: MockAuth) {
return supabase
}
function enableMfa() {
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
}
describe('requireAuth', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -142,53 +163,179 @@ describe('requireAuth', () => {
expect(getUser).not.toHaveBeenCalled()
})
it('returns 403 when MFA is required and AAL2 is not verified', async () => {
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
const getAuthenticatorAssuranceLevel = vi.fn().mockResolvedValue({
data: { currentLevel: 'aal1', nextLevel: 'aal2' },
error: null,
describe('MFA gate', () => {
beforeEach(() => {
enableMfa()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel } })
const result = await requireAuth()
expect(result.user).toBeNull()
expect(result.error?.status).toBe(403)
const body = await result.error?.json()
expect(body).toEqual({ error: 'MFA verification required' })
})
it('skips the MFA check for bankid_linked users', async () => {
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
const claims = { ...CLAIMS, app_metadata: { provider: 'email', bankid_linked: true } }
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
const getAuthenticatorAssuranceLevel = vi.fn()
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel } })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(getAuthenticatorAssuranceLevel).not.toHaveBeenCalled()
})
it('passes when MFA is required and the session is already AAL2', async () => {
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
const getAuthenticatorAssuranceLevel = vi.fn().mockResolvedValue({
data: { currentLevel: 'aal2', nextLevel: 'aal2' },
error: null,
afterEach(() => {
vi.restoreAllMocks()
})
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel } })
const result = await requireAuth()
it('returns 403 for an AAL1 session when the auth server reports a verified factor', async () => {
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
const listFactors = vi.fn().mockResolvedValue(factorList(VERIFIED_TOTP))
useSupabase({ getClaims, mfa: { listFactors } })
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(getAuthenticatorAssuranceLevel).toHaveBeenCalledTimes(1)
const result = await requireAuth()
expect(result.user).toBeNull()
expect(result.error?.status).toBe(403)
const body = await result.error?.json()
expect(body).toEqual({ error: 'MFA verification required' })
expect(listFactors).toHaveBeenCalledTimes(1)
})
it('never consults the cookie-derived assurance level', async () => {
// The attack: the sb-*-auth-token cookie is unsigned JSON, so the
// password holder strips `user.factors` and the local
// getAuthenticatorAssuranceLevel() reports nextLevel aal1 ("no MFA
// enrolled"). The gate must decide on the server's answer instead.
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
const getAuthenticatorAssuranceLevel = vi.fn().mockResolvedValue({
data: { currentLevel: 'aal1', nextLevel: 'aal1' },
error: null,
})
const listFactors = vi.fn().mockResolvedValue(factorList(VERIFIED_TOTP))
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel, listFactors } })
const result = await requireAuth()
expect(result.error?.status).toBe(403)
expect(getAuthenticatorAssuranceLevel).not.toHaveBeenCalled()
})
it('passes an AAL2 session on the verified claims alone (no listFactors round trip)', async () => {
const claims = { ...CLAIMS, aal: 'aal2' }
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
const listFactors = vi.fn()
const getAuthenticatorAssuranceLevel = vi.fn()
useSupabase({ getClaims, mfa: { listFactors, getAuthenticatorAssuranceLevel } })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(listFactors).not.toHaveBeenCalled()
expect(getAuthenticatorAssuranceLevel).not.toHaveBeenCalled()
})
it('passes an AAL1 session when the auth server reports no verified factor', async () => {
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
const listFactors = vi.fn().mockResolvedValue(factorList())
useSupabase({ getClaims, mfa: { listFactors } })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(listFactors).toHaveBeenCalledTimes(1)
})
it('does not count an unverified (enrolment in progress) factor as a step-up target', async () => {
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
const listFactors = vi.fn().mockResolvedValue(factorList(UNVERIFIED_TOTP))
useSupabase({ getClaims, mfa: { listFactors } })
const result = await requireAuth()
expect(result.error).toBeNull()
})
it('reads a verified phone factor from the typed array when `all` is absent', async () => {
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
const listFactors = vi.fn().mockResolvedValue({
data: { phone: [{ id: 'p1', status: 'verified', factor_type: 'phone' }] },
error: null,
})
useSupabase({ getClaims, mfa: { listFactors } })
const result = await requireAuth()
expect(result.error?.status).toBe(403)
})
it('treats a session whose verified claims carry no aal as not assured', async () => {
const { aal: _aal, ...claims } = CLAIMS
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
const listFactors = vi.fn().mockResolvedValue(factorList(VERIFIED_TOTP))
useSupabase({ getClaims, mfa: { listFactors } })
const result = await requireAuth()
expect(result.error?.status).toBe(403)
})
it('fails closed when listFactors returns an error', async () => {
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
const listFactors = vi.fn().mockResolvedValue({
data: null,
error: { name: 'AuthApiError', message: 'upstream unavailable' },
})
useSupabase({ getClaims, mfa: { listFactors } })
const result = await requireAuth()
expect(result.error?.status).toBe(403)
})
it('fails closed when listFactors throws', async () => {
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
const listFactors = vi.fn().mockRejectedValue(new Error('network down'))
useSupabase({ getClaims, mfa: { listFactors } })
const result = await requireAuth()
expect(result.error?.status).toBe(403)
})
it('fails closed when the client exposes no mfa API at all', async () => {
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
useSupabase({ getClaims })
const result = await requireAuth()
expect(result.error?.status).toBe(403)
})
it('asks listFactors on the getUser fallback path and refuses a verified factor', async () => {
// No verified claims exist here (getClaims failed), so the level is
// unknown: a verified factor means the session must be refused.
const getClaims = vi.fn().mockRejectedValue(new Error('jwks fetch failed'))
const getUser = vi.fn().mockResolvedValue({ data: { user: MOCK_USER }, error: null })
const listFactors = vi.fn().mockResolvedValue(factorList(VERIFIED_TOTP))
useSupabase({ getClaims, getUser, mfa: { listFactors } })
const result = await requireAuth()
expect(result.error?.status).toBe(403)
expect(listFactors).toHaveBeenCalledTimes(1)
})
it('passes a factor-less user on the getUser fallback path', async () => {
const getUser = vi.fn().mockResolvedValue({ data: { user: MOCK_USER }, error: null })
const listFactors = vi.fn().mockResolvedValue(factorList())
useSupabase({ getUser, mfa: { listFactors } })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(listFactors).toHaveBeenCalledTimes(1)
})
it('skips the MFA check for bankid_linked users', async () => {
const claims = { ...CLAIMS, app_metadata: { provider: 'email', bankid_linked: true } }
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
const listFactors = vi.fn()
useSupabase({ getClaims, mfa: { listFactors } })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(listFactors).not.toHaveBeenCalled()
})
})
})
+193 -26
View File
@@ -1,5 +1,22 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceClientNoCookies } from './api-keys'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { scopeKind, type ApiKeyScope } from './scope-catalog'
/**
* What an OAuth consent may grant: which redirect URIs a code may be sent
* to, who the client behind a URI is, and which scopes the consenting user's
* role in the selected company permits.
*/
// ── Redirect URI allowlist ─────────────────────────────────────
/**
* Identity of a built-in client, derived from the redirect URI pattern that
* matched. Rendered on the consent page so the user can tell a real Claude /
* ChatGPT connector from a look-alike registration.
*/
export type BuiltInProvider = 'claude' | 'chatgpt' | 'local'
/**
* Built-in redirect URI patterns. These bypass the DB lookup entirely so
@@ -11,38 +28,81 @@ import { createServiceClientNoCookies } from './api-keys'
* callback for already-published apps; both are documented at
* developers.openai.com/apps-sdk/build/auth.
*/
export const BUILT_IN_REDIRECT_PATTERNS: readonly RegExp[] = [
/^https:\/\/claude\.ai\/api\//,
/^https:\/\/claude\.com\/api\//,
/^https:\/\/chatgpt\.com\/connector\/oauth\//,
/^https:\/\/chatgpt\.com\/connector_platform_oauth_redirect$/,
/^http:\/\/localhost(:\d+)?(\/|$)/,
/^http:\/\/127\.0\.0\.1(:\d+)?(\/|$)/,
const BUILT_IN_PATTERNS: readonly { pattern: RegExp; provider: BuiltInProvider }[] = [
{ pattern: /^https:\/\/claude\.ai\/api\//, provider: 'claude' },
{ pattern: /^https:\/\/claude\.com\/api\//, provider: 'claude' },
{ pattern: /^https:\/\/chatgpt\.com\/connector\/oauth\//, provider: 'chatgpt' },
{ pattern: /^https:\/\/chatgpt\.com\/connector_platform_oauth_redirect$/, provider: 'chatgpt' },
{ pattern: /^http:\/\/localhost(:\d+)?(\/|$)/, provider: 'local' },
{ pattern: /^http:\/\/127\.0\.0\.1(:\d+)?(\/|$)/, provider: 'local' },
]
export function isBuiltInRedirectUri(uri: string): boolean {
return BUILT_IN_REDIRECT_PATTERNS.some((pattern) => pattern.test(uri))
export const BUILT_IN_REDIRECT_PATTERNS: readonly RegExp[] = BUILT_IN_PATTERNS.map((p) => p.pattern)
/** Which built-in client a redirect URI belongs to, or null when none matches. */
export function builtInRedirectProvider(uri: string): BuiltInProvider | null {
if (typeof uri !== 'string') return null
return BUILT_IN_PATTERNS.find(({ pattern }) => pattern.test(uri))?.provider ?? null
}
export function isBuiltInRedirectUri(uri: string): boolean {
return builtInRedirectProvider(uri) !== null
}
export type RedirectUriResolution =
| { allowed: true; kind: 'built_in'; provider: BuiltInProvider }
| {
allowed: true
kind: 'registered'
/** Display name the registering user gave the client (settings UI). */
clientName: string
/** True when the consenting user registered the URI themselves. */
registeredByConsentingUser: boolean
}
| { allowed: false }
export interface RedirectUriOptions {
/**
* The user about to consent at /authorize. When set, a DB-registered URI is
* accepted only if this user registered it, or shares at least one company
* with the user who did. Any authenticated user can insert into
* oauth_client_registrations (RLS: user_id = auth.uid()), so without this
* binding a stranger's registration would be a valid phishing target for
* every account on the instance. Built-in patterns are unaffected.
*
* Omitted by the anonymous /register endpoint, which has no user to bind
* to: it accepts any active registration, which is harmless because the
* code is only ever minted at /authorize where the binding is enforced.
*/
consentingUserId?: string
}
type RegistrationRow = { id: string; user_id: string; client_name: string }
/**
* Resolve whether a redirect URI is allowed. Built-in patterns short-circuit;
* otherwise we look for a non-revoked registration in oauth_client_registrations.
* Resolve a redirect URI to the client behind it. Built-in patterns
* short-circuit; otherwise we look for a non-revoked registration in
* oauth_client_registrations and, when a consenting user is given, check
* that the registration is theirs or a colleague's.
*
* The supabase client should be supplied explicitly by the caller so the
* trust boundary is visible at the callsite (SOC 2 CC6.1). When omitted, the
* function falls back to a service-role client: required for the /register
* endpoint which has no user session yet. The lookup is by exact URI; the
* unique partial index on the table ensures at most one active row.
* The lookup runs with the service role. The table's SELECT policy is
* user_id = auth.uid(), so a user-scoped client cannot see a colleague's
* registration at all; the trust boundary is instead the explicit binding to
* `consentingUserId` below (SOC 2 CC6.1). Callers may pass a client (the
* /register endpoint already holds one); otherwise one is constructed here.
*
* Fails closed on any error (client construction, DB query): for an
* allowlist, "unknown → deny" is the safe default.
* allowlist, "unknown → deny" is the safe default. The lookup is by exact
* URI; the unique partial index on the table ensures at most one active row.
*/
export async function isAllowedRedirectUri(
export async function resolveRedirectUri(
uri: string,
supabase?: SupabaseClient
): Promise<boolean> {
if (typeof uri !== 'string' || uri.length === 0) return false
if (isBuiltInRedirectUri(uri)) return true
supabase?: SupabaseClient,
options: RedirectUriOptions = {},
): Promise<RedirectUriResolution> {
if (typeof uri !== 'string' || uri.length === 0) return { allowed: false }
const provider = builtInRedirectProvider(uri)
if (provider) return { allowed: true, kind: 'built_in', provider }
// Service-role client construction can throw when Supabase env vars are
// absent (unit tests, misconfigured deploys). Treat that as "not allowed":
@@ -51,17 +111,124 @@ export async function isAllowedRedirectUri(
try {
client = supabase ?? createServiceClientNoCookies()
} catch {
return false
return { allowed: false }
}
const { data, error } = await client
.from('oauth_client_registrations')
.select('id')
.select('id, user_id, client_name')
.eq('redirect_uri', uri)
.is('revoked_at', null)
.limit(1)
.maybeSingle()
if (error) return false
return data !== null
if (error || !data) return { allowed: false }
const registration = data as RegistrationRow
const { consentingUserId } = options
if (consentingUserId === undefined) {
return { allowed: true, kind: 'registered', clientName: registration.client_name, registeredByConsentingUser: false }
}
if (registration.user_id === consentingUserId) {
return { allowed: true, kind: 'registered', clientName: registration.client_name, registeredByConsentingUser: true }
}
const shared = await usersShareCompany(client, consentingUserId, registration.user_id)
if (!shared) return { allowed: false }
return { allowed: true, kind: 'registered', clientName: registration.client_name, registeredByConsentingUser: false }
}
/**
* Boolean view of resolveRedirectUri, kept for the callers that only need
* the allow/deny answer (the /register endpoint and its tests).
*/
export async function isAllowedRedirectUri(
uri: string,
supabase?: SupabaseClient,
options: RedirectUriOptions = {},
): Promise<boolean> {
const resolution = await resolveRedirectUri(uri, supabase, options)
return resolution.allowed
}
/**
* True when the two users are both members of at least one common company.
* Both membership lists are paginated (a byrå consultant can sit in hundreds
* of companies) and intersected here rather than via an `.in()` filter, whose
* URL length would grow with the membership count. Any query failure counts
* as "not shared" (fail closed).
*/
async function usersShareCompany(
client: SupabaseClient,
userA: string,
userB: string,
): Promise<boolean> {
try {
const companyIdsOf = (userId: string) =>
fetchAllRows<{ company_id: string }>(({ from, to }) =>
client
.from('company_members')
.select('company_id')
.eq('user_id', userId)
.order('id', { ascending: true })
.range(from, to),
)
const [rowsA, rowsB] = await Promise.all([companyIdsOf(userA), companyIdsOf(userB)])
const companiesA = new Set(rowsA.map((r) => r.company_id))
return rowsB.some((r) => companiesA.has(r.company_id))
} catch {
return false
}
}
// ── Role ceiling ──────────────────────────────────────────────
/** Company roles whose members may hold write, manage, approve or signoff scopes. */
const WRITER_ROLES: ReadonlySet<string> = new Set(['owner', 'admin', 'member'])
/**
* Cap a scope set to what the consenting user's role in the selected company
* permits. Mirrors the app's own gate: `viewer` is read-only everywhere
* (withRouteContext requireWrite, the DB-level enforce_company_writer_role
* trigger), while owner/admin/member may hold every scope. The stage+approve
* segregation-of-duties combination is not capped by role, matching
* app/api/settings/api-keys (warn, acknowledge, record), so the consent page
* states the rule and the token route records the acknowledgement.
*
* A null role (no membership row) or an unrecognised role string caps to
* read-only: an unknown privilege level must never widen the grant.
*/
export function capScopesForRole(
scopes: readonly ApiKeyScope[],
role: string | null,
): ApiKeyScope[] {
if (role !== null && WRITER_ROLES.has(role)) return [...scopes]
return scopes.filter((s) => scopeKind(s) === 'read')
}
export type CompanyRoleLookup =
| { role: string | null; error: null }
| { role: null; error: string }
/**
* The user's role in a company, or null when no membership row exists. A
* failed query is reported separately so callers can fail loudly instead of
* silently downgrading (or widening) a grant on a transient error.
*/
export async function lookupCompanyRole(
supabase: SupabaseClient,
userId: string,
companyId: string,
): Promise<CompanyRoleLookup> {
const { data, error } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (error) return { role: null, error: error.message }
const role = (data as { role?: unknown } | null)?.role
return { role: typeof role === 'string' ? role : null, error: null }
}
+8
View File
@@ -28,6 +28,14 @@ export interface AuthCodePayload {
* to ALL_SCOPES so existing Claude flows are unaffected.
*/
scopes?: string[]
/**
* Company shown on the consent page and used to cap `scopes` to the user's
* role there. The token route binds the key to it and re-checks the role
* cap against it. Null for an account with no company yet (issue #1814);
* undefined on codes minted before the field existed, where the token
* route falls back to resolving the active company itself.
*/
companyId?: string | null
exp: number
}
+134
View File
@@ -0,0 +1,134 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createLogger } from '@/lib/logger'
/**
* Bind the completion of a browser-driven OAuth/consent flow to the user who
* started it.
*
* Our OAuth callbacks (Enable Banking, Stripe Connect, WooCommerce wc-auth)
* locate the pending connection row by the single-use `oauth_state` token and
* then finalize it for that row's `user_id` / `company_id`. The token proves
* the callback belongs to a flow WE started; it does not prove that the
* browser completing it belongs to the user who started it. Without this
* check, a victim who is lured into completing a consent an attacker
* initiated (the authorize URL is shareable) has their bank / Stripe / store
* attached to the attacker's company.
*
* Consent redirects are top-level navigations, so on the legitimate path the
* initiator's own session cookies arrive with the callback. This helper reads
* that cookie session and compares it to the expected initiator.
*
* Outcomes:
* - ok: the session user is the initiator; carry on.
* - no_session: nobody is signed in (expired mid-flow, cookies cleared).
* `response` redirects to /login?next=<this callback URL> so the initiator
* can sign in and the callback re-runs with the same code + state.
* - mismatch: a different user is signed in. `response` is a 403 in the
* canonical error envelope; a route whose UX is a settings redirect
* inspects `reason` and builds its own redirect instead. The mismatch is
* logged with both ids redacted to prefixes.
*
* Deliberately not `requireAuth()`: this is an equality check on identity,
* not an authorization gate. The route that STARTED the flow already ran the
* MFA-enforcing guard for this user, and a 403 here for an aal1 session would
* strand the user (the callback has no MFA prompt to send them to).
*/
const log = createLogger('auth/oauth-flow-binding')
/** Swedish user-facing message for the mismatch outcome (shared by callers). */
export const FLOW_INITIATOR_MISMATCH_MESSAGE =
'Anslutningen kunde inte slutföras: den startades från ett annat användarkonto än det du är inloggad med. Logga in med det kontot eller starta anslutningen på nytt.'
export const FLOW_INITIATOR_MISMATCH_MESSAGE_EN =
'The connection could not be completed: it was started from a different user account than the one you are signed in with. Sign in with that account or start the connection again.'
export type FlowInitiatorResult =
| { ok: true; userId: string }
| { ok: false; reason: 'no_session'; response: Response }
| { ok: false; reason: 'mismatch'; response: Response; sessionUserId: string }
export interface RequireFlowInitiatorOptions {
/** Short label for the log line, e.g. 'stripe.callback'. */
flow?: string
}
/**
* Shorten a user id to a stable prefix for log lines. Enough to correlate two
* log records, not enough to identify the account outside the database.
*/
export function redactUserId(id: string | null | undefined): string {
if (!id) return '(none)'
return id.length <= 8 ? id : `${id.slice(0, 8)}...`
}
/**
* The /login redirect for a callback reached without a session. `next` is the
* callback's own path + query (same-origin relative, which is the only form
* the login page's safeReturnTo accepts), so signing in resumes the flow.
*/
export function buildLoginRedirect(request: Request): Response {
const current = new URL(request.url)
const appOrigin = process.env.NEXT_PUBLIC_APP_URL || current.origin
const next = `${current.pathname}${current.search}`
const login = new URL('/login', appOrigin)
login.searchParams.set('next', next)
return NextResponse.redirect(login.toString())
}
export async function requireFlowInitiator(
request: Request,
expectedUserId: string,
options: RequireFlowInitiatorOptions = {},
): Promise<FlowInitiatorResult> {
const flow = options.flow ?? 'oauth-callback'
const path = new URL(request.url).pathname
let sessionUserId: string | null = null
try {
const supabase = await createClient()
const { data, error } = await supabase.auth.getUser()
if (!error && data?.user?.id) sessionUserId = data.user.id
} catch (err) {
// Fail closed: an auth outage or a missing request scope is treated as
// "no session". The login redirect below re-runs the callback once a
// session can be read, nothing is finalized on a guess.
log.error('could not read the cookie session for an OAuth callback', err as Error, {
flow,
path,
})
}
if (!sessionUserId) {
log.warn('oauth callback reached without a session; sending to login', {
flow,
path,
expectedUser: redactUserId(expectedUserId),
})
return { ok: false, reason: 'no_session', response: buildLoginRedirect(request) }
}
if (sessionUserId !== expectedUserId) {
log.warn('oauth callback completed by a different user than the initiator', {
flow,
path,
expectedUser: redactUserId(expectedUserId),
sessionUser: redactUserId(sessionUserId),
alert: true,
})
const response = NextResponse.json(
{
error: {
code: 'OAUTH_FLOW_INITIATOR_MISMATCH',
message: FLOW_INITIATOR_MISMATCH_MESSAGE,
message_en: FLOW_INITIATOR_MISMATCH_MESSAGE_EN,
},
},
{ status: 403 },
)
return { ok: false, reason: 'mismatch', response, sessionUserId }
}
return { ok: true, userId: sessionUserId }
}
+45 -2
View File
@@ -1,9 +1,22 @@
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { NextResponse } from 'next/server'
import { isSelfHosted } from '@/lib/env/public-flags'
import { createLogger } from '@/lib/logger'
const log = createLogger('auth.rate-limit')
let redis: Redis | null = null
/**
* Whether the Upstash credentials the limiter needs are present. Exposed so
* health / version surfaces can report the limiter's state instead of every
* caller re-reading the env.
*/
export function isRateLimiterConfigured(): boolean {
return Boolean(process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN)
}
function getRedis(): Redis | null {
if (redis) return redis
const url = process.env.UPSTASH_REDIS_REST_URL
@@ -33,6 +46,31 @@ function getLimiter(prefix: string, maxRequests: number, windowMs: number): Rate
return limiter
}
// Once per process: the first rate-limited request on a hosted deployment
// without Redis produces one error record. Repeating it per request would
// drown the logs the alert is meant to surface in.
let reportedNotConfigured = false
/**
* Fail-open is deliberate for local dev and self-hosted installs (no Redis
* required to run the product). On the hosted product it is a
* misconfiguration: every limiter-protected surface (MCP OAuth registration,
* sandbox seeding, client log ingestion, webshop connects) is unthrottled.
* Say so loudly, exactly once, at error level with the alert flag so the
* observability sink pages on it. Never fail closed here: that would turn a
* missing env var into a 503 on every protected route.
*/
function reportNotConfiguredOnce(): void {
if (reportedNotConfigured) return
reportedNotConfigured = true
if (isSelfHosted()) return
if (process.env.NODE_ENV !== 'production') return
log.error(
'HTTP rate limiting is disabled: UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN are not set on a hosted deployment; checkRateLimit() is failing open',
{ alert: true, operation: 'rate-limit.not-configured' },
)
}
export interface RateLimitOptions {
prefix: string
identifier: string
@@ -54,11 +92,16 @@ export interface RateLimitResult {
* No-ops (allows the request) when Upstash env vars are not configured:
* intentional so local dev and self-hosted deployments without Redis still work.
* Production hosted deployments must set UPSTASH_REDIS_REST_URL/TOKEN for the
* limit to be enforced; absence is logged once at startup by other call sites.
* limit to be enforced; a hosted process without them logs one error-level
* record (see reportNotConfiguredOnce) and `isRateLimiterConfigured()` reports
* the state for health surfaces.
*/
export async function checkRateLimit(opts: RateLimitOptions): Promise<RateLimitResult> {
const limiter = getLimiter(opts.prefix, opts.maxRequests, opts.windowMs)
if (!limiter) return { ok: true }
if (!limiter) {
reportNotConfiguredOnce()
return { ok: true }
}
const { success, reset, limit, remaining } = await limiter.limit(opts.identifier)
if (success) return { ok: true }
+76 -15
View File
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { shouldEnforceMfa } from './mfa'
import type { User, SupabaseClient } from '@supabase/supabase-js'
import type { JwtPayload, User, SupabaseClient } from '@supabase/supabase-js'
import { claimsPinned, userFromClaims } from './claims'
type AuthResult =
@@ -32,19 +32,23 @@ export async function requireAuth(): Promise<AuthResult> {
const supabase = await createClient()
let user: User | null = null
// The signature-verified claims, kept for the MFA gate below: null on the
// getUser fallback path, where no locally verified claims exist.
let claims: JwtPayload | null = null
try {
// The typeof guard keeps legacy test mocks (auth object with only
// getUser) on the old path.
if (typeof supabase.auth.getClaims === 'function') {
const { data } = await supabase.auth.getClaims()
const claims = data?.claims
if (claims?.sub) {
if (claimsPinned(claims)) {
user = userFromClaims(claims)
const verified = data?.claims
if (verified?.sub) {
if (claimsPinned(verified)) {
claims = verified
user = userFromClaims(verified)
} else {
console.error('requireAuth: getClaims iss/aud pinning failed; falling back to getUser', {
iss: claims.iss,
aud: claims.aud,
iss: verified.iss,
aud: verified.aud,
})
}
}
@@ -68,16 +72,73 @@ export async function requireAuth(): Promise<AuthResult> {
}
}
if (shouldEnforceMfa(user)) {
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (aal?.nextLevel === 'aal2' && aal?.currentLevel !== 'aal2') {
return {
user: null,
supabase,
error: NextResponse.json({ error: 'MFA verification required' }, { status: 403 }),
}
if (shouldEnforceMfa(user) && !(await sessionIsMfaAssured(supabase, claims))) {
return {
user: null,
supabase,
error: NextResponse.json({ error: 'MFA verification required' }, { status: 403 }),
}
}
return { user, supabase, error: null }
}
/**
* Whether the session may pass the MFA gate.
*
* An AAL2 session, per the signature-verified claims, passes with no extra
* round trip. Anything else (AAL1, no `aal` claim, or the getUser fallback
* path where no verified claims exist) asks the auth server through
* listFactors() (a getUser() call under the hood) whether a verified factor
* exists: if one does, the session is stuck below the level it could reach
* and is refused. A failed or throwing lookup is refused too (fail closed):
* the alternative lets a transient auth error switch MFA off.
*
* Never `mfa.getAuthenticatorAssuranceLevel()` without a JWT: its `nextLevel`
* is computed from `session.user.factors`, i.e. from the unsigned
* sb-*-auth-token cookie, which whoever holds the password can edit to hide
* the factor and turn an enrolled account into a "no MFA needed" one
* (security audit 2026-09). The cost of the honest check is one listFactors
* round trip per API request for AAL1 sessions of users without a factor.
*/
async function sessionIsMfaAssured(
supabase: SupabaseClient,
claims: JwtPayload | null,
): Promise<boolean> {
if (claims?.aal === 'aal2') return true
try {
const { data, error } = await supabase.auth.mfa.listFactors()
if (error || !data) {
console.error('requireAuth: listFactors failed; treating session as not MFA-assured', error)
return false
}
return !factorsIncludeVerified(data)
} catch (err) {
console.error('requireAuth: listFactors threw; treating session as not MFA-assured', err)
return false
}
}
type FactorList = ReadonlyArray<{ status: string }> | undefined
/**
* Whether a listFactors() payload contains a verified factor of any type.
* `all` carries every factor; the typed arrays carry only the verified ones.
* Both are consulted so a payload missing either shape still reads right.
*/
function factorsIncludeVerified(data: {
all?: FactorList
totp?: FactorList
phone?: FactorList
webauthn?: FactorList
}): boolean {
const verified = (list: FactorList) =>
list?.some((factor) => factor.status === 'verified') ?? false
return (
verified(data.all) ||
verified(data.totp) ||
verified(data.phone) ||
verified(data.webauthn)
)
}
@@ -72,6 +72,7 @@ import {
createPendingDocumentUpload,
completePendingDocumentUpload,
computeSHA256,
resolveStoredMimeType,
PENDING_DOCUMENT_UPLOAD_RETENTION_MS,
SIGNED_DOCUMENT_UPLOAD_TTL_MS,
isCompanyScopedDocumentPath,
@@ -1243,3 +1244,170 @@ describe('verifyIntegrity', () => {
expect(result.computedHash).not.toBe('stored-hash-abc')
})
})
describe('validated mime type persistence (stored type is what the bytes are)', () => {
const company = '11111111-1111-4111-8111-111111111111'
const user = '22222222-2222-4222-8222-222222222222'
const uploadId = '33333333-3333-4333-8333-333333333333'
// 16-byte ISO-BMFF ftyp box with the given major brand (see the HEIC tests).
const isoBmff = (brand: string): ArrayBuffer => {
const bytes = new Uint8Array(16)
bytes[3] = 16
bytes.set([0x66, 0x74, 0x79, 0x70], 4)
bytes.set(new TextEncoder().encode(brand), 8)
return bytes.buffer as ArrayBuffer
}
const pngBytes = (): ArrayBuffer =>
new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).buffer as ArrayBuffer
const textBytes = (text: string): ArrayBuffer => {
const bytes = new TextEncoder().encode(text)
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
function insertPayloadOf(client: ReturnType<typeof makeClient>, fromIndex: number) {
const builder = client.from.mock.results[fromIndex]?.value as { insert: ReturnType<typeof vi.fn> }
return builder.insert.mock.calls[0]?.[0] as Record<string, unknown> | undefined
}
describe('resolveStoredMimeType', () => {
it('returns the sniffed type for binary formats, including the HEIC/HEIF family swap', () => {
expect(resolveStoredMimeType(pdfBuffer(), 'application/pdf')).toBe('application/pdf')
expect(resolveStoredMimeType(pngBytes(), 'image/png')).toBe('image/png')
expect(resolveStoredMimeType(isoBmff('heic'), 'image/heif')).toBe('image/heic')
expect(resolveStoredMimeType(isoBmff('mif1'), 'image/heic')).toBe('image/heif')
})
it('keeps the declared type for the shape-checked text formats (no magic number)', () => {
expect(resolveStoredMimeType(textBytes('<?xml version="1.0"?><Invoice/>'), 'application/xml')).toBe('application/xml')
expect(resolveStoredMimeType(textBytes('<?xml version="1.0"?><Invoice/>'), 'text/xml')).toBe('text/xml')
expect(resolveStoredMimeType(textBytes('<!doctype html><html></html>'), 'text/html')).toBe('text/html')
expect(resolveStoredMimeType(textBytes('<?xml version="1.0"?><html/>'), 'application/xhtml+xml')).toBe('application/xhtml+xml')
expect(resolveStoredMimeType(textBytes('{"a":1}'), 'application/json')).toBe('application/json')
})
it('sniffs an undeclared type and stores null rather than an unverified string', () => {
expect(resolveStoredMimeType(pdfBuffer(), undefined)).toBe('application/pdf')
expect(resolveStoredMimeType(pdfBuffer(), '')).toBe('application/pdf')
expect(resolveStoredMimeType(textBytes('just text'), undefined)).toBeNull()
})
})
it('uploadDocument stores and stamps the sniffed type, not the declared one', async () => {
results = [{ data: makeDocumentAttachment({ id: 'doc-1' }), error: null }]
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
const client = makeClient({ upload })
await uploadDocument(client as never, 'user-1', 'company-1', {
name: 'IMG_0001.heif',
buffer: isoBmff('heic'),
type: 'image/heif',
})
expect(insertPayloadOf(client, 0)?.mime_type).toBe('image/heic')
expect((upload.mock.calls[0]?.[2] as { contentType: string }).contentType).toBe('image/heic')
})
it('uploadDocument sniffs an undeclared type instead of storing null for a real PDF', async () => {
results = [{ data: makeDocumentAttachment({ id: 'doc-1' }), error: null }]
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
const client = makeClient({ upload })
await uploadDocument(client as never, 'user-1', 'company-1', {
name: 'kvitto.pdf',
buffer: pdfBuffer('undeclared'),
})
expect(insertPayloadOf(client, 0)?.mime_type).toBe('application/pdf')
expect((upload.mock.calls[0]?.[2] as { contentType: string }).contentType).toBe('application/pdf')
})
it('completePendingDocumentUpload persists the sniffed type', async () => {
const buffer = isoBmff('heic')
const document = makeDocumentAttachment({
id: uploadId,
mime_type: 'image/heic',
sha256_hash: await computeSHA256(buffer),
})
results = [
{ data: null, error: null },
{ data: document, error: null },
]
serviceClientOverride = makeClient({
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
})
const client = makeClient()
await completePendingDocumentUpload(client as never, company, user, uploadId, 'IMG_0001.heif', 'image/heif')
expect(insertPayloadOf(client, 1)?.mime_type).toBe('image/heic')
})
it('completePendingDocumentUpload retry accepts the stored family member for the declared one', async () => {
const buffer = isoBmff('heic')
const document = makeDocumentAttachment({
id: uploadId,
user_id: user,
company_id: company,
file_name: 'IMG_0001.heif',
mime_type: 'image/heic',
storage_path: buildReservedDocumentStoragePath(company, user, uploadId, 'IMG_0001.heif'),
sha256_hash: await computeSHA256(buffer),
})
results = [{ data: document, error: null }]
const move = vi.fn().mockResolvedValue({ data: {}, error: null })
serviceClientOverride = makeClient({
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
move,
})
const completed = await completePendingDocumentUpload(
makeClient() as never,
company,
user,
uploadId,
'IMG_0001.heif',
'image/heif',
)
expect(completed.document).toEqual(document)
expect(move).not.toHaveBeenCalled()
})
it('completePendingDocumentUpload retry still rejects a genuinely different stored type', async () => {
const buffer = isoBmff('heic')
const document = makeDocumentAttachment({
id: uploadId,
file_name: 'IMG_0001.heif',
mime_type: 'image/jpeg',
sha256_hash: await computeSHA256(buffer),
})
results = [{ data: document, error: null }]
serviceClientOverride = makeClient({
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
})
await expect(
completePendingDocumentUpload(makeClient() as never, company, user, uploadId, 'IMG_0001.heif', 'image/heif'),
).rejects.toThrow(/different file metadata/)
})
it('createNewVersion passes the sniffed type to the versioning RPC and the storage object', async () => {
results = [
{ data: { company_id: 'company-1' }, error: null },
{ data: 'doc-2', error: null },
{ data: makeDocumentAttachment({ id: 'doc-2', version: 2 }), error: null },
]
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
const client = makeClient({ upload })
await createNewVersion(client as never, 'user-1', 'doc-1', {
name: 'IMG_0002.heif',
buffer: isoBmff('heic'),
type: 'image/heif',
})
expect((client.rpc.mock.calls[0]?.[1] as { p_mime_type: string }).p_mime_type).toBe('image/heic')
expect((upload.mock.calls[0]?.[2] as { contentType: string }).contentType).toBe('image/heic')
})
})
+56 -6
View File
@@ -362,6 +362,49 @@ export function validateDocumentMagicBytes(buffer: ArrayBuffer, declaredMimeType
return null
}
/**
* Declared types validateDocumentMagicBytes checks by content shape rather
* than by signature. They have no magic number, so the declared type is the
* only type there is once the shape check has passed.
*/
const SHAPE_CHECKED_TYPES = new Set([
'application/xhtml+xml',
'application/xml',
'text/xml',
'text/html',
'application/json',
])
/**
* The mime type to persist on the document row and stamp on the storage
* object: what the bytes are, never what the client declared. Call after
* validateDocumentMagicBytes has accepted the buffer for `declaredMimeType`.
*
* Binary formats take the sniffed type (an iOS capture declared image/heif
* but branded heic lands as image/heic). The shape-checked text formats keep
* their declared type, which the validator has already held against the
* content. With no declared type the sniffed type is stored when there is
* one, else null: a serving route treats null as unknown and serves it
* opaque, whereas an unverified client string could name an active type.
*/
export function resolveStoredMimeType(
buffer: ArrayBuffer,
declaredMimeType: string | undefined,
): string | null {
if (declaredMimeType && SHAPE_CHECKED_TYPES.has(declaredMimeType)) return declaredMimeType
return detectFileMagic(new Uint8Array(buffer))
}
/**
* True when a stored type and a declared type name the same content. The
* stored type is the validated one (resolveStoredMimeType), so the only
* legitimate difference is the HEIC/HEIF family swap.
*/
function sameStoredMimeType(stored: string | null, declared: string): boolean {
if (stored === declared) return true
return stored !== null && HEIC_FAMILY.has(stored) && HEIC_FAMILY.has(declared)
}
let bucketVerified = false
/** @internal Reset bucket verification flag: for testing only */
@@ -513,7 +556,7 @@ function validateReservedDocumentMetadata(
fileName: string,
mimeType: string
): void {
if (document.file_name !== fileName || document.mime_type !== mimeType) {
if (document.file_name !== fileName || !sameStoredMimeType(document.mime_type, mimeType)) {
throw new Error('Upload ID was already completed with different file metadata')
}
}
@@ -607,6 +650,10 @@ export async function completePendingDocumentUpload(
await storage.remove([sourcePath])
throw error
}
// Persist what the bytes are, not what the client said (see
// resolveStoredMimeType). The storage object itself keeps the metadata
// the PUT declared: nothing serving from this app trusts it.
const storedMimeType = resolveStoredMimeType(buffer, mimeType)
if (options.dedupeByContent) {
// Same lookup as uploadDocument: oldest current-version match wins, and
@@ -646,7 +693,7 @@ export async function completePendingDocumentUpload(
storage_path: permanentPath,
file_name: fileName,
file_size_bytes: buffer.byteLength,
mime_type: mimeType,
mime_type: storedMimeType,
sha256_hash: sha256Hash,
version: 1,
is_current_version: true,
@@ -754,6 +801,8 @@ export async function uploadDocument(
const magicError = validateDocumentMagicBytes(file.buffer, file.type)
if (magicError) throw new Error(magicError)
}
// Stored and stamped type is the validated one, never the raw client type.
const storedMimeType = resolveStoredMimeType(file.buffer, file.type)
// Compute SHA-256 hash
const sha256Hash = await computeSHA256(file.buffer)
@@ -801,7 +850,7 @@ export async function uploadDocument(
const { error: uploadError } = await supabase.storage
.from('documents')
.upload(storagePath, file.buffer, {
contentType: file.type || 'application/octet-stream',
contentType: storedMimeType ?? 'application/octet-stream',
upsert: false,
})
@@ -819,7 +868,7 @@ export async function uploadDocument(
storage_path: storagePath,
file_name: file.name,
file_size_bytes: file.buffer.byteLength,
mime_type: file.type || null,
mime_type: storedMimeType,
sha256_hash: sha256Hash,
version: 1,
is_current_version: true,
@@ -916,6 +965,7 @@ export async function createNewVersion(
const magicError = validateDocumentMagicBytes(file.buffer, file.type)
if (magicError) throw new Error(magicError)
}
const storedMimeType = resolveStoredMimeType(file.buffer, file.type)
// Compute SHA-256 hash
const sha256Hash = await computeSHA256(file.buffer)
@@ -945,7 +995,7 @@ export async function createNewVersion(
const { error: uploadError } = await supabase.storage
.from('documents')
.upload(storagePath, file.buffer, {
contentType: file.type || 'application/octet-stream',
contentType: storedMimeType ?? 'application/octet-stream',
upsert: false,
})
@@ -960,7 +1010,7 @@ export async function createNewVersion(
p_storage_path: storagePath,
p_file_name: file.name,
p_file_size_bytes: file.buffer.byteLength,
p_mime_type: file.type || null,
p_mime_type: storedMimeType,
p_sha256_hash: sha256Hash,
})
+61
View File
@@ -18,6 +18,67 @@
export const STORAGE_PROXY_ROUTE = '/api/storage'
/**
* Content types a browser renders natively without a script context: PDF
* and the raster image formats the document archive accepts. These are the
* only types either document-serving route (the inline preview proxy and the
* signed-URL proxy below) hands to the browser as-is. Everything else that
* can reach the archive (text/html mail bodies, application/xhtml+xml
* iXBRL, Peppol application/xml and text/xml, image/svg+xml, application/json,
* unknown or legacy types) is active content when it lands on our origin:
* an uploader-controlled <script> inside it would run with the app origin's
* authority (stored XSS). Those types are served under OPAQUE_DOCUMENT_CSP.
*/
export const INLINE_SAFE_MIME_TYPES: ReadonlySet<string> = new Set([
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'image/heic',
'image/heif',
])
/**
* Policy for every served document that is not natively inline-safe.
* `sandbox` (no tokens) makes the rendered document opaque-origin and
* script-free wherever it is opened, iframe or direct tab. The source
* directives block outbound requests on top of that: sandbox alone still
* loads remote images, so a tracking pixel in a mail body would notify the
* sender when the preview is opened. Inline styles and embedded data:/blob:
* images keep working, so HTML mail previews and XML views still render.
*/
export const OPAQUE_DOCUMENT_CSP =
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:"
/**
* Canonical (lower-case, parameter-free) form of `mimeType` when it is one
* of INLINE_SAFE_MIME_TYPES, else null. Legacy rows hold client-declared
* strings, so parameters and case are tolerated on the way in but never
* echoed back out.
*/
export function inlineSafeMimeType(mimeType: string | null | undefined): string | null {
if (!mimeType) return null
const essence = mimeType.split(';')[0]?.trim().toLowerCase() ?? ''
return INLINE_SAFE_MIME_TYPES.has(essence) ? essence : null
}
/**
* The documents-bucket object key behind a proxied download path
* (`sign/documents/<key>`, still percent-encoded), decoded so it can be
* matched against `document_attachments.storage_path`. Null for upload paths
* and anything else.
*/
export function documentKeyFromProxyPath(objectPath: string): string | null {
const prefix = 'sign/documents/'
if (!objectPath.startsWith(prefix)) return null
try {
return decodeURIComponent(objectPath.slice(prefix.length))
} catch {
return null
}
}
/** Upstream prefix under the Storage API that every signed object URL shares. */
const UPSTREAM_OBJECT_PREFIX = '/storage/v1/object/'
@@ -73,6 +73,26 @@ describe('buildAuthEmail', () => {
expect(mail.text).toContain('Kod: 123456')
})
it('renders the BankID signup confirmation with the ignore-and-stay-inactive note', () => {
// Sent to whatever address the BankID holder typed, so the copy must say
// the account was opened with BankID and that ignoring the mail leaves it
// inactive: a stranger must not be nudged into activating it.
const mail = buildAuthEmail({
actionType: 'bankid_signup',
appName: 'Siffra',
actionUrl: 'https://app.siffra.se/auth/callback?token_hash=abc&type=magiclink',
})
expect(mail.subject).toBe('Bekräfta din e-postadress')
expect(mail.text).toContain('BankID')
expect(mail.text).toContain('Siffra')
expect(mail.text).toContain('förblir inaktivt')
expect(mail.text).toContain(
'Bekräfta e-postadress: https://app.siffra.se/auth/callback?token_hash=abc&type=magiclink',
)
expect(mail.html).toContain('type=magiclink')
expect(mail.html).not.toMatch(/accounted/i)
})
it('falls back to a generic mail for unknown action types', () => {
const mail = buildAuthEmail({
actionType: 'some_future_type',
+15
View File
@@ -20,6 +20,7 @@ export type AuthEmailActionType =
| 'email_change'
| 'email_change_current'
| 'reauthentication'
| 'bankid_signup'
interface AuthEmailContent {
subject: string
@@ -76,6 +77,20 @@ const CONTENT: Record<AuthEmailActionType, AuthEmailContent> = {
body: (appName) => `Ange koden nedan för att bekräfta din identitet hos ${appName}.`,
cta: '',
},
// Sent by the BankID signup (extensions/general/tic) to the address the
// person typed, and again when a still-unconfirmed identity tries to log
// in. Not a Supabase hook type: the link is minted by generateLink and only
// ever travels by mail. The copy says plainly that the account was opened
// with BankID and that ignoring the mail leaves it inactive, so a stranger
// whose address was typed by mistake (or on purpose) is not nudged into
// activating someone else's BankID login.
bankid_signup: {
subject: 'Bekräfta din e-postadress',
heading: 'Bekräfta din e-postadress',
body: (appName) =>
`Ett konto hos ${appName} har skapats med BankID och den här e-postadressen. Klicka på knappen nedan för att bekräfta att adressen är din och aktivera kontot. Om det inte var du som skapade kontot kan du bortse från det här meddelandet: kontot förblir inaktivt och kan inte användas för att logga in.`,
cta: 'Bekräfta e-postadress',
},
}
// Availability first: an action type this module does not know (Supabase can
+249
View File
@@ -0,0 +1,249 @@
import { describe, expect, it, vi } from 'vitest'
import {
isUnsafeUrlError,
readBodyWithCap,
safeFetch,
UnsafeUrlError,
} from '@/lib/http/safe-fetch'
import type { validateWebhookUrl } from '@/lib/webhooks/url-guard'
type Validator = typeof validateWebhookUrl
function okValidator(addresses = ['203.0.113.10']): Validator {
return vi.fn(async (rawUrl: string) => ({
ok: true as const,
hostname: new URL(rawUrl).hostname,
resolvedAddresses: addresses,
})) as unknown as Validator
}
function refusingValidator(
reason: 'private_address' | 'non_https_scheme' | 'metadata_address',
detail = 'nope',
): Validator {
return vi.fn(async () => ({ ok: false as const, reason, detail })) as unknown as Validator
}
describe('safeFetch', () => {
it('validates the hostname, forces redirect: manual and returns the response', async () => {
const validateUrl = okValidator()
const response = new Response('ok', { status: 200 })
const fetchImpl = vi.fn(async () => response)
const result = await safeFetch(
'https://shop.example.se/wp-json/',
{ headers: { Accept: 'application/json' } },
{},
{ validateUrl, fetchImpl },
)
expect(result).toBe(response)
expect(validateUrl).toHaveBeenCalledWith('https://shop.example.se/wp-json/', undefined)
expect(fetchImpl).toHaveBeenCalledWith(
'https://shop.example.se/wp-json/',
expect.objectContaining({ redirect: 'manual', headers: { Accept: 'application/json' } }),
)
})
it('refuses a URL whose hostname resolves to a private address without opening a socket', async () => {
const fetchImpl = vi.fn()
const error = await safeFetch(
'https://internal.example/',
{},
{},
{ validateUrl: refusingValidator('private_address', '10.0.0.4 is private'), fetchImpl },
).catch((e) => e)
expect(error).toBeInstanceOf(UnsafeUrlError)
expect(isUnsafeUrlError(error)).toBe(true)
expect((error as UnsafeUrlError).reason).toBe('private_address')
expect((error as UnsafeUrlError).detail).toBe('10.0.0.4 is private')
expect(fetchImpl).not.toHaveBeenCalled()
})
it('classifies IP-literal hostnames without DNS (the literal is the only "record")', async () => {
const fetchImpl = vi.fn()
const validateUrl = vi.fn(
async (rawUrl: string, opts?: { resolve4?: (h: string) => Promise<string[]>; resolve6?: (h: string) => Promise<string[]> }) => {
// Behave like the real validator: consume the injected resolvers.
const v4 = await opts!.resolve4!(new URL(rawUrl).hostname).catch(() => [])
const v6 = await opts!.resolve6!(new URL(rawUrl).hostname).catch(() => [])
return {
ok: false as const,
reason: 'metadata_address' as const,
detail: `resolved ${[...v4, ...v6].join(',')}`,
}
},
) as unknown as Validator
const error = await safeFetch(
'https://169.254.169.254/latest/meta-data/',
{},
{},
{ validateUrl, fetchImpl },
).catch((e) => e)
expect((error as UnsafeUrlError).reason).toBe('metadata_address')
// The literal was handed to the classifier as the v4 answer and v6 had none.
expect((error as UnsafeUrlError).detail).toBe('resolved 169.254.169.254')
expect(fetchImpl).not.toHaveBeenCalled()
// IPv6 literal: brackets are stripped before the family check.
const validateV6 = vi.fn(
async (_rawUrl: string, opts?: { resolve4?: (h: string) => Promise<string[]>; resolve6?: (h: string) => Promise<string[]> }) => {
const v6 = await opts!.resolve6!('x')
const v4 = await opts!.resolve4!('x').catch((e: NodeJS.ErrnoException) => e.code)
return { ok: false as const, reason: 'loopback_address' as const, detail: `${v6[0]}|${v4}` }
},
) as unknown as Validator
const v6Error = await safeFetch('https://[::1]/', {}, {}, { validateUrl: validateV6, fetchImpl }).catch(
(e) => e,
)
expect((v6Error as UnsafeUrlError).detail).toBe('::1|ENODATA')
})
it('treats any 3xx as a refusal instead of following it', async () => {
const cancel = vi.fn(async () => undefined)
const redirect = {
status: 302,
type: 'basic',
headers: new Headers({ location: 'http://169.254.169.254/' }),
body: { cancel },
} as unknown as Response
const fetchImpl = vi.fn(async () => redirect)
const error = await safeFetch(
'https://shop.example.se/',
{},
{},
{ validateUrl: okValidator(), fetchImpl },
).catch((e) => e)
expect(isUnsafeUrlError(error)).toBe(true)
expect((error as UnsafeUrlError).reason).toBe('redirect_blocked')
expect(cancel).toHaveBeenCalled()
})
it('treats an opaque-redirect response as a refusal too', async () => {
const opaque = { status: 0, type: 'opaqueredirect', headers: new Headers(), body: null } as unknown as Response
const error = await safeFetch(
'https://shop.example.se/',
{},
{},
{ validateUrl: okValidator(), fetchImpl: vi.fn(async () => opaque) },
).catch((e) => e)
expect((error as UnsafeUrlError).reason).toBe('redirect_blocked')
})
it('refuses non-http(s) schemes before validation', async () => {
const validateUrl = okValidator()
const error = await safeFetch('file:///etc/passwd', {}, {}, { validateUrl, fetchImpl: vi.fn() }).catch(
(e) => e,
)
expect((error as UnsafeUrlError).reason).toBe('unsupported_scheme')
expect(validateUrl).not.toHaveBeenCalled()
const invalid = await safeFetch('not a url', {}, {}, { validateUrl, fetchImpl: vi.fn() }).catch((e) => e)
expect((invalid as UnsafeUrlError).reason).toBe('invalid_url')
})
it('skips the address check for a trusted origin but still refuses its redirects', async () => {
const validateUrl = refusingValidator('private_address')
const okResponse = new Response('logo-bytes', { status: 200 })
const fetchImpl = vi.fn(async () => okResponse)
const result = await safeFetch(
'http://192.168.1.50:8000/storage/v1/object/public/logos/a.png',
{},
{ trustedOrigins: ['http://192.168.1.50:8000/'] },
{ validateUrl, fetchImpl },
)
expect(result).toBe(okResponse)
expect(validateUrl).not.toHaveBeenCalled()
// Different port is a different origin: back to the strict path.
const otherPort = await safeFetch(
'http://192.168.1.50:9000/x.png',
{},
{ trustedOrigins: ['http://192.168.1.50:8000'] },
{ validateUrl, fetchImpl },
).catch((e) => e)
expect((otherPort as UnsafeUrlError).reason).toBe('private_address')
// Trusted origin that answers with a redirect is still refused.
const redirect = { status: 301, type: 'basic', headers: new Headers(), body: null } as unknown as Response
const bounced = await safeFetch(
'http://192.168.1.50:8000/storage/v1/object/public/logos/b.png',
{},
{ trustedOrigins: ['http://192.168.1.50:8000'] },
{ validateUrl, fetchImpl: vi.fn(async () => redirect) },
).catch((e) => e)
expect((bounced as UnsafeUrlError).reason).toBe('redirect_blocked')
})
it('lets transport errors propagate unchanged so callers keep their retry semantics', async () => {
const boom = new TypeError('fetch failed')
await expect(
safeFetch('https://shop.example.se/', {}, {}, {
validateUrl: okValidator(),
fetchImpl: vi.fn(async () => {
throw boom
}),
}),
).rejects.toBe(boom)
})
})
describe('readBodyWithCap', () => {
it('returns the bytes when under the cap', async () => {
const res = new Response(Buffer.from('hello'), { status: 200 })
const buf = await readBodyWithCap(res, 1024)
expect(buf?.toString('utf8')).toBe('hello')
})
it('rejects on a declared Content-Length over the cap without reading the body', async () => {
const arrayBuffer = vi.fn()
const res = {
headers: new Headers({ 'content-length': String(3 * 1024 * 1024) }),
body: { cancel: vi.fn(async () => undefined), getReader: vi.fn() },
arrayBuffer,
} as unknown as Response
expect(await readBodyWithCap(res, 2 * 1024 * 1024)).toBeNull()
expect(arrayBuffer).not.toHaveBeenCalled()
expect((res.body as unknown as { getReader: ReturnType<typeof vi.fn> }).getReader).not.toHaveBeenCalled()
})
it('cuts a streamed body off at the cap when the length header is absent or lies', async () => {
const chunk = new Uint8Array(1024)
let pulls = 0
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1
if (pulls > 100) controller.close()
else controller.enqueue(chunk)
},
})
const res = new Response(stream, { status: 200 })
expect(await readBodyWithCap(res, 3 * 1024)).toBeNull()
// Stopped shortly after crossing the cap, not after draining 100 KiB.
expect(pulls).toBeLessThan(10)
})
it('falls back to arrayBuffer() for non-streaming doubles and still applies the cap', async () => {
const small = {
headers: { get: () => null },
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as unknown as Response
expect((await readBodyWithCap(small, 10))?.length).toBe(3)
const big = {
headers: { get: () => null },
arrayBuffer: async () => new Uint8Array(11).buffer,
} as unknown as Response
expect(await readBodyWithCap(big, 10)).toBeNull()
})
})
+224
View File
@@ -0,0 +1,224 @@
/**
* Guarded outbound `fetch` for URLs a tenant controls.
*
* Several server-side paths fetch a URL that a company member can write
* straight into the database through PostgREST (RLS lets members update
* their own rows, so app-side normalisation at create time is not a
* boundary): `woocommerce_connections.store_url`, `shopify_connections.
* shop_domain`, `company_settings.logo_url`. Fetching those with a bare
* `fetch()` from the Vercel function's network position is a textbook SSRF:
* cloud metadata (169.254.169.254), loopback, RFC 1918 ranges, and any 3xx
* that bounces the request there AFTER a hostname check passed.
*
* This helper is the one place such fetches go through:
*
* 1. The URL must be http(s). Anything else is refused up front.
* 2. Unless the URL's origin is explicitly trusted by the caller (e.g. the
* deployment's own Supabase storage origin, which may legitimately be a
* private address on a self-hosted install), EVERY A/AAAA record of the
* hostname must be publicly routable and the scheme must be https. That
* check is `validateWebhookUrl` from the webhook dispatcher, reused
* verbatim so there is a single definition of "safe address" in the
* codebase. IP-literal hostnames are classified directly, without DNS.
* 3. Redirects are never followed: `redirect: 'manual'` is forced and any
* 3xx (or an opaque-redirect response) is a refusal, not a response.
*
* Compared to `lib/webhooks/pinned-fetch.ts` this keeps the `fetch` /
* `Response` surface that the callers (and their tests) are written against,
* at the cost of the DNS-rebinding window between validation and connect that
* the pinned transport closes. That window is the same one url-guard.ts
* documents for its own callers and is acceptable for these read-only feeds;
* a caller that needs pinning should use pinnedHttpsFetch instead.
*
* Timeouts stay the caller's responsibility (`signal: AbortSignal.timeout()`),
* and body size is bounded with `readBodyWithCap` so a hostile host cannot
* balloon memory before a size check runs.
*/
import { isIP } from 'node:net'
import {
validateWebhookUrl as validateWebhookUrlDefault,
type WebhookUrlValidationReason,
} from '@/lib/webhooks/url-guard'
export type SafeFetchRefusalReason =
| WebhookUrlValidationReason
| 'unsupported_scheme'
| 'redirect_blocked'
/**
* Thrown when the guard refuses to open (or to keep) a connection. Callers
* must treat this as terminal for the URL: retrying does not help, and the
* stored URL should be surfaced to the user as invalid rather than fetched.
*/
export class UnsafeUrlError extends Error {
readonly name = 'UnsafeUrlError'
constructor(
readonly reason: SafeFetchRefusalReason,
readonly detail: string,
) {
super(`Refused to fetch URL (${reason}): ${detail}`)
}
}
/** Name-based so it survives duplicate module instances (vitest isolation). */
export function isUnsafeUrlError(error: unknown): error is UnsafeUrlError {
return error instanceof Error && error.name === 'UnsafeUrlError'
}
export interface SafeFetchOptions {
/**
* Origins (`scheme://host[:port]`) that skip the public-address check.
* Reserved for infrastructure the app already talks to (the deployment's
* own Supabase storage). Redirect refusal still applies to these.
*/
trustedOrigins?: readonly string[]
}
export interface SafeFetchDeps {
/** DNS validation seam. Defaults to url-guard's validateWebhookUrl. */
validateUrl?: typeof validateWebhookUrlDefault
/** Transport seam. Defaults to the global fetch (resolved at call time). */
fetchImpl?: typeof fetch
}
type ValidateUrlOptions = NonNullable<Parameters<typeof validateWebhookUrlDefault>[1]>
/**
* `dns.resolve4('10.0.0.1')` is not a lookup, it is an error (or worse, a
* provider-dependent echo). For IP-literal hostnames, feed the literal to the
* validator as if it were the single DNS answer so the address classifier
* runs on it directly and the result is deterministic.
*/
function literalResolvers(hostname: string): ValidateUrlOptions | undefined {
const bare =
hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
const family = isIP(bare)
if (family === 0) return undefined
const literal = (async () => [bare]) as unknown as ValidateUrlOptions['resolve4']
const noRecords = (async () => {
const err = new Error(`No records for ${bare}`) as NodeJS.ErrnoException
err.code = 'ENODATA'
throw err
}) as unknown as ValidateUrlOptions['resolve4']
return family === 4
? { resolve4: literal, resolve6: noRecords }
: { resolve4: noRecords, resolve6: literal }
}
function originOf(value: string): string | null {
try {
return new URL(value).origin
} catch {
return null
}
}
function isTrustedOrigin(parsed: URL, trustedOrigins: readonly string[] | undefined): boolean {
if (!trustedOrigins || trustedOrigins.length === 0) return false
const target = parsed.origin
return trustedOrigins.some((entry) => originOf(entry) === target)
}
function isRedirectResponse(res: Response): boolean {
if (res.type === 'opaqueredirect') return true
return res.status >= 300 && res.status < 400
}
async function discardBody(res: Response): Promise<void> {
try {
await res.body?.cancel()
} catch {
// Best-effort socket cleanup; the response is being refused anyway.
}
}
/**
* Fetch `rawUrl` only if it is safe to connect to, never following redirects.
* Throws `UnsafeUrlError` on refusal; transport errors propagate unchanged so
* callers keep their existing retry semantics for the network-blip case.
*/
export async function safeFetch(
rawUrl: string,
init: RequestInit = {},
options: SafeFetchOptions = {},
deps: SafeFetchDeps = {},
): Promise<Response> {
const validateUrl = deps.validateUrl ?? validateWebhookUrlDefault
const fetchImpl = deps.fetchImpl ?? globalThis.fetch
let parsed: URL
try {
parsed = new URL(rawUrl)
} catch {
throw new UnsafeUrlError('invalid_url', 'URL did not parse.')
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new UnsafeUrlError(
'unsupported_scheme',
`Only http(s) URLs can be fetched (got ${parsed.protocol}).`,
)
}
if (!isTrustedOrigin(parsed, options.trustedOrigins)) {
const validation = await validateUrl(rawUrl, literalResolvers(parsed.hostname))
if (!validation.ok) {
throw new UnsafeUrlError(validation.reason, validation.detail)
}
}
const response = await fetchImpl(rawUrl, { ...init, redirect: 'manual' })
if (isRedirectResponse(response)) {
await discardBody(response)
throw new UnsafeUrlError(
'redirect_blocked',
`${parsed.hostname} answered ${response.status || 'with a redirect'}; redirects are never followed.`,
)
}
return response
}
/**
* Read a response body into a Buffer, refusing anything over `maxBytes`.
*
* Checks the declared Content-Length first (cheap, no bytes read), then
* streams with a running total so a host that lies about (or omits) the
* length is cut off at the cap instead of being buffered whole. Returns null
* when the cap is exceeded; the body is cancelled in that case.
*/
export async function readBodyWithCap(res: Response, maxBytes: number): Promise<Buffer | null> {
const declared = Number(res.headers.get('content-length') ?? '')
if (Number.isFinite(declared) && declared > maxBytes) {
await discardBody(res)
return null
}
const body = res.body
if (!body || typeof body.getReader !== 'function') {
// Bodyless or non-streaming Response (test doubles, some polyfills):
// fall back to a whole read with a post-hoc check.
const buf = Buffer.from(await res.arrayBuffer())
return buf.byteLength > maxBytes ? null : buf
}
const reader = body.getReader()
const chunks: Buffer[] = []
let total = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) {
await reader.cancel().catch(() => undefined)
return null
}
chunks.push(Buffer.from(value))
}
return Buffer.concat(chunks)
}
@@ -28,6 +28,34 @@ vi.mock('@/lib/supabase/server', () => ({
}),
}))
// The logo fetch runs through the outbound URL guard, which resolves DNS.
// Stub the validator (same seam the webhook dispatcher tests use): https hosts
// resolve to a public address, plain http is refused like the real guard does.
const guard = vi.hoisted(() => ({ validateUrl: vi.fn() }))
vi.mock('@/lib/webhooks/url-guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/webhooks/url-guard')>()
return {
...actual,
validateWebhookUrl: (...args: unknown[]) => guard.validateUrl(...args),
}
})
function guardPublicByDefault() {
guard.validateUrl.mockReset()
guard.validateUrl.mockImplementation(async (rawUrl: string) => {
const parsed = new URL(rawUrl)
if (parsed.protocol !== 'https:') {
return { ok: false, reason: 'non_https_scheme', detail: `${parsed.protocol} refused` }
}
return { ok: true, hostname: parsed.hostname, resolvedAddresses: ['203.0.113.10'] }
})
}
// The deployment's own storage origin: logos uploaded through the app live
// here, and it is exempt from the public-address check (a self-hosted NAS
// install may legitimately serve storage from a private address).
const STORAGE_ORIGIN = 'https://example.test'
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'
const SVG_LOGO = Buffer.from(
@@ -60,6 +88,8 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
beforeEach(() => {
vi.unstubAllGlobals()
fontDownloadMock.mockReset()
guardPublicByDefault()
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', STORAGE_ORIGIN)
})
afterEach(() => {
vi.unstubAllGlobals()
@@ -124,14 +154,101 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
const { company: resolved } = await prepareInvoicePdfRender(company)
// Fetched with a timeout signal so a slow logo host can't hang the render.
// Fetched with a timeout signal so a slow logo host can't hang the render,
// and with redirects disabled so the host can't bounce us elsewhere.
expect(fetchMock).toHaveBeenCalledWith(
'https://example.test/svg-logo-1.svg',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
expect.objectContaining({ signal: expect.any(AbortSignal), redirect: 'manual' }),
)
// Our own storage origin skips the DNS guard (it may be private on self-host).
expect(guard.validateUrl).not.toHaveBeenCalled()
await expectValidEmbeddedPng(resolved.logo_url)
})
it('embeds a logo from a public https host once the DNS guard passes', async () => {
const fetchMock = mockFetchOnce(SVG_LOGO, 'image/svg+xml')
const url = 'https://cdn.example.org/public-logo-1.svg'
const company = makeCompanySettings({ logo_url: url })
const { company: resolved } = await prepareInvoicePdfRender(company)
expect(guard.validateUrl).toHaveBeenCalledWith(url, undefined)
expect(fetchMock).toHaveBeenCalledWith(url, expect.objectContaining({ redirect: 'manual' }))
await expectValidEmbeddedPng(resolved.logo_url)
})
it('renders without a logo when the logo host resolves to a private address (SSRF guard)', async () => {
guard.validateUrl.mockResolvedValue({
ok: false,
reason: 'private_address',
detail: 'Resolved address 10.0.0.9 for intranet.example.org is not publicly routable (private_address).',
})
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const company = makeCompanySettings({ logo_url: 'https://intranet.example.org/logo.png' })
const { company: resolved } = await prepareInvoicePdfRender(company)
// Refused means refused: no socket, and @react-pdf is not handed the URL either.
expect(fetchMock).not.toHaveBeenCalled()
expect(resolved.logo_url).toBeNull()
})
it('renders without a logo for a plain-http URL off the storage origin', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const company = makeCompanySettings({ logo_url: 'http://cdn.example.org/http-logo.png' })
const { company: resolved } = await prepareInvoicePdfRender(company)
expect(fetchMock).not.toHaveBeenCalled()
expect(resolved.logo_url).toBeNull()
})
it('renders without a logo for a non-http(s) scheme', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const company = makeCompanySettings({ logo_url: 'file:///etc/hostname' })
const { company: resolved } = await prepareInvoicePdfRender(company)
expect(fetchMock).not.toHaveBeenCalled()
expect(guard.validateUrl).not.toHaveBeenCalled()
expect(resolved.logo_url).toBeNull()
})
it('renders without a logo when the logo host answers with a redirect, even from the storage origin', async () => {
const cancel = vi.fn(async () => undefined)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 302,
type: 'basic',
headers: new Headers({ location: 'http://169.254.169.254/latest/meta-data/' }),
body: { cancel },
}),
)
const company = makeCompanySettings({ logo_url: 'https://example.test/redirecting-logo.png' })
const { company: resolved } = await prepareInvoicePdfRender(company)
expect(resolved.logo_url).toBeNull()
expect(cancel).toHaveBeenCalled()
})
it('drops the logo instead of handing @react-pdf a remote URL when a non-storage host fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
const url = 'https://cdn.example.org/flaky-logo.png'
const company = makeCompanySettings({ logo_url: url })
const { company: resolved } = await prepareInvoicePdfRender(company)
// @react-pdf's own fetch follows redirects with no guard; a tenant host
// that fails us and then redirects it would reopen the hole.
expect(resolved.logo_url).toBeNull()
})
it('embeds a WebP logo as a PNG data URL', async () => {
const webp = await sharp(SVG_LOGO).webp().toBuffer()
mockFetchOnce(webp, 'image/webp')
+108 -32
View File
@@ -25,6 +25,7 @@ import { brandingFromCompanySettings, SHOW_SWISH_ON_INVOICE, type InvoiceBrandin
import { buildSwishQrPayload } from '@/lib/payments/swish'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { createLogger } from '@/lib/logger'
import { isUnsafeUrlError, readBodyWithCap, safeFetch } from '@/lib/http/safe-fetch'
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
import { prepareInvoiceFont } from '@/lib/invoices/pdf-fonts'
import {
@@ -34,14 +35,18 @@ import {
const log = createLogger('invoice.swish-qr')
const paymentLinkLog = createLogger('invoice.payment-link-qr')
const logoLog = createLogger('invoice.logo')
export interface InvoicePdfRenderExtras {
branding: InvoiceBranding
/**
* The company settings to pass to InvoicePDF. Identical to the input except
* `logo_url` is replaced by an embedded PNG data URL when the stored logo
* could be fetched and re-encoded. Falls back to the original settings
* unchanged on any failure, so behaviour is never worse than before.
* could be fetched and re-encoded, or set to null when the stored URL was
* refused by the outbound URL guard (the invoice then renders without a
* logo). A transient failure keeps the original URL only when it points at
* the deployment's own storage origin; @react-pdf must never be handed an
* arbitrary remote URL to fetch unguarded.
*/
company: CompanySettings
}
@@ -65,29 +70,67 @@ const logoDataUrlCache = new Map<string, { dataUrl: string; at: number }>()
const LOGO_MAX_PX = 600
// Bound the logo fetch so a slow or oversized response can't hang or balloon an
// invoice render. logo_url is currently always a Supabase `logos`-bucket public
// URL (set only by the upload route), so SSRF is not reachable today: these
// caps are defense-in-depth for that invariant plus plain robustness.
// invoice render. The upload route only ever writes Supabase `logos`-bucket
// URLs, but company members can PATCH `company_settings.logo_url` directly
// through PostgREST, so the stored value is tenant-controlled input that this
// server fetches: it goes through `safeFetch` (public address only, no
// redirects) unless it sits on the deployment's own storage origin.
const LOGO_FETCH_TIMEOUT_MS = 5_000
/**
* What became of a stored logo URL:
* embedded: fetched, re-encoded, safe to hand to @react-pdf as a data URL
* refused: the outbound URL guard said no (private address, non-http(s),
* redirect); the invoice renders without a logo
* failed: transient or decode failure after the guard passed
*/
type LogoResolution =
| { kind: 'embedded'; dataUrl: string }
| { kind: 'refused' }
| { kind: 'failed' }
// Coalesce concurrent renders of the same logo (preflight + final on a send, and
// every invoice in a recurring/batch loop) onto one in-flight fetch+encode
// instead of each doing the full round-trip before the first result is cached.
const logoInflight = new Map<string, Promise<string | null>>()
const logoInflight = new Map<string, Promise<LogoResolution>>()
/**
* Fetch a stored logo and re-encode it to a PNG data URL. Returns null on any
* failure (network error, timeout, oversized payload, unreadable image, sharp
* unavailable): the caller then keeps the original URL, which @react-pdf can
* still fetch directly for PNG/JPEG logos. Concurrent calls for the same URL
* share a single in-flight request.
* The origin the app's own Supabase storage lives on. Logos uploaded through
* the app always resolve here, and on a self-hosted install this origin may
* legitimately be a private address (a NAS on the LAN), so it is exempt from
* the public-address check. Redirect refusal and the size cap still apply.
*/
async function resolveLogoDataUrl(logoUrl: string): Promise<string | null> {
function trustedLogoOrigins(): string[] {
const raw = process.env.NEXT_PUBLIC_SUPABASE_URL
if (!raw) return []
try {
return [new URL(raw).origin]
} catch {
return []
}
}
function isTrustedLogoOrigin(logoUrl: string): boolean {
try {
return trustedLogoOrigins().includes(new URL(logoUrl).origin)
} catch {
return false
}
}
/**
* Fetch a stored logo and re-encode it to a PNG data URL. Concurrent calls
* for the same URL share a single in-flight request; only successes are
* cached so a transient blip is retried on the next render.
*/
async function resolveLogoDataUrl(logoUrl: string): Promise<LogoResolution> {
// Already embedded: nothing to fetch or convert.
if (logoUrl.startsWith('data:')) return logoUrl
if (logoUrl.startsWith('data:')) return { kind: 'embedded', dataUrl: logoUrl }
const cached = logoDataUrlCache.get(logoUrl)
if (cached && Date.now() - cached.at < LOGO_CACHE_TTL_MS) return cached.dataUrl
if (cached && Date.now() - cached.at < LOGO_CACHE_TTL_MS) {
return { kind: 'embedded', dataUrl: cached.dataUrl }
}
const inflight = logoInflight.get(logoUrl)
if (inflight) return inflight
@@ -103,17 +146,32 @@ async function resolveLogoDataUrl(logoUrl: string): Promise<string | null> {
}
}
async function encodeLogo(logoUrl: string): Promise<string | null> {
async function encodeLogo(logoUrl: string): Promise<LogoResolution> {
let res: Response
try {
const res = await fetch(logoUrl, { signal: AbortSignal.timeout(LOGO_FETCH_TIMEOUT_MS) })
if (!res.ok) return null
res = await safeFetch(
logoUrl,
{ signal: AbortSignal.timeout(LOGO_FETCH_TIMEOUT_MS) },
{ trustedOrigins: trustedLogoOrigins() },
)
} catch (err) {
if (isUnsafeUrlError(err)) {
logoLog.warn('logo URL refused by outbound URL guard; rendering without logo', {
reason: err.reason,
detail: err.detail,
})
return { kind: 'refused' }
}
return { kind: 'failed' }
}
// Reject oversized payloads up front when the server declares a length, and
// again after reading in case the header lied or was absent.
const declared = Number(res.headers.get('content-length') ?? '')
if (Number.isFinite(declared) && declared > LOGO_UPLOAD_MAX_BYTES) return null
const input = Buffer.from(await res.arrayBuffer())
if (input.byteLength > LOGO_UPLOAD_MAX_BYTES) return null
try {
if (!res.ok) return { kind: 'failed' }
// Declared Content-Length is checked before any byte is read, and the
// stream is cut off at the cap in case the header lied or was absent.
const input = await readBodyWithCap(res, LOGO_UPLOAD_MAX_BYTES)
if (!input) return { kind: 'failed' }
// SVGs must be rasterized at a higher density or sharp renders them at
// their intrinsic (often tiny) pixel size and the result looks blurry.
@@ -123,7 +181,7 @@ async function encodeLogo(logoUrl: string): Promise<string | null> {
input.subarray(0, 256).toString('utf8').trimStart().startsWith('<')
// Lazy, isolated import: if sharp ever fails to load in a given runtime we
// degrade to the original URL instead of breaking invoice sending entirely.
// degrade instead of breaking invoice sending entirely.
const { default: sharp } = await import('sharp')
const png = await sharp(input, isSvg ? { density: 288 } : {})
.resize({
@@ -144,12 +202,34 @@ async function encodeLogo(logoUrl: string): Promise<string | null> {
if (oldest !== undefined) logoDataUrlCache.delete(oldest)
}
logoDataUrlCache.set(logoUrl, { dataUrl, at: Date.now() })
return dataUrl
return { kind: 'embedded', dataUrl }
} catch {
return null
return { kind: 'failed' }
}
}
/**
* Apply the logo resolution to the company handed to the PDF template.
*
* On a transient failure the original URL is kept only when it points at our
* own storage origin (the pre-existing "never worse than before" fallback:
* @react-pdf can still draw a PNG/JPEG from there). For any other origin the
* logo is dropped instead: handing @react-pdf a remote URL means it fetches
* it with a plain, redirect-following fetch, which is exactly the unguarded
* request this module exists to prevent.
*/
function applyLogoResolution(company: CompanySettings, resolution: LogoResolution): CompanySettings {
if (resolution.kind === 'embedded') {
return resolution.dataUrl === company.logo_url
? company
: { ...company, logo_url: resolution.dataUrl }
}
if (resolution.kind === 'refused') return { ...company, logo_url: null }
return company.logo_url && isTrustedLogoOrigin(company.logo_url)
? company
: { ...company, logo_url: null }
}
export async function prepareInvoicePdfRender(
company: CompanySettings,
currency?: Currency,
@@ -167,12 +247,8 @@ export async function prepareInvoicePdfRender(
: company
if (!paymentCompany.logo_url) return { branding, company: paymentCompany }
const dataUrl = await resolveLogoDataUrl(paymentCompany.logo_url)
const resolved =
dataUrl && dataUrl !== paymentCompany.logo_url
? { ...paymentCompany, logo_url: dataUrl }
: paymentCompany
return { branding, company: resolved }
const resolution = await resolveLogoDataUrl(paymentCompany.logo_url)
return { branding, company: applyLogoResolution(paymentCompany, resolution) }
}
/**
+221 -33
View File
@@ -6,7 +6,9 @@ import { NextRequest } from 'next/server'
*
* Focus: every auth bounce must (a) remember where the user was heading,
* (b) reject an off-origin destination, and (c) not leak the original query
* string onto the auth page. MFA enforcement conditions must be unchanged.
* string onto the auth page. MFA enforcement decides on server-authenticated
* data only (the getUser() factor list and the signature-verified `aal`
* claim), never on the editable cookie session.
*/
const state = vi.hoisted(() => ({
@@ -14,12 +16,26 @@ const state = vi.hoisted(() => ({
id: string
email?: string
app_metadata?: Record<string, unknown>
// What GoTrue returns on /user: the server-side factor list.
factors?: Array<{ id: string; status: string; factor_type: string }>
},
sessionId: 'session-1' as string | null,
authError: null as unknown,
aal: null as null | { currentLevel: string; nextLevel: string },
factors: null as null | { totp: Array<{ id: string; status: string }> },
listFactors: vi.fn(async () => ({ data: state.factors })),
// `aal` claim of the (mock) signature-verified access token, i.e. what
// getClaims() reports. null = the token carries no aal claim.
jwtAal: null as string | null,
// Make getClaims() fail: an error result or a throw.
claimsFailure: null as null | 'error' | 'throw',
// The cookie-derived assurance lookup and the listFactors round trip. The
// proxy must call NEITHER any more: the first computes nextLevel from the
// editable cookie session, the second is a getUser() the proxy has already
// paid for. Spies so tests can prove it. getAal answers the way a cookie
// with `user.factors` stripped would: "nothing to step up to".
getAal: vi.fn(async () => ({
data: { currentLevel: 'aal1', nextLevel: 'aal1' },
error: null,
})),
listFactors: vi.fn(async () => ({ data: { totp: [] }, error: null })),
company: {
data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }],
error: null as unknown,
@@ -86,13 +102,32 @@ vi.mock('@supabase/ssr', () => ({
error: state.authError,
}
}),
getClaims: vi.fn(async () => ({
data: { claims: state.sessionId ? { session_id: state.sessionId } : {} },
})),
getClaims: vi.fn(async () => {
if (state.claimsFailure === 'throw') throw new Error('jwks fetch failed')
if (state.claimsFailure === 'error') {
return {
data: null,
error: { name: 'AuthInvalidJwtError', message: 'Invalid JWT signature' },
}
}
return {
data: {
claims: {
...(state.sessionId ? { session_id: state.sessionId } : {}),
...(state.jwtAal ? { aal: state.jwtAal } : {}),
// Satisfy the iss/aud pinning the MFA gates apply (lib/auth/claims.ts).
iss: `${(process.env.NEXT_PUBLIC_SUPABASE_URL ?? '').replace(/\/+$/, '')}/auth/v1`,
aud: 'authenticated',
sub: state.user?.id,
},
},
error: null,
}
}),
signOut: state.signOut,
mfa: {
getAuthenticatorAssuranceLevel: vi.fn(async () => ({ data: state.aal })),
listFactors: (...args: unknown[]) => state.listFactors(...args),
getAuthenticatorAssuranceLevel: () => state.getAal(),
listFactors: () => state.listFactors(),
},
},
rpc: vi.fn(async () => state.company),
@@ -172,6 +207,9 @@ import { SESSION_TIMEOUT_COOKIE } from '@/lib/auth/session-timeout-shared'
const ORIGIN = 'http://localhost:3000'
const SIGNED_IN = { id: 'user-1', app_metadata: {} }
const VERIFIED_TOTP = { id: 'f1', status: 'verified', factor_type: 'totp' }
/** A user whose server-side record carries a verified TOTP factor. */
const MFA_USER = { ...SIGNED_IN, factors: [VERIFIED_TOTP] }
function locationOf(response: Response) {
return response.headers.get('location')
@@ -199,12 +237,13 @@ describe('updateSession redirect destinations', () => {
vi.clearAllMocks()
logState.info.mockClear()
state.listFactors.mockClear()
state.getAal.mockClear()
state.user = null
state.sessionId = 'session-1'
state.authError = null
state.cookieWrites = []
state.aal = null
state.factors = null
state.jwtAal = null
state.claimsFailure = null
state.company = {
data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }],
error: null,
@@ -619,8 +658,9 @@ describe('updateSession redirect destinations', () => {
describe('MFA step-up bounce to /mfa/verify', () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_REQUIRE_MFA = 'true'
state.user = SIGNED_IN
state.aal = { currentLevel: 'aal1', nextLevel: 'aal2' }
// Verified factor on the server-side user, single-factor token.
state.user = MFA_USER
state.jwtAal = 'aal1'
})
it('preserves the destination as ?returnTo=', async () => {
@@ -657,14 +697,48 @@ describe('updateSession redirect destinations', () => {
expect(new URL(locationOf(response)!).pathname).toBe('/mfa/verify')
expect(response.cookies.get('sb-test-auth-token')?.value).toBe('rotated')
})
it('decides on the server-side factor list, never on the cookie session', async () => {
// The attack: the sb-*-auth-token cookie is unsigned JSON, so the
// password holder strips `user.factors` and the local assurance lookup
// reports nextLevel aal1 ("nothing to step up to"). state.getAal is
// that view; the proxy must not even ask for it.
const response = await run('/invoices')
expect(new URL(locationOf(response)!).pathname).toBe('/mfa/verify')
expect(state.getAal).not.toHaveBeenCalled()
expect(state.listFactors).not.toHaveBeenCalled()
})
it.each(['error', 'throw'] as const)(
'fails closed when getClaims reports %s',
async (failure) => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
state.claimsFailure = failure
const response = await run('/invoices')
expect(new URL(locationOf(response)!).pathname).toBe('/mfa/verify')
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
},
)
it('fails closed when the verified claims carry no aal', async () => {
state.jwtAal = null
const response = await run('/invoices')
expect(new URL(locationOf(response)!).pathname).toBe('/mfa/verify')
})
})
describe('forced enrollment bounce to /mfa/enroll', () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_REQUIRE_MFA = 'true'
// No factor on the server-side user, single-factor token.
state.user = SIGNED_IN
state.aal = { currentLevel: 'aal1', nextLevel: 'aal1' }
state.factors = { totp: [] }
state.jwtAal = 'aal1'
})
it('preserves the destination as ?returnTo=', async () => {
@@ -675,12 +749,15 @@ describe('updateSession redirect destinations', () => {
expect(url.searchParams.get('returnTo')).toBe('/invoices/new')
})
it('still forces enrollment (the gate itself is unchanged)', async () => {
state.factors = { totp: [{ id: 'f1', status: 'verified' }] }
it('steps up instead of enrolling when the server-side user already has a verified factor', async () => {
// Previously this scenario (cookie says no factor, server says one
// exists, token at aal1) rendered the page at AAL1: the verify bounce
// trusted the cookie and the enrolment check then found the factor.
state.user = MFA_USER
const response = await run('/invoices/new')
expect(response.status).toBe(200)
expect(new URL(locationOf(response)!).pathname).toBe('/mfa/verify')
})
it('skips enrollment for a user with no company, as before', async () => {
@@ -691,18 +768,19 @@ describe('updateSession redirect destinations', () => {
expect(response.status).toBe(200)
})
it('still asks for the factor list at aal1/aal1 before bouncing', async () => {
it('reads the factor list off the getUser() round trip, not a second auth call', async () => {
const response = await run('/invoices/new')
expect(new URL(locationOf(response)!).pathname).toBe('/mfa/enroll')
expect(state.listFactors).toHaveBeenCalledTimes(1)
expect(state.listFactors).not.toHaveBeenCalled()
expect(state.getAal).not.toHaveBeenCalled()
})
it('never calls listFactors once the session is at aal2 (a verified factor is implied)', async () => {
state.aal = { currentLevel: 'aal2', nextLevel: 'aal2' }
// Even a factor list that would read as "none" must not matter here:
// the call is skipped, not just its result ignored.
state.factors = { totp: [] }
it('lets an aal2 token through even when the server-side user has no factor left', async () => {
// A user who unenrols their last factor mid-session keeps aal2 until
// the next token refresh; the enrolment bounce lands on the refresh,
// not on the next click (unchanged deferral, PR #1922).
state.jwtAal = 'aal2'
const response = await run('/invoices/new')
@@ -711,12 +789,123 @@ describe('updateSession redirect destinations', () => {
})
it('does not spend an MFA lookup on RSC and prefetch requests at aal2 either', async () => {
state.aal = { currentLevel: 'aal2', nextLevel: 'aal2' }
state.jwtAal = 'aal2'
await run('/invoices', { headers: { rsc: '1' } })
await run('/invoices', { headers: { 'next-router-prefetch': '1', rsc: '1' } })
expect(state.listFactors).not.toHaveBeenCalled()
expect(state.getAal).not.toHaveBeenCalled()
})
})
// ── API branch: the MFA gate for cookie sessions ──────────────────────
describe('API MFA gate for cookie sessions', () => {
const FORBIDDEN = { error: 'MFA-verifiering krävs.' }
beforeEach(() => {
process.env.NEXT_PUBLIC_REQUIRE_MFA = 'true'
state.user = MFA_USER
state.jwtAal = 'aal1'
})
it('returns 403 for an AAL1 session whose server-side user has a verified factor', async () => {
const response = await run('/api/invoices')
expect(response.status).toBe(403)
await expect(response.json()).resolves.toEqual(FORBIDDEN)
})
it('never consults the cookie-derived assurance level or a second factor lookup', async () => {
// state.getAal reports nextLevel aal1: the answer a cookie with
// `user.factors` stripped produces. It must not be asked at all.
const response = await run('/api/invoices')
expect(response.status).toBe(403)
expect(state.getAal).not.toHaveBeenCalled()
expect(state.listFactors).not.toHaveBeenCalled()
})
it('lets an AAL2 session through', async () => {
state.jwtAal = 'aal2'
const response = await run('/api/invoices')
expect(response.status).toBe(200)
})
it.each(['error', 'throw'] as const)(
'fails closed when getClaims reports %s',
async (failure) => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
state.claimsFailure = failure
const response = await run('/api/invoices')
expect(response.status).toBe(403)
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
},
)
it('fails closed when the verified claims carry no aal', async () => {
state.jwtAal = null
const response = await run('/api/invoices')
expect(response.status).toBe(403)
})
it("passes a user with nothing to step up to (enrolment stays the page gate's job)", async () => {
state.user = SIGNED_IN
expect((await run('/api/invoices')).status).toBe(200)
state.user = {
...SIGNED_IN,
factors: [{ id: 'f2', status: 'unverified', factor_type: 'totp' }],
}
expect((await run('/api/invoices')).status).toBe(200)
expect(state.getAal).not.toHaveBeenCalled()
})
it('keeps the AAL1 escape hatches and the OAuth endpoints open', async () => {
for (const path of ['/api/account/delete', '/api/company/current', '/api/mcp-oauth/token']) {
expect((await run(path)).status).toBe(200)
}
})
it('skips Bearer-auth surfaces by path, and only with the header present', async () => {
const headers = { authorization: 'Bearer key' }
expect((await run('/api/v1/companies/c1/invoices', { headers })).status).toBe(200)
expect((await run('/api/extensions/ext/mcp-server/mcp', { headers })).status).toBe(200)
// A cookie session on a v1 path without the header is still a cookie session.
expect((await run('/api/v1/companies/c1/invoices')).status).toBe(403)
// A forged header on a cookie-authenticated route never disables the gate.
expect((await run('/api/invoices', { headers })).status).toBe(403)
})
it('does not gate BankID-linked users, nor anyone when MFA is off', async () => {
state.user = { ...MFA_USER, app_metadata: { bankid_linked: true } }
expect((await run('/api/invoices')).status).toBe(200)
state.user = MFA_USER
delete process.env.NEXT_PUBLIC_REQUIRE_MFA
expect((await run('/api/invoices')).status).toBe(200)
})
it('carries the rotated auth cookie on the 403', async () => {
state.cookieWrites = [
{ name: 'sb-test-auth-token', value: 'rotated', options: { path: '/' } },
]
const response = await run('/api/invoices')
expect(response.status).toBe(403)
expect(response.cookies.get('sb-test-auth-token')?.value).toBe('rotated')
})
})
@@ -1080,8 +1269,8 @@ describe('updateSession redirect destinations', () => {
describe('MFA-disabled and self-hosted paths are unchanged', () => {
it('does not redirect when NEXT_PUBLIC_REQUIRE_MFA is unset', async () => {
state.user = SIGNED_IN
state.aal = { currentLevel: 'aal1', nextLevel: 'aal2' }
state.user = MFA_USER
state.jwtAal = 'aal1'
const response = await run('/settings/tax')
@@ -1091,9 +1280,8 @@ describe('updateSession redirect destinations', () => {
it('does not redirect on self-hosted even with MFA required', async () => {
process.env.NEXT_PUBLIC_REQUIRE_MFA = 'true'
process.env.NEXT_PUBLIC_SELF_HOSTED = 'true'
state.user = SIGNED_IN
state.aal = { currentLevel: 'aal1', nextLevel: 'aal2' }
state.factors = { totp: [] }
state.user = MFA_USER
state.jwtAal = 'aal1'
const response = await run('/settings/tax')
@@ -1102,8 +1290,8 @@ describe('updateSession redirect destinations', () => {
it('does not redirect BankID-linked users, who are already 2FA', async () => {
process.env.NEXT_PUBLIC_REQUIRE_MFA = 'true'
state.user = { id: 'user-1', app_metadata: { bankid_linked: true } }
state.aal = { currentLevel: 'aal1', nextLevel: 'aal2' }
state.user = { ...MFA_USER, app_metadata: { bankid_linked: true } }
state.jwtAal = 'aal1'
const response = await run('/settings/tax')
+2
View File
@@ -8,6 +8,8 @@ const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
const isBuildPlaceholder = !url || url.startsWith('__')
// Cookie encoding must match lib/supabase/server.ts (default
// `user-and-tokens`); see the note there before switching to `tokens-only`.
export function createClient() {
return createBrowserClient(
isBuildPlaceholder ? 'https://placeholder.supabase.co' : url,
+100 -32
View File
@@ -1,4 +1,5 @@
import { createServerClient } from '@supabase/ssr'
import type { User } from '@supabase/supabase-js'
import { NextResponse, type NextRequest } from 'next/server'
import { createLogger } from '@/lib/logger'
import {
@@ -11,6 +12,7 @@ import {
type ProxyTimings,
} from '@/lib/supabase/proxy-timing'
import { shouldEnforceMfa } from '@/lib/auth/mfa'
import { claimsPinned } from '@/lib/auth/claims'
import { isMultiUserEnforced } from '@/lib/entitlements/multi-user'
import { MULTI_USER_GRACE_DAYS } from '@/lib/entitlements/multi-user-state'
import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
@@ -262,11 +264,22 @@ async function updateSessionInner(
pathname,
hasAuthorizationHeader,
)
if (!skipMfaGate && user && shouldEnforceMfa(user)) {
const { data: aal } = await timed(timing, 'mfaMs', () =>
supabase.auth.mfa.getAuthenticatorAssuranceLevel(),
// `user` is the getUser() result above: server-authenticated, so its
// factor list is trustworthy. Only a session with something to step up
// TO is gated here; forcing enrolment stays the page branch's job, as
// before. The assurance level itself comes from the signature-verified
// claims and fails CLOSED (see resolveVerifiedAal), never from the
// cookie's session object.
if (
!skipMfaGate &&
user &&
shouldEnforceMfa(user) &&
userHasVerifiedFactor(user)
) {
const aal = await timed(timing, 'mfaMs', () =>
resolveVerifiedAal(supabase),
)
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
if (aal !== 'aal2') {
const response = NextResponse.json(
{ error: 'MFA-verifiering krävs.' },
{ status: 403 },
@@ -455,38 +468,31 @@ async function updateSessionInner(
// MFA enforcement (application-side only, not RLS)
if (shouldEnforceMfa(user)) {
const { data: aal } = await timed(timing, 'mfaMs', () =>
supabase.auth.mfa.getAuthenticatorAssuranceLevel(),
)
const aal = await timed(timing, 'mfaMs', () => resolveVerifiedAal(supabase))
// User has MFA enrolled but hasn't verified this session → redirect to verify
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
return bounceToAuth(request, supabaseResponse, '/mfa/verify')
}
// Nothing below applies at AAL2: reaching it requires having verified a
// challenge on a verified factor. Deliberately also skipped for a user
// who unenrols their last factor mid-session: the JWT keeps aal2 until
// the next token refresh, so the enrolment bounce lands on the refresh
// instead of the next click (unchanged from the listFactors-era gate,
// PR #1922).
if (aal !== 'aal2') {
// The factor list is read off the server-authenticated getUser()
// result above, never off the cookie session. A cookie edited to hide
// the factor used to sail past this bounce, and because the enrolment
// check below then found the factor server-side, straight onto the
// page at AAL1. Reading it here also drops the listFactors() round
// trip that check used to pay: auth-js implements listFactors() as
// that very getUser() call.
if (userHasVerifiedFactor(user)) {
return bounceToAuth(request, supabaseResponse, '/mfa/verify')
}
// MFA required but user has no factor enrolled yet force enrollment
// Skip for users with no companies (still setting up).
//
// Only worth asking when the session is NOT at AAL2: reaching AAL2
// requires having verified a challenge on a verified factor, so the
// factor list cannot be empty there. auth-js implements listFactors()
// as a getUser() network round trip, and running it here on every
// page, RSC and prefetch request for every MFA-verified user was the
// second Supabase Auth call per request (measured via mw-mfa, PR #1922).
// The narrow case this defers is a user who unenrols their last factor
// mid-session: the JWT keeps aal2 until the next token refresh, so the
// enrolment bounce lands on the refresh instead of the next click.
if (aal?.currentLevel !== 'aal2') {
// MFA required but no factor enrolled yet: force enrolment. Skipped
// for users with no companies (still setting up).
const { companyId: companyIdForMfa } = await resolveCompanyOnce()
if (companyIdForMfa) {
const { data: factors } = await timed(timing, 'mfaMs', () =>
supabase.auth.mfa.listFactors(),
)
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
if (!hasVerifiedFactor) {
return bounceToAuth(request, supabaseResponse, '/mfa/enroll')
}
return bounceToAuth(request, supabaseResponse, '/mfa/enroll')
}
}
}
@@ -641,6 +647,68 @@ async function getSupabaseSessionId(
}
}
/**
* Whether the SERVER-authenticated user carries a verified MFA factor.
*
* Only ever call this with the getUser() result: GoTrue returns `factors` on
* /user (auth-js's listFactors() is that same call, filtered), so reading it
* off the round trip the proxy has already paid costs nothing extra. The
* server omits an empty list, so a missing array means "no factor", exactly
* the reading listFactors() would give.
*
* Never call it with a user deserialised from the session cookie: the
* sb-*-auth-token cookie is unsigned base64 JSON, so whoever holds the
* password can strip `factors` from it and make an enrolled account look
* like one with nothing to step up to.
*/
function userHasVerifiedFactor(user: Pick<User, 'factors'>): boolean {
return user.factors?.some((factor) => factor.status === 'verified') ?? false
}
/**
* Assurance level of the current session, read from the signature-verified
* JWT claims: getClaims() checks the token locally against the cached JWKS
* (server-side for HS256 projects) and the iss/aud pinning is the same one
* require-auth applies. Returns null on ANY failure so both MFA gates fail
* closed; each failure is logged because a spike means MFA users are being
* refused, which must be visible in production.
*
* Deliberately not `mfa.getAuthenticatorAssuranceLevel()`: called without a
* JWT it computes `nextLevel` from `session.user.factors`, and that session
* is the editable cookie described on userHasVerifiedFactor. Its
* `currentLevel` happens to be sound (the JWT the preceding getUser() was
* accepted with), but the two halves come as one answer, so neither gate
* consumes it any more (security audit 2026-09).
*/
async function resolveVerifiedAal(
supabase: ReturnType<typeof createServerClient>,
): Promise<string | null> {
if (typeof supabase.auth.getClaims !== 'function') {
console.error('[middleware] getClaims unavailable; treating session as not MFA-assured')
return null
}
try {
const { data, error } = await supabase.auth.getClaims()
const claims = data?.claims
if (error || !claims) {
console.error('[middleware] getClaims failed; treating session as not MFA-assured', error)
return null
}
if (!claimsPinned(claims)) {
console.error('[middleware] getClaims iss/aud pinning failed; treating session as not MFA-assured', {
iss: claims.iss,
aud: claims.aud,
})
return null
}
return typeof claims.aal === 'string' ? claims.aal : null
} catch (error) {
console.error('[middleware] getClaims threw; treating session as not MFA-assured', error)
return null
}
}
async function signOutTimedOutSession(
supabase: ReturnType<typeof createServerClient>,
): Promise<void> {
+19
View File
@@ -9,6 +9,25 @@ const isBuildPlaceholder = url?.startsWith('__')
const safeUrl = isBuildPlaceholder ? 'https://placeholder.supabase.co' : url
const safeKey = isBuildPlaceholder ? 'placeholder' : key
/**
* Cookie-session client for server components, server actions and routes.
*
* Cookie encoding stays the default `user-and-tokens` on purpose.
* @supabase/ssr 0.12 offers an experimental `cookies.encode: 'tokens-only'`
* that keeps the user object out of the cookie, but auth-js then substitutes a
* THROWING proxy for `session.user` wherever no user store holds it: every
* fresh server request (app/api/mcp-oauth/authorize/route.ts calls
* `mfa.getAuthenticatorAssuranceLevel()`, which reads `session.user.factors`)
* and, in the browser, every `getSession().user` read after a session minted
* by the server-side PKCE callback (reset-password, SendInvoiceDialog) until
* the next token refresh. The trust problem is solved at the consumers
* instead: lib/supabase/middleware.ts and lib/auth/require-auth.ts never read
* MFA state off the cookie's user object (factors come from getUser() or
* listFactors(), the level from signature-verified claims). Switch the
* encoding only together with those call sites, and identically here, in
* client.ts and in middleware.ts: @supabase/ssr requires the two sides to
* match.
*/
export async function createClient() {
const cookieStore = await cookies()
+3 -1
View File
@@ -297,6 +297,7 @@
"turnstile_error": "The security check could not load. Reload the page and try again.",
"login_failed_title": "Sign in failed",
"login_failed_bankid": "Could not complete BankID sign in.",
"bankid_email_unconfirmed": "Confirm your e-mail address through the link we sent before signing in with BankID. We have sent the link again.",
"login_invalid_credentials": "Wrong email address or password.",
"login_error_email_not_confirmed": "Your email address hasn't been confirmed yet. Click the link in the email you received when the account was created.",
"login_error_rate_limited": "Too many sign-in attempts. Wait a moment and try again.",
@@ -509,6 +510,7 @@
"disconnect_failed_title": "Disconnect failed",
"error_generic": "Something went wrong. Please try again.",
"error_denied": "The connection was denied in the store.",
"error_wrong_user": "The connection was started by another user in this company. Sign in as that user or restart the connection.",
"sync_now": "Sync now",
"syncing": "Syncing…",
"sync_done_title": "Sync complete",
@@ -1844,7 +1846,7 @@
"invitations_pending_title": "Pending invitations",
"invitations_expires": "Expires {date}",
"logo_heading": "Logo",
"logo_help": "Shown in the header of your invoices. Max 10 MB, PNG/JPG/SVG/WebP.",
"logo_help": "Shown in the header of your invoices. Max 10 MB, PNG/JPG/WebP.",
"logo_disallowed_type_title": "File type not allowed",
"logo_disallowed_type_description": "PNG, JPG, SVG or WebP.",
"logo_too_large": "File is too large (max 10 MB)",
+3 -1
View File
@@ -297,6 +297,7 @@
"turnstile_error": "Säkerhetskontrollen kunde inte laddas. Ladda om sidan och försök igen.",
"login_failed_title": "Inloggning misslyckades",
"login_failed_bankid": "Kunde inte slutföra BankID-inloggningen.",
"bankid_email_unconfirmed": "Bekräfta din e-postadress via länken vi skickade innan du loggar in med BankID. Vi har skickat länken igen.",
"login_invalid_credentials": "Fel e-postadress eller lösenord.",
"login_error_email_not_confirmed": "E-postadressen är inte bekräftad än. Klicka på länken i mejlet du fick när kontot skapades.",
"login_error_rate_limited": "För många inloggningsförsök. Vänta en stund och försök igen.",
@@ -509,6 +510,7 @@
"disconnect_failed_title": "Frånkopplingen misslyckades",
"error_generic": "Något gick fel. Försök igen.",
"error_denied": "Anslutningen nekades i butiken.",
"error_wrong_user": "Anslutningen startades av en annan användare i det här företaget. Logga in som den användaren eller starta om anslutningen.",
"sync_now": "Synka nu",
"syncing": "Synkar…",
"sync_done_title": "Synkronisering klar",
@@ -1844,7 +1846,7 @@
"invitations_pending_title": "Väntande inbjudningar",
"invitations_expires": "Går ut {date}",
"logo_heading": "Logotyp",
"logo_help": "Visas i sidhuvudet på dina fakturor. Max 10 MB, PNG/JPG/SVG/WebP.",
"logo_help": "Visas i sidhuvudet på dina fakturor. Max 10 MB, PNG/JPG/WebP.",
"logo_disallowed_type_title": "Otillåten filtyp",
"logo_disallowed_type_description": "PNG, JPG, SVG eller WebP.",
"logo_too_large": "Filen är för stor (max 10 MB)",
+505
View File
@@ -85,6 +85,7 @@
"eslint-config-next": "16.3.1",
"pg": "^8.22.0",
"tailwindcss": "^4",
"tsx": "4.20.5",
"typescript": "^5",
"vitest": "^4.1.9"
}
@@ -16186,6 +16187,510 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.5",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz",
"integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+11 -10
View File
@@ -4,14 +4,14 @@
"private": true,
"license": "AGPL-3.0-or-later",
"scripts": {
"setup:extensions": "npx tsx scripts/generate-extension-registry.ts",
"skills:generate": "npx tsx scripts/generate-skill-bodies.ts",
"skills:check": "npx tsx scripts/generate-skill-bodies.ts --check",
"apiskill:generate": "npx tsx --conditions react-server scripts/api-skill/generate.ts",
"apiskill:check": "npx tsx --conditions react-server scripts/api-skill/generate.ts --check",
"crontabs:generate": "npx tsx scripts/generate-crontabs.ts",
"taxonomy:generate": "npx tsx scripts/generate-taxonomy-registry.ts",
"taxonomy:check": "npx tsx scripts/generate-taxonomy-registry.ts --check",
"setup:extensions": "tsx scripts/generate-extension-registry.ts",
"skills:generate": "tsx scripts/generate-skill-bodies.ts",
"skills:check": "tsx scripts/generate-skill-bodies.ts --check",
"apiskill:generate": "tsx --conditions react-server scripts/api-skill/generate.ts",
"apiskill:check": "tsx --conditions react-server scripts/api-skill/generate.ts --check",
"crontabs:generate": "tsx scripts/generate-crontabs.ts",
"taxonomy:generate": "tsx scripts/generate-taxonomy-registry.ts",
"taxonomy:check": "tsx scripts/generate-taxonomy-registry.ts --check",
"validate:ixbrl": "node scripts/validate-ixbrl.mjs",
"predev": "npm run setup:extensions && node scripts/inject-public-branding.mjs",
"dev": "next dev",
@@ -20,8 +20,8 @@
"start": "next start",
"lint": "eslint",
"check:guards": "node scripts/checks/no-new-antipatterns.mjs",
"validate:packs": "npx tsx scripts/validate-packs.ts",
"validate:registry": "npx tsx scripts/validate-registry.ts",
"validate:packs": "tsx scripts/validate-packs.ts",
"validate:registry": "tsx scripts/validate-registry.ts",
"check:lint": "node scripts/checks/no-new-lint-errors.mjs",
"check:types": "node scripts/checks/no-new-type-errors.mjs",
"test": "vitest run --project unit",
@@ -106,6 +106,7 @@
"eslint-config-next": "16.3.1",
"pg": "^8.22.0",
"tailwindcss": "^4",
"tsx": "4.20.5",
"typescript": "^5",
"vitest": "^4.1.9"
},
+2 -2
View File
@@ -1,5 +1,5 @@
{
"totalErrors": 538,
"totalErrors": 537,
"perFile": {
"app/api/assets/__tests__/id.test.ts": 9,
"app/api/auth/email-hook/__tests__/route.test.ts": 1,
@@ -80,7 +80,7 @@
"lib/invoices/__tests__/supplier-invoice-matching.test.ts": 1,
"lib/pending-operations/__tests__/commit-authorization-recoverable.test.ts": 1,
"lib/reports/__tests__/vat-declaration.test.ts": 5,
"lib/supabase/__tests__/middleware.test.ts": 2,
"lib/supabase/__tests__/middleware.test.ts": 1,
"tests/pg/categorize-calibration-samples.pg.test.ts": 2,
"tests/pg/match-batch-allocate.pg.test.ts": 1
}
@@ -0,0 +1,239 @@
-- Security audit 2026-09-01, critical items (report "Accounted Security Audit").
-- pg-test: tests/pg/security-api-keys-provider-tokens.pg.test.ts
--
-- 1. api_keys identity binding. 20260422120000 replaced the original
-- `auth.uid() = user_id` INSERT check with `user_is_company_admin(company_id)`
-- and dropped the self-binding. Every API-key consumer trusts
-- api_keys.user_id as the acting identity (validate_and_increment_api_key,
-- lib/auth/api-keys.ts, lib/api/v1/with-api-v1.ts, the MCP company
-- routing), so an admin of ANY company could insert a key with a
-- co-member's user_id and act as that user in every company they belong
-- to. INSERT now requires user_id = auth.uid() again, on top of the admin
-- gate; a BEFORE trigger repeats that for JWT sessions and freezes the
-- identity and credential columns against UPDATE from user sessions. The
-- service role (settings routes, OAuth token endpoint, rotation) is
-- unaffected: the trigger is a no-op when the JWT role is not
-- anon/authenticated.
--
-- 2. api_keys SELECT was company-scoped, so every member (including
-- viewers) could read every other member's key_hash and
-- refresh_token_hash. Now: own keys, or company admins.
--
-- 3. rotate_mcp_refresh_token and validate_and_increment_api_key match rows
-- purely by a presented SHA-256 and were executable by `authenticated`.
-- Combined with (2) that made the stored hash a bearer credential: read a
-- colleague's refresh_token_hash, call rotate with your own new key hash,
-- own their connector. Both functions are only ever called through the
-- cookieless service client, so EXECUTE is now service_role only.
--
-- 4. validate_and_increment_api_key fails closed when the key's user is no
-- longer a member of the key's company (offboarded users kept reading a
-- company through MCP resources/read on the key's company_id snapshot).
-- Company-less keys (the OAuth lazy-bind path, 20260826090000) are left
-- alone: they carry no company to check.
--
-- 5. provider_consent_tokens / provider_otc. The DELETE policies from
-- 20260402010000 subselect `company_id FROM team_members`; team_members has
-- no such column, so Postgres binds the name to the outer
-- provider_consents.company_id and the predicate collapses to "the caller
-- has at least one team_members row", which every user has
-- (ensure_user_team). Live check 2026-09-01: an unrelated team member
-- matched 123 of 126 token rows. The 20260415000000 schema sync only
-- recreated the policy IF NOT EXISTS, so the broken one survived. Every
-- code path to these two tables uses the service client
-- (lib/providers/resolve-consent.ts, extensions/general/arcim-migration),
-- so all member policies are dropped and table privileges revoked from
-- anon/authenticated: service role only.
-- 1 + 2: api_keys policies -----------------------------------------------
DROP POLICY IF EXISTS "api_keys_insert" ON public.api_keys;
CREATE POLICY "api_keys_insert" ON public.api_keys
FOR INSERT
WITH CHECK (user_id = auth.uid() AND public.user_is_company_admin(company_id));
DROP POLICY IF EXISTS "api_keys_select" ON public.api_keys;
CREATE POLICY "api_keys_select" ON public.api_keys
FOR SELECT
USING (user_id = auth.uid() OR public.user_is_company_admin(company_id));
CREATE OR REPLACE FUNCTION public.api_keys_guard_jwt_writes()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
v_jwt_role text := coalesce(
nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role',
''
);
BEGIN
-- service_role, pg_cron, migrations and the pg-real harness carry no
-- end-user role claim: nothing to enforce.
IF v_jwt_role NOT IN ('anon', 'authenticated') THEN
RETURN NEW;
END IF;
IF TG_OP = 'INSERT' THEN
IF NEW.user_id IS DISTINCT FROM auth.uid() THEN
RAISE EXCEPTION 'api_keys: user_id must be the calling user'
USING ERRCODE = '42501';
END IF;
IF NEW.refresh_token_hash IS NOT NULL
OR NEW.previous_key_hash IS NOT NULL
OR NEW.previous_refresh_token_hash IS NOT NULL THEN
RAISE EXCEPTION 'api_keys: refresh-token material is set only by the OAuth token endpoint'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END IF;
IF NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.key_hash IS DISTINCT FROM OLD.key_hash
OR NEW.key_prefix IS DISTINCT FROM OLD.key_prefix
OR NEW.mode IS DISTINCT FROM OLD.mode
OR NEW.refresh_token_hash IS DISTINCT FROM OLD.refresh_token_hash
OR NEW.previous_key_hash IS DISTINCT FROM OLD.previous_key_hash
OR NEW.previous_key_expires_at IS DISTINCT FROM OLD.previous_key_expires_at
OR NEW.previous_refresh_token_hash IS DISTINCT FROM OLD.previous_refresh_token_hash
OR NEW.previous_refresh_expires_at IS DISTINCT FROM OLD.previous_refresh_expires_at THEN
RAISE EXCEPTION 'api_keys: identity and credential columns are immutable from a user session'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS api_keys_guard_jwt_writes ON public.api_keys;
CREATE TRIGGER api_keys_guard_jwt_writes
BEFORE INSERT OR UPDATE ON public.api_keys
FOR EACH ROW EXECUTE FUNCTION public.api_keys_guard_jwt_writes();
-- 3: hash-as-bearer RPCs become service_role only --------------------------
REVOKE EXECUTE ON FUNCTION public.rotate_mcp_refresh_token(text, text, text, text, integer)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.rotate_mcp_refresh_token(text, text, text, text, integer)
TO service_role;
ALTER FUNCTION public.rotate_mcp_refresh_token(text, text, text, text, integer)
SET search_path = public;
-- 4: validate_and_increment_api_key with a membership check -----------------
-- Body identical to 20260831111519 apart from the membership block and the
-- fixed search_path.
CREATE OR REPLACE FUNCTION public.validate_and_increment_api_key(p_key_hash text)
RETURNS TABLE(
user_id uuid,
company_id uuid,
api_key_id uuid,
api_key_name text,
rate_limited boolean,
scopes text[],
mode text,
unattended_commit_limit numeric
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_id uuid;
v_user_id uuid;
v_company_id uuid;
v_api_key_name text;
v_rate_limit_rpm integer;
v_request_count integer;
v_window_start timestamptz;
v_scopes text[];
v_mode text;
v_unattended_commit_limit numeric;
BEGIN
-- Match the live key_hash, OR a previous (just-rotated) key_hash that is still
-- inside its grace window. Both gated by revoked_at IS NULL.
SELECT ak.id, ak.user_id, ak.company_id, ak.name,
ak.rate_limit_rpm, ak.request_count, ak.rate_limit_window_start, ak.scopes, ak.mode,
ak.unattended_commit_limit
INTO v_id, v_user_id, v_company_id, v_api_key_name,
v_rate_limit_rpm, v_request_count, v_window_start, v_scopes, v_mode,
v_unattended_commit_limit
FROM public.api_keys ak
WHERE ak.revoked_at IS NULL
AND (
ak.key_hash = p_key_hash
OR (
ak.previous_key_hash = p_key_hash
AND ak.previous_key_expires_at IS NOT NULL
AND ak.previous_key_expires_at > now()
)
)
FOR UPDATE;
IF v_id IS NULL THEN
RETURN; -- no live match (incl. expired grace): caller returns 401, as before
END IF;
-- A key outlives neither the membership it was minted under nor the company
-- itself. Company-less keys (OAuth lazy bind) have nothing to check yet.
IF v_company_id IS NOT NULL AND NOT EXISTS (
SELECT 1
FROM public.company_members cm
JOIN public.companies c ON c.id = cm.company_id AND c.archived_at IS NULL
WHERE cm.user_id = v_user_id
AND cm.company_id = v_company_id
) THEN
RETURN; -- treated as an unknown key: 401 upstream
END IF;
-- Reset the rate-limit window if it is unset or older than one minute.
IF v_window_start IS NULL OR v_window_start < now() - interval '1 minute' THEN
UPDATE public.api_keys
SET request_count = 1,
rate_limit_window_start = now(),
last_used_at = now()
WHERE id = v_id;
RETURN QUERY SELECT v_user_id, v_company_id, v_id, v_api_key_name, false, v_scopes, v_mode,
v_unattended_commit_limit;
RETURN;
END IF;
IF v_request_count >= v_rate_limit_rpm THEN
RETURN QUERY SELECT v_user_id, v_company_id, v_id, v_api_key_name, true, v_scopes, v_mode,
v_unattended_commit_limit;
RETURN;
END IF;
UPDATE public.api_keys
SET request_count = request_count + 1,
last_used_at = now()
WHERE id = v_id;
RETURN QUERY SELECT v_user_id, v_company_id, v_id, v_api_key_name, false, v_scopes, v_mode,
v_unattended_commit_limit;
END;
$$;
REVOKE EXECUTE ON FUNCTION public.validate_and_increment_api_key(text)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.validate_and_increment_api_key(text)
TO service_role;
-- 5: provider token tables become service_role only --------------------------
DROP POLICY IF EXISTS provider_consent_tokens_select ON public.provider_consent_tokens;
DROP POLICY IF EXISTS provider_consent_tokens_insert ON public.provider_consent_tokens;
DROP POLICY IF EXISTS provider_consent_tokens_update ON public.provider_consent_tokens;
DROP POLICY IF EXISTS provider_consent_tokens_delete ON public.provider_consent_tokens;
DROP POLICY IF EXISTS provider_otc_select ON public.provider_otc;
DROP POLICY IF EXISTS provider_otc_insert ON public.provider_otc;
DROP POLICY IF EXISTS provider_otc_update ON public.provider_otc;
DROP POLICY IF EXISTS provider_otc_delete ON public.provider_otc;
ALTER TABLE public.provider_consent_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.provider_otc ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE public.provider_consent_tokens FROM PUBLIC, anon, authenticated;
REVOKE ALL ON TABLE public.provider_otc FROM PUBLIC, anon, authenticated;
GRANT ALL ON TABLE public.provider_consent_tokens TO service_role;
GRANT ALL ON TABLE public.provider_otc TO service_role;
@@ -0,0 +1,780 @@
-- Security audit 2026-09-01, high items (report "Accounted Security Audit").
-- pg-test: tests/pg/security-role-gates-and-posting-integrity.pg.test.ts
--
-- A. The read-only `viewer` role could write. The 20260702093000 design
-- blocks viewers only through RLS predicates that AND in
-- current_user_can_write(); everything that bypasses RLS skipped the gate:
-- 15 authenticated-callable SECURITY DEFINER writers (commit_journal_entry,
-- import_sie_journal_entries, bulk_book_transactions, match_batch_allocate,
-- link_*_to_voucher, create_document_version, next_voucher_number,
-- reserve/release_voucher_range, rotate_company_inbox,
-- generate_invoice_number, ensure_company_dimensions,
-- relink_documents_to_correction, seed_chart_of_accounts) and ~45 tables
-- whose write policies were membership-only. Rather than re-emitting 15
-- function bodies and 130 policies, this adds ONE table-level guard,
-- enforce_company_writer_role(), on every company-scoped table a viewer
-- must never write. It keys on the JWT role claim (so it fires inside
-- SECURITY DEFINER bodies too, where current_user is the owner) and on
-- auth.uid(), and is a no-op for service_role, pg_cron, migrations and
-- trigger cascades (pg_trigger_depth() > 1). Deliberately NOT attached:
-- agent_conversations/agent_messages (viewer chat is a feature),
-- booking_template_usage and categorize_calibration_samples (telemetry a
-- read-only browsing session emits).
--
-- B. Admin -> owner escalation. company_members_update only guarded role
-- changes, so an admin could re-point the owner row's user_id; the
-- invitations CHECK allowed role 'owner' and the accept path copies the
-- role verbatim under the service client; team_members had no transition
-- guard at all. Triggers below close all three. companies gains an
-- owner-only guard on team_id/archived_at/created_by and the INSERT policy
-- now requires membership of the team a company is attached to.
--
-- C. Posting integrity. Direct PostgREST statements (current_user =
-- authenticated) could INSERT lines under a posted verifikat, INSERT a
-- header with status 'posted', or flip a self-numbered draft to posted,
-- bypassing commit_journal_entry and voucher sequencing (BFL 5 kap. 5 §,
-- BFNAR 2013:2). These checks key on current_user IN ('anon',
-- 'authenticated'): inside SECURITY DEFINER RPCs current_user is the
-- definer, so the engine's sanctioned paths (commit_journal_entry,
-- import_sie_journal_entries, correct_entry_lines_inline, storno) are
-- untouched, while the engine's own direct writes stay legal: it inserts
-- drafts (voucher 0, or a sequence-issued number for reversals) and flips
-- draft -> posted only with a sequence-issued number.
--
-- D. create_document_version: role gate + storage_path must live under the
-- document's company (or the caller's user folder). validate_version_chain
-- was anon-callable with no tenant check (a document-UUID oracle):
-- membership required, anon revoked. match_documents /
-- match_booking_templates lose anon EXECUTE. update_overdue_supplier_invoices
-- and redact_expired_invoice_delivery_pii are cron maintenance with no
-- application caller: service_role only. seed_asset_categories existed only
-- in production (no migration, no code reference): dropped.
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.jwt_caller_is_end_user()
RETURNS boolean
LANGUAGE sql
STABLE
SET search_path = public
AS $$
SELECT coalesce(
nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role',
''
) IN ('anon', 'authenticated');
$$;
-- Membership with a writing role, for an explicit company (the
-- current_user_can_write() twin that does not depend on the active company).
CREATE OR REPLACE FUNCTION public.caller_can_write_company(p_company_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
-- No archived_at filter here: archived companies are already frozen by the
-- migration-reset triggers, which must keep producing their own message.
SELECT p_company_id IS NOT NULL AND EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.user_id = auth.uid()
AND cm.company_id = p_company_id
AND cm.role <> 'viewer'
);
$$;
CREATE OR REPLACE FUNCTION public.caller_is_company_owner(p_company_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT p_company_id IS NOT NULL AND EXISTS (
SELECT 1 FROM public.company_members cm
WHERE cm.user_id = auth.uid() AND cm.company_id = p_company_id AND cm.role = 'owner'
);
$$;
CREATE OR REPLACE FUNCTION public.caller_is_team_owner(p_team_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT p_team_id IS NOT NULL AND EXISTS (
SELECT 1 FROM public.team_members tm
WHERE tm.user_id = auth.uid() AND tm.team_id = p_team_id AND tm.role = 'owner'
);
$$;
-- ---------------------------------------------------------------------------
-- A. Viewer write guard
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.enforce_company_writer_role()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
v_company uuid;
BEGIN
IF NOT public.jwt_caller_is_end_user() OR pg_trigger_depth() > 1 THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
v_company := OLD.company_id;
ELSE
v_company := NEW.company_id;
END IF;
IF v_company IS NOT NULL AND NOT public.caller_can_write_company(v_company) THEN
RAISE EXCEPTION 'row-level security: no write access to company % for % on %', v_company, TG_OP, TG_TABLE_NAME
USING ERRCODE = '42501';
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END;
$$;
-- Child tables: resolve company_id through the parent named in TG_ARGV.
CREATE OR REPLACE FUNCTION public.enforce_company_writer_role_via_parent()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_row jsonb;
v_fk uuid;
v_company uuid;
BEGIN
IF NOT public.jwt_caller_is_end_user() OR pg_trigger_depth() > 1 THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
v_row := to_jsonb(OLD);
ELSE
v_row := to_jsonb(NEW);
END IF;
v_fk := (v_row ->> TG_ARGV[1])::uuid;
IF v_fk IS NOT NULL THEN
EXECUTE format('SELECT company_id FROM public.%I WHERE id = $1', TG_ARGV[0])
INTO v_company USING v_fk;
IF v_company IS NOT NULL AND NOT public.caller_can_write_company(v_company) THEN
RAISE EXCEPTION 'row-level security: no write access to company % for % on %', v_company, TG_OP, TG_TABLE_NAME
USING ERRCODE = '42501';
END IF;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END;
$$;
DO $$
DECLARE
t text;
BEGIN
FOREACH t IN ARRAY ARRAY[
-- core tables reachable through membership-only SECURITY DEFINER writers
'journal_entries', 'transactions', 'invoices', 'invoice_payments',
'supplier_invoices', 'supplier_invoice_payments', 'document_attachments',
'voucher_sequences', 'company_inboxes', 'chart_of_accounts',
-- tables whose own write policies were membership-only
'account_dimension_rules', 'accrual_schedule_installments', 'accrual_schedules',
'agent_memory', 'agent_profiles', 'agi_declarations',
'arsredovisning_narratives', 'arsredovisning_signature_requests', 'articles',
'assets', 'cash_accounts', 'depreciation_schedules', 'dimension_values',
'dimensions', 'employee_benefits', 'employee_opening_balances',
'employee_vacation_balances', 'employees', 'journal_entry_no_doc_required',
'mileage_trips', 'recurring_invoice_schedules', 'rot_rut_payout_requests',
'salary_absence_days', 'salary_line_items', 'salary_payslip_deliveries',
'salary_payslip_links', 'salary_run_employees', 'salary_runs',
'salary_worked_days', 'shift_premium_rules', 'shopify_connections',
'skattekonto_file_imports', 'skattekonto_rules', 'skattekonto_transactions',
'stripe_connections', 'supplier_payment_batch_items', 'supplier_payment_batches',
'transaction_voucher_links', 'vacation_year_closures', 'webshop_orders',
'webshop_store_settings', 'woocommerce_connections'
] LOOP
IF to_regclass('public.' || t) IS NULL THEN
RAISE NOTICE 'enforce_company_writer_role: table % missing, skipped', t;
CONTINUE;
END IF;
EXECUTE format('DROP TRIGGER IF EXISTS aa_enforce_company_writer_role ON public.%I', t);
EXECUTE format(
'CREATE TRIGGER aa_enforce_company_writer_role BEFORE INSERT OR UPDATE OR DELETE ON public.%I '
'FOR EACH ROW EXECUTE FUNCTION public.enforce_company_writer_role()', t);
END LOOP;
END $$;
DO $$
DECLARE
spec text[];
BEGIN
FOREACH spec SLICE 1 IN ARRAY ARRAY[
ARRAY['journal_entry_lines', 'journal_entries', 'journal_entry_id'],
ARRAY['invoice_items', 'invoices', 'invoice_id'],
ARRAY['supplier_invoice_items', 'supplier_invoices', 'supplier_invoice_id'],
ARRAY['recurring_invoice_schedule_items', 'recurring_invoice_schedules', 'schedule_id'],
ARRAY['rot_rut_payout_request_items', 'rot_rut_payout_requests', 'request_id']
] LOOP
IF to_regclass('public.' || spec[1]) IS NULL THEN
RAISE NOTICE 'enforce_company_writer_role_via_parent: table % missing, skipped', spec[1];
CONTINUE;
END IF;
EXECUTE format('DROP TRIGGER IF EXISTS aa_enforce_company_writer_role ON public.%I', spec[1]);
EXECUTE format(
'CREATE TRIGGER aa_enforce_company_writer_role BEFORE INSERT OR UPDATE OR DELETE ON public.%I '
'FOR EACH ROW EXECUTE FUNCTION public.enforce_company_writer_role_via_parent(%L, %L)',
spec[1], spec[2], spec[3]);
END LOOP;
END $$;
-- ---------------------------------------------------------------------------
-- B. Membership and ownership guards
-- ---------------------------------------------------------------------------
-- company_members: identity columns are immutable from user sessions; role
-- changes stay owner-only (unchanged semantics from 20260826130100).
CREATE OR REPLACE FUNCTION public.enforce_company_member_role_transitions()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
caller_role text;
BEGIN
-- Service role, SECURITY DEFINER cascades, and direct SQL have no auth context.
IF auth.uid() IS NULL THEN
RETURN NEW;
END IF;
-- Trigger-cascaded writes come from our own sync triggers
-- (team_member_sync_role_update), not from a direct user statement.
IF pg_trigger_depth() > 1 THEN
RETURN NEW;
END IF;
-- Who a membership row belongs to is never editable: re-pointing the owner
-- row's user_id was an admin -> owner takeover.
IF NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.company_id IS DISTINCT FROM OLD.company_id THEN
RAISE EXCEPTION 'company_members: user_id and company_id are immutable'
USING ERRCODE = '42501';
END IF;
-- Nothing to enforce if the role field isn't changing.
IF NEW.role IS NOT DISTINCT FROM OLD.role THEN
RETURN NEW;
END IF;
caller_role := public.user_role_in_company(OLD.company_id);
IF caller_role IS DISTINCT FROM 'owner' THEN
RAISE EXCEPTION
'Only owners can change member roles (your role: %)',
COALESCE(caller_role, 'none');
END IF;
RETURN NEW;
END;
$$;
-- company_invitations: an invitation never mints an owner. The API schema
-- already refused it; the RLS INSERT policy (admin) and the CHECK constraint
-- did not, and the accept path copies the role under the service client.
CREATE OR REPLACE FUNCTION public.enforce_company_invitation_role()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
IF NEW.role = 'owner' THEN
RAISE EXCEPTION 'company_invitations: role owner cannot be granted by invitation'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS enforce_company_invitation_role ON public.company_invitations;
CREATE TRIGGER enforce_company_invitation_role
BEFORE INSERT OR UPDATE OF role ON public.company_invitations
FOR EACH ROW EXECUTE FUNCTION public.enforce_company_invitation_role();
-- team_members: mirror of the company_members guard. The first owner of a
-- team may be self-inserted (create_team_with_owner / ensure_user_team run as
-- definer with the caller's JWT); every later owner grant needs a team owner.
CREATE OR REPLACE FUNCTION public.enforce_team_member_role_transitions()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
IF auth.uid() IS NULL OR pg_trigger_depth() > 1 THEN
RETURN NEW;
END IF;
IF TG_OP = 'INSERT' THEN
IF NEW.role = 'owner'
AND NOT public.caller_is_team_owner(NEW.team_id)
AND NOT (
NEW.user_id = auth.uid()
AND NOT EXISTS (
SELECT 1 FROM public.team_members tm
WHERE tm.team_id = NEW.team_id AND tm.role = 'owner'
)
) THEN
RAISE EXCEPTION 'team_members: only a team owner can add another owner'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END IF;
IF NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.team_id IS DISTINCT FROM OLD.team_id THEN
RAISE EXCEPTION 'team_members: user_id and team_id are immutable'
USING ERRCODE = '42501';
END IF;
-- Team admins keep the pre-existing ability to move members between the
-- non-owner roles; anything touching 'owner' needs a team owner.
IF NEW.role IS DISTINCT FROM OLD.role
AND NOT public.caller_is_team_owner(OLD.team_id)
AND NOT (
public.user_is_team_admin(OLD.team_id)
AND OLD.role <> 'owner' AND NEW.role <> 'owner'
) THEN
RAISE EXCEPTION 'team_members: only a team owner can grant or remove the owner role'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS enforce_team_member_role_transitions ON public.team_members;
CREATE TRIGGER enforce_team_member_role_transitions
BEFORE INSERT OR UPDATE ON public.team_members
FOR EACH ROW EXECUTE FUNCTION public.enforce_team_member_role_transitions();
-- companies: team attachment, archiving and provenance are owner-only from a
-- user session, and a company may only be attached to a team the caller
-- belongs to. Service-role paths (delete route, migration reset internals)
-- are unaffected.
CREATE OR REPLACE FUNCTION public.enforce_company_owner_only_columns()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
IF auth.uid() IS NULL OR pg_trigger_depth() > 1 THEN
RETURN NEW;
END IF;
IF NEW.created_by IS DISTINCT FROM OLD.created_by THEN
RAISE EXCEPTION 'companies: created_by is immutable' USING ERRCODE = '42501';
END IF;
IF NEW.team_id IS DISTINCT FROM OLD.team_id THEN
IF NOT public.caller_is_company_owner(OLD.id) THEN
RAISE EXCEPTION 'companies: only the owner can change the team attachment'
USING ERRCODE = '42501';
END IF;
IF NEW.team_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM public.team_members tm WHERE tm.team_id = NEW.team_id AND tm.user_id = auth.uid()
) THEN
RAISE EXCEPTION 'companies: cannot attach a company to a team you are not a member of'
USING ERRCODE = '42501';
END IF;
END IF;
IF (NEW.archived_at IS DISTINCT FROM OLD.archived_at
OR NEW.archived_by IS DISTINCT FROM OLD.archived_by)
AND NOT public.caller_is_company_owner(OLD.id) THEN
RAISE EXCEPTION 'companies: only the owner can archive or restore a company'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS enforce_company_owner_only_columns ON public.companies;
CREATE TRIGGER enforce_company_owner_only_columns
BEFORE UPDATE ON public.companies
FOR EACH ROW EXECUTE FUNCTION public.enforce_company_owner_only_columns();
DROP POLICY IF EXISTS "companies_insert" ON public.companies;
CREATE POLICY "companies_insert" ON public.companies
FOR INSERT
WITH CHECK (
created_by = auth.uid()
AND (team_id IS NULL OR team_id IN (SELECT public.user_team_ids()))
);
-- ---------------------------------------------------------------------------
-- C. Posting integrity for direct statements
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_insert_shape()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
-- Direct PostgREST statement (current_user is the end-user role). Inside a
-- SECURITY DEFINER RPC current_user is the definer, so the engine's
-- sanctioned writers are not affected.
IF current_user NOT IN ('anon', 'authenticated') THEN
RETURN NEW;
END IF;
IF NEW.status IS DISTINCT FROM 'draft'
OR NEW.committed_at IS NOT NULL
OR NEW.commit_method IS NOT NULL THEN
RAISE EXCEPTION 'journal_entries: direct inserts must be drafts; post through commit_journal_entry'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS aa_enforce_journal_entry_insert_shape ON public.journal_entries;
CREATE TRIGGER aa_enforce_journal_entry_insert_shape
BEFORE INSERT ON public.journal_entries
FOR EACH ROW EXECUTE FUNCTION public.enforce_journal_entry_insert_shape();
-- Header immutability: identical to the live definition, plus one rule in the
-- draft branch: a direct statement may flip draft -> posted only with a
-- voucher number the sequence has actually issued (0 < n <= last_number).
-- The engine's reversal path (next_voucher_number then UPDATE) satisfies it;
-- a self-chosen number outside the sequence does not.
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
v_last_number integer;
BEGIN
IF TG_OP = 'DELETE' THEN
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
OLD.id, OLD.status;
END IF;
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
IF NEW.status = 'posted' AND current_user IN ('anon', 'authenticated') THEN
SELECT vs.last_number INTO v_last_number
FROM public.voucher_sequences vs
WHERE vs.company_id = NEW.company_id
AND vs.fiscal_period_id = NEW.fiscal_period_id
AND vs.voucher_series = NEW.voucher_series;
IF NEW.voucher_number IS NULL OR NEW.voucher_number <= 0
OR v_last_number IS NULL OR NEW.voucher_number > v_last_number THEN
RAISE EXCEPTION 'journal_entries: voucher number % was not issued by the sequence; post through commit_journal_entry',
NEW.voucher_number USING ERRCODE = '42501';
END IF;
END IF;
RETURN NEW;
END IF;
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
IF NEW.status = 'reversed' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number
OR NEW.commit_method IS DISTINCT FROM OLD.commit_method
OR NEW.rubric_version IS DISTINCT FROM OLD.rubric_version
OR NEW.source_voucher_series IS DISTINCT FROM OLD.source_voucher_series
OR NEW.source_voucher_number IS DISTINCT FROM OLD.source_voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
END IF;
END IF;
RETURN NEW;
END IF;
-- Narrow un-reversal path: when delete_last_voucher removes a storno entry,
-- it flips the original from 'reversed' back to 'posted'. No other fields
-- may change, and the bypass flag must be set.
IF OLD.status = 'reversed' AND NEW.status = 'posted'
AND current_setting('gnubok.allow_delete', true) = 'true' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields during un-reversal (id: %)', OLD.id;
END IF;
RETURN NEW;
END IF;
-- Notes-only annotation on a committed entry (posted/reversed/cancelled).
IF OLD.status = NEW.status
AND OLD.status IN ('posted', 'reversed', 'cancelled')
AND (to_jsonb(NEW) - 'notes' - 'updated_at')
= (to_jsonb(OLD) - 'notes' - 'updated_at') THEN
RETURN NEW;
END IF;
-- Source-type re-tag of a mis-typed opening balance (mark_entry_as_opening_balance).
IF OLD.status = NEW.status
AND OLD.status = 'posted'
AND current_setting('gnubok.allow_source_type_retag', true) = 'true'
AND OLD.source_type IN ('manual', 'import')
AND NEW.source_type = 'opening_balance'
AND (to_jsonb(NEW) - 'source_type' - 'updated_at')
= (to_jsonb(OLD) - 'source_type' - 'updated_at') THEN
RETURN NEW;
END IF;
-- Metadata rättelse of a posted verifikation (correct_entry_metadata).
IF OLD.status = NEW.status
AND OLD.status = 'posted'
AND current_setting('gnubok.allow_metadata_rattelse', true) = 'true'
AND (to_jsonb(NEW) - 'description' - 'entry_date' - 'updated_at')
= (to_jsonb(OLD) - 'description' - 'entry_date' - 'updated_at') THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
OLD.status, OLD.id;
END;
$$;
-- Line immutability: identical to the live definition, plus INSERT coverage.
-- A direct statement may only add lines to a draft header; sanctioned RPCs
-- (correct_entry_lines_inline under its GUC, import, storno) run as definer.
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_line_immutability()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE v_status text;
BEGIN
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
SELECT status INTO v_status FROM public.journal_entries
WHERE id = COALESCE(NEW.journal_entry_id, OLD.journal_entry_id);
IF TG_OP = 'INSERT' THEN
IF v_status IS DISTINCT FROM 'draft'
AND current_user IN ('anon', 'authenticated')
AND current_setting('gnubok.allow_line_rattelse', true) IS DISTINCT FROM 'true' THEN
RAISE EXCEPTION 'journal_entry_lines: cannot add lines to a % journal entry', v_status
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END IF;
-- Dimension retag carve-out (dimensions plan PR6, founder-approved):
-- while the transaction-local GUC set by retag_line_dimensions is active,
-- permit UPDATE of a POSTED line iff ONLY the dimension columns change.
IF TG_OP = 'UPDATE'
AND v_status = 'posted'
AND current_setting('gnubok.allow_dimension_retag', true) = 'true'
AND (to_jsonb(NEW) - 'dimensions' - 'cost_center' - 'project')
= (to_jsonb(OLD) - 'dimensions' - 'cost_center' - 'project') THEN
RETURN NEW;
END IF;
-- Inline rättelse carve-out (BFL 5 kap 5 §, founder-approved 2026-07-23):
-- while the transaction-local GUC set by correct_entry_lines_inline() is
-- active, permit DELETE of a POSTED line (a struck line).
IF TG_OP = 'DELETE'
AND v_status = 'posted'
AND current_setting('gnubok.allow_line_rattelse', true) = 'true' THEN
RETURN OLD;
END IF;
IF v_status = 'draft' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
IF v_status = 'cancelled' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RAISE EXCEPTION 'Cannot % lines of a cancelled journal entry.', TG_OP;
END IF;
RAISE EXCEPTION 'Cannot % lines of a % journal entry.', TG_OP, v_status;
END; $$;
DROP TRIGGER IF EXISTS enforce_journal_entry_line_immutability ON public.journal_entry_lines;
CREATE TRIGGER enforce_journal_entry_line_immutability
BEFORE INSERT OR UPDATE OR DELETE ON public.journal_entry_lines
FOR EACH ROW EXECUTE FUNCTION public.enforce_journal_entry_line_immutability();
-- ---------------------------------------------------------------------------
-- D. Document RPCs and leftover grants
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.create_document_version(
p_user_id uuid, p_original_doc_id uuid, p_storage_path text, p_file_name text,
p_file_size_bytes bigint, p_mime_type text, p_sha256_hash text)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_caller uuid := auth.uid();
v_current document_attachments%ROWTYPE;
v_new_id uuid;
v_root_id uuid;
v_next_version integer;
v_caller_role text;
BEGIN
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Authentication required to create document version';
END IF;
IF p_user_id IS DISTINCT FROM v_caller THEN
RAISE EXCEPTION 'p_user_id does not match authenticated user';
END IF;
SELECT * INTO v_current
FROM public.document_attachments
WHERE id = p_original_doc_id
AND is_current_version = true
FOR UPDATE;
IF v_current IS NULL THEN
RAISE EXCEPTION 'Document % not found or is not the current version', p_original_doc_id;
END IF;
SELECT cm.role INTO v_caller_role
FROM public.company_members cm
WHERE cm.company_id = v_current.company_id
AND cm.user_id = v_caller;
IF v_caller_role IS NULL THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (v_caller, v_current.company_id, 'SECURITY_EVENT', 'document_attachments', p_original_doc_id,
'Blocked cross-company create_document_version attempt');
RAISE EXCEPTION 'User is not a member of the document''s company';
END IF;
-- Read-only members supersede nothing: same rule as every document write.
IF v_caller_role = 'viewer' THEN
RAISE EXCEPTION 'read-only role cannot create document versions' USING ERRCODE = '42501';
END IF;
-- The new object must live in this company's folder (or the caller's own
-- legacy user folder): a foreign key would re-anchor another tenant's file.
IF p_storage_path IS NULL
OR NOT (
p_storage_path LIKE 'documents/' || v_current.company_id::text || '/%'
OR p_storage_path LIKE 'documents/' || v_caller::text || '/%'
) THEN
RAISE EXCEPTION 'storage_path must be under the document''s company folder' USING ERRCODE = '42501';
END IF;
v_root_id := COALESCE(v_current.original_id, v_current.id);
v_next_version := v_current.version + 1;
PERFORM set_config('gnubok.allow_supersede', 'true', true);
INSERT INTO public.document_attachments (
user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash, version, original_id, is_current_version,
uploaded_by, upload_source, digitization_date,
journal_entry_id, journal_entry_line_id, prev_version_hash
) VALUES (
p_user_id, v_current.company_id, p_storage_path, p_file_name,
p_file_size_bytes, p_mime_type, p_sha256_hash, v_next_version,
v_root_id, true, p_user_id, v_current.upload_source, now(),
v_current.journal_entry_id, v_current.journal_entry_line_id,
v_current.sha256_hash
)
RETURNING id INTO v_new_id;
UPDATE public.document_attachments
SET is_current_version = false,
superseded_by_id = v_new_id
WHERE id = p_original_doc_id;
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, actor_id, description)
VALUES (
v_caller, v_current.company_id, 'UPDATE', 'document_attachments', p_original_doc_id, v_caller,
'Document superseded: v' || v_current.version || ' (' || v_current.sha256_hash || ') -> v' || v_next_version || ' (' || p_sha256_hash || '); new id=' || v_new_id
);
RETURN v_new_id;
END;
$$;
CREATE OR REPLACE FUNCTION public.validate_version_chain(p_document_id uuid)
RETURNS TABLE(version integer, document_id uuid, hash_valid boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_root_id uuid;
v_company uuid;
BEGIN
SELECT COALESCE(da.original_id, da.id), da.company_id INTO v_root_id, v_company
FROM public.document_attachments da
WHERE da.id = p_document_id;
-- One message for "missing" and "not yours": no existence oracle.
IF v_root_id IS NULL
OR (public.jwt_caller_is_end_user() AND NOT public.caller_is_company_member(v_company)) THEN
RAISE EXCEPTION 'Document % not found', p_document_id;
END IF;
RETURN QUERY
WITH chain AS (
SELECT
da.id AS doc_id,
da.version AS ver,
da.sha256_hash,
da.prev_version_hash,
LAG(da.sha256_hash) OVER (ORDER BY da.version) AS expected_prev_hash
FROM public.document_attachments da
WHERE da.id = v_root_id OR da.original_id = v_root_id
ORDER BY da.version
)
SELECT
chain.ver,
chain.doc_id,
CASE
WHEN chain.ver = 1 THEN chain.prev_version_hash IS NULL
ELSE chain.prev_version_hash IS NOT DISTINCT FROM chain.expected_prev_hash
END AS hash_valid
FROM chain
ORDER BY chain.ver;
END;
$$;
REVOKE EXECUTE ON FUNCTION public.validate_version_chain(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.validate_version_chain(uuid) TO authenticated, service_role;
REVOKE EXECUTE ON FUNCTION public.match_documents(vector, integer, double precision) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.match_documents(vector, integer, double precision) TO authenticated, service_role;
REVOKE EXECUTE ON FUNCTION public.match_booking_templates(vector, integer, double precision) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.match_booking_templates(vector, integer, double precision) TO authenticated, service_role;
REVOKE EXECUTE ON FUNCTION public.update_overdue_supplier_invoices() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.update_overdue_supplier_invoices() TO service_role;
REVOKE EXECUTE ON FUNCTION public.redact_expired_invoice_delivery_pii() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.redact_expired_invoice_delivery_pii() TO service_role;
-- Production-only leftover with no migration and no caller.
DROP FUNCTION IF EXISTS public.seed_asset_categories(uuid);
@@ -0,0 +1,27 @@
-- Security audit 2026-09-01: bind the arcim-migration OAuth state to the user
-- who started the flow.
--
-- provider_otc rows are the `state` handed to Fortnox/Visma when a user starts
-- a provider connect (extensions/general/arcim-migration). The unauthenticated
-- GET /callback resolved the consent from that row and exchanged the code for
-- tokens without asking WHO completed the flow: a victim lured into approving
-- a consent someone else started had their provider account bound to the
-- initiator's consent (and the initiator's next migration imported the
-- victim's ledger). The callback now compares the completing browser's cookie
-- session to the initiator recorded here (lib/auth/oauth-flow-binding.ts).
--
-- Nullable on purpose: rows minted before this migration carry no initiator.
-- They expire within 10 minutes and the callback refuses them, so at most one
-- in-flight connect has to be restarted at deploy time.
--
-- No policy change: 20260902090000 made provider_otc service_role only (all
-- member policies dropped, privileges revoked from anon/authenticated), and
-- every reader and writer goes through createServiceClient().
ALTER TABLE public.provider_otc
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
COMMENT ON COLUMN public.provider_otc.user_id IS
'The user who started the OAuth flow this state was minted for. GET /callback refuses a completion by any other session.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,33 @@
-- BankID signup: the e-mail address must be proven before the identity counts.
--
-- Security audit 2026-09: in signup mode the BankID /complete route created
-- an email-confirmed, MFA-exempt (app_metadata.bankid_linked) account for
-- whatever address the caller typed, without ever mailing that address. The
-- real owner of the address could later adopt the account (forgot-password,
-- Google login) while the BankID holder kept a permanent login into it
-- (account pre-hijacking).
--
-- email_verified_at records when the address on the linked auth user was
-- confirmed through the confirmation mail the BankID signup now sends. NULL
-- means the identity is PENDING: BankID login is refused and the account is
-- not MFA-exempt until the link in that mail is clicked (app/(auth)/auth/
-- callback promotes it). A pending row attached to an account that has since
-- been adopted through another credential is deleted, never promoted.
--
-- No column default on purpose (fail closed): every insert path must say
-- whether the address is proven. The authenticated /bankid/link route writes
-- now(); the BankID signup writes NULL. Existing rows predate this change and
-- were adopted through the old flow, so they are grandfathered as verified at
-- their creation time.
ALTER TABLE public.bankid_identities
ADD COLUMN IF NOT EXISTS email_verified_at timestamptz NULL;
UPDATE public.bankid_identities
SET email_verified_at = created_at
WHERE email_verified_at IS NULL;
COMMENT ON COLUMN public.bankid_identities.email_verified_at IS
'When the linked auth user''s e-mail address was proven for this BankID identity. NULL = pending: BankID login refused, no MFA exemption, until the signup confirmation mail is clicked. Rows created before 2026-09-02 are backfilled with created_at.';
NOTIFY pgrst, 'reload schema';
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { insertAuthUser, insertCompany } from './fixtures'
import { insertAuthUser, insertCompany, insertCompanyMember } from './fixtures'
import { getPool } from './setup'
/**
@@ -28,6 +28,7 @@ describe('api_keys.unattended_commit_limit (pg)', () => {
async function seedKey(limit: number | null = null) {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const apiKeyId = randomUUID()
const keyHash = randomUUID().replaceAll('-', '')
await getPool().query(
@@ -42,6 +43,7 @@ describe('api_keys.unattended_commit_limit (pg)', () => {
it('defaults to NULL so pre-existing keys stay unlimited', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
// Deliberately omits the column instead of storing an explicit NULL: this
// test exists to pin the DATABASE DEFAULT, and passing NULL in would keep
// it green even if the default changed to a positive ceiling, which is the
@@ -59,6 +61,7 @@ describe('api_keys.unattended_commit_limit (pg)', () => {
it('rejects a zero or negative ceiling with the CHECK constraint', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
for (const bad of [0, -1]) {
await expect(
+12 -2
View File
@@ -86,8 +86,13 @@ describe('set_committed_at trusted-writer preservation', () => {
const { entryId, userId } = await seedBackdatedDraft()
const before = Date.now()
const committedAt = await withUserContext(userId, async (client) => {
// Direct draft -> posted flips from a user session must carry a
// sequence-issued voucher number (20260902093000); take one inline.
const updated = await client.query(
`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1 RETURNING id`,
`UPDATE public.journal_entries
SET status = 'posted',
voucher_number = public.next_voucher_number(company_id, fiscal_period_id, voucher_series)
WHERE id = $1 RETURNING id`,
[entryId],
)
// RLS must actually let the member's UPDATE through; 0 rows would make
@@ -231,8 +236,13 @@ describe('committed_at override audit trail', () => {
it('writes no override row when an authenticated member posts (stamp path)', async () => {
const { entryId, userId } = await seedBackdatedDraft()
await withUserContext(userId, async (client) => {
// Direct draft -> posted flips from a user session must carry a
// sequence-issued voucher number (20260902093000); take one inline.
const updated = await client.query(
`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1 RETURNING id`,
`UPDATE public.journal_entries
SET status = 'posted',
voucher_number = public.next_voucher_number(company_id, fiscal_period_id, voucher_series)
WHERE id = $1 RETURNING id`,
[entryId],
)
expect(updated.rowCount).toBe(1)
@@ -0,0 +1,248 @@
import { createHash, randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, runAsServiceRole, withUserContext } from './setup'
import { insertAuthUser, insertCompany, insertCompanyMember } from './fixtures'
/**
* Migration 20260902090000: the critical items of the 2026-09-01 security
* audit.
*
* - api_keys INSERT binds user_id to the caller again (an admin could forge
* a key for any co-member and act as them everywhere).
* - api_keys SELECT is own-keys-or-admin (viewers could read every hash).
* - identity/credential columns are frozen against JWT-session UPDATEs.
* - rotate_mcp_refresh_token / validate_and_increment_api_key are
* service_role only (hash-as-bearer takeover).
* - validate_and_increment_api_key fails closed once the key's user is no
* longer a member of the key's company.
* - provider_consent_tokens / provider_otc are service_role only (the DELETE
* policy had collapsed to "caller has any team row").
*/
function sha256(s: string): string {
return createHash('sha256').update(s).digest('hex')
}
async function seedCompanyWithAdmin() {
const owner = await insertAuthUser()
const companyId = await insertCompany({ createdBy: owner })
await insertCompanyMember({ companyId, userId: owner, role: 'owner' })
const admin = await insertAuthUser()
await insertCompanyMember({ companyId, userId: admin, role: 'admin' })
return { owner, admin, companyId }
}
describe('security audit: api_keys identity binding (pg)', () => {
it('lets an admin mint a key for themselves', async () => {
const { admin, companyId } = await seedCompanyWithAdmin()
await withUserContext(admin, async (client) => {
const res = await client.query<{ id: string }>(
`INSERT INTO public.api_keys (user_id, company_id, key_hash, key_prefix, name, scopes)
VALUES ($1, $2, $3, 'gnubok_sk_aaaaaaaa', 'own', ARRAY['companies:read'])
RETURNING id`,
[admin, companyId, sha256(randomUUID())],
)
expect(res.rows).toHaveLength(1)
})
})
it('refuses an admin minting a key that impersonates a co-member', async () => {
const { owner, admin, companyId } = await seedCompanyWithAdmin()
await expect(
withUserContext(admin, (client) =>
client.query(
`INSERT INTO public.api_keys (user_id, company_id, key_hash, key_prefix, name, scopes)
VALUES ($1, $2, $3, 'gnubok_sk_bbbbbbbb', 'forged', ARRAY['bookkeeping:write'])`,
[owner, companyId, sha256(randomUUID())],
),
),
).rejects.toMatchObject({ code: '42501' })
})
it('refuses a JWT session rewriting identity or credential columns', async () => {
const { owner, admin, companyId } = await seedCompanyWithAdmin()
const { rows } = await getPool().query<{ id: string }>(
`INSERT INTO public.api_keys (user_id, company_id, key_hash, key_prefix, name, scopes)
VALUES ($1, $2, $3, 'gnubok_sk_cccccccc', 'victim', ARRAY['companies:read'])
RETURNING id`,
[owner, companyId, sha256(randomUUID())],
)
const keyId = rows[0]!.id
for (const set of [
`user_id = '${admin}'`,
`key_hash = '${sha256('attacker')}'`,
`refresh_token_hash = '${sha256('rt')}'`,
`mode = 'test'`,
]) {
await expect(
withUserContext(admin, (client) =>
client.query(`UPDATE public.api_keys SET ${set} WHERE id = $1`, [keyId]),
),
).rejects.toMatchObject({ code: '42501' })
}
// Revoking stays possible for admins: that is the settings route's job.
await withUserContext(admin, async (client) => {
const res = await client.query(
`UPDATE public.api_keys SET revoked_at = now() WHERE id = $1 RETURNING id`,
[keyId],
)
expect(res.rows).toHaveLength(1)
})
})
it('hides other members keys from non-admins and shows them to admins', async () => {
const { owner, admin, companyId } = await seedCompanyWithAdmin()
const viewer = await insertAuthUser()
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
await getPool().query(
`INSERT INTO public.api_keys (user_id, company_id, key_hash, key_prefix, name, scopes)
VALUES ($1, $2, $3, 'gnubok_sk_dddddddd', 'owner key', ARRAY['companies:read'])`,
[owner, companyId, sha256(randomUUID())],
)
await withUserContext(viewer, async (client) => {
const res = await client.query(
`SELECT id FROM public.api_keys WHERE company_id = $1`,
[companyId],
)
expect(res.rows).toHaveLength(0)
})
await withUserContext(admin, async (client) => {
const res = await client.query(
`SELECT id FROM public.api_keys WHERE company_id = $1`,
[companyId],
)
expect(res.rows.length).toBeGreaterThanOrEqual(1)
})
})
})
describe('security audit: hash-as-bearer RPCs (pg)', () => {
it('denies EXECUTE on rotate_mcp_refresh_token and validate_and_increment_api_key to anon and authenticated', async () => {
const { rows } = await getPool().query<{ fn: string; anon: boolean; auth: boolean; svc: boolean }>(
`SELECT p.proname AS fn,
has_function_privilege('anon', p.oid, 'execute') AS anon,
has_function_privilege('authenticated', p.oid, 'execute') AS auth,
has_function_privilege('service_role', p.oid, 'execute') AS svc
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public'
AND p.proname IN ('rotate_mcp_refresh_token', 'validate_and_increment_api_key')`,
)
expect(rows).toHaveLength(2)
for (const r of rows) {
expect(r.anon, r.fn).toBe(false)
expect(r.auth, r.fn).toBe(false)
expect(r.svc, r.fn).toBe(true)
}
})
it('validate_and_increment_api_key returns no row once the key user left the company', async () => {
const { owner, companyId } = await seedCompanyWithAdmin()
const key = `gnubok_sk_${randomUUID()}`
await getPool().query(
`INSERT INTO public.api_keys (user_id, company_id, key_hash, key_prefix, name, scopes)
VALUES ($1, $2, $3, 'gnubok_sk_eeeeeeee', 'k', ARRAY['companies:read'])`,
[owner, companyId, sha256(key)],
)
const live = await runAsServiceRole((client) =>
client.query(`SELECT * FROM public.validate_and_increment_api_key($1)`, [sha256(key)]),
)
expect(live.rows).toHaveLength(1)
await getPool().query(
`DELETE FROM public.company_members WHERE company_id = $1 AND user_id = $2`,
[companyId, owner],
)
const stale = await runAsServiceRole((client) =>
client.query(`SELECT * FROM public.validate_and_increment_api_key($1)`, [sha256(key)]),
)
expect(stale.rows).toHaveLength(0)
})
it('keeps validating company-less (lazy-bind) keys', async () => {
const user = await insertAuthUser()
const key = `gnubok_sk_${randomUUID()}`
await getPool().query(
`INSERT INTO public.api_keys (user_id, company_id, key_hash, key_prefix, name, scopes)
VALUES ($1, NULL, $2, 'gnubok_sk_ffffffff', 'unbound', ARRAY['companies:read'])`,
[user, sha256(key)],
)
const res = await runAsServiceRole((client) =>
client.query(`SELECT * FROM public.validate_and_increment_api_key($1)`, [sha256(key)]),
)
expect(res.rows).toHaveLength(1)
})
})
describe('security audit: provider token tables are service_role only (pg)', () => {
async function seedConsentWithToken() {
const { owner, companyId } = await seedCompanyWithAdmin()
const consent = await getPool().query<{ id: string }>(
`INSERT INTO public.provider_consents (company_id, name, status, provider)
VALUES ($1, 'Fortnox', 1, 'fortnox') RETURNING id`,
[companyId],
)
const consentId = consent.rows[0]!.id
await getPool().query(
`INSERT INTO public.provider_consent_tokens (consent_id, provider, access_token, refresh_token, token_expires_at)
VALUES ($1, 'fortnox', 'access-secret', 'refresh-secret', now() + interval '1 hour')`,
[consentId],
)
return { owner, companyId, consentId }
}
it('an unrelated user with a team row can no longer delete anyone tokens', async () => {
const { consentId } = await seedConsentWithToken()
const outsider = await insertAuthUser()
const team = await getPool().query<{ id: string }>(
`INSERT INTO public.teams (name, created_by) VALUES ('Personal', $1) RETURNING id`,
[outsider],
)
await getPool().query(
`INSERT INTO public.team_members (team_id, user_id, role) VALUES ($1, $2, 'owner')`,
[team.rows[0]!.id, outsider],
)
await expect(
withUserContext(outsider, (client) =>
client.query(`DELETE FROM public.provider_consent_tokens WHERE consent_id = $1`, [consentId]),
),
).rejects.toMatchObject({ code: '42501' })
const { rows } = await getPool().query(
`SELECT 1 FROM public.provider_consent_tokens WHERE consent_id = $1`,
[consentId],
)
expect(rows).toHaveLength(1)
})
it('a company member cannot read the plaintext tokens of their own company', async () => {
const { owner, consentId } = await seedConsentWithToken()
await expect(
withUserContext(owner, (client) =>
client.query(`SELECT access_token FROM public.provider_consent_tokens WHERE consent_id = $1`, [
consentId,
]),
),
).rejects.toMatchObject({ code: '42501' })
})
it('the service role still reads and deletes them', async () => {
const { consentId } = await seedConsentWithToken()
const read = await runAsServiceRole((client) =>
client.query(`SELECT access_token FROM public.provider_consent_tokens WHERE consent_id = $1`, [
consentId,
]),
)
expect(read.rows).toHaveLength(1)
const del = await runAsServiceRole((client) =>
client.query(`DELETE FROM public.provider_consent_tokens WHERE consent_id = $1 RETURNING consent_id`, [
consentId,
]),
)
expect(del.rows).toHaveLength(1)
})
})
@@ -0,0 +1,425 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from './setup'
import {
insertAuthUser,
insertBalancedLines,
insertCompany,
insertCompanyMember,
insertDraftJournalEntry,
insertPostedJournalEntry,
seedCompany,
} from './fixtures'
/**
* Migration 20260902093000: the high items of the 2026-09-01 security audit.
*
* A. viewer role cannot write, including through membership-only
* SECURITY DEFINER RPCs (table-level guard).
* B. admin cannot seize the owner seat (company_members.user_id, invitation
* role owner, team_members self-promotion); companies.team_id/archived_at
* are owner-only and team attachment needs team membership.
* C. direct statements cannot insert posted headers, add lines under posted
* headers, or post a draft with a voucher number the sequence never
* issued; the engine's own direct shapes still work.
* D. create_document_version refuses viewers and foreign storage paths;
* validate_version_chain needs membership; leftover grants tightened.
*/
async function seedWithRoles() {
const seeded = await seedCompany()
const admin = await insertAuthUser()
await insertCompanyMember({ companyId: seeded.companyId, userId: admin, role: 'admin' })
const member = await insertAuthUser()
await insertCompanyMember({ companyId: seeded.companyId, userId: member, role: 'member' })
const viewer = await insertAuthUser()
await insertCompanyMember({ companyId: seeded.companyId, userId: viewer, role: 'viewer' })
return { ...seeded, owner: seeded.userId, admin, member, viewer }
}
describe('A. viewer role cannot write (pg)', () => {
it('blocks a viewer inserting into a membership-only table and lets a member through', async () => {
const { companyId, member, viewer } = await seedWithRoles()
await expect(
withUserContext(viewer, (c) =>
c.query(
`INSERT INTO public.employees (company_id, user_id, first_name, last_name, personnummer, personnummer_last4, employment_type, employment_start, employment_degree, salary_type)
VALUES ($1, $2, 'Eva', 'Viewer', '199001011234', '1234', 'employee', '2026-01-01', 100, 'monthly')`,
[companyId, viewer],
),
),
).rejects.toMatchObject({ code: '42501' })
await withUserContext(member, async (c) => {
const res = await c.query(
`INSERT INTO public.employees (company_id, user_id, first_name, last_name, personnummer, personnummer_last4, employment_type, employment_start, employment_degree, salary_type)
VALUES ($1, $2, 'Max', 'Member', '199001011235', '1235', 'employee', '2026-01-01', 100, 'monthly') RETURNING id`,
[companyId, member],
)
expect(res.rows).toHaveLength(1)
})
})
it('blocks a viewer burning voucher numbers through next_voucher_number (SECURITY DEFINER)', async () => {
const { companyId, fiscalPeriodId, member, viewer } = await seedWithRoles()
await expect(
withUserContext(viewer, (c) =>
c.query(`SELECT public.next_voucher_number($1, $2, 'A')`, [companyId, fiscalPeriodId]),
),
).rejects.toMatchObject({ code: '42501' })
await withUserContext(member, async (c) => {
const res = await c.query<{ n: number }>(
`SELECT public.next_voucher_number($1, $2, 'A') AS n`,
[companyId, fiscalPeriodId],
)
expect(res.rows[0]!.n).toBe(1)
})
})
it('blocks a viewer posting through import_sie_journal_entries', async () => {
const { companyId, fiscalPeriodId, viewer } = await seedWithRoles()
await expect(
withUserContext(viewer, (c) =>
c.query(`SELECT public.import_sie_journal_entries($1, $2, $3, $4::jsonb)`, [
companyId,
viewer,
fiscalPeriodId,
JSON.stringify([
{
date: '2026-06-01',
description: 'viewer',
sourceType: 'import',
series: 'A',
lines: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 100 },
],
},
]),
]),
),
).rejects.toMatchObject({ code: '42501' })
})
})
describe('B. admin cannot seize ownership (pg)', () => {
it('refuses re-pointing a membership row to another user', async () => {
const { companyId, owner, admin } = await seedWithRoles()
const accomplice = await insertAuthUser()
await expect(
withUserContext(admin, (c) =>
c.query(`UPDATE public.company_members SET user_id = $1 WHERE company_id = $2 AND user_id = $3`, [
accomplice,
companyId,
owner,
]),
),
).rejects.toMatchObject({ code: '42501' })
})
it('still lets the owner change a member role', async () => {
const { companyId, owner, member } = await seedWithRoles()
await withUserContext(owner, async (c) => {
const res = await c.query(
`UPDATE public.company_members SET role = 'admin' WHERE company_id = $1 AND user_id = $2 RETURNING role`,
[companyId, member],
)
expect(res.rows[0]).toMatchObject({ role: 'admin' })
})
})
it('refuses invitations that grant owner, allows member', async () => {
const { companyId, admin } = await seedWithRoles()
const tokenHash = randomUUID().replace(/-/g, '')
await expect(
withUserContext(admin, (c) =>
c.query(
`INSERT INTO public.company_invitations (company_id, email, role, token_hash, invited_by, expires_at)
VALUES ($1, 'x@example.com', 'owner', $2, $3, now() + interval '7 days')`,
[companyId, tokenHash, admin],
),
),
).rejects.toMatchObject({ code: '42501' })
await withUserContext(admin, async (c) => {
const res = await c.query(
`INSERT INTO public.company_invitations (company_id, email, role, token_hash, invited_by, expires_at)
VALUES ($1, 'y@example.com', 'member', $2, $3, now() + interval '7 days') RETURNING id`,
[companyId, tokenHash + 'b', admin],
)
expect(res.rows).toHaveLength(1)
})
})
it('team admin cannot promote themselves; owner can; first owner self-insert still works', async () => {
const founder = await insertAuthUser()
const team = await getPool().query<{ id: string }>(
`INSERT INTO public.teams (name, created_by) VALUES ('Byrå', $1) RETURNING id`,
[founder],
)
const teamId = team.rows[0]!.id
await getPool().query(`INSERT INTO public.team_members (team_id, user_id, role) VALUES ($1, $2, 'owner')`, [
teamId,
founder,
])
const teamAdmin = await insertAuthUser()
await getPool().query(`INSERT INTO public.team_members (team_id, user_id, role) VALUES ($1, $2, 'admin')`, [
teamId,
teamAdmin,
])
await expect(
withUserContext(teamAdmin, (c) =>
c.query(`UPDATE public.team_members SET role = 'owner' WHERE team_id = $1 AND user_id = $2`, [
teamId,
teamAdmin,
]),
),
).rejects.toMatchObject({ code: '42501' })
await withUserContext(founder, async (c) => {
const res = await c.query(
`UPDATE public.team_members SET role = 'owner' WHERE team_id = $1 AND user_id = $2 RETURNING role`,
[teamId, teamAdmin],
)
expect(res.rows[0]).toMatchObject({ role: 'owner' })
})
// The very first owner of a fresh team is self-inserted by the definer RPC.
const newcomer = await insertAuthUser()
await withUserContext(newcomer, async (c) => {
const res = await c.query<{ id: string }>(`SELECT public.create_team_with_owner('Ny byrå') AS id`)
const mine = await c.query<{ role: string }>(
`SELECT role FROM public.team_members WHERE team_id = $1 AND user_id = $2`,
[res.rows[0]!.id, newcomer],
)
expect(mine.rows[0]).toMatchObject({ role: 'owner' })
})
})
it('companies: admin cannot archive or re-team; owner can only attach own team', async () => {
const { companyId, owner, admin } = await seedWithRoles()
const foreignTeam = await getPool().query<{ id: string }>(
`INSERT INTO public.teams (name, created_by) VALUES ('Other byrå', $1) RETURNING id`,
[await insertAuthUser()],
)
await expect(
withUserContext(admin, (c) =>
c.query(`UPDATE public.companies SET archived_at = now() WHERE id = $1`, [companyId]),
),
).rejects.toMatchObject({ code: '42501' })
await expect(
withUserContext(admin, (c) =>
c.query(`UPDATE public.companies SET team_id = $1 WHERE id = $2`, [foreignTeam.rows[0]!.id, companyId]),
),
).rejects.toMatchObject({ code: '42501' })
await expect(
withUserContext(owner, (c) =>
c.query(`UPDATE public.companies SET team_id = $1 WHERE id = $2`, [foreignTeam.rows[0]!.id, companyId]),
),
).rejects.toMatchObject({ code: '42501' })
const own = await getPool().query<{ id: string }>(
`INSERT INTO public.teams (name, created_by) VALUES ('Egen', $1) RETURNING id`,
[owner],
)
const ownTeam = own.rows[0]!.id
await getPool().query(`INSERT INTO public.team_members (team_id, user_id, role) VALUES ($1, $2, 'owner')`, [
ownTeam,
owner,
])
await withUserContext(owner, async (c) => {
const res = await c.query(`UPDATE public.companies SET team_id = $1 WHERE id = $2 RETURNING team_id`, [
ownTeam,
companyId,
])
expect(res.rows[0]).toMatchObject({ team_id: ownTeam })
})
})
it('companies_insert refuses attaching a new company to a foreign team', async () => {
const user = await insertAuthUser()
const foreignTeam = await getPool().query<{ id: string }>(
`INSERT INTO public.teams (name, created_by) VALUES ('Victim byrå', $1) RETURNING id`,
[await insertAuthUser()],
)
await expect(
withUserContext(user, (c) =>
c.query(
`INSERT INTO public.companies (name, entity_type, created_by, team_id) VALUES ('Bogus AB', 'aktiebolag', $1, $2)`,
[user, foreignTeam.rows[0]!.id],
),
),
).rejects.toMatchObject({ code: '42501' })
})
})
describe('C. posting integrity for direct statements (pg)', () => {
it('refuses a direct posted header insert, accepts a draft', async () => {
const { companyId, fiscalPeriodId, member } = await seedWithRoles()
await expect(
withUserContext(member, (c) =>
c.query(
`INSERT INTO public.journal_entries (user_id, company_id, fiscal_period_id, voucher_number, voucher_series, entry_date, description, source_type, status)
VALUES ($1, $2, $3, 4711, 'A', '2026-06-01', 'direct', 'manual', 'posted')`,
[member, companyId, fiscalPeriodId],
),
),
).rejects.toMatchObject({ code: '42501' })
await withUserContext(member, async (c) => {
const res = await c.query(
`INSERT INTO public.journal_entries (user_id, company_id, fiscal_period_id, voucher_number, voucher_series, entry_date, description, source_type, status)
VALUES ($1, $2, $3, 0, 'A', '2026-06-01', 'draft', 'manual', 'draft') RETURNING id`,
[member, companyId, fiscalPeriodId],
)
expect(res.rows).toHaveLength(1)
})
})
it('refuses adding lines to a posted verifikat from a user session', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const posted = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
await expect(
withUserContext(userId, (c) =>
c.query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', 500, 0)`,
[posted],
),
),
).rejects.toMatchObject({ code: '42501' })
})
it('refuses posting a draft with a voucher number the sequence never issued', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const draft = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 999 })
await insertBalancedLines(draft)
await expect(
withUserContext(userId, (c) =>
c.query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [draft]),
),
).rejects.toMatchObject({ code: '42501' })
})
it('keeps the engine reversal shape: sequence-issued number, lines while draft, then post', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
await withUserContext(userId, async (c) => {
const n = await c.query<{ n: number }>(`SELECT public.next_voucher_number($1, $2, 'A') AS n`, [
companyId,
fiscalPeriodId,
])
const header = await c.query<{ id: string }>(
`INSERT INTO public.journal_entries (user_id, company_id, fiscal_period_id, voucher_number, voucher_series, entry_date, description, source_type, status)
VALUES ($1, $2, $3, $4, 'A', '2026-06-01', 'reversal', 'manual', 'draft') RETURNING id`,
[userId, companyId, fiscalPeriodId, n.rows[0]!.n],
)
const id = header.rows[0]!.id
await c.query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', 100, 0), ($1, '3001', 0, 100)`,
[id],
)
const posted = await c.query(
`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1 RETURNING status`,
[id],
)
expect(posted.rows[0]).toMatchObject({ status: 'posted' })
})
})
it('commit_journal_entry (SECURITY DEFINER) still posts drafts for members', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const draft = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await insertBalancedLines(draft)
await withUserContext(userId, async (c) => {
const res = await c.query<{ n: number }>(`SELECT public.commit_journal_entry($1, $2) AS n`, [
companyId,
draft,
])
expect(res.rows[0]!.n).toBeGreaterThan(0)
})
})
})
describe('D. document RPCs and leftover grants (pg)', () => {
async function seedDocument() {
const { companyId, owner, viewer, member } = await seedWithRoles()
const doc = await getPool().query<{ id: string }>(
`INSERT INTO public.document_attachments
(user_id, company_id, storage_path, file_name, file_size_bytes, mime_type, sha256_hash, version, is_current_version, uploaded_by)
VALUES ($1, $2, $3, 'faktura.pdf', 10, 'application/pdf', repeat('a', 64), 1, true, $1)
RETURNING id`,
[owner, companyId, `documents/${companyId}/${owner}/1.pdf`],
)
return { companyId, owner, viewer, member, docId: doc.rows[0]!.id }
}
it('create_document_version refuses viewers and foreign storage paths', async () => {
const { companyId, viewer, member, docId } = await seedDocument()
await expect(
withUserContext(viewer, (c) =>
c.query(`SELECT public.create_document_version($1, $2, $3, 'v2.pdf', 11, 'application/pdf', repeat('b', 64))`, [
viewer,
docId,
`documents/${companyId}/${viewer}/2.pdf`,
]),
),
).rejects.toMatchObject({ code: '42501' })
await expect(
withUserContext(member, (c) =>
c.query(`SELECT public.create_document_version($1, $2, $3, 'v2.pdf', 11, 'application/pdf', repeat('c', 64))`, [
member,
docId,
`documents/${randomUUID()}/x/2.pdf`,
]),
),
).rejects.toMatchObject({ code: '42501' })
await withUserContext(member, async (c) => {
const res = await c.query<{ id: string }>(
`SELECT public.create_document_version($1, $2, $3, 'v2.pdf', 11, 'application/pdf', repeat('d', 64)) AS id`,
[member, docId, `documents/${companyId}/${member}/2.pdf`],
)
expect(res.rows[0]!.id).toBeTruthy()
})
})
it('validate_version_chain answers not found to non-members and is not anon-callable', async () => {
const { docId, owner } = await seedDocument()
const outsider = await insertAuthUser()
await expect(
withUserContext(outsider, (c) => c.query(`SELECT * FROM public.validate_version_chain($1)`, [docId])),
).rejects.toMatchObject({ code: 'P0001' })
await withUserContext(owner, async (c) => {
const res = await c.query(`SELECT * FROM public.validate_version_chain($1)`, [docId])
expect(res.rows).toHaveLength(1)
})
const { rows } = await getPool().query<{ anon: boolean }>(
`SELECT has_function_privilege('anon', 'public.validate_version_chain(uuid)', 'execute') AS anon`,
)
expect(rows[0]!.anon).toBe(false)
})
it('tightens the leftover grants', async () => {
const { rows } = await getPool().query<{ fn: string; anon: boolean; auth: boolean }>(
`SELECT p.proname AS fn,
has_function_privilege('anon', p.oid, 'execute') AS anon,
has_function_privilege('authenticated', p.oid, 'execute') AS auth
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public'
AND p.proname IN ('match_documents', 'match_booking_templates',
'update_overdue_supplier_invoices', 'redact_expired_invoice_delivery_pii')`,
)
const byName = Object.fromEntries(rows.map((r) => [r.fn, r]))
expect(byName.match_documents!.anon).toBe(false)
expect(byName.match_booking_templates!.anon).toBe(false)
expect(byName.update_overdue_supplier_invoices!.auth).toBe(false)
expect(byName.redact_expired_invoice_delivery_pii!.auth).toBe(false)
const dropped = await getPool().query(
`SELECT 1 FROM pg_proc WHERE proname = 'seed_asset_categories'`,
)
expect(dropped.rows).toHaveLength(0)
})
})