74 lines
3.3 KiB
Markdown
74 lines
3.3 KiB
Markdown
# ADR-0015: Frontends reach the API through their own origin
|
|
|
|
- **Status:** Accepted
|
|
- **Date:** 2026-08-12
|
|
|
|
## Context
|
|
|
|
The refresh token is delivered as an httpOnly cookie (ADR-0008) — that is what
|
|
stops an XSS from stealing the credential that mints new sessions.
|
|
|
|
Cookies are governed by `SameSite`. If the browser calls `api.example.com` from
|
|
`admin.example.com`, that is a cross-site request, and the cookie only rides
|
|
along with `SameSite=None`. `SameSite=None` requires `Secure`, which requires
|
|
HTTPS. Local development runs on plain HTTP, so the cookie would simply never be
|
|
set — presenting as a login that "succeeds" and then immediately forgets you.
|
|
|
|
The alternatives are all worse: put the refresh token in `localStorage` (readable
|
|
by any script, which defeats the entire httpOnly design), run local development
|
|
over self-signed HTTPS (friction on every machine and in CI), or accept that dev
|
|
and production authenticate differently (the class of bug you only find in
|
|
staging).
|
|
|
|
## Decision
|
|
|
|
**The browser always calls the API on the same origin as the page it is on.**
|
|
|
|
- Production: Nginx already routes `/api/` to the API container on the same host
|
|
— this was in the topology from milestone 0.
|
|
- Development: a Next.js `rewrites()` entry maps `/api/:path*` to
|
|
`API_INTERNAL_URL`, reproducing that topology exactly.
|
|
- `browserApi` is therefore created with `baseUrl: ''`, and `HttpClient`
|
|
resolves a relative base against `window.location.origin`.
|
|
|
|
Server-side rendering is unaffected: it calls `API_INTERNAL_URL` directly over
|
|
the internal network, because there is no cookie and no browser involved.
|
|
|
|
The cookie is then first-party, `SameSite=Lax`, `httpOnly`, `Secure` in
|
|
production, and scoped to `path=/api/v1/auth` so it is attached to two endpoints
|
|
rather than every API call.
|
|
|
|
## Consequences
|
|
|
|
Development and production share one authentication topology, so a cookie
|
|
problem is reproducible locally instead of appearing first in staging.
|
|
|
|
CORS effectively disappears for browser traffic — the requests are same-origin.
|
|
The API's CORS config remains for non-browser and tooling access.
|
|
|
|
Storefront and admin get separately named cookies (`sport_refresh`,
|
|
`sport_admin_refresh`). Sharing a name would mean signing into the admin
|
|
silently replaced a customer session in the same browser.
|
|
|
|
The costs, stated plainly:
|
|
|
|
- One extra network hop in development (browser → Next → API). Irrelevant
|
|
locally, and absent in production where Nginx was already the front door.
|
|
- The frontends now have a route namespace (`/api/*`) they must not use for
|
|
their own route handlers. Worth noting in review; neither app has any.
|
|
- `API_INTERNAL_URL` becomes required for the dev rewrite, not just for SSR.
|
|
|
|
## Alternatives considered
|
|
|
|
**Refresh token in `localStorage`.** Removes the cookie problem entirely and
|
|
removes the security property with it — any injected script can read it.
|
|
Rejected outright.
|
|
|
|
**`SameSite=None; Secure` with HTTPS in development.** Correct, but forces every
|
|
developer and CI job to trust a local certificate. Rejected as friction that
|
|
buys nothing production does not already provide.
|
|
|
|
**A dedicated auth subdomain with a parent-domain cookie.** Works in production,
|
|
but `localhost` has no usable parent domain, so development still diverges.
|
|
Rejected for the same reason as above.
|