Add social sign-in for customers. Google first; Apple is a separate decision with a materially different cost, set out below.
Do this before launch. Production is not live and there are no customer accounts, which makes the schema change free and the account-linking policy a design decision rather than a migration. Both get considerably more expensive the day real accounts exist — linking in particular, because a wrong policy applied to accounts that already exist is an account-takeover incident rather than a code change.
What already fits
More than expected, and most of it recent:
createSession and setSessionCookie are reusable as they stand. A social sign-in must end in exactly the same rd_session cookie, with the same flags, expiry and logout behaviour. A second, subtly different session path is how auth bugs get in.
attachCustomer needs no change. It reads a session; it does not care how one was created.
AuthForm.tsx is the single sign-in implementation, rendered by both AuthRouteModal and AuthPromptModal. The buttons go in one place, and the consent capture comes along for free.
The redirect URI derives from PUBLIC_URL, exactly as the WebAuthn RP ID does (#37). Same reasoning, same single source.
#38 has just established the pattern to copy: a table of credentials linked to customers, keyed on a provider-issued identifier.
The schema delta
customers.password_hash is NOT NULL, and a Google-only customer has no password. That is the change with the widest blast radius. Making the column nullable is trivial; auditing what assumes it is not is the actual work. The sites, all in routes/customers.ts:
Site
What it assumes
Registration INSERT
Always supplies a hash
Password reset
Sets password_hash and email_verified = true
Login
bcrypt.compare against a hash that exists
Change password
Compares the current one first
Account deletion
Confirms with the password
The last three become "this account has no password" cases rather than failures. Deletion and change-password need a different confirmation for a social-only account, and asking for a password that was never set is a dead end.
Plus a customer_identities table — provider, provider subject, customer id, created/last used — rather than columns on customers, because one customer may eventually hold both a Google and an Apple identity. Keyed on the provider's stable subject, never the email: emails change, and on Apple the email may not be real.
The decision that matters more than any of the code
Account linking. Someone registers with email and password, then later clicks Sign in with Google using the same address. Link automatically, or refuse?
Auto-link on a provider-verified email. Google asserts email_verified, so this is defensible for Google. It is an account-takeover path for any provider that does not, and it must never be done on an unverified one.
Refuse and require signing in the existing way first, then link deliberately from the account page. Safer, and worse to use.
Treat them as separate accounts. Simplest and wrong — two accounts, one person, one email, and orders split across both.
This is a security decision, not a preference, and it should be settled on this issue before implementation rather than discovered in the code.
Apple's Hide My Email makes it sharper. Apple can return a per-app relay address at @privaterelay.appleid.com, so the email is one that will never match anything else and linking on it is meaningless.
Google
Free, and the straightforward half.
OAuth client from Google Cloud Console; client id and secret per environment, alongside the existing secrets.
localhost redirect URIs are allowed, so local development works normally.
The sub claim is the stable identifier. email is not.
email_verified is asserted and is what makes auto-linking defensible.
Apple, and why it is a different category
The code is perhaps thirty percent more. The operational cost is not comparable:
$99/year Apple Developer Program. Not optional.
The client secret is a JWT signed with a .p8 key, and it expires — six months maximum. That is a recurring rotation task with an outage attached when it is missed. This project has exactly one such task today and it is already noted as unowned.
No localhost redirect URIs. Google allows them; Apple does not. The local development loop for this feature effectively disappears, and QA becomes the first place it can be exercised.
Name and email are returned only on the very first authorization. Miss that response and they are gone permanently — there is no second chance and no endpoint to ask again.
Worth deciding explicitly rather than by drift: Apple is a requirement for iOS App Store apps offering third-party sign-in, and this is a website. That rule does not apply here.
Interactions
#313 changes the domain, and redirect URIs are registered per-domain in both consoles. Unlike passkeys, this is recoverable configuration rather than destroyed credentials — but it is another list to update at the cutover, and it should be in that checklist.
#56's consent work applies unchanged. A social sign-up still has to capture marketing and analytics consent, with the same wording stored verbatim. AuthForm being the single implementation is what keeps that from drifting.
#33's disabled accounts must be refused here too, for the same reason #39 requires it of passkeys: enforcing it on one sign-in path and not another is how a disabled account keeps a way in.
This creates the project's first customers with no password, which quietly invalidates an assumption #40 rests on. Its note reads: "Removing the last passkey must not lock the customer out. Since password login remains available, this is safe today." Once social-only accounts exist, password login is not always available, and that check stops being theoretical.
Suggested shape
Google first, on its own, and Apple as a separate issue once Google is working. Most of the cost is shared — the identities table, the nullable hash, the linking policy, the router, the buttons — so Apple afterwards is genuinely incremental, and doing them together means paying Apple's operational cost before knowing whether the shared half is right.
Done when
A customer can sign in with Google, arriving at the same session a password login produces; a returning customer reaches the same account rather than a second one; the linking policy decided above is implemented and tested; a social-only account can be deleted and can manage itself without being asked for a password it never set; and a disabled account is refused.
Add social sign-in for customers. Google first; Apple is a separate decision with a materially different cost, set out below.
**Do this before launch.** Production is not live and there are no customer accounts, which makes the schema change free and the account-linking policy a design decision rather than a migration. Both get considerably more expensive the day real accounts exist — linking in particular, because a wrong policy applied to accounts that already exist is an account-takeover incident rather than a code change.
## What already fits
More than expected, and most of it recent:
- **`createSession` and `setSessionCookie` are reusable as they stand.** A social sign-in must end in exactly the same `rd_session` cookie, with the same flags, expiry and logout behaviour. A second, subtly different session path is how auth bugs get in.
- **`attachCustomer` needs no change.** It reads a session; it does not care how one was created.
- **`AuthForm.tsx` is the single sign-in implementation**, rendered by both `AuthRouteModal` and `AuthPromptModal`. The buttons go in one place, and the consent capture comes along for free.
- **The redirect URI derives from `PUBLIC_URL`**, exactly as the WebAuthn RP ID does (#37). Same reasoning, same single source.
- **#38 has just established the pattern to copy**: a table of credentials linked to `customers`, keyed on a provider-issued identifier.
## The schema delta
**`customers.password_hash` is `NOT NULL`, and a Google-only customer has no password.** That is the change with the widest blast radius. Making the column nullable is trivial; auditing what assumes it is not is the actual work. The sites, all in `routes/customers.ts`:
| Site | What it assumes |
| --- | --- |
| Registration `INSERT` | Always supplies a hash |
| Password reset | Sets `password_hash` and `email_verified = true` |
| Login | `bcrypt.compare` against a hash that exists |
| Change password | Compares the current one first |
| Account deletion | Confirms with the password |
The last three become "this account has no password" cases rather than failures. Deletion and change-password need a different confirmation for a social-only account, and asking for a password that was never set is a dead end.
Plus a **`customer_identities`** table — provider, provider subject, customer id, created/last used — rather than columns on `customers`, because one customer may eventually hold both a Google and an Apple identity. Keyed on the provider's stable subject, **never the email**: emails change, and on Apple the email may not be real.
## The decision that matters more than any of the code
**Account linking.** Someone registers with email and password, then later clicks Sign in with Google using the same address. Link automatically, or refuse?
- **Auto-link on a provider-verified email.** Google asserts `email_verified`, so this is defensible for Google. It is an account-takeover path for any provider that does not, and it must never be done on an unverified one.
- **Refuse and require signing in the existing way first**, then link deliberately from the account page. Safer, and worse to use.
- **Treat them as separate accounts.** Simplest and wrong — two accounts, one person, one email, and orders split across both.
This is a security decision, not a preference, and it should be settled on this issue before implementation rather than discovered in the code.
**Apple's Hide My Email makes it sharper.** Apple can return a per-app relay address at `@privaterelay.appleid.com`, so the email is one that will never match anything else and linking on it is meaningless.
## Google
Free, and the straightforward half.
- OAuth client from Google Cloud Console; client id and secret per environment, alongside the existing secrets.
- `localhost` redirect URIs are allowed, so local development works normally.
- The `sub` claim is the stable identifier. `email` is not.
- `email_verified` is asserted and is what makes auto-linking defensible.
## Apple, and why it is a different category
The code is perhaps thirty percent more. The operational cost is not comparable:
- **$99/year Apple Developer Program.** Not optional.
- **The client secret is a JWT signed with a `.p8` key, and it expires — six months maximum.** That is a recurring rotation task with an outage attached when it is missed. This project has exactly one such task today and it is already noted as unowned.
- **No `localhost` redirect URIs.** Google allows them; Apple does not. The local development loop for this feature effectively disappears, and QA becomes the first place it can be exercised.
- **Name and email are returned only on the very first authorization.** Miss that response and they are gone permanently — there is no second chance and no endpoint to ask again.
Worth deciding explicitly rather than by drift: Apple is a requirement for iOS App Store apps offering third-party sign-in, and this is a website. That rule does not apply here.
## Interactions
- **#313 changes the domain**, and redirect URIs are registered per-domain in both consoles. Unlike passkeys, this is recoverable configuration rather than destroyed credentials — but it is another list to update at the cutover, and it should be in that checklist.
- **#56's consent work applies unchanged.** A social sign-up still has to capture marketing and analytics consent, with the same wording stored verbatim. `AuthForm` being the single implementation is what keeps that from drifting.
- **#33's disabled accounts must be refused here too**, for the same reason #39 requires it of passkeys: enforcing it on one sign-in path and not another is how a disabled account keeps a way in.
- **This creates the project's first customers with no password**, which quietly invalidates an assumption #40 rests on. Its note reads: "Removing the last passkey must not lock the customer out. Since password login remains available, this is safe today." Once social-only accounts exist, password login is *not* always available, and that check stops being theoretical.
## Suggested shape
Google first, on its own, and Apple as a separate issue once Google is working. Most of the cost is shared — the identities table, the nullable hash, the linking policy, the router, the buttons — so Apple afterwards is genuinely incremental, and doing them together means paying Apple's operational cost before knowing whether the shared half is right.
## Done when
A customer can sign in with Google, arriving at the same session a password login produces; a returning customer reaches the same account rather than a second one; the linking policy decided above is implemented and tested; a social-only account can be deleted and can manage itself without being asked for a password it never set; and a disabled account is refused.
Account linking. Link only when Google asserts email_verified and the address matches exactly. Refuse otherwise.
Defensible because Google asserting the address means whoever signed in demonstrably controls the mailbox — and that mailbox is already the root of trust for a password reset on the account, which takes it over completely. So linking grants nothing that was not already reachable.
The identity lookup runs before the address comparison, so an identity that has signed in before keeps working after the address changes on either side.
Apple: no. Not now, and the reasoning on this issue stands. A paid developer programme, a client secret that expires every six months with an outage attached when it is missed, no localhost redirect URIs at all, and a name and email returned exactly once. The App Store rule that makes Apple mandatory does not apply to a website.
Two things this issue recorded that turned out to be wrong
Account deletion does not confirm with a password. The route takes none; the confirmation is a modal in the account page. So it needed no change, and there is now a test so it stays true.
QA cannot host this feature at all. Not "needs configuring" — impossible. Google refuses a redirect URI whose host is not under a domain whose ownership is proved by DNS, and Synology owns the registrable domain above bermudalamb.synology.me. Same wall as #285.
So the whole feature was built and exercised on localhost. QA sees it when #313 moves QA to qa.redefined-designs.com, which is two stack variables and one console entry with no code change. That checklist is in docs/ops/google-sign-in.md and in #345.
Still not offered
Unlinking a 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 asks.
Not yet verified by CI
Every phase merged while the runner was blocked by the hung run identified in #154. The suites pass locally apart from the integration and end-to-end ones, which need a database this machine has no Docker for. A green run across #340 through #345 is still outstanding, and it is the first thing worth having once the runner is clear.
## Done, in six phases
| Phase | Issue | Landed |
| --- | --- | --- |
| Groundwork | #340 | Nullable password hash, `customer_identities`, config derived from `PUBLIC_URL` |
| The round trip | #341 | Code flow with PKCE, state and nonce, claim verification |
| New accounts | #342 | Account creation, and the consent step |
| Linking | #343 | The policy, implemented and pinned |
| No password | #344 | Every route that assumed one exists |
| The button | #345 | UI, public config flag, ops documentation |
## The decisions this issue asked for
**Account linking.** Link only when Google asserts `email_verified` and the address matches exactly. Refuse otherwise.
Defensible because Google asserting the address means whoever signed in demonstrably controls the mailbox — and that mailbox is already the root of trust for a password reset on the account, which takes it over completely. So linking grants nothing that was not already reachable.
The identity lookup runs **before** the address comparison, so an identity that has signed in before keeps working after the address changes on either side.
**Apple: no.** Not now, and the reasoning on this issue stands. A paid developer programme, a client secret that expires every six months with an outage attached when it is missed, no `localhost` redirect URIs at all, and a name and email returned exactly once. The App Store rule that makes Apple mandatory does not apply to a website.
## Two things this issue recorded that turned out to be wrong
**Account deletion does not confirm with a password.** The route takes none; the confirmation is a modal in the account page. So it needed no change, and there is now a test so it stays true.
**QA cannot host this feature at all.** Not "needs configuring" — impossible. Google refuses a redirect URI whose host is not under a domain whose ownership is proved by DNS, and Synology owns the registrable domain above `bermudalamb.synology.me`. Same wall as #285.
So the whole feature was built and exercised on `localhost`. QA sees it when #313 moves QA to `qa.redefined-designs.com`, which is two stack variables and one console entry with no code change. That checklist is in `docs/ops/google-sign-in.md` and in #345.
## Still not offered
**Unlinking a 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 asks.
## Not yet verified by CI
Every phase merged while the runner was blocked by the hung run identified in #154. The suites pass locally apart from the integration and end-to-end ones, which need a database this machine has no Docker for. **A green run across #340 through #345 is still outstanding**, and it is the first thing worth having once the runner is clear.
That summary lists, under things this issue recorded that turned out to be wrong:
QA cannot host this feature at all. Not "needs configuring" — impossible.
That was itself wrong, which makes it the more embarrassing of the two.
Registering https://qa-redefined-designs.bermudalamb.synology.me/api/auth/google/callback under Authorized redirect URIs works, and QA has been running Google sign-in since. The claim was inferred from #285, where Cloudflare's free tier genuinely cannot be applied to that hostname, and asserted as fact without being checked.
What this changes
This feature never depended on #313. The whole of it — sign-in, sign-up, the consent step, linking, the passwordless routes, the button — can be exercised in QA today.
#313 is still worth doing for its own reasons, and when it lands the QA callback simply gains a second entry for the new hostname.
The other correction in that summary stands: account deletion genuinely does not confirm with a password, and there is a test pinning it.
Where it was written down
Corrected in #356: docs/ops/google-sign-in.md, docker-compose.qa.yml, and the comment on backend/src/google/config.ts. The wrong sentences are corrected in place rather than deleted, so the reasoning error stays visible rather than being tidied away. Also corrected on #345 and in the two planning documents.
Worth carrying forward
Twice in this project a confident inference about somebody else's platform has cost a day. The first was assuming a passing local build said something about another machine; this was the second. Both were stated as fact, both were checkable in minutes, and neither was checked.
## Correction to the closing summary
That summary lists, under things this issue recorded that turned out to be wrong:
> **QA cannot host this feature at all.** Not "needs configuring" — impossible.
**That was itself wrong**, which makes it the more embarrassing of the two.
Registering `https://qa-redefined-designs.bermudalamb.synology.me/api/auth/google/callback` under Authorized redirect URIs works, and QA has been running Google sign-in since. The claim was inferred from #285, where Cloudflare's free tier genuinely cannot be applied to that hostname, and asserted as fact without being checked.
### What this changes
**This feature never depended on #313.** The whole of it — sign-in, sign-up, the consent step, linking, the passwordless routes, the button — can be exercised in QA today.
#313 is still worth doing for its own reasons, and when it lands the QA callback simply gains a second entry for the new hostname.
The other correction in that summary stands: account deletion genuinely does not confirm with a password, and there is a test pinning it.
### Where it was written down
Corrected in #356: `docs/ops/google-sign-in.md`, `docker-compose.qa.yml`, and the comment on `backend/src/google/config.ts`. The wrong sentences are corrected in place rather than deleted, so the reasoning error stays visible rather than being tidied away. Also corrected on #345 and in the two planning documents.
### Worth carrying forward
Twice in this project a confident inference about somebody else's platform has cost a day. The first was assuming a passing local build said something about another machine; this was the second. Both were stated as fact, both were checkable in minutes, and neither was checked.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Add social sign-in for customers. Google first; Apple is a separate decision with a materially different cost, set out below.
Do this before launch. Production is not live and there are no customer accounts, which makes the schema change free and the account-linking policy a design decision rather than a migration. Both get considerably more expensive the day real accounts exist — linking in particular, because a wrong policy applied to accounts that already exist is an account-takeover incident rather than a code change.
What already fits
More than expected, and most of it recent:
createSessionandsetSessionCookieare reusable as they stand. A social sign-in must end in exactly the samerd_sessioncookie, with the same flags, expiry and logout behaviour. A second, subtly different session path is how auth bugs get in.attachCustomerneeds no change. It reads a session; it does not care how one was created.AuthForm.tsxis the single sign-in implementation, rendered by bothAuthRouteModalandAuthPromptModal. The buttons go in one place, and the consent capture comes along for free.PUBLIC_URL, exactly as the WebAuthn RP ID does (#37). Same reasoning, same single source.customers, keyed on a provider-issued identifier.The schema delta
customers.password_hashisNOT NULL, and a Google-only customer has no password. That is the change with the widest blast radius. Making the column nullable is trivial; auditing what assumes it is not is the actual work. The sites, all inroutes/customers.ts:INSERTpassword_hashandemail_verified = truebcrypt.compareagainst a hash that existsThe last three become "this account has no password" cases rather than failures. Deletion and change-password need a different confirmation for a social-only account, and asking for a password that was never set is a dead end.
Plus a
customer_identitiestable — provider, provider subject, customer id, created/last used — rather than columns oncustomers, because one customer may eventually hold both a Google and an Apple identity. Keyed on the provider's stable subject, never the email: emails change, and on Apple the email may not be real.The decision that matters more than any of the code
Account linking. Someone registers with email and password, then later clicks Sign in with Google using the same address. Link automatically, or refuse?
email_verified, so this is defensible for Google. It is an account-takeover path for any provider that does not, and it must never be done on an unverified one.This is a security decision, not a preference, and it should be settled on this issue before implementation rather than discovered in the code.
Apple's Hide My Email makes it sharper. Apple can return a per-app relay address at
@privaterelay.appleid.com, so the email is one that will never match anything else and linking on it is meaningless.Google
Free, and the straightforward half.
localhostredirect URIs are allowed, so local development works normally.subclaim is the stable identifier.emailis not.email_verifiedis asserted and is what makes auto-linking defensible.Apple, and why it is a different category
The code is perhaps thirty percent more. The operational cost is not comparable:
.p8key, and it expires — six months maximum. That is a recurring rotation task with an outage attached when it is missed. This project has exactly one such task today and it is already noted as unowned.localhostredirect URIs. Google allows them; Apple does not. The local development loop for this feature effectively disappears, and QA becomes the first place it can be exercised.Worth deciding explicitly rather than by drift: Apple is a requirement for iOS App Store apps offering third-party sign-in, and this is a website. That rule does not apply here.
Interactions
AuthFormbeing the single implementation is what keeps that from drifting.Suggested shape
Google first, on its own, and Apple as a separate issue once Google is working. Most of the cost is shared — the identities table, the nullable hash, the linking policy, the router, the buttons — so Apple afterwards is genuinely incremental, and doing them together means paying Apple's operational cost before knowing whether the shared half is right.
Done when
A customer can sign in with Google, arriving at the same session a password login produces; a returning customer reaches the same account rather than a second one; the linking policy decided above is implemented and tested; a social-only account can be deleted and can manage itself without being asked for a password it never set; and a disabled account is refused.
Done, in six phases
customer_identities, config derived fromPUBLIC_URLThe decisions this issue asked for
Account linking. Link only when Google asserts
email_verifiedand the address matches exactly. Refuse otherwise.Defensible because Google asserting the address means whoever signed in demonstrably controls the mailbox — and that mailbox is already the root of trust for a password reset on the account, which takes it over completely. So linking grants nothing that was not already reachable.
The identity lookup runs before the address comparison, so an identity that has signed in before keeps working after the address changes on either side.
Apple: no. Not now, and the reasoning on this issue stands. A paid developer programme, a client secret that expires every six months with an outage attached when it is missed, no
localhostredirect URIs at all, and a name and email returned exactly once. The App Store rule that makes Apple mandatory does not apply to a website.Two things this issue recorded that turned out to be wrong
Account deletion does not confirm with a password. The route takes none; the confirmation is a modal in the account page. So it needed no change, and there is now a test so it stays true.
QA cannot host this feature at all. Not "needs configuring" — impossible. Google refuses a redirect URI whose host is not under a domain whose ownership is proved by DNS, and Synology owns the registrable domain above
bermudalamb.synology.me. Same wall as #285.So the whole feature was built and exercised on
localhost. QA sees it when #313 moves QA toqa.redefined-designs.com, which is two stack variables and one console entry with no code change. That checklist is indocs/ops/google-sign-in.mdand in #345.Still not offered
Unlinking a 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 asks.
Not yet verified by CI
Every phase merged while the runner was blocked by the hung run identified in #154. The suites pass locally apart from the integration and end-to-end ones, which need a database this machine has no Docker for. A green run across #340 through #345 is still outstanding, and it is the first thing worth having once the runner is clear.
Correction to the closing summary
That summary lists, under things this issue recorded that turned out to be wrong:
That was itself wrong, which makes it the more embarrassing of the two.
Registering
https://qa-redefined-designs.bermudalamb.synology.me/api/auth/google/callbackunder Authorized redirect URIs works, and QA has been running Google sign-in since. The claim was inferred from #285, where Cloudflare's free tier genuinely cannot be applied to that hostname, and asserted as fact without being checked.What this changes
This feature never depended on #313. The whole of it — sign-in, sign-up, the consent step, linking, the passwordless routes, the button — can be exercised in QA today.
#313 is still worth doing for its own reasons, and when it lands the QA callback simply gains a second entry for the new hostname.
The other correction in that summary stands: account deletion genuinely does not confirm with a password, and there is a test pinning it.
Where it was written down
Corrected in #356:
docs/ops/google-sign-in.md,docker-compose.qa.yml, and the comment onbackend/src/google/config.ts. The wrong sentences are corrected in place rather than deleted, so the reasoning error stays visible rather than being tidied away. Also corrected on #345 and in the two planning documents.Worth carrying forward
Twice in this project a confident inference about somebody else's platform has cost a day. The first was assuming a passing local build said something about another machine; this was the second. Both were stated as fact, both were checkable in minutes, and neither was checked.