feat(08): disable apple sign-in

This commit is contained in:
Arthur Belleville 2026-05-15 21:41:22 +02:00
parent 85b8c7bce1
commit 90af9bdaef
No known key found for this signature in database
16 changed files with 59 additions and 391 deletions

View file

@ -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 |

View file

@ -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.

View file

@ -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*

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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")

View file

@ -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 {

View file

@ -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

View file

@ -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"},
}
}

View file

@ -75,7 +75,6 @@ templ LoginFormFragment(form LoginForm, errs LoginErrors, csrfToken string) {
templ AuthProviderButtonsBlock(providers AuthProviderButtons) {
<div class="auth-provider-stack">
@AuthProviderButtonControl(providers.Google)
@AuthProviderButtonControl(providers.Apple)
</div>
<div class="auth-provider-separator" aria-hidden="true">
<span></span>