docs(08): create social sign-in phase plans

This commit is contained in:
Arthur Belleville 2026-05-15 20:50:59 +02:00
parent 25e07a7f44
commit 2f4a4f9ebb
No known key found for this signature in database
7 changed files with 905 additions and 6 deletions

View file

@ -2,13 +2,13 @@
gsd_state_version: 1.0
milestone: v2.0
milestone_name: Collaboration, planning, and social sign-in
status: planning
last_updated: "2026-05-15T18:45:31.692Z"
last_activity: 2026-05-15
status: executing
last_updated: "2026-05-15T18:50:49.735Z"
last_activity: 2026-05-15 -- Phase 08 planning complete
progress:
total_phases: 5
completed_phases: 0
total_plans: 0
total_plans: 5
completed_plans: 0
percent: 0
---
@ -30,8 +30,8 @@ See: `.planning/PROJECT.md` (updated 2026-05-15)
Phase: 8 — Social Sign-in
Plan: —
Status: Ready for discussion/planning
Last activity: 2026-05-15 — Phase 8 UI-SPEC approved
Status: Ready to execute
Last activity: 2026-05-15 -- Phase 08 planning complete
## Phase Status

View file

@ -0,0 +1,184 @@
---
phase: 08-social-sign-in
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/migrations/0006_social_identities.sql
- backend/internal/db/queries/users.sql
- backend/internal/db/queries/user_identities.sql
- backend/internal/db/sqlc/
- backend/internal/auth/types.go
- backend/internal/auth/session.go
- backend/internal/web/handlers_auth.go
- backend/internal/web/handlers_auth_test.go
autonomous: true
requirements_addressed: [AUTH-09, AUTH-11, AUTH-12, AUTH-13]
requirements:
- AUTH-09
- AUTH-11
- AUTH-12
- AUTH-13
must_haves:
truths:
- "D-01: Verified provider email matching an existing Xtablo user automatically links the provider identity to that user."
- "D-02: Missing or unverified provider email is rejected before account creation or linking."
- "D-03: Once a provider subject is linked, provider identity wins over later email changes."
- "D-05: users.password_hash is nullable and email/password login only applies when a password hash is present."
- "D-06: Social-only users can exist without a password; add-password stays deferred."
- "D-07: Email/password signup for an email owned by a social-only user is blocked with the social-only conflict message."
- "D-08: Existing password users linked to a provider keep password login enabled."
- "D-13: Provider display name and avatar URL are stored when supplied."
- "D-14: Apple name is persisted immediately when present on the first callback payload."
- "D-15: Apple private relay emails are accepted when Apple marks them verified."
- "D-16: Provider email changes update user_identities.email while provider subject remains the durable key."
- "D-17: Provider email changes update users.email when possible, with explicit unique-email conflict handling."
- "AUTH-12: Social sign-in uses the same auth.Store/Create and SetSessionCookie path as email/password login."
- "AUTH-13: Existing signup, login, logout, CSRF, and rate limiting continue to work."
artifacts:
- path: "backend/migrations/0006_social_identities.sql"
provides: "nullable password_hash and user_identities schema"
- path: "backend/internal/db/queries/user_identities.sql"
provides: "identity lookup, insert, profile update, and linked-provider status queries"
- path: "backend/internal/web/handlers_auth.go"
provides: "email/password auth behavior compatible with nullable password_hash"
---
## Phase Goal
Create the local account and identity foundation that Google and Apple callbacks can use safely. This plan does not contact real providers; it establishes schema, generated queries, nullable password handling, and account-linking semantics that later vertical slices call.
<objective>
Implement the Phase 8 data model and local account-linking rules:
1. Make `users.password_hash` nullable for social-only users.
2. Add `user_identities` with unique `(provider, provider_subject)`, verified email, display name, avatar URL, and timestamps.
3. Add sqlc queries for identity lookup, insert, profile/email updates, and provider status.
4. Update auth/session user mapping so nullable password hashes do not panic or make social-only users password-login capable.
5. Update signup/login behavior for social-only conflicts and nullable password hashes.
6. Add tests for D-01, D-02, D-03, D-05, D-06, D-07, D-08, D-13, D-14, D-15, D-16, D-17.
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.codex/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-PATTERNS.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Add nullable-password and user_identities schema plus sqlc queries</name>
<files>
backend/migrations/0006_social_identities.sql
backend/internal/db/queries/users.sql
backend/internal/db/queries/user_identities.sql
backend/internal/db/sqlc/
</files>
<read_first>
- backend/migrations/0002_auth.sql
- backend/migrations/0004_tasks.sql
- backend/internal/db/queries/users.sql
- backend/internal/db/queries/sessions.sql
- backend/sqlc.yaml
- backend/go.mod
</read_first>
<action>
Add a goose migration `0006_social_identities.sql`. In `Up`, run `ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL`, create `user_identities` with `id uuid primary key default gen_random_uuid()`, `user_id uuid not null references users(id) on delete cascade`, `provider text not null check (provider in ('google','apple'))`, `provider_subject text not null`, `email citext not null`, `email_verified boolean not null default true`, nullable `display_name`, nullable `avatar_url`, `created_at`, `updated_at`, and nullable `last_login_at`. Add `unique(provider, provider_subject)` and an index on `user_id`. In `Down`, drop `user_identities` first and restore `users.password_hash SET NOT NULL` only after replacing NULLs with a sentinel impossible hash or blocking downgrade with an explicit SQL comment; do not silently make social-only rows password-login capable.
Add `user_identities.sql` queries: get by provider+subject, list by user id, insert identity, update identity login/profile metadata, and update identity email. Update `users.sql` with queries needed for social users: insert user with nullable password hash, get by email including nullable password hash, update user email by id, and detect social-only conflict.
Run sqlc generation using the repo's established command. Update generated files only through sqlc, not hand edits.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/db/... -count=1</automated>
</verify>
<acceptance_criteria>
- `backend/migrations/0006_social_identities.sql` contains `ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL`.
- `backend/migrations/0006_social_identities.sql` contains `UNIQUE (provider, provider_subject)`.
- `backend/internal/db/queries/user_identities.sql` contains named queries for provider-subject lookup, insert, update, and list-by-user behavior.
- Generated sqlc code compiles under `go test ./internal/db/... -count=1`.
</acceptance_criteria>
<done>
Schema and generated query code support nullable password users and provider identities without manual generated-code edits.
</done>
</task>
<task type="auto">
<name>Task 2: Adapt local auth to nullable password hashes and social-only conflicts</name>
<files>
backend/internal/auth/types.go
backend/internal/auth/session.go
backend/internal/web/handlers_auth.go
backend/internal/web/handlers_auth_test.go
</files>
<read_first>
- backend/internal/auth/types.go
- backend/internal/auth/session.go
- backend/internal/auth/password.go
- backend/internal/web/handlers_auth.go
- backend/internal/web/handlers_auth_test.go
- backend/templates/auth_forms.go
- backend/templates/auth_signup.templ
</read_first>
<action>
Update `auth.User.PasswordHash` and session-row mapping to represent nullable password hashes explicitly. Email/password login must return the existing generic `Invalid email or password` error when `password_hash` is NULL. Email/password signup must check for an existing user by email before insert; if the existing user has NULL password hash, render the exact UI copy from `08-UI-SPEC.md`: `An account already exists for this email. Sign in with your provider.` Existing password users keep password login unchanged after a social identity is linked.
Add tests proving: social-only user cannot log in with email/password; social-only email/password signup is blocked with the social-only copy; existing password user login still succeeds; existing auth CSRF/rate-limit/logout tests still pass.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/auth ./internal/web -run 'Test.*(Login|Signup|Session|SocialOnly)' -count=1</automated>
</verify>
<acceptance_criteria>
- Login with a NULL `password_hash` user returns the same generic invalid-credentials path as a bad password.
- Signup for a NULL `password_hash` existing user renders `An account already exists for this email. Sign in with your provider.`
- Existing password signup/login/logout/rate-limit tests still pass.
- No provider token, auth code, or raw OAuth error is introduced into auth form errors.
</acceptance_criteria>
<done>
Existing local auth works with nullable password hashes and social-only conflict handling.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Risk | Mitigation |
|----------|------|------------|
| Provider claims to local account | Account takeover by unverified email or wrong subject | D-01/D-02/D-03 enforced in transaction tests before callbacks can use the path |
| Nullable local password | Social-only row accidentally accepted by password login | Login requires present password hash and keeps generic error on NULL |
| Email update | Provider email collides with another local user | D-17 conflict path updates identity metadata, does not silently relink or overwrite another account |
## Threat Register
| Threat ID | Severity | Component | Mitigation |
|-----------|----------|-----------|------------|
| T-08-01 | high | `user_identities` linking | Unique `(provider, provider_subject)` and transaction lookup by subject first |
| T-08-02 | high | social-only signup conflict | Block email/password signup for NULL-password existing user |
| T-08-03 | medium | provider profile storage | Store display/avatar only as metadata, never as authorization source |
</threat_model>
<verification>
Run:
```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/db/... ./internal/auth ./internal/web -count=1
```
</verification>
<success_criteria>
1. `users.password_hash` is nullable after migrations.
2. `user_identities` supports Google and Apple subject-based linking.
3. Email/password auth behavior is unchanged for password users and generic for NULL-password users.
4. D-01, D-02, D-03, D-05, D-06, D-07, D-08, D-13, D-14, D-15, D-16, and D-17 are covered by tests or explicit assertions.
</success_criteria>

View file

@ -0,0 +1,174 @@
---
phase: 08-social-sign-in
plan: 02
type: execute
wave: 2
depends_on:
- 08-01
files_modified:
- backend/go.mod
- backend/go.sum
- backend/internal/auth/oauth.go
- backend/internal/auth/oauth_test.go
- backend/internal/web/auth_deps.go
- backend/cmd/web/main.go
- backend/internal/web/router.go
- backend/internal/web/handlers_social.go
- backend/internal/web/handlers_social_test.go
autonomous: true
requirements_addressed: [AUTH-08, AUTH-09, AUTH-12, AUTH-13]
requirements:
- AUTH-08
- AUTH-09
- AUTH-12
- AUTH-13
must_haves:
truths:
- "D-01: Google verified email auto-links to an existing Xtablo user."
- "D-02: Google missing or unverified email is rejected before local account creation/linking."
- "D-03: Google provider subject wins after link, even if email changes."
- "D-12: Successful Google sign-in redirects to `/`."
- "D-13: Google display name and avatar URL are stored when supplied."
- "D-16: Google email changes update user_identities.email while provider subject remains durable."
- "D-17: Google email changes update users.email when possible and handle unique conflicts explicitly."
- "AUTH-12: Google callback issues the existing Xtablo session cookie."
- "AUTH-13: Existing local auth middleware and CSRF behavior continue to pass."
artifacts:
- path: "backend/internal/auth/oauth.go"
provides: "provider config, state/nonce cookie helpers, verifier interfaces, normalized claims"
- path: "backend/internal/web/handlers_social.go"
provides: "Google start and callback handlers"
---
## Phase Goal
Deliver a complete Google sign-in vertical slice using local Xtablo users and sessions.
<objective>
Add provider primitives and Google OAuth/OIDC flow:
1. Add provider config parsing and missing-config detection.
2. Add state/nonce helpers with short-lived HTTP-only cookies.
3. Add normalized provider claims and verifier interfaces for mockable tests.
4. Add `/auth/google/start` and `/auth/google/callback`.
5. Exchange authorization code, verify Google ID token, link/create local user, create Xtablo session, and redirect to `/`.
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.codex/get-shit-done/workflows/execute-plan.md
</execution_context>
<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-PATTERNS.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Add OAuth provider primitives and Google config wiring</name>
<files>
backend/go.mod
backend/go.sum
backend/internal/auth/oauth.go
backend/internal/auth/oauth_test.go
backend/internal/web/auth_deps.go
backend/cmd/web/main.go
</files>
<read_first>
- backend/internal/auth/csrf.go
- backend/internal/auth/session.go
- backend/internal/web/handlers_auth.go
- backend/cmd/web/main.go
- backend/go.mod
</read_first>
<action>
Add direct dependencies `golang.org/x/oauth2` and `github.com/coreos/go-oidc/v3/oidc`. Create `auth.ProviderConfig` and `auth.OAuthConfig` with Google fields: client ID, client secret, redirect URL, scopes, auth URL/token URL/issuer values. Add `Configured()` methods that return false when required Google env vars are missing. Add state and nonce helpers that create cryptographically random values, set short-lived HTTP-only cookies, validate returned values with constant-time comparison where applicable, and clear cookies on callback.
Add a verifier interface so handler tests can use fake Google claims without contacting real Google. Normalized claims must include provider, subject, email, email_verified, display_name, avatar_url, and nonce.
In `cmd/web/main.go`, read `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and `GOOGLE_REDIRECT_URL`, pass config through `web.AuthDeps`, and do not log secret values.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/auth -run 'Test.*(OAuth|State|Nonce|ProviderConfig)' -count=1</automated>
</verify>
<acceptance_criteria>
- `backend/internal/auth/oauth.go` exposes a Google config whose `Configured()` is false with empty env values.
- OAuth state and nonce tests fail for mismatched values and pass for matching values.
- No tests call real Google endpoints.
- `cmd/web/main.go` reads Google env vars without logging `GOOGLE_CLIENT_SECRET`.
</acceptance_criteria>
<done>
Google config and provider primitives are testable without network access.
</done>
</task>
<task type="auto">
<name>Task 2: Implement Google start/callback handlers and linking flow</name>
<files>
backend/internal/web/router.go
backend/internal/web/handlers_social.go
backend/internal/web/handlers_social_test.go
backend/internal/db/queries/user_identities.sql
backend/internal/db/sqlc/
</files>
<read_first>
- backend/internal/web/router.go
- backend/internal/web/handlers_auth.go
- backend/internal/auth/session.go
- backend/internal/db/queries/user_identities.sql
- backend/internal/db/queries/users.sql
- backend/internal/web/handlers_auth_test.go
</read_first>
<action>
Register `GET /auth/google/start` and `GET /auth/google/callback` outside the protected route group. The start handler must reject missing Google config with a safe response, set state/nonce cookies, and redirect to Google's authorization URL with `openid email profile` scope. The callback must validate state, exchange the authorization code server-side, verify the ID token through the configured verifier, reject missing/unverified email with `This provider did not return a verified email. Try another sign-in method.`, and run provider linking in a DB transaction.
Transaction order: get identity by provider+subject; if found, update metadata/email and sign in that user; if not found, require verified email, look up local user by email, link existing user or create a social-only user with NULL password hash, then insert identity. If provider subject is already linked and email changes, update `user_identities.email`; update `users.email` only when no unique conflict occurs. Never relink the subject to another user because of an email change.
Create a local session using existing `auth.Store.Create` and `auth.SetSessionCookie`, then redirect to `/`.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web -run 'Test.*Google.*(Start|Callback|Link|Session|Unverified|Conflict)' -count=1</automated>
</verify>
<acceptance_criteria>
- `GET /auth/google/start` returns a redirect and sets state/nonce cookies when configured.
- Google callback with invalid state is rejected and creates no user/session.
- Google callback with unverified or missing email renders `This provider did not return a verified email. Try another sign-in method.`
- Google callback with verified email matching a password user inserts `user_identities` and preserves password login.
- Google callback with a new verified email creates a social-only user and sets the Xtablo session cookie.
- Google callback for an existing provider subject signs in the linked user even when the returned email changed.
- Email update conflict does not relink the provider subject to another user.
</acceptance_criteria>
<done>
Google sign-in is a tested end-to-end local session flow.
</done>
</task>
</tasks>
<threat_model>
| Threat ID | Severity | Component | Mitigation |
|-----------|----------|-----------|------------|
| T-08-04 | high | Google callback | Validate OAuth state before token exchange or linking |
| T-08-05 | high | Google ID token | Verify issuer, audience, expiry, subject, email, and email_verified through OIDC verifier |
| T-08-06 | high | Account linking | Link on verified email only when provider subject is not already known |
| T-08-07 | medium | Logging | Do not log auth code, ID token, access token, or client secret |
| T-08-08 | medium | Session fixation | Always issue the normal local session cookie after successful callback |
</threat_model>
<verification>
Run:
```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/auth ./internal/web -run 'Test.*(OAuth|Google|Login|Signup|Session)' -count=1
```
</verification>
<success_criteria>
1. Login/signup can start Google sign-in once UI plan links to `/auth/google/start`.
2. Google callback validates state, exchanges code, verifies ID token, creates/links users, and issues the local session.
3. Google tests cover verified email linking, unverified email rejection, provider-subject precedence, and email conflict handling.
</success_criteria>

View file

@ -0,0 +1,170 @@
---
phase: 08-social-sign-in
plan: 03
type: execute
wave: 3
depends_on:
- 08-01
- 08-02
files_modified:
- backend/go.mod
- backend/go.sum
- backend/internal/auth/oauth.go
- backend/internal/auth/oauth_test.go
- backend/internal/web/auth_deps.go
- backend/cmd/web/main.go
- backend/internal/web/router.go
- backend/internal/web/handlers_social.go
- backend/internal/web/handlers_social_test.go
autonomous: true
requirements_addressed: [AUTH-10, AUTH-11, AUTH-12, AUTH-13]
requirements:
- AUTH-10
- AUTH-11
- AUTH-12
- AUTH-13
must_haves:
truths:
- "D-01: Apple verified email auto-links to an existing Xtablo user."
- "D-02: Apple missing or unverified email is rejected before local account creation/linking."
- "D-03: Apple provider subject wins after link, even if email changes."
- "D-12: Successful Apple sign-in redirects to `/`."
- "D-13: Apple display name and avatar URL are stored when supplied."
- "D-14: Apple name is persisted immediately when present on the first callback payload."
- "D-15: Apple private relay emails are accepted when Apple marks them verified."
- "D-16: Apple email changes update user_identities.email while provider subject remains durable."
- "D-17: Apple email changes update users.email when possible and handle unique conflicts explicitly."
- "AUTH-12: Apple callback issues the existing Xtablo session cookie."
- "AUTH-13: Existing local auth middleware and CSRF behavior continue to pass."
artifacts:
- path: "backend/internal/auth/oauth.go"
provides: "Apple client-secret signing, nonce validation, and Apple verifier config"
- path: "backend/internal/web/handlers_social.go"
provides: "Apple start and callback handlers"
---
## Phase Goal
Deliver a complete Sign in with Apple vertical slice using the same local account-linking and session path as Google.
<objective>
Add Apple OAuth/OIDC flow:
1. Parse Apple provider config without logging private key material.
2. Generate Apple ES256 client-secret JWTs for token exchange.
3. Add `/auth/apple/start` and Apple callback handling.
4. Validate state and nonce, verify Apple ID token, persist first-login name when present, link/create local users, and issue local Xtablo session.
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.codex/get-shit-done/workflows/execute-plan.md
</execution_context>
<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-PATTERNS.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Add Apple config, client-secret signing, and verifier support</name>
<files>
backend/go.mod
backend/go.sum
backend/internal/auth/oauth.go
backend/internal/auth/oauth_test.go
backend/internal/web/auth_deps.go
backend/cmd/web/main.go
</files>
<read_first>
- backend/internal/auth/oauth.go
- backend/internal/auth/oauth_test.go
- backend/cmd/web/main.go
- backend/internal/web/auth_deps.go
- backend/go.mod
</read_first>
<action>
Add `github.com/go-jose/go-jose/v4` if needed for Apple ES256 signing. Extend provider config with `APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY`, and `APPLE_REDIRECT_URL`. `Configured()` must require all Apple fields. Implement Apple client-secret generation with issuer `APPLE_TEAM_ID`, subject/audience for Apple, `kid` header from `APPLE_KEY_ID`, and an expiration bounded to Apple's allowed lifetime. Normalize multiline/private-key env formats without printing the key.
Extend the verifier abstraction for Apple ID tokens and nonce validation. Unit tests must inspect JWT claims and headers without using real Apple credentials.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/auth -run 'Test.*Apple.*(Config|ClientSecret|Nonce|Verifier)' -count=1</automated>
</verify>
<acceptance_criteria>
- Apple config is disabled when any required Apple env var is empty.
- Apple client-secret tests assert `iss`, `sub`, `aud`, `exp`, and `kid`.
- Apple private key material is never logged or included in error responses.
- Nonce mismatch tests fail callback verification before account linking.
</acceptance_criteria>
<done>
Apple provider primitives are configured and tested without real Apple network calls.
</done>
</task>
<task type="auto">
<name>Task 2: Implement Apple start/callback and first-login profile handling</name>
<files>
backend/internal/web/router.go
backend/internal/web/handlers_social.go
backend/internal/web/handlers_social_test.go
</files>
<read_first>
- backend/internal/web/router.go
- backend/internal/web/handlers_social.go
- backend/internal/web/handlers_social_test.go
- backend/internal/auth/oauth.go
- backend/internal/db/queries/user_identities.sql
- backend/internal/auth/session.go
</read_first>
<action>
Register `GET /auth/apple/start` and the Apple callback route selected by implementation. Prefer a callback shape that works with Apple `form_post`; if using POST, account for existing CSRF middleware by routing or validating provider state before processing, without weakening CSRF for local form posts. The start handler sets state and nonce cookies and redirects to Apple authorization with scopes `name email` and the chosen response mode.
Callback processing validates state and nonce, exchanges the authorization code with Apple using the generated client secret, verifies ID token issuer/audience/expiry/subject/email/email_verified, accepts Apple private relay emails when verified, persists Apple name from the first authorization payload when present, links/creates local users through the shared transaction path, creates the existing Xtablo session cookie, and redirects to `/`.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web -run 'Test.*Apple.*(Start|Callback|Nonce|Relay|Name|Session|Conflict)' -count=1</automated>
</verify>
<acceptance_criteria>
- `GET /auth/apple/start` returns a redirect and sets state/nonce cookies when configured.
- Apple callback with invalid state or nonce creates no user/session.
- Apple callback with unverified/missing email renders `This provider did not return a verified email. Try another sign-in method.`
- Apple private relay email marked verified creates or links a local user.
- Apple first-login name is stored as display name when present.
- Apple callback for an existing subject signs in the linked user despite email changes.
- Apple callback issues the Xtablo session cookie and redirects to `/`.
</acceptance_criteria>
<done>
Apple sign-in is a tested end-to-end local session flow.
</done>
</task>
</tasks>
<threat_model>
| Threat ID | Severity | Component | Mitigation |
|-----------|----------|-----------|------------|
| T-08-09 | high | Apple callback | Validate OAuth state and ID-token nonce before token claims affect local accounts |
| T-08-10 | high | Apple ID token | Verify issuer, audience, expiry, subject, email, email_verified, and nonce |
| T-08-11 | high | Apple client secret | Keep private key server-side, never log it, and bound JWT expiration |
| T-08-12 | medium | Apple first-login name | Treat name as display metadata only, never as identity proof |
| T-08-13 | medium | CSRF middleware interaction | Do not bypass local CSRF protection beyond the provider callback route that is protected by OAuth state |
</threat_model>
<verification>
Run:
```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/auth ./internal/web -run 'Test.*(Apple|Login|Signup|Session)' -count=1
```
</verification>
<success_criteria>
1. Login/signup can start Apple sign-in once UI plan links to `/auth/apple/start`.
2. Apple callback validates state/nonce, exchanges code, verifies ID token, creates/links users, and issues the local session.
3. Apple tests cover verified relay email acceptance, first-login name persistence, subject precedence, and email conflict handling.
</success_criteria>

View file

@ -0,0 +1,157 @@
---
phase: 08-social-sign-in
plan: 04
type: execute
wave: 4
depends_on:
- 08-02
- 08-03
files_modified:
- backend/templates/auth_forms.go
- backend/templates/auth_login.templ
- backend/templates/auth_signup.templ
- backend/templates/*_templ.go
- backend/internal/web/handlers_auth.go
- backend/internal/web/handlers_auth_test.go
- backend/internal/web/ui/button.css
autonomous: true
requirements_addressed: [AUTH-08, AUTH-10, AUTH-13]
requirements:
- AUTH-08
- AUTH-10
- AUTH-13
must_haves:
truths:
- "D-09: Google and Apple sign-in buttons appear on both login and signup pages."
- "D-10: Missing provider config renders disabled not-configured provider buttons."
- "D-11: Google and Apple have equal prominence wherever provider buttons are shown."
- "D-12: Successful social sign-in redirects to `/`; UI does not add provider-specific welcome pages."
- "AUTH-08: User can start Google sign-in from login/signup."
- "AUTH-10: User can start Apple sign-in from login/signup."
- "AUTH-13: Existing email/password forms and validation remain available and tested."
artifacts:
- path: "backend/templates/auth_login.templ"
provides: "login provider entry points"
- path: "backend/templates/auth_signup.templ"
provides: "signup provider entry points"
- path: "backend/internal/web/ui/button.css"
provides: "neutral provider button styling"
---
## Phase Goal
Expose the Google and Apple start routes in the existing auth pages while preserving the current simple auth UI.
<objective>
Implement the UI contract for provider buttons:
1. Login and signup show equal Google/Apple provider entry points.
2. Missing provider config renders visible disabled buttons with exact not-configured copy.
3. Provider buttons do not submit the email/password forms.
4. Existing form validation and layout stay intact.
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.codex/get-shit-done/workflows/execute-plan.md
</execution_context>
<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-UI-SPEC.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-PATTERNS.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Add provider button template data and neutral button pattern</name>
<files>
backend/templates/auth_forms.go
backend/internal/web/handlers_auth.go
backend/internal/web/ui/button.css
</files>
<read_first>
- backend/templates/auth_forms.go
- backend/internal/web/handlers_auth.go
- backend/internal/web/ui/button.templ
- backend/internal/web/ui/button.css
- .planning/phases/08-social-sign-in/08-UI-SPEC.md
</read_first>
<action>
Add template data for provider availability and provider start URLs. Use exact labels from `08-UI-SPEC.md`: `Continue with Google`, `Continue with Apple`, `Google sign-in not configured`, `Apple sign-in not configured`, and separator `or`. Provider buttons must be full width, at least 44px high, neutral outline, stacked with 8px gap, and not submit the email/password form. Enabled controls link to `/auth/google/start` and `/auth/apple/start`; disabled controls use native disabled button semantics or `aria-disabled="true"` with no actionable `href`.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web -run 'Test.*Auth.*Provider.*(Configured|Disabled)' -count=1</automated>
</verify>
<acceptance_criteria>
- Provider label constants or template output include `Continue with Google` and `Continue with Apple`.
- Disabled template output includes `Google sign-in not configured` and `Apple sign-in not configured`.
- Provider controls do not live inside a form submit button path.
- CSS or classes establish a neutral outline provider button with minimum height 44px.
</acceptance_criteria>
<done>
Auth handlers can render enabled or disabled provider button states.
</done>
</task>
<task type="auto">
<name>Task 2: Render provider buttons on login/signup and regenerate templ output</name>
<files>
backend/templates/auth_login.templ
backend/templates/auth_signup.templ
backend/templates/*_templ.go
backend/internal/web/handlers_auth_test.go
</files>
<read_first>
- backend/templates/auth_login.templ
- backend/templates/auth_signup.templ
- backend/templates/auth_forms.go
- backend/internal/web/handlers_auth.go
- backend/internal/web/handlers_auth_test.go
- backend/justfile
</read_first>
<action>
Insert the provider block below each auth page heading and above the existing email/password form. Keep the centered card, `w-full max-w-sm`, existing padding, form labels, and validation error rendering. Add a separator with left line, center text `or`, and right line. Regenerate templ output using the repo's established command.
Add handler/template tests for: login page with both providers configured has links to `/auth/google/start` and `/auth/apple/start`; signup page has the same; missing Google config shows disabled Google copy; missing Apple config shows disabled Apple copy; existing email/password form fields still render.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web -run 'Test.*(Login|Signup).*Provider' -count=1</automated>
</verify>
<acceptance_criteria>
- `/login` rendered HTML contains `Continue with Google`, `Continue with Apple`, and `or`.
- `/signup` rendered HTML contains `Continue with Google`, `Continue with Apple`, and `or`.
- Configured providers render actionable links to `/auth/google/start` and `/auth/apple/start`.
- Missing provider config renders disabled not-configured copy and no actionable start URL for that provider.
- Existing login/signup email and password inputs still render.
</acceptance_criteria>
<done>
Login and signup satisfy AUTH-08, AUTH-10, and the approved UI-SPEC.
</done>
</task>
</tasks>
<threat_model>
| Threat ID | Severity | Component | Mitigation |
|-----------|----------|-----------|------------|
| T-08-14 | medium | Provider buttons | Enabled buttons use GET start routes only; disabled buttons have no actionable href |
| T-08-15 | low | UI error copy | UI never exposes raw OAuth errors, token values, claim names, or secret names |
| T-08-16 | low | Auth form regression | Provider controls do not submit the email/password form or bypass existing CSRF on local POSTs |
</threat_model>
<verification>
Run:
```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web -run 'Test.*(Login|Signup|Provider)' -count=1
```
</verification>
<success_criteria>
1. Login and signup pages both show Google and Apple entry points.
2. Disabled provider states are visible when config is missing.
3. Existing email/password form behavior remains available and tested.
</success_criteria>

View file

@ -0,0 +1,172 @@
---
phase: 08-social-sign-in
plan: 05
type: execute
wave: 5
depends_on:
- 08-01
- 08-02
- 08-03
- 08-04
files_modified:
- backend/templates/account_providers.templ
- backend/templates/*_templ.go
- backend/internal/web/handlers_account.go
- backend/internal/web/handlers_account_test.go
- backend/internal/web/router.go
- backend/.env.example
- backend/README.md
autonomous: true
requirements_addressed: [AUTH-08, AUTH-09, AUTH-10, AUTH-11, AUTH-12, AUTH-13]
requirements:
- AUTH-08
- AUTH-09
- AUTH-10
- AUTH-11
- AUTH-12
- AUTH-13
must_haves:
truths:
- "D-04: Phase 8 includes a simple linked providers account view showing Google/Apple connection status; unlinking is deferred."
- "D-09: Google and Apple buttons remain on both login and signup pages."
- "D-10: Missing provider config remains visible as disabled buttons in local/dev."
- "D-11: Google and Apple remain equal prominence."
- "D-12: Successful provider sign-in redirects to `/`."
- "D-13: Linked providers view can show stored provider email/profile metadata without exposing tokens."
- "AUTH-08: Google start route is visible from auth UI."
- "AUTH-09: Google callback creates/links users and issues local session."
- "AUTH-10: Apple start route is visible from auth UI."
- "AUTH-11: Apple callback creates/links users and issues local session."
- "AUTH-12: Social sign-in uses Xtablo server-managed session cookies."
- "AUTH-13: Existing auth regression suite remains green."
artifacts:
- path: "backend/templates/account_providers.templ"
provides: "minimal linked providers status view"
- path: "backend/.env.example"
provides: "Google and Apple provider config placeholders"
- path: "backend/README.md"
provides: "operator setup notes for provider config"
---
## Phase Goal
Finish the user-visible account status view, operator docs, and full regression coverage for Phase 8.
<objective>
Complete Phase 8 hardening:
1. Add a protected linked-provider status view with Google and Apple rows.
2. Document required Google and Apple env vars without committing secrets.
3. Run full auth regression coverage across email/password, Google, Apple, sessions, CSRF, and rate limiting.
4. Confirm every Phase 8 requirement AUTH-08 through AUTH-13 is verifiable.
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.codex/get-shit-done/workflows/execute-plan.md
</execution_context>
<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-UI-SPEC.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-PATTERNS.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Add protected linked providers status view</name>
<files>
backend/templates/account_providers.templ
backend/templates/*_templ.go
backend/internal/web/handlers_account.go
backend/internal/web/handlers_account_test.go
backend/internal/web/router.go
</files>
<read_first>
- backend/internal/web/router.go
- backend/internal/auth/middleware.go
- backend/templates/layout.templ
- backend/templates/tablos_list.templ
- backend/internal/db/queries/user_identities.sql
- .planning/phases/08-social-sign-in/08-UI-SPEC.md
</read_first>
<action>
Add a protected route, preferably `GET /account/providers`, inside the `auth.RequireAuth` group. Query linked identities for the current user and render `Linked providers` with one Google row and one Apple row. Each row shows `Connected` plus stored provider email when present, otherwise `Not connected`. Do not add unlink buttons or add-password UI. Regenerate templ output.
Keep layout minimal: single column on mobile, bordered rows or subtle row surfaces, no nested cards, no marketing copy, no token/provider diagnostic details.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web -run 'Test.*LinkedProviders' -count=1</automated>
</verify>
<acceptance_criteria>
- Unauthenticated request to `/account/providers` redirects to `/login` or returns the existing auth-required HTMX redirect.
- Authenticated user with Google identity sees `Linked providers`, `Google`, `Connected`, and that identity email.
- Authenticated user without Apple identity sees `Apple` and `Not connected`.
- Rendered HTML contains no unlink button and no add-password copy.
</acceptance_criteria>
<done>
Linked provider status view satisfies D-04 and the UI-SPEC.
</done>
</task>
<task type="auto">
<name>Task 2: Add provider env docs and run full Phase 8 regression</name>
<files>
backend/.env.example
backend/README.md
backend/internal/web/handlers_social_test.go
backend/internal/web/handlers_auth_test.go
</files>
<read_first>
- backend/.env.example
- backend/README.md
- .planning/phases/08-social-sign-in/08-RESEARCH.md
- backend/internal/web/handlers_social_test.go
- backend/internal/web/handlers_auth_test.go
</read_first>
<action>
Add placeholder-only env documentation for `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URL`, `APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY`, and `APPLE_REDIRECT_URL`. Do not include real secrets. README should state that missing provider config keeps buttons visible but disabled in local/dev.
Run and fix failures in full auth-related regression. Required behavior: Google and Apple start routes work when configured; callbacks reject invalid state/nonce/unverified email; callbacks create/link users and issue Xtablo sessions; existing signup/login/logout/CSRF/rate-limit tests pass.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1</automated>
</verify>
<acceptance_criteria>
- `backend/.env.example` contains all Google and Apple env var names with placeholder values only.
- `backend/README.md` documents missing-config disabled provider buttons.
- `go test ./... -count=1` exits 0.
- Test names or assertions cover AUTH-08, AUTH-09, AUTH-10, AUTH-11, AUTH-12, and AUTH-13.
</acceptance_criteria>
<done>
Phase 8 is documented and the full backend test suite passes.
</done>
</task>
</tasks>
<threat_model>
| Threat ID | Severity | Component | Mitigation |
|-----------|----------|-----------|------------|
| T-08-17 | low | Linked providers view | Protected by `auth.RequireAuth`; only current user's identities are queried |
| T-08-18 | medium | Env docs | `.env.example` and README use placeholders only, no committed secrets |
| T-08-19 | medium | Regression drift | Full `go test ./...` must pass after Google, Apple, nullable-password, UI, and account-view changes |
</threat_model>
<verification>
Run:
```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1
```
</verification>
<success_criteria>
1. Protected linked-provider view shows Google/Apple connection status and provider email when connected.
2. Provider env vars are documented with placeholders only.
3. Full backend suite passes.
4. AUTH-08 through AUTH-13 are all covered by final regression evidence.
</success_criteria>

View file

@ -0,0 +1,42 @@
# Phase 8: Social Sign-in - Pattern Map
**Generated:** 2026-05-15
**Purpose:** Map Phase 8 files to closest existing codebase analogs before execution.
## File Pattern Map
| Target file | Role | Closest existing analogs | Pattern to preserve |
|-------------|------|--------------------------|---------------------|
| `backend/migrations/0006_social_identities.sql` | Auth schema migration | `backend/migrations/0002_auth.sql`, `backend/migrations/0004_tasks.sql` | Goose SQL migration with `-- +goose Up/Down`, citext-friendly auth schema, explicit constraints and indexes |
| `backend/internal/db/queries/user_identities.sql` | sqlc identity persistence queries | `backend/internal/db/queries/users.sql`, `backend/internal/db/queries/sessions.sql` | Small named queries with typed params, no dynamic SQL, transaction-safe use through generated `WithTx` |
| `backend/internal/db/queries/users.sql` | Nullable password and lookup changes | Existing `users.sql` | Preserve `InsertUser`/`GetUserByEmail` names only if sqlc call sites are updated together |
| `backend/internal/auth/types.go` | Domain user shape | Existing `auth.User` | Mirror sqlc nullable password shape without treating social-only users as password users |
| `backend/internal/auth/session.go` | Session lookup/store mapping | Existing `auth.Store` methods | Keep local DB-backed sessions authoritative; provider tokens never become sessions |
| `backend/internal/auth/oauth.go` | Provider config, state, nonce, and verifier contracts | `backend/internal/auth/csrf.go`, `backend/internal/auth/password.go`, `backend/internal/auth/session.go` | Package-local auth primitives with unit tests, standard Go error handling |
| `backend/internal/auth/oauth_test.go` | Auth primitive tests | `backend/internal/auth/csrf_test.go`, `backend/internal/auth/password_test.go`, `backend/internal/auth/session_test.go` | Table-driven tests, no real provider network calls |
| `backend/internal/web/handlers_social.go` | OAuth start/callback handlers | `backend/internal/web/handlers_auth.go`, `backend/internal/web/handlers_tasks.go` | Dependency struct injection, clear redirects, form/callback parsing compatible with CSRF middleware |
| `backend/internal/web/handlers_social_test.go` | OAuth handler tests | `backend/internal/web/handlers_auth_test.go`, `backend/internal/web/handlers_tasks_test.go` | `httptest` handler coverage, local DB/test deps, no real Google/Apple calls |
| `backend/templates/auth_login.templ` | Login provider buttons | Existing `LoginPage` template | Keep centered `w-full max-w-sm` auth card and existing error rendering |
| `backend/templates/auth_signup.templ` | Signup provider buttons | Existing `SignupPage` template | Same provider block as login, existing signup validation preserved |
| `backend/templates/auth_forms.go` | Template data structs | Existing login/signup form structs | Add provider status/error data without echoing passwords or tokens |
| `backend/internal/web/ui/button.css` | Neutral provider button styling | Existing local UI button CSS | Add neutral outline/disabled state with stable 44px height, no broad design-system rewrite |
| `backend/templates/account_providers.templ` | Linked providers view | `backend/templates/tablos_list.templ`, `backend/templates/layout.templ` | Protected app surface, simple rows, no nested cards or unlink controls |
| `backend/cmd/web/main.go` | Provider env wiring | Existing env parsing for `SESSION_SECRET`, S3, upload size | Read env vars once at startup, no secret values in logs |
| `backend/internal/web/router.go` | Route registration | Existing auth and protected route groups | Start/callback routes outside `RequireAuth`, account status route inside `RequireAuth`, static routes before parametric routes |
| `backend/.env.example`, `backend/README.md` | Operator docs | Existing env docs | Placeholder config only, no secrets, local missing-config behavior documented |
## Data Flow Pattern
1. Browser clicks `/auth/{provider}/start`.
2. Handler validates provider config, creates state/nonce, stores them in short-lived HTTP-only cookies, and redirects to provider.
3. Callback validates state and nonce, exchanges the code, verifies the ID token, and normalizes provider claims.
4. Account-linking code runs in a database transaction: provider subject first, verified email second, create/link identity, update profile metadata.
5. Handler calls the existing `auth.Store.Create` and `auth.SetSessionCookie`, then redirects to `/`.
## Constraints To Carry Into Plans
- D-01 through D-17 from `08-CONTEXT.md` must be visible in plan `must_haves`.
- AUTH-08 through AUTH-13 must all appear in plan frontmatter.
- Every plan handling authentication needs a `<threat_model>` block.
- UI changes must follow `08-UI-SPEC.md`: equal Google/Apple prominence, disabled missing-config states, neutral provider buttons, no provider-specific welcome page.