diff --git a/.env.example b/.env.example index d7c4b0ef..5c87e928 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,13 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-or-secret-key # App base URL (local dev) NEXT_PUBLIC_APP_URL=http://localhost:3000 +# Shared hosted white-label deployments only: exact comma-separated hostnames +# that are registered on this deployment. No wildcards. Invite and auth links +# use a listed request/browser host; every other host falls back to +# NEXT_PUBLIC_APP_URL. Also add each listed host's /auth/callback and /invite/* +# URLs to the Supabase Auth redirect allowlist before deploying it. +# NEXT_PUBLIC_WHITELABEL_DOMAINS=portal.brand-one.example,books.brand-two.example + # Secret for authenticating cron/scheduled requests. # Any non-empty random string for local dev: openssl rand -hex 16 CRON_SECRET=generate-a-random-secret diff --git a/DECISIONS.md b/DECISIONS.md index fe579542..42c6bb4e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2,6 +2,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and humans when a non-obvious choice is made (approach picked over an alternative, dependency declined, action stopped by a CLAUDE.md rule). Read before re-litigating a past decision. +[2026-08-18] Shared-host white-label auth links use an exact NEXT_PUBLIC_WHITELABEL_DOMAINS allowlist and direct per-brand callbacks, with NEXT_PUBLIC_APP_URL as fallback: bouncing recovery through the canonical host would scope the recovery session cookie to that unrelated domain, while exact registered hosts preserve the brand session without trusting arbitrary Host headers or browser origins. [2026-08-18] Bokio connection validation uses the documented GET /v1/companies/{companyId}/company-information contract and treats only 401/403 as credential rejection: the removed bare company path returned 404 for valid credentials, while a 404 from the documented endpoint identifies the company ID and other failures are not evidence that the token is wrong (#1670). [2026-08-13] E-invoice product-truth correction covers the MCP workflow skills and the MCP-exposed swedish-invoice-compliance atom: the atom's "for Accounted e-invoice generation" heading made the same unsupported product claim as issue #1577, so all active guidance now directs external delivery followed by gnubok_mark_invoice_as_sent; Peppol implementation remains tracked in #546. diff --git a/app/(auth)/login/__tests__/password-reset-redirect.test.ts b/app/(auth)/login/__tests__/password-reset-redirect.test.ts new file mode 100644 index 00000000..869afd0d --- /dev/null +++ b/app/(auth)/login/__tests__/password-reset-redirect.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SOURCE = readFileSync( + fileURLToPath(new URL('../login-client.tsx', import.meta.url)), + 'utf8', +) + +describe('password reset redirect wiring', () => { + it('routes the browser origin through the trusted app-origin resolver', () => { + expect(SOURCE).toContain('buildPasswordResetRedirectTo(window.location.origin)') + expect(SOURCE).not.toContain( + '`${window.location.origin}/auth/callback?next=/reset-password`', + ) + }) +}) diff --git a/app/(auth)/login/login-client.tsx b/app/(auth)/login/login-client.tsx index 2d5eaf42..719f84f9 100644 --- a/app/(auth)/login/login-client.tsx +++ b/app/(auth)/login/login-client.tsx @@ -32,6 +32,7 @@ import { consumeInviteCookie, INVITE_PROBLEM_MESSAGE_KEYS, } from '@/lib/auth/consume-invite-cookie' +import { buildPasswordResetRedirectTo } from '@/lib/domains/trusted-app-origin' import { AuthFormError } from '@/components/auth/AuthFormError' import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton' import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth' @@ -326,7 +327,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu try { const { error } = await supabase.auth.resetPasswordForEmail(emailValue, { - redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`, + redirectTo: buildPasswordResetRedirectTo(window.location.origin), }) if (error) { diff --git a/app/api/company/members/invite/__tests__/route.test.ts b/app/api/company/members/invite/__tests__/route.test.ts index acc294e0..bbde3276 100644 --- a/app/api/company/members/invite/__tests__/route.test.ts +++ b/app/api/company/members/invite/__tests__/route.test.ts @@ -46,9 +46,10 @@ vi.mock('@/lib/email/service', () => ({ getEmailService: () => ({ isConfigured: isConfiguredMock, sendEmail: sendEmailMock }), })) +const generateInviteEmailHtmlMock = vi.fn(() => '

html

') vi.mock('@/lib/email/invite-templates', () => ({ generateInviteEmailSubject: () => 'subject', - generateInviteEmailHtml: () => '

html

', + generateInviteEmailHtml: (...args: unknown[]) => generateInviteEmailHtmlMock(...args), generateInviteEmailText: () => 'text', })) @@ -56,17 +57,22 @@ import { POST } from '../route' const routeParams = { params: Promise.resolve({}) } -function post(body: unknown) { +function post(body: unknown, url = '/api/company/members/invite') { return POST( - createMockRequest('/api/company/members/invite', { method: 'POST', body }), + createMockRequest(url, { method: 'POST', body }), routeParams, ) } +const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL +const originalWhiteLabelDomains = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + beforeEach(() => { vi.clearAllMocks() reset() delete process.env.AUTH_SIGNUPS_DISABLED + process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test' + delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS requireAuthMock.mockResolvedValue({ user: { id: 'user-1', email: 'owner@example.com' }, supabase: {}, @@ -80,6 +86,14 @@ beforeEach(() => { afterEach(() => { delete process.env.AUTH_SIGNUPS_DISABLED + if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL + else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl + + if (originalWhiteLabelDomains === undefined) { + delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + } else { + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = originalWhiteLabelDomains + } }) describe('POST /api/company/members/invite', () => { @@ -152,6 +166,52 @@ describe('POST /api/company/members/invite', () => { expect(body.data.status).toBe('pending') expect(body.data.email_sent).toBe(false) }) + + it('uses a registered white-label request host in the invitation email', async () => { + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' + enqueue({ data: { role: 'owner' } }) + enqueue({ data: [] }) + enqueue({ data: null }) + enqueue({ data: { name: 'Acme AB' } }) + enqueue({ data: null }) + + const { status } = await parseJsonResponse( + await post( + { email: 'client@example.com' }, + 'https://portal.brand.test/api/company/members/invite', + ), + ) + + expect(status).toBe(200) + expect(generateInviteEmailHtmlMock).toHaveBeenCalledWith( + expect.objectContaining({ + inviteUrl: 'https://portal.brand.test/invite/tok-plain', + }), + ) + }) + + it('falls back to the canonical app for an untrusted spoofed request host', async () => { + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' + enqueue({ data: { role: 'owner' } }) + enqueue({ data: [] }) + enqueue({ data: null }) + enqueue({ data: { name: 'Acme AB' } }) + enqueue({ data: null }) + + const { status } = await parseJsonResponse( + await post( + { email: 'client@example.com' }, + 'https://portal.brand.test.attacker.test/api/company/members/invite', + ), + ) + + expect(status).toBe(200) + expect(generateInviteEmailHtmlMock).toHaveBeenCalledWith( + expect.objectContaining({ + inviteUrl: 'https://app.accounted.test/invite/tok-plain', + }), + ) + }) }) describe('POST /api/company/members/invite: AUTH_SIGNUPS_DISABLED provisioning', () => { @@ -220,6 +280,29 @@ describe('POST /api/company/members/invite: AUTH_SIGNUPS_DISABLED provisioning', expect(body.data.email_sent).toBe(true) }) + it('uses a registered white-label request host for the GoTrue invite redirect', async () => { + process.env.AUTH_SIGNUPS_DISABLED = 'true' + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' + enqueue({ data: { role: 'owner' } }) + enqueue({ data: [] }) + enqueue({ data: null }) + enqueue({ data: { name: 'Acme AB' } }) + enqueue({ data: false }) + enqueue({ data: null }) + + const { status } = await parseJsonResponse( + await post( + { email: 'client@example.com' }, + 'https://portal.brand.test/api/company/members/invite', + ), + ) + + expect(status).toBe(200) + expect(inviteUserByEmailMock).toHaveBeenCalledWith('client@example.com', { + redirectTo: 'https://portal.brand.test/invite/tok-plain', + }) + }) + it('flag on + provisioning fails: surfaces a Swedish error, sends nothing, logs a masked address', async () => { process.env.AUTH_SIGNUPS_DISABLED = 'true' enqueue({ data: { role: 'owner' } }) // caller membership diff --git a/app/api/company/members/invite/route.ts b/app/api/company/members/invite/route.ts index 4e390609..dfc9f735 100644 --- a/app/api/company/members/invite/route.ts +++ b/app/api/company/members/invite/route.ts @@ -12,6 +12,7 @@ import { generateInviteEmailHtml, generateInviteEmailText, } from '@/lib/email/invite-templates' +import { resolveRequestAppOrigin } from '@/lib/domains/trusted-app-origin' // Loads the email extension so getEmailService() returns the Resend // implementation instead of the noop default. Without this, the invite email @@ -111,7 +112,11 @@ export const POST = withRouteContext( const { token, hash } = generateInviteToken() const expiresAt = getInviteExpiry() - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + // The request host is used only when it is the canonical app host or an + // exact registered white-label domain. A spoofed Host header falls back to + // NEXT_PUBLIC_APP_URL, so neither the email nor GoTrue gets an open + // redirect target. + const appOrigin = resolveRequestAppOrigin(request) // Self-hosted installations that turn public signup off in GoTrue // (disable_signup) set AUTH_SIGNUPS_DISABLED=true to mirror that config: @@ -145,7 +150,7 @@ export const POST = withRouteContext( // set-password surface first. const { error: provisionError } = await serviceClient.auth.admin.inviteUserByEmail( email, - { redirectTo: `${appUrl}/invite/${token}` }, + { redirectTo: `${appOrigin}/invite/${token}` }, ) if (provisionError) { @@ -211,7 +216,7 @@ export const POST = withRouteContext( const emailService = getEmailService() let emailSent = false if (emailService.isConfigured()) { - const inviteUrl = `${appUrl}/invite/${token}` + const inviteUrl = `${appOrigin}/invite/${token}` const emailData = { companyName: company?.name || 'Företag', @@ -238,7 +243,7 @@ export const POST = withRouteContext( // In development, return the invite URL directly (no email service) const isDev = process.env.NODE_ENV === 'development' - const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined + const devInviteUrl = isDev ? `${appOrigin}/invite/${token}` : undefined return NextResponse.json({ data: { diff --git a/docs/WHITELABEL.md b/docs/WHITELABEL.md index 6f9530de..f63dd27a 100644 --- a/docs/WHITELABEL.md +++ b/docs/WHITELABEL.md @@ -41,6 +41,7 @@ All branding can be set via env vars. Public ones use `NEXT_PUBLIC_BRANDING_*` ( | `BRANDING_SECURITY_EMAIL` | `securityEmail` | `security@arcim.io` | | `NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM` | `authEmailFrom`: From address Supabase Auth sends verification / reset emails from. Used to pre-populate the `from:` query on the "open in Gmail" button after signup. Set to whatever you configured in your Supabase Auth SMTP. | `noreply@gnubok.se` | | `NEXT_PUBLIC_APP_URL` | `appUrl` | `https://app.gnubok.se` | +| `NEXT_PUBLIC_WHITELABEL_DOMAINS` | Exact comma-separated hostnames served by the same hosted deployment. No wildcards. Invite and auth redirects use a listed host and otherwise fall back to `NEXT_PUBLIC_APP_URL`. | `` | | `NEXT_PUBLIC_BRANDING_LOGO_PATH` | `logoPath` | `/gnubokiceon-removebg-preview.png` | | `NEXT_PUBLIC_BRANDING_FAVICON_PATH` | `faviconPath` | `/favicon.ico` | | `NEXT_PUBLIC_BRANDING_APPLE_ICON_PATH` | `appleTouchIconPath` | `/icons/icon-192.png` | @@ -101,9 +102,21 @@ A few things that look brand-related but are configured elsewhere: - **DNS / domain**: point `app.your-brand.se` at your Vercel deployment. - **OAuth redirect allowlist for MCP**: `app/api/mcp-oauth/authorize/route.ts` lists `claude.ai/api/*`, `claude.com/api/*`, and localhost. Your domain is the OAuth issuer, not a redirect target: no change needed unless you're integrating with new MCP clients. - **iCal feed PRODID** (`lib/calendar/ics-generator.ts`): defaults to `erp-base.se`, callers may pass their domain. -- **`NEXT_PUBLIC_APP_URL`**: used as the OAuth issuer. Set this to your domain (e.g. `https://app.your-brand.se`). +- **`NEXT_PUBLIC_APP_URL`**: used as the OAuth issuer and safe auth-link fallback. For a dedicated one-brand deployment, set this to your domain (e.g. `https://app.your-brand.se`). For a shared hosted deployment, keep the canonical main app URL here and register additional hosts through `NEXT_PUBLIC_WHITELABEL_DOMAINS`. - **Skatteverket submission identity**: `extensions/general/skatteverket/lib/api-client.ts` does not set a custom `User-Agent`; submissions go out with the Node/Vercel runtime default. If your deployment needs to identify itself to Skatteverket under a different brand, that's a future enhancement (env var + header), not something the current branding service covers. +## Shared hosted deployment with custom domains + +Use this checklist when several white-label domains point at one hosted Accounted deployment: + +1. Register the exact custom hostname on the hosting deployment and finish its DNS verification. +2. Add that hostname to the comma-separated `NEXT_PUBLIC_WHITELABEL_DOMAINS` value. Entries are exact hostnames such as `portal.partner.se`; wildcard entries are ignored. +3. Add `https://portal.partner.se/auth/callback` and `https://portal.partner.se/invite/*` to the Supabase Auth Redirect URLs allowlist. Keep the canonical `NEXT_PUBLIC_APP_URL` callback there too. +4. Redeploy after changing the environment variable. It is public build-time configuration because the browser must validate password-reset callbacks before calling GoTrue. +5. Test a new-user invitation, an existing-user invitation, and a password reset from the custom domain. + +The request `Host` header and `window.location.origin` are inputs, not trust anchors. Accounted uses them only when the hostname exactly matches the canonical app host or a configured white-label hostname. Unknown or spoofed hosts fall back to `NEXT_PUBLIC_APP_URL`, so they cannot become invite links or GoTrue redirect targets. + ## Staying in sync with upstream Add this workflow at `.github/workflows/sync-upstream.yml` to your fork. It runs weekly and opens a PR with upstream changes: diff --git a/lib/domains/__tests__/trusted-app-origin.test.ts b/lib/domains/__tests__/trusted-app-origin.test.ts new file mode 100644 index 00000000..574536a8 --- /dev/null +++ b/lib/domains/__tests__/trusted-app-origin.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + buildPasswordResetRedirectTo, + getCanonicalAppOrigin, + resolveRequestAppOrigin, + resolveTrustedAppOrigin, +} from '../trusted-app-origin' + +const ORIGINAL_APP_URL = process.env.NEXT_PUBLIC_APP_URL +const ORIGINAL_WHITELABEL_DOMAINS = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + +describe('trusted application origins', () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test' + delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + }) + + afterEach(() => { + if (ORIGINAL_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL + else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_APP_URL + + if (ORIGINAL_WHITELABEL_DOMAINS === undefined) { + delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + } else { + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = ORIGINAL_WHITELABEL_DOMAINS + } + }) + + it('uses an exact registered white-label host over HTTPS', () => { + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test, books.partner.test' + + expect(resolveTrustedAppOrigin('https://portal.brand.test')).toBe( + 'https://portal.brand.test', + ) + expect(resolveTrustedAppOrigin('PORTAL.BRAND.TEST.')).toBe( + 'https://portal.brand.test', + ) + }) + + it('rejects spoofed, credential, wildcard, and non-default-port hosts', () => { + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test,*.wildcard.test' + + for (const candidate of [ + 'https://portal.brand.test.attacker.test', + 'https://portal.brand.test@attacker.test', + 'https://child.wildcard.test', + 'https://portal.brand.test:444', + ]) { + expect(resolveTrustedAppOrigin(candidate), candidate).toBe( + 'https://app.accounted.test', + ) + } + }) + + it('falls back to the canonical origin when the request host is not registered', () => { + expect(resolveTrustedAppOrigin('https://unregistered.test')).toBe( + 'https://app.accounted.test', + ) + expect(resolveTrustedAppOrigin(null)).toBe('https://app.accounted.test') + }) + + it('normalises the canonical URL to its origin and has a local safe fallback', () => { + process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test/base?ignored=yes' + expect(getCanonicalAppOrigin()).toBe('https://app.accounted.test') + + process.env.NEXT_PUBLIC_APP_URL = 'javascript:alert(1)' + expect(getCanonicalAppOrigin()).toBe('http://localhost:3000') + }) + + it('validates the request URL and ignores a spoofed forwarded host', () => { + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' + + const trusted = new Request('https://portal.brand.test/api/company/members/invite', { + headers: { 'x-forwarded-host': 'attacker.test' }, + }) + const spoofed = new Request('https://attacker.test/api/company/members/invite', { + headers: { 'x-forwarded-host': 'portal.brand.test' }, + }) + + expect(resolveRequestAppOrigin(trusted)).toBe('https://portal.brand.test') + expect(resolveRequestAppOrigin(spoofed)).toBe('https://app.accounted.test') + }) +}) + +describe('password reset callback', () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test' + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' + }) + + afterEach(() => { + if (ORIGINAL_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL + else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_APP_URL + + if (ORIGINAL_WHITELABEL_DOMAINS === undefined) { + delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + } else { + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = ORIGINAL_WHITELABEL_DOMAINS + } + }) + + it('keeps a registered brand callback on the brand domain', () => { + expect(buildPasswordResetRedirectTo('https://portal.brand.test')).toBe( + 'https://portal.brand.test/auth/callback?next=/reset-password', + ) + }) + + it('uses the allowlisted canonical callback for an unknown browser origin', () => { + expect(buildPasswordResetRedirectTo('https://attacker.test')).toBe( + 'https://app.accounted.test/auth/callback?next=/reset-password', + ) + }) +}) diff --git a/lib/domains/trusted-app-origin.ts b/lib/domains/trusted-app-origin.ts new file mode 100644 index 00000000..080232db --- /dev/null +++ b/lib/domains/trusted-app-origin.ts @@ -0,0 +1,113 @@ +const LOCAL_APP_ORIGIN = 'http://localhost:3000' + +interface ParsedHost { + hostname: string + port: string +} + +function normalizeHostname(hostname: string): string { + return hostname.toLowerCase().replace(/\.$/, '') +} + +function parseHttpOrigin(value: string | undefined): URL | null { + if (!value) return null + + try { + const url = new URL(value) + if (!['http:', 'https:'].includes(url.protocol)) return null + if (url.username || url.password) return null + return url + } catch { + return null + } +} + +function parseHost(value: string | null | undefined): ParsedHost | null { + if (!value) return null + + const trimmed = value.trim() + if (!trimmed) return null + + try { + const url = parseHttpOrigin( + trimmed.includes('://') ? trimmed : `https://${trimmed}`, + ) + if (!url) return null + if (url.pathname !== '/' || url.search || url.hash) return null + + return { + hostname: normalizeHostname(url.hostname), + port: url.port, + } + } catch { + return null + } +} + +function registeredWhiteLabelHosts(): Set { + const configured = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + if (!configured) return new Set() + + const hosts = configured + .split(',') + .map((value) => parseHost(value)) + .filter((value): value is ParsedHost => value !== null && value.port === '') + .map(({ hostname }) => hostname) + + return new Set(hosts) +} + +/** + * Return the configured canonical application origin. + * + * Paths, queries, and fragments in NEXT_PUBLIC_APP_URL are deliberately + * discarded so callers cannot accidentally append auth paths below them. + */ +export function getCanonicalAppOrigin(): string { + const configured = parseHttpOrigin(process.env.NEXT_PUBLIC_APP_URL) + return configured?.origin ?? LOCAL_APP_ORIGIN +} + +/** + * Resolve a browser origin or request host to an application origin. + * + * The canonical app host is always trusted. Additional hosts must be exact + * entries in NEXT_PUBLIC_WHITELABEL_DOMAINS. Wildcards and suffix matching are + * intentionally unsupported: auth links may never follow an attacker-chosen + * Host header. Registered white-label domains are always upgraded to HTTPS. + */ +export function resolveTrustedAppOrigin(candidate: string | null | undefined): string { + const canonicalOrigin = getCanonicalAppOrigin() + const canonical = new URL(canonicalOrigin) + const parsed = parseHost(candidate) + + if (!parsed) return canonicalOrigin + + if (parsed.hostname === normalizeHostname(canonical.hostname)) { + return canonicalOrigin + } + + if (!registeredWhiteLabelHosts().has(parsed.hostname)) { + return canonicalOrigin + } + + // A non-default port is not a registered hosted domain, even when its + // hostname matches. URL normalisation represents :443 as an empty port. + if (parsed.port !== '') return canonicalOrigin + + return `https://${parsed.hostname}` +} + +/** Resolve an API request to a trusted application origin. */ +export function resolveRequestAppOrigin(request: Request): string { + const requestOrigin = parseHttpOrigin(request.url)?.origin + return resolveTrustedAppOrigin(requestOrigin) +} + +/** + * Build a GoTrue password recovery callback on a registered application host. + * Unknown browser origins fall back to the canonical application URL. + */ +export function buildPasswordResetRedirectTo(browserOrigin: string): string { + return `${resolveTrustedAppOrigin(browserOrigin)}/auth/callback?next=/reset-password` +}