Compare commits

...
2 Commits
Author SHA1 Message Date
bermudalamb 843c51dd91 Merge pull request 'feat(auth): offer Google sign-in on the login form (#345)' (#352) from feature/345-google-button into main
Linting / lint (push) Successful in 2m49s
SonarQube Analysis / sonarqube (push) Failing after 30m25s
Reviewed-on: #352
2026-09-10 16:47:29 -05:00
synAdminandClaude Opus 5 2c6ac4d2be feat(auth): offer Google sign-in on the login form (#345)
Linting / lint (pull_request) Successful in 3m49s
SonarQube Analysis / sonarqube (pull_request) Failing after 30m35s
The last of the six, and the first a customer can see. The auth form is the single sign-in implementation rendered by both the route modal and the cart prompt, so the button goes in one place and appears in both.

Below the passkey button, which is below the password form. The order is deliberate and it is not about preference: a passkey is already on the device in front of the customer, while Google is a round trip to somebody else's site, and passwords are how every existing customer signs in. Each step down that list asks more of the person using it.

Absent rather than disabled where it is not configured, which is the same call #41 made for a browser without WebAuthn. It matters more here, because being unconfigured is the normal state rather than the exception: local development has no credentials, and QA cannot have any until #313. The storefront advertises a boolean through the existing public config, never the client id — the browser has no use for one, since the whole flow is a redirect the server builds.

Google's mark is inlined as SVG with their published colours and geometry. A hand-drawn approximation of somebody else's trademark is a compliance problem rather than a style choice, and a second origin on the sign-in path is a second thing that can be down.

The button is a navigation rather than a fetch, which makes it unlike every other control on that form. The flow leaves the application entirely, so there is no promise to await and no error to catch — the callback decides and redirects.

Where to return to is supplied by the caller, because only the caller knows. The route modal renders over a backdrop location and its own path is /login, so reading the current URL there would send the customer back to the form they just left; the router builds it from the backdrop instead. The cart prompt uses the page it interrupted. It cannot resume the interrupted action the way onSuccess does — the redirect leaves the app — so the customer lands back on the page and presses the button again.

That value is validated on the server and not in the browser. It has to be, since anyone can type the URL, and doing it in one place beats doing it twice in two languages.

The end-to-end test asserts the button is ABSENT, which is the behaviour local and QA actually have, and then signs in with the password form to show that its absence changes nothing. That is the point of putting the alternatives below rather than above.

docs/ops/google-sign-in.md records what has to be true outside the repository: the seven sections of the Google Auth Platform, the three scopes that keep publishing out of a verification review, the cutover checklist for #313, and the production smoke test. It states plainly that QA on the Synology hostname is impossible rather than merely unconfigured, because Google will not accept a redirect URI whose domain nobody can prove they own — the same wall #285 hit with Cloudflare.

The failure that document warns about hardest is leaving the consent screen in Testing. Only listed test users can then sign in, the refusal happens on Google's own page, and nothing reaches the storefront at all — so a customer reports a broken button and the logs are silent.

Verified: backend tsc clean for src and tests, 590 unit tests pass, lint at the seven warnings that predate this branch, frontend tsc, lint and build clean. The integration and end-to-end suites need a database this machine has no Docker for.

Closes #345

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:13:18 -05:00
10 changed files with 334 additions and 9 deletions
+11 -1
View File
@@ -20,6 +20,7 @@ import customersRouter from './routes/customers';
import passkeysRouter from './routes/passkeys';
import passkeyLoginRouter from './routes/passkeyLogin';
import googleAuthRouter from './routes/googleAuth';
import { googleConfig } from './google/config';
import publicRouter from './routes/public';
import cartRouter from './routes/cart';
import shippingAddressesRouter from './routes/shippingAddresses';
@@ -72,7 +73,16 @@ app.get('/api/config', (_req, res) => {
// keeps QA out of production's Brevo account: QA sets no key, so no QA
// browsing is ever reported, and there is no flag anyone can forget to
// turn off. Same shape as paypalClientId above.
brevoTrackerKey: process.env.BREVO_TRACKER_KEY?.trim() || null
brevoTrackerKey: process.env.BREVO_TRACKER_KEY?.trim() || null,
// Whether to offer the Google button at all (#345). A boolean, never the
// client id: the browser does not need it, because the whole flow is a
// redirect this server builds.
//
// Absent rather than disabled is the point. A developer with no credentials
// gets a storefront that works and simply does not offer the option, the
// same choice #41 made for a browser without WebAuthn — and QA, which
// cannot have credentials until #313, gets the same.
googleSignIn: googleConfig().enabled
});
});
@@ -730,3 +730,54 @@ describe('GET /api/customers/me/identities', () => {
expect(res.status).toBe(401);
});
});
/**
* Whether the storefront offers a Google button at all (#345).
*
* A boolean and never the client id: the browser does not need one, because
* the whole flow is a redirect the server builds.
*/
describe('GET /api/config, google sign-in', () => {
// The suite-wide beforeEach configures Google so the flow above can run.
// These tests are about the unconfigured case too, so they start from clean.
beforeEach(() => {
delete process.env.GOOGLE_CLIENT_ID;
delete process.env.GOOGLE_CLIENT_SECRET;
});
it('is false when the environment has no credentials', async () => {
const res = await request(app).get('/api/config');
// Which is the state of local development, and of QA until #313 moves it
// off a hostname whose domain nobody can prove they own.
expect(res.body.googleSignIn).toBe(false);
});
it('is true when both credentials are set', async () => {
process.env.GOOGLE_CLIENT_ID = 'id.apps.googleusercontent.com';
process.env.GOOGLE_CLIENT_SECRET = 'shh';
const res = await request(app).get('/api/config');
expect(res.body.googleSignIn).toBe(true);
});
it('is false with only one of the pair, matching what the backend refuses to boot on', async () => {
process.env.GOOGLE_CLIENT_ID = 'id.apps.googleusercontent.com';
const res = await request(app).get('/api/config');
expect(res.body.googleSignIn).toBe(false);
});
it('never sends the client id or secret to the browser', async () => {
process.env.GOOGLE_CLIENT_ID = 'id.apps.googleusercontent.com';
process.env.GOOGLE_CLIENT_SECRET = 'a-real-looking-secret';
const res = await request(app).get('/api/config');
const body = JSON.stringify(res.body);
expect(body).not.toContain('a-real-looking-secret');
expect(body).not.toContain('googleusercontent');
});
});
+106
View File
@@ -0,0 +1,106 @@
# Google sign-in
What has to be true outside the repository for the Google button to work, and
what to do at the domain cutover. The code side is #332 and the six issues under
it; this is only the parts that live in a browser tab at Google.
## Where it is configured
The **Google Auth Platform** in the Google Cloud Console, in one project. There
is one consent screen per project and every OAuth client in it shares that
screen, so what appears there is the production identity even while testing.
| Section | What it holds |
| --- | --- |
| Branding | App name, support email, authorized domains, the three app links |
| Audience | External, publishing status, test users |
| Clients | The OAuth client, its redirect URIs, the id and secret |
| Data Access | Exactly `openid`, `email`, `profile` |
| Verification Center | Nothing to submit, and it should stay that way |
## The constraint that shapes everything
**Google refuses a redirect URI whose host is not under an authorized domain,
and a domain can only be authorized after ownership is proved by DNS in Search
Console.** `localhost` is the only exemption.
`qa-redefined-designs.bermudalamb.synology.me` therefore cannot ever be used:
Synology owns the registrable domain above it, so there is no record to add and
nothing to prove. This is the same wall #285 hit with Cloudflare.
The consequence, stated plainly because it changes how the feature is worked on:
| Environment | Google sign-in |
| --- | --- |
| Local, on `localhost` | Works, by exemption |
| QA on the Synology hostname | **Impossible**, not merely unconfigured |
| QA on `qa.redefined-designs.com` | Works, after the cutover |
| Production on `redefined-designs.com` | Works, after the cutover |
So this feature is built and exercised locally, and QA cannot see it at all
until QA moves onto a subdomain of the real domain. The Search Console property
for `redefined-designs.com` already covers `qa.redefined-designs.com`, because
a Domain property covers every subdomain.
`docker-compose.qa.yml` sets both credentials to empty deliberately, with a
comment saying so, and the storefront then offers no button rather than one that
fails at Google.
## Scopes, and why publishing needs no review
`openid` produces the id token carrying the subject claim, which is the identity
stored. `email` carries the address and the `email_verified` flag the linking
policy turns on. `profile` carries the names used when an account is created.
All three are non-sensitive. Requesting only them is what lets the app publish
without verification and without customers seeing an unverified-app warning.
**Add one sensitive scope and publishing becomes a review with a video
walkthrough and a wait measured in weeks.** Nothing in this feature needs one.
Uploading an app logo also triggers a brand review, which is why Branding has
none.
## The cutover checklist, for #313
1. Point QA at `qa.redefined-designs.com` and set its `PUBLIC_URL` to match.
2. Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in the QA stack. Both or
neither — the backend refuses to start on one without the other, because the
failure would otherwise arrive the moment a customer presses the button.
3. In **Clients**, add the QA callback:
`https://qa.redefined-designs.com/api/auth/google/callback`
4. Confirm the production callback is registered:
`https://redefined-designs.com/api/auth/google/callback`
5. In **Audience**, move the publishing status from Testing to **In production**.
Do it once the domain resolves, so the home page and privacy links Google
shows actually answer.
No code changes at any step. The redirect URI is derived from `PUBLIC_URL`, so
the environment variable and the console entry are the whole of it.
**Leaving it in Testing is the failure to watch for.** Only listed test users can
sign in, and the refusal happens on Google's own page, so nothing reaches the
storefront and nothing appears in its logs. A customer reports a broken button
and the logs are silent.
## The production smoke test
The consent screen, the redirect and the domain are all environment-specific, so
QA proves the flow and not the configuration. After the cutover:
1. Sign in with a Google account that has never been used on the site. A new
customer is created and lands on the consent step.
2. Sign in again with the same account. It reaches the same customer rather than
a second one.
3. Check the account page lists Google under connected accounts.
## What is not offered, and why
**Unlinking.** A customer cannot detach their Google account. Removing the only
way into an account is guarded for passkeys and the same guard would be needed
here first. Worth its own issue when somebody actually asks.
**Apple.** A separate decision with a materially different cost, set out on
#332: a paid developer programme, a client secret that expires every six months,
no `localhost` redirect URIs at all, and a name and email returned exactly once.
Apple is required for iOS apps offering third-party sign-in, and this is a
website, so that rule does not apply here.
+8
View File
@@ -61,6 +61,14 @@ export interface SiteConfig {
* A key alone does not start tracking — see brevo.ts.
*/
brevoTrackerKey: string | null;
/**
* Whether Google sign-in is configured in this environment (#345).
*
* False locally without credentials, and false in QA until #313 moves it off
* the Synology hostname — Google refuses a redirect URI whose domain nobody
* can prove they own. The button is then absent rather than disabled.
*/
googleSignIn: boolean;
}
export async function fetchConfig(): Promise<SiteConfig> {
+43 -5
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import Form from 'antd/es/form';
import Input from 'antd/es/input';
@@ -10,6 +10,8 @@ import Typography from 'antd/es/typography';
import Divider from 'antd/es/divider';
import { registerCustomer, loginCustomer, signInWithPasskey, passkeysSupported } from './customerApi';
import { useCustomerAuth } from './CustomerAuthContext';
import GoogleSignInButton from './GoogleSignInButton';
import { fetchConfig } from '../api';
const { Text } = Typography;
@@ -42,6 +44,15 @@ type Props = Readonly<{
// route closes back to the page behind it, while the cart and favorite
// prompts resume the action the customer was interrupted doing.
onSuccess: () => void;
/**
* Where a Google sign-in should return the customer (#345).
*
* Supplied by the caller because only the caller knows: the route modal has a
* page behind it, and the cart prompt has the page it interrupted. An OAuth
* redirect leaves the application entirely, so this cannot be recovered
* afterwards the way onSuccess recovers it for every other path.
*/
returnTo?: string;
}>;
/**
@@ -73,7 +84,7 @@ function googleNotice(reason: string | null): string | null {
// written twice — once as the /login and /register pages, once inside the
// prompt shown when a signed-out visitor adds to the cart — which had already
// drifted in consent wording and in which links each offered.
export default function AuthForm({ mode, onModeChange, onForgotPassword, onSuccess }: Props) {
export default function AuthForm({ mode, onModeChange, onForgotPassword, onSuccess, returnTo = '/' }: Props) {
const [searchParams] = useSearchParams();
const notice = googleNotice(searchParams.get('auth'));
const [error, setError] = useState<string | null>(null);
@@ -89,6 +100,20 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
// rather than whether pressing it works.
const canUsePasskeys = passkeysSupported();
// Whether this environment has Google credentials at all. Fetched rather than
// built in, because one image serves every environment — and false is the
// right starting value: a button that appears a moment late is better than
// one that appears and then vanishes.
const [googleEnabled, setGoogleEnabled] = useState(false);
useEffect(() => {
fetchConfig()
.then((config) => setGoogleEnabled(config.googleSignIn))
// Silent, and the button simply never appears. The password form behind
// it works regardless, which is the whole reason it is below rather than
// above.
.catch(() => setGoogleEnabled(false));
}, []);
async function submit(action: () => Promise<unknown>) {
setLoading(true);
setError(null);
@@ -238,11 +263,13 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
works for everyone. Absent entirely where WebAuthn is not
available, rather than shown disabled: a greyed button
invites a customer to wonder what they are missing (#41). */}
{(canUsePasskeys || googleEnabled) && (
<Divider plain style={{ marginBlock: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>or</Text>
</Divider>
)}
{canUsePasskeys && (
<>
<Divider plain style={{ marginBlock: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>or</Text>
</Divider>
<Button
block
loading={passkeyLoading}
@@ -258,6 +285,17 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
</Text>
</>
)}
{/* Below the passkey button, which is below the password form.
The order is deliberate and it is not about preference: a
passkey is already on the device in front of the customer,
while Google is a round trip to somebody else's site. Absent
rather than disabled where it is not configured, for the
same reason as the one above (#345). */}
{googleEnabled && (
<div style={{ marginTop: canUsePasskeys ? 16 : 0 }}>
<GoogleSignInButton returnTo={returnTo} />
</div>
)}
</Form>
)
}
@@ -37,6 +37,12 @@ export default function AuthPromptModal({ open, onClose, onSuccess }: Props) {
onClose();
navigate('/forgot-password', { state: { background: location } });
}}
// The page the customer was on when this interrupted them, which is
// where a Google round trip should put them back (#345). Unlike
// onSuccess it cannot resume the interrupted action — the redirect
// leaves the application — so it returns them to the page and they
// press the button again.
returnTo={`${location.pathname}${location.search}`}
onSuccess={onSuccess}
/>
</Modal>
+11 -1
View File
@@ -7,6 +7,15 @@ type Props = Readonly<{
// Moving between the auth routes, supplied by the router so the rule about
// keeping the whole detour to one history entry lives in one place.
onNavigate: (path: string) => void;
/**
* The page behind this modal, for a Google sign-in to return to (#345).
*
* Supplied by the router, which is the only thing that knows it: this modal
* renders over a backdrop location, and its own path is /login, so reading
* the current URL here would send the customer back to the form they just
* left.
*/
returnTo: string;
}>;
const TITLES: Record<AuthMode, string> = {
@@ -18,7 +27,7 @@ const TITLES: Record<AuthMode, string> = {
// clicks Log in while browsing and changes their mind is not stranded. Both
// stay real routes: /reset-password links to /login, and customers may have
// bookmarks.
export default function AuthRouteModal({ mode, onClose, onNavigate }: Props) {
export default function AuthRouteModal({ mode, onClose, onNavigate, returnTo }: Props) {
return (
<Modal
title={TITLES[mode]}
@@ -36,6 +45,7 @@ export default function AuthRouteModal({ mode, onClose, onNavigate }: Props) {
// in while browsing wants to carry on browsing rather than be moved to
// their account page.
onSuccess={onClose}
returnTo={returnTo}
/>
</Modal>
);
@@ -0,0 +1,68 @@
import Button from 'antd/es/button';
/**
* Google's own mark, inlined as SVG (#345).
*
* Their identity guidelines specify the four colours and the geometry, and a
* hand-drawn approximation of somebody else's trademark is a compliance problem
* rather than a style choice. These are the published values.
*
* Inlined rather than fetched, for the reason every other asset in this app is:
* a second origin is a second thing that can be down, blocked, or slow, and
* this one sits on the sign-in path.
*/
function GoogleMark() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" focusable="false">
<path
fill="#4285F4"
d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"
/>
<path
fill="#34A853"
d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.34A9 9 0 0 0 9 18z"
/>
<path
fill="#FBBC05"
d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.94H.96a9 9 0 0 0 0 8.12l3.01-2.34z"
/>
<path
fill="#EA4335"
d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.9 11.43 0 9 0A9 9 0 0 0 .96 4.94l3.01 2.34C4.68 5.16 6.66 3.58 9 3.58z"
/>
</svg>
);
}
type Props = Readonly<{
/** Where to send the customer back to. Validated again on the server. */
returnTo: string;
}>;
/**
* Signing in with Google (#345).
*
* A navigation rather than a fetch, which is what makes this different from
* every other control on the auth form. The flow leaves this application
* entirely, so there is no promise to await and no error to catch here — the
* server's callback decides what happens and redirects accordingly.
*
* `returnTo` is sent as a query parameter and **validated on the server**, not
* here. It has to be, since anyone can type the URL, and doing it in one place
* beats doing it in two languages. See `google/returnTo.ts`.
*/
export default function GoogleSignInButton({ returnTo }: Props) {
return (
<Button
block
icon={<GoogleMark />}
onClick={() => {
// assign rather than the router: this is a full page departure to
// another origin, and react-router would try to match it as a route.
window.location.assign(`/api/auth/google/start?returnTo=${encodeURIComponent(returnTo)}`);
}}
>
Sign in with Google
</Button>
);
}
+6 -2
View File
@@ -148,6 +148,10 @@ function AppRoutes() {
// the storefront, so closing always lands somewhere real.
const background = state?.background;
const backdrop = modalPath ? background ?? { ...location, ...STOREFRONT_BACKDROP } : location;
// Where a Google sign-in should land the customer: the page behind the modal,
// not the modal's own path. Built here because the backdrop is only known
// here, and validated again on the server (#345).
const returnTo = `${backdrop.pathname}${backdrop.search ?? ''}`;
function closeModal() {
// Back, when there is somewhere to go back to, so closing the modal and
@@ -193,10 +197,10 @@ function AppRoutes() {
{import.meta.env.DEV && <DevThrow scope="modal" />}
{modalPath === '/account' && <Account onClose={closeModal} />}
{modalPath === '/login' && (
<AuthRouteModal mode="login" onClose={closeModal} onNavigate={goWithinAuth} />
<AuthRouteModal mode="login" onClose={closeModal} onNavigate={goWithinAuth} returnTo={returnTo} />
)}
{modalPath === '/register' && (
<AuthRouteModal mode="register" onClose={closeModal} onNavigate={goWithinAuth} />
<AuthRouteModal mode="register" onClose={closeModal} onNavigate={goWithinAuth} returnTo={returnTo} />
)}
{modalPath === '/forgot-password' && (
<ForgotPassword onClose={closeModal} onBackToSignIn={() => goWithinAuth('/login')} />
+24
View File
@@ -119,6 +119,30 @@ test.describe('Customer accounts', () => {
await header.waitForSignedIn();
});
// #345. Local development has no Google credentials, and neither does QA
// until #313 moves it off a hostname whose domain nobody can prove they own.
// So the button being ABSENT is the behaviour under test here, and it is the
// one that matters: a control that appears and then fails at Google is worse
// than one that was never offered.
test('offers no Google button when the environment is not configured for it', async ({
authModal,
accountModal,
customer,
header
}) => {
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();
await authModal.gotoLogIn();
const google = authModal.logInDialog.getByRole('button', { name: /Sign in with Google/i });
await expect(google).toHaveCount(0);
// And the password form is untouched by its absence, which is the whole
// reason the alternatives sit below it rather than above.
await authModal.logIn(customer.email, customer.password);
await header.waitForSignedIn();
});
test('rejects login with the wrong password', async ({ page, customer, accountModal, authModal, header }) => {
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();