feat(08): disable apple sign-in
This commit is contained in:
parent
85b8c7bce1
commit
90af9bdaef
16 changed files with 59 additions and 391 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue