# 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:

1. Add `user_identities` and make `users.password_hash` nullable.
2. Add provider config parsing and disabled-button state.
3. Add Google start/callback.
4. Add Apple start/callback, including ES256 client-secret generation and Apple ID token verification.
5. Add linked-provider status view.
6. 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_hash` nullable 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.email` and local `users.email` when 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 `NULL` as "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 cascade`
- `provider text not null` or enum constrained to `google`, `apple`
- `provider_subject text not null`
- `email citext not null`
- `email_verified boolean not null`
- `display_name text`
- `avatar_url text`
- `created_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:

1. Look up identity by `(provider, provider_subject)`.
2. If found: update identity profile/email metadata, update local `users.email` if needed, create session for identity's `user_id`.
3. If not found: require verified email.
4. Look up local `users.email`.
5. If found: insert identity for that user.
6. 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/start`
- `GET /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/callback`
- `POST or GET /auth/apple/callback` depending 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_hash` and 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}/start` redirects and `/auth/{provider}/callback` session 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/web`
- `go test ./internal/db/... ./internal/web -run 'Test.*(Social|OAuth|Provider|Login|Signup|Session)'`

Validation dimensions:

1. **Schema:** migrations apply cleanly; `password_hash` nullable; `user_identities` constraints exist.
2. **Provider Verification:** invalid state, invalid nonce, invalid audience, unverified email, and missing email are rejected.
3. **Linking Semantics:** every D-01..D-17 decision has an integration test or explicit acceptance assertion.
4. **Session Continuity:** social callback uses existing session cookie path and protected routes recognize the session.
5. **Regression:** existing auth tests continue to pass.
6. **Config:** `.env.example` documents provider secrets without real values.
7. **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_post` vs query response mode).
- Whether provider status view lives at `/account` or `/account/providers`.
- Whether `provider` is 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

---

## RESEARCH COMPLETE
