Skip to content

feat(auth): A4 — Sign in with Google on the web and in the app - #292

Merged
oratis merged 1 commit into
feat/auth-otp-clientsfrom
feat/auth-google-clients
Jul 24, 2026
Merged

feat(auth): A4 — Sign in with Google on the web and in the app#292
oratis merged 1 commit into
feat/auth-otp-clientsfrom
feat/auth-google-clients

Conversation

@oratis

@oratis oratis commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Stacked on #291. The client half of Google sign-in.

Web

Google Identity Services renders its own button, drawn only when the instance advertises a web client id. The same nonce goes to Google and to our server (Google echoes it raw, unlike Apple's hash). The /api/auth/config fetch now feeds both providers, and the "or use email" divider appears only when something sits above it.

iOS — authorization code + PKCE, no SDK

The flow is ~100 lines of OAuth, so we run it ourselves rather than take a dependency — the same call the server side makes (verify Apple/Google JWTs with node:crypto instead of a library, and no RevenueCat for IAP).

  • The verifier never leaves the device — only its SHA-256 goes to the authorize endpoint, so intercepting the redirect gains nothing.
  • state is checked on the way back, guarding against a redirect we didn't start.
  • The session is ephemeral, so signing out of LISA doesn't leave the next person silently signed in as the last one.
  • ASWebAuthenticationSession intercepts the callback itself, so no URL scheme has to be registered in project.yml — which also means the client id doesn't need to be known at build time.
  • iOS OAuth clients are public clients: there is no client secret to leak.
  • The button sits below Sign in with Apple — Guideline 4.8 wants SIWA offered with equal prominence wherever a third-party login is.

Config

/api/auth/config now advertises both client ids, and each client draws its button only if its own id is present — so an instance can run Google on the app and not the web, or vice versa. Client ids are public by design: they identify the app, they don't authorize it.

Verification

  • Web: driven in a browser against a stub that serves a fake GIS script — the button renders in the right place, initialize receives the configured client id and a nonce, and clicking it posts the token plus the matching nonce to /api/auth/google and lands signed in. Server log confirms the nonce round-trips identically. Screenshot in the thread.
  • iOS: full swiftc -typecheck against the iOS 17 simulator SDK, clean. Added an XCTest for the redirect-scheme derivation, and verified the same logic standalone under swift (correct reversal; nil for a non-Google id and for empty input, rather than building a redirect Google would reject).
  • The live OAuth round-trip can't be exercised until the GCP client ids exist — see the operator steps in A5.
  • npm test1173 green, typecheck clean.

🤖 Generated with Claude Code

- web: Google Identity Services renders its own button, drawn only when
  the instance advertises a web client id. Same nonce goes to Google and
  to our server (Google echoes it raw). The config fetch now feeds both
  providers, and the divider appears only when something sits above it.

- iOS: authorization code + PKCE via ASWebAuthenticationSession, with no
  SDK — the flow is ~100 lines of OAuth, and doing it here matches how
  the server verifies Apple/Google tokens with node:crypto rather than a
  library. The verifier never leaves the device, so intercepting the
  redirect gains nothing; state guards against a redirect we didn't
  start; the session is ephemeral so signing out doesn't leave the next
  person silently signed in. ASWebAuthenticationSession intercepts the
  callback itself, so no URL scheme has to be registered in project.yml.
  The button sits BELOW Sign in with Apple — Guideline 4.8 wants SIWA
  offered with equal prominence wherever a third-party login is.

/api/auth/config now advertises both client ids; each client draws its
button only if its own id is present, so an instance can run Google on
one surface and not the other. Client ids are public by design — they
identify the app, they don't authorize it.

Plan: docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md (A4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oratis

oratis commented Jul 24, 2026

Copy link
Copy Markdown
Owner Author

Reviewed the client-side Google flow closely, since it's hand-rolled OAuth. It's correct — ready to merge.

iOS PKCE is textbook

  • CSPRNG (SystemRandomNumberGenerator), 64-char verifier that never leaves the device (only its S256 challenge goes to /authorize).
  • state is checked on the callback (CSRF), a fresh nonce round-trips for server verification, and the session is ephemeral (prefersEphemeralWebBrowserSession = true) so no Google cookie bleed between users.
  • Minimal openid email scope, code exchanged directly with Google over TLS, public client with no secret to leak. redirectScheme derivation is unit-tested including the nil cases. Advertising the client ids via /api/auth/config is fine — they identify, they don't authorize.

Follow-ups (none block this PR)

  • Web GIS omits the nonce when crypto.subtle is unavailable (non-HTTPS only, so the cloud always sends it). Please make the server require a nonce for GIS rather than accept token-without-nonce, so a downgraded client can't skip replay protection.
  • The login page loads Google GIS + Apple JS from CDNs with no CSP. Pre-existing (Apple already did this), but a script-src allowlisting accounts.google.com / appleid.cdn-apple.com would be good hardening.
  • Nit: .task(id: cloudURL) refetches /api/auth/config on each keystroke of the URL field — consider debouncing.

@oratis
oratis merged commit 32191cf into feat/auth-otp-clients Jul 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant