12 KiB
Phase 8: Social Sign-in - Research
Researched: 2026-05-15 Domain: Google OAuth/OIDC, Sign in with Apple, local account linking, Go server-managed sessions, sqlc/Postgres migration impact Confidence: HIGH
Summary
Phase 8 adds Google and Apple sign-in without changing the core auth authority: Xtablo still owns users, sessions, CSRF, and cookies. Provider tokens are only proof used during callback processing. After verification, the Go server creates or links a local user, then issues the existing Xtablo session cookie through auth.Store.
The safest implementation shape is:
- Add
user_identitiesand makeusers.password_hashnullable. - Add provider config parsing and disabled-button state.
- Add Google start/callback.
- Add Apple start/callback, including ES256 client-secret generation and Apple ID token verification.
- Add linked-provider status view.
- Verify existing email/password behavior has not regressed.
Use golang.org/x/oauth2 for authorization-code exchange. Use an OIDC/JWT verification package rather than hand-parsing ID tokens. Keep provider subject as the durable external identity key; email can change and is not the identity key after first link.
User Decisions From CONTEXT.md
- Auto-link provider login to an existing Xtablo user when provider email is verified and matches.
- Reject provider callbacks with missing or unverified email.
- Provider subject wins after it is linked, even if email later changes.
- Add a simple linked-provider status view; unlinking is deferred.
- Make
users.password_hashnullable and allow social-only users. - Defer add-password/password management.
- Block email/password signup if the email already belongs to a social-only account.
- Existing password users who link Google/Apple keep password login.
- Show Google and Apple buttons on both login and signup.
- Show disabled provider buttons when config is missing.
- Give Google and Apple equal prominence.
- Redirect successful social sign-in to
/. - Store provider display name and avatar URL when available.
- Accept Apple private relay emails as verified when Apple verifies them.
- Update both
user_identities.emailand localusers.emailwhen provider email changes, with explicit conflict handling.
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| AUTH-08 | User can start Google sign-in from login/signup | Provider buttons + /auth/google/start route; disabled config state |
| AUTH-09 | Google callback validates state, exchanges code, verifies ID token, creates/links local user | OAuth2 auth code flow + ID token verification + account linking transaction |
| AUTH-10 | User can start Apple sign-in from login/signup | Provider buttons + /auth/apple/start route; Apple Services ID config |
| AUTH-11 | Apple callback validates state/nonce, exchanges code, verifies ID token, creates/links local user | Apple token endpoint + ES256 client secret + nonce/state validation |
| AUTH-12 | Social sign-in issues existing server-managed session cookie | Reuse auth.Store.Create / auth.SetSessionCookie |
| AUTH-13 | Existing email/password auth still works | Regression tests around signup/login/logout/CSRF/rate limiting |
Standard Stack
| Library / API | Purpose | Recommendation |
|---|---|---|
golang.org/x/oauth2 |
OAuth2 authorization URL and code exchange | Add as a direct dependency |
github.com/coreos/go-oidc/v3/oidc |
OIDC discovery, JWKS, ID token verification | Good fit for Google; can also verify ID tokens when issuer/JWKS are available |
github.com/go-jose/go-jose/v4 |
JOSE/JWT primitives, ES256 client secret signing | Good fit for Apple client-secret JWT if go-oidc does not cover all Apple needs cleanly |
| Google OpenID Connect | Provider docs and ID token semantics | Use official docs for issuer/audience/email_verified behavior |
| Apple Sign in REST API | Authorization/token/client-secret rules | Use official docs for client_secret, token exchange, and ID token validation |
Avoid:
- Managed auth platforms such as Clerk/Auth0/Lucia.
- Treating provider access tokens as Xtablo sessions.
- Linking on unverified email.
- Relinking a provider subject based on changed email.
Architecture Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Provider start URL | Go web handler | Browser redirect | Server owns state/nonce generation and config |
| OAuth state/nonce storage | Go server + short-lived cookie or DB table | Browser returns state | Callback must prove it belongs to an initiated flow |
| Code exchange | Go server | Provider token endpoint | Client secret stays server-side |
| ID token verification | Go server | Provider JWKS | Must validate issuer, audience, expiry, nonce where applicable, subject, verified email |
| Account linking | Postgres transaction | Go handler | Race-safe lookup/create/link logic |
| Local session creation | Existing auth.Store |
Browser cookie | Xtablo session remains authoritative |
| Provider status view | Go templates | Authenticated browser | Minimal account visibility; no unlink actions |
Data Model Research
users changes
Current users.password_hash text NOT NULL blocks social-only accounts. Phase 8 should migrate to nullable:
password_hash text NULL- Email/password login must treat
NULLas "no password set" and return the same generic login error used for invalid credentials. - Signup must check existing users by email before insert. If an existing user has
password_hash IS NULL, block email/password signup and tell the user to sign in with the provider.
New user_identities
Recommended columns:
id uuid primary key default gen_random_uuid()user_id uuid not null references users(id) on delete cascadeprovider text not nullor enum constrained togoogle,appleprovider_subject text not nullemail citext not nullemail_verified boolean not nulldisplay_name textavatar_url textcreated_at timestamptz not null default now()updated_at timestamptz not null default now()last_login_at timestamptz
Indexes/constraints:
unique(provider, provider_subject)index(user_id)- optional
check(email_verified = true)if unverified identities are never stored
Linking should happen in a DB transaction:
- Look up identity by
(provider, provider_subject). - If found: update identity profile/email metadata, update local
users.emailif needed, create session for identity'suser_id. - If not found: require verified email.
- Look up local
users.email. - If found: insert identity for that user.
- If not found: create social-only user with nullable
password_hash, insert identity.
Conflict case: if provider subject is linked to user A and provider email changes to an email owned by user B, D-03 says provider identity wins. The planner must handle the users.email uniqueness collision from D-17 explicitly. Recommended behavior: keep user A signed in, update user_identities.email, do not update users.email, and surface/log a conflict for future account cleanup. Silent relinking is forbidden.
Flow Details
Start routes
GET /auth/google/startGET /auth/apple/start
Each route:
- Verifies provider config exists.
- Generates cryptographically random state.
- Generates nonce for ID token validation where applicable.
- Stores state/nonce short-term, preferably in a signed HTTP-only cookie or a DB-backed OAuth state table.
- Redirects to the provider authorization endpoint.
Callback routes
GET /auth/google/callbackPOST or GET /auth/apple/callbackdepending on Apple response mode selected during implementation.
Each callback:
- Validates state.
- Validates nonce if included in ID token.
- Exchanges authorization code server-side.
- Verifies ID token:
- issuer
- audience/client ID
- expiry
- subject
- email present
- email verified
- Links/creates local user through a transaction.
- Calls existing session creation and cookie helper.
- Redirects to
/.
Provider buttons
Login and signup pages should both show Google and Apple buttons:
- Equal prominence.
- Disabled if required env vars are missing.
- No provider button should submit the password form; use links/buttons to provider start routes.
Testing Strategy
Use real DB integration tests for account linking and schema behavior. Mock providers locally with httptest.Server for token endpoints and JWKS responses; do not call real Google/Apple in tests.
Required test classes:
- Unit tests for provider config detection and disabled button state.
- Unit tests for OAuth state/nonce generation and validation.
- Unit tests for Apple client-secret JWT claims if generated in-process.
- Integration tests for nullable
password_hashand email/password login behavior. - Integration tests for provider identity linking:
- verified email matches existing password user -> identity inserted, session created
- missing/unverified email -> rejected
- new verified email -> social-only user created with NULL password hash
- social-only email/password signup blocked
- provider subject already linked -> linked user wins
- provider email update conflict with another user does not silently relink
- Handler tests for
/auth/{provider}/startredirects and/auth/{provider}/callbacksession issuance. - Regression tests for existing signup/login/logout/CSRF/rate-limit behavior.
Validation Architecture
Automated validation should run through go test ./... from backend/. Phase-specific fast feedback can target:
go test ./internal/auth ./internal/webgo test ./internal/db/... ./internal/web -run 'Test.*(Social|OAuth|Provider|Login|Signup|Session)'
Validation dimensions:
- Schema: migrations apply cleanly;
password_hashnullable;user_identitiesconstraints exist. - Provider Verification: invalid state, invalid nonce, invalid audience, unverified email, and missing email are rejected.
- Linking Semantics: every D-01..D-17 decision has an integration test or explicit acceptance assertion.
- Session Continuity: social callback uses existing session cookie path and protected routes recognize the session.
- Regression: existing auth tests continue to pass.
- Config:
.env.exampledocuments provider secrets without real values. - UI: login/signup render equal provider buttons and disabled states.
Manual validation:
- Configure local test OAuth credentials or mocked callback path.
- Verify disabled provider buttons with missing env vars.
- Verify login/signup buttons route to provider start routes when configured.
Threat Model Notes
Plans must include a <threat_model> block because this phase handles authentication.
Threats to cover:
- CSRF/login CSRF via OAuth callback without state validation.
- Replay/substitution via missing nonce validation.
- Token confusion by accepting ID tokens with wrong issuer or audience.
- Account takeover by linking on unverified email.
- Account takeover by relinking a provider subject after email change.
- Session fixation if callback reuses an old session without rotation.
- Open redirect if callback supports return URLs in the future.
- Secret leakage through logs (
client_secret, auth code, ID token). - Email uniqueness conflict when updating
users.email.
Open Implementation Choices for Planner
- State/nonce storage: signed short-lived cookie vs DB-backed OAuth state table.
- Exact OAuth/OIDC library combination for Google and Apple.
- Exact callback method for Apple (
form_postvs query response mode). - Whether provider status view lives at
/accountor/account/providers. - Whether
provideris a text column with CHECK constraint or a Postgres enum.
Sources
- Google OpenID Connect docs: https://developers.google.com/identity/openid-connect/openid-connect
- Google OAuth 2.0 web-server flow: https://developers.google.com/identity/protocols/oauth2/web-server
- Apple token validation docs: https://developer.apple.com/documentation/SigninwithAppleRESTAPI/Generate-and-validate-tokens
- Apple authorization request docs: https://developer.apple.com/documentation/signinwithapplerestapi/request-an-authorization-to-the-sign-in-with-apple-server.
- Apple client secret docs: https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret
- Go OAuth2 package docs: https://pkg.go.dev/golang.org/x/oauth2
- go-oidc package docs: https://pkg.go.dev/github.com/coreos/go-oidc/v3/oidc
- go-jose package docs: https://pkg.go.dev/github.com/go-jose/go-jose/v4