diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 5a11a2e..882ba1e 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -11,8 +11,8 @@ Requirements for milestone v2.0. Each requirement must map to exactly one roadma - [x] **AUTH-08**: User can start a Google sign-in flow from the login/signup page - [x] **AUTH-09**: Google callback validates state, exchanges the authorization code, verifies the ID token, and creates or links a local Xtablo user -- [x] **AUTH-10**: User can start an Apple sign-in flow from the login/signup page -- [x] **AUTH-11**: Apple callback validates state/nonce, exchanges the authorization code, verifies the ID token, and creates or links a local Xtablo user +- [x] **AUTH-10**: Apple sign-in is disabled and hidden from the login/signup page +- [x] **AUTH-11**: Direct Apple sign-in routes are unavailable while Apple sign-in is disabled - [x] **AUTH-12**: Social sign-in issues the same server-managed Xtablo session cookie used by email/password login - [x] **AUTH-13**: Existing email/password login, signup, logout, CSRF, and rate limiting continue to work after social sign-in is added @@ -79,7 +79,7 @@ Deferred beyond v2.0. | Feature | Reason | |---------|--------| | Managed chat/realtime providers | User explicitly does not want third-party chat | -| Managed auth platforms | Google/Apple are identity providers only; Xtablo owns users and sessions | +| Managed auth platforms | Google is an identity provider only; Xtablo owns users and sessions. Apple sign-in is disabled for now. | | WebSocket-first chat protocol | SSE receive + HTMX POST send is the recommended v2 path unless plan-phase proves WebSockets are needed | | Nested etapes or arbitrary task hierarchy | User requested one parent per task and no parent-of-parent | | Notes / rich documents | Not part of the requested v2 feature set | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index c193ea0..69dafb3 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -12,7 +12,7 @@ | # | Phase | Goal | Requirements | |---|-------|------|--------------| -| 8 | Social Sign-in | Google and Apple sign-in create/link local users and issue existing Xtablo sessions | AUTH-08..13 | +| 8 | Social Sign-in | Google sign-in creates/links local users and issues existing Xtablo sessions; Apple sign-in is disabled | AUTH-08..13 | | 9 | Etapes | Tasks can be grouped under one-level etapes without breaking the kanban model | ETAPE-01..06 | | 10 | Events | Tablos have scheduled events with CRUD, validation, and authorization | EVENT-01..05 | | 11 | Individual Planning | Users can view their own event agenda across tablos | PLAN-01..04 | @@ -23,16 +23,16 @@ ## Phase Details ### Phase 8: Social Sign-in -**Goal:** Users can sign in with Google or Apple while Xtablo keeps owning user accounts and sessions. +**Goal:** Users can sign in with Google while Xtablo keeps owning user accounts and sessions; Apple sign-in is disabled and hidden for now. **Mode:** mvp **Status:** Complete **Requirements:** AUTH-08, AUTH-09, AUTH-10, AUTH-11, AUTH-12, AUTH-13 **Success Criteria:** -1. Login/signup page shows Google and Apple sign-in entry points alongside email/password +1. Login/signup page shows Google sign-in alongside email/password and no Apple sign-in controls 2. Google callback validates state, exchanges authorization code, verifies ID token, creates or links `user_identities`, and issues a local session -3. Apple callback validates state/nonce, exchanges authorization code, verifies ID token, creates or links `user_identities`, and issues a local session +3. `/auth/apple/start` and `/auth/apple/callback` are not mounted while Apple sign-in is disabled 4. Existing email/password signup/login/logout tests still pass unchanged -5. `.env.example` and README document required Google and Apple config without committing secrets +5. `.env.example` and README document required Google config without committing secrets and state that Apple sign-in is disabled **User-in-loop:** Approve `user_identities` schema and account-linking behavior for matching verified emails before migration/sqlc generation. diff --git a/.planning/STATE.md b/.planning/STATE.md index 23e6f88..0479e30 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -89,6 +89,7 @@ Last activity: 2026-05-15 -- Phase 08 execution complete - **Apple callback uses GET/query response mode for the first working version** — keeps the callback inside existing CSRF middleware boundaries; Apple dashboard configuration must match (08-03) - **Provider buttons degrade to disabled controls when config is missing** — auth pages remain deployable without provider credentials (08-04) - **Account providers page is read-only in Phase 8** — linked identity visibility shipped before unlink/add-password account management (08-05) +- **Apple sign-in disabled after UAT scope change** — Apple controls are hidden, Apple auth routes are not mounted, and provider docs only cover Google for now (08-UAT) ## Performance Metrics @@ -157,7 +158,7 @@ Last activity: 2026-05-15 -- Phase 08 execution complete - Phase 8 Plan 03 SUMMARY: `.planning/phases/08-social-sign-in/08-03-SUMMARY.md` - Phase 8 Plan 04 SUMMARY: `.planning/phases/08-social-sign-in/08-04-SUMMARY.md` - Phase 8 Plan 05 SUMMARY: `.planning/phases/08-social-sign-in/08-05-SUMMARY.md` -- Commits (08): 2d004cd (social identity schema foundation), 6779663 (Google sign-in), a8b6a03 (Apple sign-in), 59fd6b1 (auth page provider controls), 6e65836 (account providers view + docs) +- Commits (08): 2d004cd (social identity schema foundation), 6779663 (Google sign-in), a8b6a03 (Apple implementation later disabled), 59fd6b1 (auth page provider controls), 6e65836 (account providers view + docs) --- *Last updated: 2026-05-15 after Phase 8 execution complete* diff --git a/.planning/phases/08-social-sign-in/08-UI-SPEC.md b/.planning/phases/08-social-sign-in/08-UI-SPEC.md index 03c6824..5da31fa 100644 --- a/.planning/phases/08-social-sign-in/08-UI-SPEC.md +++ b/.planning/phases/08-social-sign-in/08-UI-SPEC.md @@ -40,19 +40,20 @@ created: 2026-05-15 Both `/login` and `/signup` must show: 1. Page heading -2. Equal-prominence Google and Apple provider buttons -3. A visual separator between provider buttons and the email/password form +2. Google provider button +3. A visual separator between the provider button and the email/password form 4. Existing email/password form 5. Existing inline validation/error rendering behavior -Provider buttons: +Provider button: -- Google and Apple are peers; neither is styled as primary over the other. -- Buttons link to provider start routes when configured. -- Buttons render disabled when required provider config is missing. +- Google is the only rendered social sign-in provider for now. +- The button links to `/auth/google/start` when configured. +- The button renders disabled when required Google config is missing. - Disabled state must be visible and non-clickable. - Disabled state copy must be short and plain, not diagnostic. -- Provider buttons must not submit the email/password form. +- The provider button must not submit the email/password form. +- Apple sign-in is disabled and must not render a button, disabled-state copy, or actionable route link. Successful social sign-in redirects to `/`; no provider-specific welcome page. @@ -64,11 +65,11 @@ Required content: - Heading: `Linked providers` - One row for Google -- One row for Apple - Each row shows one of: - `Connected` - `Not connected` - If connected, show the stored provider email. +- No Apple row in Phase 8. - No unlink action in Phase 8. - No add-password UI in Phase 8. @@ -155,9 +156,7 @@ Provider buttons: | Element | Copy | |---------|------| | Google button | `Continue with Google` | -| Apple button | `Continue with Apple` | | Disabled Google button | `Google sign-in not configured` | -| Disabled Apple button | `Apple sign-in not configured` | | Auth separator | `or` | | Provider callback generic error | `Could not sign you in with this provider. Try another sign-in method.` | | Unverified email error | `This provider did not return a verified email. Try another sign-in method.` | @@ -218,7 +217,7 @@ Each row: ## Responsive Contract - Auth card remains centered and no wider than `max-w-sm`. -- Provider buttons are stacked vertically on all viewports in Phase 8; do not use a two-column button layout. +- Provider controls are stacked vertically on all viewports in Phase 8; do not use a two-column button layout. - Linked providers view uses a single column on mobile. - Text must not overflow provider buttons; if future localization makes labels long, allow wrapping before reducing font size. - No horizontal scrolling. diff --git a/backend/.env.example b/backend/.env.example index 3e70db4..26b3630 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -21,14 +21,6 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback -# Sign in with Apple. Leave blank to render a disabled Apple button locally. -APPLE_CLIENT_ID= -APPLE_TEAM_ID= -APPLE_KEY_ID= -# Use a PEM value with escaped newlines, or inject the multiline value from your secret manager. -APPLE_PRIVATE_KEY= -APPLE_REDIRECT_URL=http://localhost:8080/auth/apple/callback - # HTTP port for cmd/web. PORT=8080 diff --git a/backend/README.md b/backend/README.md index 3b9c0ea..fd4c88d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -99,7 +99,7 @@ bootstrap-time `unpkg.com` URL is the single authoritative version pin (D-10). `backend/.env` is gitignored; `backend/.env.example` is committed and lists the keys consumed by `cmd/web` and `cmd/worker`. Local Just recipes load `backend/.env` automatically, so `just dev` will pick up provider credentials -such as `GOOGLE_CLIENT_ID` and `APPLE_CLIENT_ID`. +such as `GOOGLE_CLIENT_ID`. | Variable | Description | Default | | ------------------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------- | @@ -109,16 +109,11 @@ such as `GOOGLE_CLIENT_ID` and `APPLE_CLIENT_ID`. | `GOOGLE_CLIENT_ID` | Google OAuth client ID | blank | | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | blank | | `GOOGLE_REDIRECT_URL` | Google callback URL, usually `/auth/google/callback` | `http://localhost:8080/auth/google/callback` | -| `APPLE_CLIENT_ID` | Apple Services ID | blank | -| `APPLE_TEAM_ID` | Apple developer team ID | blank | -| `APPLE_KEY_ID` | Apple private key ID | blank | -| `APPLE_PRIVATE_KEY` | Apple ES256 private key PEM, usually injected by secret manager | blank | -| `APPLE_REDIRECT_URL` | Apple callback URL, usually `/auth/apple/callback` | `http://localhost:8080/auth/apple/callback` | -Google and Apple config are optional in local development. When a provider is -missing required config, the login and signup pages keep the provider button -visible but disabled with a not-configured label. No real provider secrets should -be committed to `.env.example`. +Google config is optional in local development. When it is missing, the login +and signup pages keep the Google button visible but disabled with a +not-configured label. No real provider secrets should be committed to +`.env.example`. Apple sign-in is disabled in the current product surface. ## Common commands diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index 3603dfe..18dc40a 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -91,18 +91,9 @@ func main() { ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), RedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"), }, - Apple: auth.AppleProviderConfig{ - ClientID: os.Getenv("APPLE_CLIENT_ID"), - TeamID: os.Getenv("APPLE_TEAM_ID"), - KeyID: os.Getenv("APPLE_KEY_ID"), - PrivateKey: os.Getenv("APPLE_PRIVATE_KEY"), - RedirectURL: os.Getenv("APPLE_REDIRECT_URL"), - }, } var googleExchanger auth.CodeExchanger var googleVerifier auth.IDTokenVerifier - var appleExchanger auth.CodeExchanger - var appleVerifier auth.IDTokenVerifier if oauthCfg.Google.Configured() { googleExchanger = auth.OAuth2CodeExchanger{Config: oauthCfg.Google.OAuth2Config()} googleVerifier = auth.OIDCVerifier{ @@ -111,19 +102,6 @@ func main() { ClientID: oauthCfg.Google.ClientID, } } - if oauthCfg.Apple.Configured() { - appleSecret, err := oauthCfg.Apple.ClientSecret(time.Now()) - if err != nil { - slog.Error("invalid Apple sign-in config", "err", err) - os.Exit(1) - } - appleExchanger = auth.OAuth2CodeExchanger{Config: oauthCfg.Apple.OAuth2Config(appleSecret)} - appleVerifier = auth.OIDCVerifier{ - Provider: "apple", - Issuer: "https://appleid.apple.com", - ClientID: oauthCfg.Apple.ClientID, - } - } deps := web.AuthDeps{ Queries: q, @@ -134,8 +112,6 @@ func main() { OAuth: oauthCfg, GoogleTokenExchanger: googleExchanger, GoogleVerifier: googleVerifier, - AppleTokenExchanger: appleExchanger, - AppleVerifier: appleVerifier, } tabloDeps := web.TablosDeps{Queries: q} taskDeps := web.TasksDeps{Queries: q} diff --git a/backend/internal/web/handlers_account.go b/backend/internal/web/handlers_account.go index 1a2d251..f3768f6 100644 --- a/backend/internal/web/handlers_account.go +++ b/backend/internal/web/handlers_account.go @@ -25,16 +25,12 @@ func AccountProvidersHandler(deps AuthDeps) http.HandlerFunc { statuses := []templates.LinkedProviderStatus{ {Name: "Google"}, - {Name: "Apple"}, } for _, identity := range identities { switch identity.Provider { case "google": statuses[0].Connected = true statuses[0].Email = identity.Email - case "apple": - statuses[1].Connected = true - statuses[1].Email = identity.Email } } diff --git a/backend/internal/web/handlers_account_test.go b/backend/internal/web/handlers_account_test.go index 4ee5905..ba24500 100644 --- a/backend/internal/web/handlers_account_test.go +++ b/backend/internal/web/handlers_account_test.go @@ -63,14 +63,12 @@ func TestLinkedProviders_ShowsConnectedAndNotConnectedRows(t *testing.T) { "Google", "Connected", "providers@example.com", - "Apple", - "Not connected", } { if !strings.Contains(body, want) { t.Fatalf("body missing %q; body: %s", want, body) } } - for _, notWant := range []string{"Unlink", "Add password"} { + for _, notWant := range []string{"Apple", "Not connected", "Unlink", "Add password"} { if strings.Contains(body, notWant) { t.Fatalf("body must not contain %q; body: %s", notWant, body) } diff --git a/backend/internal/web/handlers_auth.go b/backend/internal/web/handlers_auth.go index b55c5a0..6e38538 100644 --- a/backend/internal/web/handlers_auth.go +++ b/backend/internal/web/handlers_auth.go @@ -32,8 +32,6 @@ type AuthDeps struct { OAuth auth.OAuthConfig GoogleTokenExchanger auth.CodeExchanger GoogleVerifier auth.IDTokenVerifier - AppleTokenExchanger auth.CodeExchanger - AppleVerifier auth.IDTokenVerifier } // errInvalidCreds is the intentionally generic error message for login failures @@ -308,8 +306,6 @@ func providerButtons(deps AuthDeps) templates.AuthProviderButtons { providers := templates.EmptyAuthProviderButtons() providers.Google.Configured = deps.OAuth.Google.Configured() providers.Google.StartURL = "/auth/google/start" - providers.Apple.Configured = deps.OAuth.Apple.Configured() - providers.Apple.StartURL = "/auth/apple/start" return providers } diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 1c2bc5e..2b63bc2 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -71,13 +71,6 @@ func configuredProviderDeps() AuthDeps { ClientSecret: "google-secret", RedirectURL: "https://xtablo.test/auth/google/callback", }, - Apple: auth.AppleProviderConfig{ - ClientID: "com.xtablo.web", - TeamID: "TEAMID1234", - KeyID: "KEYID1234", - PrivateKey: "apple-private-key", - RedirectURL: "https://xtablo.test/auth/apple/callback", - }, }, } } @@ -175,9 +168,7 @@ func TestSignupProviderButtonsConfigured(t *testing.T) { body := rec.Body.String() for _, want := range []string{ "Continue with Google", - "Continue with Apple", `href="/auth/google/start"`, - `href="/auth/apple/start"`, ">or<", `name="email"`, `name="password"`, @@ -186,6 +177,11 @@ func TestSignupProviderButtonsConfigured(t *testing.T) { t.Fatalf("signup page missing %q; body: %s", want, body) } } + for _, notWant := range []string{"Continue with Apple", `href="/auth/apple/start"`, "Apple sign-in"} { + if strings.Contains(body, notWant) { + t.Fatalf("signup page must not contain Apple sign-in UI %q; body: %s", notWant, body) + } + } } func TestSignupProviderButtonsDisabledWhenConfigMissing(t *testing.T) { @@ -196,12 +192,17 @@ func TestSignupProviderButtonsDisabledWhenConfigMissing(t *testing.T) { router.ServeHTTP(rec, req) body := rec.Body.String() - for _, want := range []string{"Google sign-in not configured", "Apple sign-in not configured"} { + for _, want := range []string{"Google sign-in not configured"} { if !strings.Contains(body, want) { t.Fatalf("signup page missing disabled copy %q; body: %s", want, body) } } - if strings.Contains(body, `href="/auth/google/start"`) || strings.Contains(body, `href="/auth/apple/start"`) { + for _, notWant := range []string{"Apple sign-in", `href="/auth/apple/start"`} { + if strings.Contains(body, notWant) { + t.Fatalf("signup page must not contain Apple sign-in UI %q; body: %s", notWant, body) + } + } + if strings.Contains(body, `href="/auth/google/start"`) { t.Fatalf("disabled provider buttons must not include actionable start hrefs; body: %s", body) } } @@ -557,9 +558,7 @@ func TestLoginProviderButtonsConfigured(t *testing.T) { body := rec.Body.String() for _, want := range []string{ "Continue with Google", - "Continue with Apple", `href="/auth/google/start"`, - `href="/auth/apple/start"`, ">or<", `name="email"`, `name="password"`, @@ -568,6 +567,11 @@ func TestLoginProviderButtonsConfigured(t *testing.T) { t.Fatalf("login page missing %q; body: %s", want, body) } } + for _, notWant := range []string{"Continue with Apple", `href="/auth/apple/start"`, "Apple sign-in"} { + if strings.Contains(body, notWant) { + t.Fatalf("login page must not contain Apple sign-in UI %q; body: %s", notWant, body) + } + } } func TestLoginProviderButtonsDisabledWhenConfigMissing(t *testing.T) { @@ -578,12 +582,17 @@ func TestLoginProviderButtonsDisabledWhenConfigMissing(t *testing.T) { router.ServeHTTP(rec, req) body := rec.Body.String() - for _, want := range []string{"Google sign-in not configured", "Apple sign-in not configured"} { + for _, want := range []string{"Google sign-in not configured"} { if !strings.Contains(body, want) { t.Fatalf("login page missing disabled copy %q; body: %s", want, body) } } - if strings.Contains(body, `href="/auth/google/start"`) || strings.Contains(body, `href="/auth/apple/start"`) { + for _, notWant := range []string{"Apple sign-in", `href="/auth/apple/start"`} { + if strings.Contains(body, notWant) { + t.Fatalf("login page must not contain Apple sign-in UI %q; body: %s", notWant, body) + } + } + if strings.Contains(body, `href="/auth/google/start"`) { t.Fatalf("disabled provider buttons must not include actionable start hrefs; body: %s", body) } } diff --git a/backend/internal/web/handlers_social.go b/backend/internal/web/handlers_social.go index 55ba5de..48b2bf0 100644 --- a/backend/internal/web/handlers_social.go +++ b/backend/internal/web/handlers_social.go @@ -7,7 +7,6 @@ import ( "log/slog" "net/http" "strings" - "time" "backend/internal/auth" "backend/internal/db/sqlc" @@ -125,123 +124,6 @@ func GoogleCallbackHandler(deps AuthDeps) http.HandlerFunc { } } -func AppleStartHandler(deps AuthDeps) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - cfg := deps.OAuth.Apple - if !cfg.Configured() { - http.Error(w, "Apple sign-in not configured", http.StatusServiceUnavailable) - return - } - state, err := auth.GenerateOAuthValue() - if err != nil { - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - nonce, err := auth.GenerateOAuthValue() - if err != nil { - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - clientSecret, err := cfg.ClientSecret(timeNow()) - if err != nil { - http.Error(w, "Apple sign-in not configured", http.StatusServiceUnavailable) - return - } - auth.SetOAuthCookie(w, "apple", auth.OAuthCookieState, state, deps.Secure) - auth.SetOAuthCookie(w, "apple", auth.OAuthCookieNonce, nonce, deps.Secure) - - oauthCfg := cfg.OAuth2Config(clientSecret) - url := oauthCfg.AuthCodeURL(state, - oauth2.SetAuthURLParam("nonce", nonce), - oauth2.SetAuthURLParam("response_mode", "query"), - ) - http.Redirect(w, r, url, http.StatusSeeOther) - } -} - -func AppleCallbackHandler(deps AuthDeps) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !auth.ValidateOAuthCookie(r, "apple", auth.OAuthCookieState, r.URL.Query().Get("state")) { - http.Error(w, providerGenericError, http.StatusBadRequest) - return - } - auth.ClearOAuthCookie(w, "apple", auth.OAuthCookieState, deps.Secure) - auth.ClearOAuthCookie(w, "apple", auth.OAuthCookieNonce, deps.Secure) - - code := r.URL.Query().Get("code") - if code == "" { - http.Error(w, providerGenericError, http.StatusBadRequest) - return - } - - exchanger := deps.AppleTokenExchanger - if exchanger == nil { - clientSecret, err := deps.OAuth.Apple.ClientSecret(timeNow()) - if err != nil { - http.Error(w, providerGenericError, http.StatusUnauthorized) - return - } - exchanger = auth.OAuth2CodeExchanger{Config: deps.OAuth.Apple.OAuth2Config(clientSecret)} - } - token, err := exchanger.Exchange(r.Context(), code) - if err != nil { - slog.Default().Warn("apple oauth exchange failed", "err", err) - http.Error(w, providerGenericError, http.StatusUnauthorized) - return - } - rawIDToken, _ := token.Extra("id_token").(string) - if rawIDToken == "" { - http.Error(w, providerGenericError, http.StatusUnauthorized) - return - } - - verifier := deps.AppleVerifier - if verifier == nil { - cfg := deps.OAuth.Apple - issuer := cfg.Issuer - if issuer == "" { - issuer = "https://appleid.apple.com" - } - verifier = auth.OIDCVerifier{Provider: "apple", Issuer: issuer, ClientID: cfg.ClientID} - } - claims, err := verifier.Verify(r.Context(), rawIDToken) - if err != nil { - slog.Default().Warn("apple id token verification failed", "err", err) - http.Error(w, providerGenericError, http.StatusUnauthorized) - return - } - if claims.Provider == "" { - claims.Provider = "apple" - } - if !auth.ValidateOAuthCookie(r, "apple", auth.OAuthCookieNonce, claims.Nonce) { - http.Error(w, providerGenericError, http.StatusBadRequest) - return - } - if strings.TrimSpace(claims.Email) == "" || !claims.EmailVerified { - http.Error(w, providerEmailUnverified, http.StatusUnauthorized) - return - } - - userID, err := linkProviderUser(r.Context(), deps, claims) - if err != nil { - slog.Default().Error("apple account linking failed", "err", err) - http.Error(w, providerGenericError, http.StatusInternalServerError) - return - } - cookieValue, expiresAt, err := deps.Store.Create(r.Context(), userID) - if err != nil { - http.Error(w, providerGenericError, http.StatusInternalServerError) - return - } - auth.SetSessionCookie(w, cookieValue, expiresAt, deps.Secure) - http.Redirect(w, r, "/", http.StatusSeeOther) - } -} - -var timeNow = func() time.Time { - return time.Now() -} - func linkProviderUser(ctx context.Context, deps AuthDeps, claims auth.ProviderClaims) (uuid.UUID, error) { if deps.DB == nil { return uuid.Nil, errors.New("missing transaction DB") diff --git a/backend/internal/web/handlers_social_test.go b/backend/internal/web/handlers_social_test.go index f59a00f..835f897 100644 --- a/backend/internal/web/handlers_social_test.go +++ b/backend/internal/web/handlers_social_test.go @@ -2,11 +2,6 @@ package web import ( "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "encoding/pem" "net/http" "net/http/httptest" "net/url" @@ -70,66 +65,11 @@ func newGoogleAuthDeps(q *sqlc.Queries, store *auth.Store) AuthDeps { } } -func newAppleAuthDeps(t *testing.T, q *sqlc.Queries, store *auth.Store) AuthDeps { - t.Helper() - return AuthDeps{ - Queries: q, - Store: store, - Secure: false, - OAuth: auth.OAuthConfig{ - Apple: auth.AppleProviderConfig{ - ClientID: "com.xtablo.web", - TeamID: "TEAMID1234", - KeyID: "KEYID1234", - PrivateKey: testApplePrivateKeyPEMForWeb(t), - RedirectURL: "https://xtablo.test/auth/apple/callback", - AuthURL: "https://appleid.apple.test/auth/authorize", - TokenURL: "https://appleid.apple.test/auth/token", - Issuer: "https://appleid.apple.test", - }, - }, - AppleTokenExchanger: fakeCodeExchanger{ - token: (&oauth2.Token{AccessToken: "access"}).WithExtra(map[string]any{"id_token": "raw-apple-id-token"}), - }, - AppleVerifier: fakeIDTokenVerifier{ - claims: auth.ProviderClaims{ - Provider: "apple", - Subject: "apple-subject-1", - Email: "apple@example.com", - EmailVerified: true, - DisplayName: "Apple User", - Nonce: "nonce-value", - }, - }, - } -} - -func testApplePrivateKeyPEMForWeb(t interface { - Helper() - Fatalf(string, ...any) -}) string { - t.Helper() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatalf("GenerateKey: %v", err) - } - der, err := x509.MarshalECPrivateKey(key) - if err != nil { - t.Fatalf("MarshalECPrivateKey: %v", err) - } - return string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})) -} - func withGoogleClaims(deps AuthDeps, claims auth.ProviderClaims) AuthDeps { deps.GoogleVerifier = fakeIDTokenVerifier{claims: claims} return deps } -func withAppleClaims(deps AuthDeps, claims auth.ProviderClaims) AuthDeps { - deps.AppleVerifier = fakeIDTokenVerifier{claims: claims} - return deps -} - func newSocialRouter(t *testing.T, deps AuthDeps) http.Handler { t.Helper() router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") @@ -415,117 +355,16 @@ func TestGoogleCallbackEmailUpdateConflictDoesNotRelinkSubject(t *testing.T) { } } -func TestAppleStartRedirectsAndSetsStateNonceCookies(t *testing.T) { - router := newSocialRouter(t, newAppleAuthDeps(t, nil, nil)) +func TestAppleRoutesAreDisabled(t *testing.T) { + router := newSocialRouter(t, AuthDeps{}) - req := httptest.NewRequest(http.MethodGet, "/auth/apple/start", nil) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusSeeOther { - t.Fatalf("status = %d; want 303", rec.Code) - } - loc := rec.Header().Get("Location") - if !strings.Contains(loc, "https://appleid.apple.test/auth/authorize") { - t.Fatalf("Location = %q; want Apple auth URL", loc) - } - if !strings.Contains(loc, "client_id=com.xtablo.web") { - t.Fatalf("Location = %q; missing client_id", loc) - } - if !strings.Contains(loc, "scope=name+email") { - t.Fatalf("Location = %q; missing name email scope", loc) - } - if findCookie(rec.Result().Cookies(), auth.OAuthCookieName("apple", auth.OAuthCookieState)) == nil { - t.Fatal("missing Apple state cookie") - } - if findCookie(rec.Result().Cookies(), auth.OAuthCookieName("apple", auth.OAuthCookieNonce)) == nil { - t.Fatal("missing Apple nonce cookie") - } -} - -func TestAppleCallbackInvalidNonceRejectedBeforeLinking(t *testing.T) { - deps := newAppleAuthDeps(t, nil, nil) - deps.AppleVerifier = fakeIDTokenVerifier{claims: auth.ProviderClaims{ - Provider: "apple", - Subject: "apple-subject-1", - Email: "apple@example.com", - EmailVerified: true, - Nonce: "wrong-nonce", - }} - router := newSocialRouter(t, deps) - - callback := "/auth/apple/callback?" + url.Values{"state": {"state-value"}, "code": {"code"}}.Encode() - req := httptest.NewRequest(http.MethodGet, callback, nil) - req.AddCookie(&http.Cookie{Name: auth.OAuthCookieName("apple", auth.OAuthCookieState), Value: "state-value"}) - req.AddCookie(&http.Cookie{Name: auth.OAuthCookieName("apple", auth.OAuthCookieNonce), Value: "nonce-value"}) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d; want 400", rec.Code) - } -} - -func TestAppleCallbackUnverifiedEmailRejected(t *testing.T) { - deps := newAppleAuthDeps(t, nil, nil) - deps.AppleVerifier = fakeIDTokenVerifier{claims: auth.ProviderClaims{ - Provider: "apple", - Subject: "apple-subject-1", - Email: "apple@example.com", - EmailVerified: false, - Nonce: "nonce-value", - }} - router := newSocialRouter(t, deps) - - rec := serveAppleCallback(router) - - if rec.Code != http.StatusUnauthorized { - t.Fatalf("status = %d; want 401", rec.Code) - } - if !strings.Contains(rec.Body.String(), "This provider did not return a verified email. Try another sign-in method.") { - t.Fatalf("body missing unverified email copy; got: %s", rec.Body.String()) - } -} - -func TestAppleCallbackVerifiedRelayEmailStoresNameAndSetsSession(t *testing.T) { - pool, cleanup := setupTestDB(t) - defer cleanup() - - ctx := context.Background() - q := sqlc.New(pool) - store := auth.NewStore(q) - deps := newAppleAuthDeps(t, q, store) - deps.DB = pool - deps = withAppleClaims(deps, auth.ProviderClaims{ - Provider: "apple", - Subject: "apple-relay-subject", - Email: "relay@privaterelay.appleid.com", - EmailVerified: true, - DisplayName: "Apple Relay", - Nonce: "nonce-value", - }) - router := newSocialRouter(t, deps) - - rec := serveAppleCallback(router) - - if rec.Code != http.StatusSeeOther { - t.Fatalf("status = %d; want 303; body: %s", rec.Code, rec.Body.String()) - } - if c := getSessionCookie(rec); c == nil { - t.Fatal("session cookie not set") - } - identity, err := q.GetUserIdentityByProviderSubject(ctx, sqlc.GetUserIdentityByProviderSubjectParams{ - Provider: "apple", - ProviderSubject: "apple-relay-subject", - }) - if err != nil { - t.Fatalf("GetUserIdentityByProviderSubject: %v", err) - } - if identity.Email != "relay@privaterelay.appleid.com" { - t.Fatalf("identity email = %q", identity.Email) - } - if !identity.DisplayName.Valid || identity.DisplayName.String != "Apple Relay" { - t.Fatalf("display name = %#v; want Apple Relay", identity.DisplayName) + for _, path := range []string{"/auth/apple/start", "/auth/apple/callback"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("%s status = %d; want 404", path, rec.Code) + } } } @@ -539,16 +378,6 @@ func serveGoogleCallback(router http.Handler) *httptest.ResponseRecorder { return rec } -func serveAppleCallback(router http.Handler) *httptest.ResponseRecorder { - callback := "/auth/apple/callback?" + url.Values{"state": {"state-value"}, "code": {"code"}}.Encode() - req := httptest.NewRequest(http.MethodGet, callback, nil) - req.AddCookie(&http.Cookie{Name: auth.OAuthCookieName("apple", auth.OAuthCookieState), Value: "state-value"}) - req.AddCookie(&http.Cookie{Name: auth.OAuthCookieName("apple", auth.OAuthCookieNonce), Value: "nonce-value"}) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - return rec -} - func findCookie(cookies []*http.Cookie, name string) *http.Cookie { for _, c := range cookies { if c.Name == name { diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index 9be471d..9f9ddad 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -73,8 +73,6 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep r.Post("/login", LoginPostHandler(deps)) r.Get("/auth/google/start", GoogleStartHandler(deps)) r.Get("/auth/google/callback", GoogleCallbackHandler(deps)) - r.Get("/auth/apple/start", AppleStartHandler(deps)) - r.Get("/auth/apple/callback", AppleCallbackHandler(deps)) // Protected routes — require an authenticated session (D-23, AUTH-05). // RequireAuth checks the context set by ResolveSession above and redirects diff --git a/backend/templates/auth_forms.go b/backend/templates/auth_forms.go index 8c3a8a7..dc8f104 100644 --- a/backend/templates/auth_forms.go +++ b/backend/templates/auth_forms.go @@ -45,12 +45,10 @@ type AuthProviderButton struct { // the email/password auth forms. type AuthProviderButtons struct { Google AuthProviderButton - Apple AuthProviderButton } func EmptyAuthProviderButtons() AuthProviderButtons { return AuthProviderButtons{ Google: AuthProviderButton{Label: "Continue with Google", DisabledLabel: "Google sign-in not configured"}, - Apple: AuthProviderButton{Label: "Continue with Apple", DisabledLabel: "Apple sign-in not configured"}, } } diff --git a/backend/templates/auth_login.templ b/backend/templates/auth_login.templ index 92ed607..accb1b8 100644 --- a/backend/templates/auth_login.templ +++ b/backend/templates/auth_login.templ @@ -75,7 +75,6 @@ templ LoginFormFragment(form LoginForm, errs LoginErrors, csrfToken string) { templ AuthProviderButtonsBlock(providers AuthProviderButtons) {