fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS (#2376)
* fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS Password reset, invite, email change and signup links now resolve the request host against brands.domain server-side. The env var was a second copy of that registry compiled into the browser; every new brand needed the row, the env var, the GoTrue allowlist and a redeploy, and two partners shipped with the env var stale, so their reset mails went out canonical-branded to the canonical host. - New POST /api/auth/password-reset: the login page no longer calls GoTrue directly, so the browser carries no domain list. - lib/domains/trusted-app-origin.ts is async and registry-backed; it also trusts this deployment's own VERCEL_URL / VERCEL_BRANCH_URL so previews keep sending links to themselves. - Signup shares the same resolver instead of following the raw host. - Docs and .env.example describe the single registry; GoTrue keeps the redirect allowlist as backstop (hosted: *.accounted.se wildcard). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx * fix(auth): await the async origin resolver in the billing routes merged from main PR #2370 added resolveRequestAppOrigin callers in billing/checkout and billing/portal after this branch made the resolver async. Await them and move their tests from the removed env var to the brands mock; update the login source-assert test to the server-routed reset. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx * fix(auth): refuse auth links on a failed brand lookup, keep local dev hosts, correct GoTrue allowlist docs Skeptic and CI findings on #2376, one pass: - A failed brands lookup now throws BrandLookupFailedError (TRANSIENT_ERROR, 503, retryable) instead of falling back to the canonical origin: a canonical link is the wrong-brand mail this PR removes. Password reset and email change answer 503 themselves; withRouteContext routes map the code. - A local canonical (dev) trusts other local hosts and ports on the same scheme, so lane servers on 3001-3003 confirm signups on themselves. - GoTrue matches the full redirect_to including the query and `*` stops at `.` and `/`: docs and decision line now prescribe https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/**. - The Turnstile contract test asserts the server-routed reset forwards the captcha token (it still asserted the removed browser call). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
7a30f623ba
commit
d29a5bda14
+6
-8
@@ -41,7 +41,6 @@ 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`. Enable Banking consent callbacks started from a listed host return to it; an unlisted host is sent to the canonical login instead. | `` |
|
||||
| `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` |
|
||||
@@ -102,7 +101,7 @@ 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**: `lib/auth/oauth-allowlist.ts` has built-in entries for Claude (`claude.ai/api/*`, `claude.com/api/*`), ChatGPT, Grok, Cursor and localhost; anything else is registered per user under Settings > API & MCP > OAuth clients. 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 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`.
|
||||
- **`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; additional hosts are the `brands.domain` rows (see below).
|
||||
- **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
|
||||
@@ -113,17 +112,16 @@ Accounted's hosted product serves its customers from the `accounted.se` zone: `a
|
||||
|
||||
Because the rule is stated as "this namespace belongs to the production project", a newly launched `<brand>.accounted.se` host is protected as soon as it resolves. There is no list to remember to update. The first version of the guard did the opposite: it enumerated seven approved hostnames, and on 2026-08-26 it failed open on `improveone.accounted.se`, a customer host nobody had added, which a feature-branch preview served from the staging project for hours. Vercel preview domains (`*.vercel.app`) and local development names stay out of scope. A host inside the namespace that is deliberately not production has to be excluded explicitly in `lib/domains/production-white-label-backend.ts`, in the same change that creates it.
|
||||
|
||||
Two kinds of host are not derivable from the namespace, so they are still classified by hand in `CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS` (`lib/domains/production-white-label-backend.ts`): Accounted's legacy canonical host `app.gnubok.se`, and a customer that brings its own domain (step 1 below). Add those as part of the same reviewed rollout. The set also still lists the `accounted.se` hosts the namespace rule already covers: there they are a checked-in inventory the tests pin host by host, not what makes those hosts protected. Do not derive the set from `NEXT_PUBLIC_WHITELABEL_DOMAINS`: that variable is an auth callback allowlist, not an authoritative production inventory, and it can also contain demo, pilot, or self-hosted domains.
|
||||
Two kinds of host are not derivable from the namespace, so they are still classified by hand in `CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS` (`lib/domains/production-white-label-backend.ts`): Accounted's legacy canonical host `app.gnubok.se`, and a customer that brings its own domain (step 1 below). Add those as part of the same reviewed rollout. The set also still lists the `accounted.se` hosts the namespace rule already covers: there they are a checked-in inventory the tests pin host by host, not what makes those hosts protected. Do not derive the set from the `brands` table: that is the auth-link registry, not an authoritative production inventory, and it can also hold demo, pilot, or staging-only brands.
|
||||
|
||||
The guard contains a misrouted deployment. It does not classify domains outside the hosted namespace, prove cross-tenant isolation, or replace the operational work of placing customer environments under production ownership and controls. Its `alert: true` flag also pages nobody on its own: middleware never registers the observability sink, so the alerting rule is configured on the hosting side and matches `operation=white_label_backend_guard` in the emitted log line.
|
||||
|
||||
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.
|
||||
2. Create the brand row with that hostname as `brands.domain` (exact hostname such as `portal.partner.se`, no wildcards). This row is the only application-side registry: password reset, invite, email change and signup links, Stripe billing return URLs and Enable Banking consent callbacks all resolve the request host against it (`lib/domains/trusted-app-origin.ts`), so no environment variable and no redeploy is involved. An Enable Banking consent started from an unregistered host returns to the canonical login instead.
|
||||
3. Make sure GoTrue accepts the callback. GoTrue matches allowlist entries against the FULL `redirect_to`, query string included, and a single `*` stops at `.` and `/`, so an entry must end in `**` to accept `/auth/callback?next=/reset-password` or `?flow=email_change`. Hosts under `accounted.se` are covered by the two wildcard entries `https://*.accounted.se/auth/callback**` and `https://*.accounted.se/invite/**` in the Supabase Auth Redirect URLs allowlist. A partner that brings its own domain needs `https://portal.partner.se/auth/callback**` and `https://portal.partner.se/invite/**` added there explicitly. Keep the canonical `NEXT_PUBLIC_APP_URL` callback there too. A host missing from this allowlist is the one remaining way to get a canonical-branded mail: GoTrue silently replaces an unlisted `redirect_to` with the Site URL before the Send Email hook runs.
|
||||
4. Test a new-user invitation, an existing-user invitation, a password reset (signed out and signed in), and an email change from the custom domain, and check the landing domain of each real mail.
|
||||
|
||||
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.
|
||||
The request `Host` header is an input, not a trust anchor. Accounted uses it only when the hostname exactly matches the canonical app host, one of the deployment's own Vercel hostnames, or a registered `brands.domain`. 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user