PZB-1113: Use browser origin for register and reset instead of hardcoded origin - #5235
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the user-service auth flows so the callbackUrl sent to the authorization service is derived dynamically from the current browser origin, enabling seamless domain cutover and correct post-email redirects across environments.
Changes:
- Replaced the hardcoded
CALLBACK_URLwith agetCallbackUrl()helper based onwindow.location.origin. - Applied the dynamic callback URL to both
registerandresetPasswordrequests. - Added Jest coverage asserting the emitted
callbackUrlreflects the current browser origin.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| services/user.js | Switches callback URL generation to runtime browser origin and uses it for sign-up/reset requests. |
| services/tests/user.spec.js | Adds unit tests to validate callbackUrl generation for register and resetPassword. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const isServer = typeof window === 'undefined'; | ||
|
|
||
| const CALLBACK_URL = 'https://www.globalforestwatch.org/my-gfw/'; | ||
| const getCallbackUrl = () => `${window.location.origin}/my-gfw/`; |
There was a problem hiding this comment.
Why the isServer guard is unnecessary for register / resetPassword
Companion note to the "dynamic callback URL" PR. Explains why removing/omitting the isServer branch around getCallbackUrl() is safe, and links to authoritative React sources so newer devs can build the mental model.
One-line summary
The only path to register / resetPassword is <form onSubmit>, and React does not attach onSubmit to any DOM until hydrateRoot runs in the browser — so window.location.origin is guaranteed to exist at the moment getCallbackUrl() executes.
The concrete call chain
Traced from services/user.js outward:
services/user.js— exportsregister/resetPassword.components/forms/login/actions.js:26, 40— wraps them in redux thunksregisterUser/resetUserPassword.components/forms/login/component.jsx:110—<Form onSubmit={submitFunc}>(react-final-form), wheresubmitFunc = registerUser | resetUserPassword.components/forms/login/component.jsx:173—<form className="c-login-form" onSubmit={handleSubmit}>.
That native <form>'s submit event is the only code path that reaches our service functions. Verified by grep: only actions.js imports them, and only the LoginForm uses those thunks.
Why a DOM submit event guarantees a browser context
Three things must all be true for our code to run, and none of them exist on the server:
| Requirement | Server-side render | Browser |
|---|---|---|
A real DOM <form> element to dispatch submit |
❌ output is an HTML string | ✅ |
React on* handlers attached to that element |
❌ stripped from SSR output | ✅ attached at hydration |
| A user (or script) to trigger the event | ❌ SSR is synchronous; no event loop | ✅ |
React makes this explicit — event handlers are not attached during renderToString; they're attached only when hydrateRoot runs in the browser.
Authoritative React sources
Read these in order — they're short and directly address the question:
-
renderToString(server) — https://react.dev/reference/react-dom/server/renderToString"It only calls your components. It does not run Effects or read data. ... In the produced HTML, only the minimal HTML necessary to render the initial UI is included. Event handlers are not attached."
-
hydrateRoot(client) — https://react.dev/reference/react-dom/client/hydrateRoot"
hydrateRootlets you display React components inside a browser DOM node whose HTML content was previously generated byreact-dom/server. React will attach to the existing HTML ... React will attach event handlers to the HTML that you pass to hydrateRoot." -
react-dom/serveroverview — https://react.dev/reference/react-dom/server
Confirms all server APIs "render React components to HTML" — none of them execute event handlers. -
Next.js rendering model (this app is Pages Router) — https://nextjs.org/docs/pages/building-your-application/rendering/server-side-rendering
Server functions likegetServerSidePropsand the SSR pass produce HTML; interactivity comes from the client-side JS bundle after hydration. -
React source (if you want to see it in code) —
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.jsin the React repo skips props matchingon*when writing server HTML: https://github.com/facebook/react/tree/main/packages/react-dom-bindings/src/server
Where the isServer guard does still make sense in services/user.js
It's not that isServer is useless everywhere in this file — it's specifically unnecessary in register / resetPassword because their entry points are DOM event handlers. Contrast with:
setUserToken(line 15) — could theoretically be called from a code path that runs at module import or during SSR, and it toucheslocalStorage. The guard is genuinely load-bearing there.- Module top-level code in
utils/request.js— runs on both server and client at import time, so theisServerbranching onbaseURLis necessary.
Mental model
Guard against window / localStorage / document when the function can be called from module-init or a lifecycle path, e.g.:
- import-time side effects
getServerSideProps/getStaticProps- class constructors
- top-level
useStateinitializers that read storage useLayoutEffectbodies during SSR-adjacent rendering
Skip the guard when the function is only reachable via a user-driven DOM event — those simply cannot fire before hydration.
dmannarino
left a comment
There was a problem hiding this comment.
Nice explanation, thanks for writing a test! As long as you're satisfied that the lack of URL encoding is okay (looks like no behavior change there), I approve!
Summary
Replaces the hardcoded
CALLBACK_URLinservices/user.jswith a value derived fromwindow.location.originat call time. ThecallbackUrlwe send to the authorization microservice on sign-up and password-reset is now whichever host the user's browser is actually on.Why
We're cutting over from
globalforestwatch.orgtoglobalnaturewatch.orgnext week. The old constant —— pinned the value we hand to the auth service on every request. Since that value is persisted onto the Okta user's profile as the
originattribute (see below), every new account created and every password reset issued would keep steering users back to the legacy domain regardless of where they actually signed up.With this change, we can deploy to production today and the DNS cutover becomes a no-op for auth flows. No coordinated release, no follow-up hotfix, no window where new accounts silently break. When DNS flips,
window.location.originflips with it, the value stored on the Okta user profile flips with it, and the post-email redirect resolves to whichever host the user started from — production, staging, preview builds, and the newglobalnaturewatch.orgdomain alike.What changed
services/user.js:CALLBACK_URLconstant →getCallbackUrl()helper returning`${window.location.origin}/my-gfw/`.registerandresetPassword.services/__tests__/user.spec.js: new spec verifying both endpoints emit acallbackUrlmatching the current browser origin.How the callback actually flows
Verified against
resource-watch/authorization— specificallysetCallbackUrlmiddleware (auth.router.ts), andsignUp/sendResetMailinokta.provider.ts.The
callbackUrlwe send is not embedded directly into the confirmation/reset email. Instead the auth service stores it as theorigincustom attribute on the Okta user profile, and later reads it back to redirect the browser after the user clicks the email link.sequenceDiagram autonumber actor U as User participant B as Browser<br/>(GFW client JS) participant GFW as GFW Server<br/>(Next.js /api/gfw proxy) participant AUTH as authorization svc<br/>(api.resourcewatch.org) participant Okta as Okta participant Mail as Email inbox U->>B: Submits register / reset form on https://HOST Note over B: getCallbackUrl() runs here — window.location.origin<br/>is a browser API, only defined in the browser B->>GFW: POST /api/gfw/auth/sign-up?callbackUrl=https://HOST/my-gfw/ Note over GFW: pages/api/gfw/[...params].js<br/>next-http-proxy-middleware forwards to GFW_API GFW->>AUTH: POST /auth/sign-up?callbackUrl=https://HOST/my-gfw/ Note over AUTH: setCallbackUrl middleware<br/>stashes value in ctx.session.callbackUrl AUTH->>Okta: createUser / updateUserProtectedFields<br/>{ origin: "https://HOST/my-gfw/" } Okta->>Mail: Send Okta-templated activation / recovery email<br/>(link points at Okta → auth svc, NOT at HOST directly) U->>Mail: Opens email, clicks link Mail->>AUTH: GET /auth/sign-up-redirect?email=…<br/>(or Okta recovery landing) AUTH->>Okta: getOktaUserByEmail(email) Okta-->>AUTH: user.profile.origin = "https://HOST/my-gfw/" AUTH-->>B: 302 redirect → https://HOST/my-gfw/?token=… B-->>U: Lands on /my-gfw/ on the same HOST they started from, signed inTwo consequences worth noting:
sendResetMailrefreshes the storedoriginevery time a reset is requested (okta.provider.tsline 849). So even users who registered on the legacy domain will get theiroriginupdated the next time they hit "forgot password" from the new domain.origin. If we want zero fallout there we can trigger a resend from the new host, or leave a temporary redirect on the legacy hostname for/my-gfw/*.Safety
register/resetPasswordare only invoked from theLoginFormsubmit handler (components/forms/login/actions.js), which runs client-side after mount —windowis always defined at call time.loginitself does not usecallbackUrland is untouched.Test plan
Automated
jest services/__tests__/user.spec.js— 2/2 passing (register + resetPassword each assert callbackUrl matcheswindow.location.origin).eslint services/user.js services/__tests__/user.spec.js— clean.Manual — sign-up (per environment: localhost, then the Heroku review app for this PR)
ctx.session.callbackUrl).http://localhost:3000, then repeat against this PR's Heroku review app URL (e.g.https://gfw-pr-<N>.herokuapp.com).sign-up.gfw-pr-verify-<timestamp>@yopmail.com.POSTto/api/gfw/auth/sign-up(our Next.js proxy atpages/api/gfw/[...params].js, which forwards toapi.resourcewatch.org/auth/sign-up), confirm the query string contains the host you're on — e.g.callbackUrl=http://localhost:3000/my-gfw/on localhost andcallbackUrl=https://gfw-pr-<N>.herokuapp.com/my-gfw/on the review app — i.e. the host matches the URL bar, not a hardcoded value.https://yopmail.com/, enter the inbox name, open the activation email.origin.)/my-gfw/on the same host you registered from and the account is activated.Manual — password reset
reset-passwordinstead ofsign-up.Cross-host verification (proves the dynamic behavior)
Register once from
http://localhost:3000with one yopmail address, then register again from this PR's Heroku review app (https://gfw-pr-<N>.herokuapp.com) with a different yopmail address. Confirm both:callbackUrlquery param reflects the host of that run (localhost vs. the review app), andwith no code change between the two runs.
Okta admin console — verify the
originattribute plumbingBecause the callback is persisted on the Okta user profile, the piece to verify in Okta is the custom profile attribute and the resulting stored value — not a redirect allow-list.
originexists and is writable by the API. This is whatOktaService.createUserWithoutPassword/updateUserProtectedFieldstargets.originis set to the exacthttps://HOST/my-gfw/you registered from (scheme + host + path, no extra slashes).originshould now reflect the new host, provingsendResetMail's update path.originto drive the final destination.