Files
MealQuest/internal/gcal/connect.go
T
dev_agentandClaude Opus 5.5 2cd2e5af32
CI / ci (push) Successful in 3m32s
CI / ci (pull_request) Successful in 4m2s
Serialize Google connect and disconnect; consume connect state once
A disconnect read the stored refresh token, revoked it at Google, then
deleted the row only if it still held that token. Google revokes the whole
grant, so a reconnect whose token was issued before that revoke was kept
as connected with a dead token. Connect's exchange-and-store and
disconnect's read-revoke-delete now hold one mutex across the Google
request and the database write, and the delete is unconditional (the
conditional delete and ErrChanged are gone: the lock is the one guard).

The connect cookie was single-use only in that the browser was asked to
drop it; a saved copy verified again until it expired. The service now
keeps each started connect's state in memory until the cookie expires and
the callback consumes it, so a replayed cookie is refused as bad state.

The fake Google now issues a distinct refresh token per exchange, revokes
the whole grant like Google, and can hold a token or revoke request so
tests can interleave a reconnect and a disconnect.

Maestro task 1e9d7850-3a09-4572-b8a1-cb18cd4774cb (MQ125-01, MQ125-02).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:46:35 -04:00

402 lines
14 KiB
Go

// Package gcal connects the household's Google account for Push to Calendar.
// The service is a confidential OAuth web client: the authorization code,
// tokens and client secret stay on the server. The refresh token is stored
// sealed by secretbox in integrations_google and is never logged or
// returned.
package gcal
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/endpoints"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// CallbackPath is where Google returns the browser after consent. It is
// served under the signed-in /api/v1 router, so the redirect URI registered
// with Google is MEALQUEST_PUBLIC_URL's origin followed by this path.
const CallbackPath = "/api/v1/integrations/google/callback"
// Scopes requested at connect: create and change the events MealQuest pushes,
// and list the calendars the household can push to.
var Scopes = []string{
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
}
// GoogleRevokeURL is Google's OAuth token revocation endpoint.
const GoogleRevokeURL = "https://oauth2.googleapis.com/revoke"
// GoogleEndpoint is Google's OAuth authorization and token endpoint.
var GoogleEndpoint = oauth2.Endpoint{
AuthURL: endpoints.Google.AuthURL,
TokenURL: endpoints.Google.TokenURL,
AuthStyle: oauth2.AuthStyleInParams,
}
const (
connectCookieName = "__Host-mealquest_google"
connectCookieTTL = 10 * time.Minute
// connectCookieKeyLabel separates the connect cookie's signing key from
// the login cookie's and every other key derived from the server secret.
connectCookieKeyLabel = "mealquest google connect cookie v1"
// googleTimeout bounds every request to Google.
googleTimeout = 10 * time.Second
// maxRevokeBody caps how much of a revoke response is read.
maxRevokeBody = 4 << 10
)
var (
// ErrBadState means the callback's state is missing, does not match the
// connect cookie, or the cookie is missing, expired or belongs to another
// session.
ErrBadState = errors.New("gcal: connect state is missing, expired or does not match this session")
// ErrDenied means Google returned an authorization error, usually because
// the household declined consent.
ErrDenied = errors.New("gcal: Google did not grant access")
// ErrNoCode means the callback carries no authorization code.
ErrNoCode = errors.New("gcal: callback has no authorization code")
// ErrNoRefreshToken means Google granted access without a refresh token,
// so MealQuest could not push later; the household must connect again.
ErrNoRefreshToken = errors.New("gcal: Google returned no refresh token")
// ErrScopes means the household did not grant every scope in Scopes.
ErrScopes = errors.New("gcal: Google did not grant every Calendar permission")
// ErrNotConnected means no Google account is connected.
ErrNotConnected = errors.New("gcal: Google Calendar is not connected")
)
// Config is what NewService needs to talk to Google.
type Config struct {
ClientID string
ClientSecret string
// PublicURL is the service's public URL; its origin prefixes CallbackPath
// to form the redirect URI.
PublicURL *url.URL
// SecretKey is the server key the connect cookie's signing key derives
// from.
SecretKey []byte
// Endpoint is Google's authorization and token endpoint; production uses
// GoogleEndpoint and tests a fake.
Endpoint oauth2.Endpoint
// RevokeURL is Google's revocation endpoint; production uses
// GoogleRevokeURL and tests a fake.
RevokeURL string
}
// Service connects and disconnects the household's Google account.
// MealQuest runs as one process, so the service's in-memory lock and pending
// states see every request.
type Service struct {
st *store.Store
box *secretbox.Box
oauth oauth2.Config
revokeURL string
client *http.Client
cookie auth.FlowCookie
// now is the clock; tests replace it.
now func() time.Time
// account serializes every change to the connected account — a
// connect's exchange and store, a disconnect's read, revoke and delete —
// across the request to Google and the database write. Google revokes the
// whole grant, so a revoke overlapping a connect would also kill the
// token the connect stores.
account sync.Mutex
// pending holds the state of every connect started and not yet
// completed, with when it expires. Complete consumes a state once, so a
// replayed cookie finds it gone.
pendingMu sync.Mutex
pending map[string]time.Time
}
// NewService returns the service over st, sealing refresh tokens with box.
func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("gcal: nil store")
case box == nil:
return nil, errors.New("gcal: nil secret box")
case cfg.ClientID == "", cfg.ClientSecret == "":
return nil, errors.New("gcal: client ID and client secret are required")
case cfg.PublicURL == nil:
return nil, errors.New("gcal: public URL is required")
case len(cfg.SecretKey) == 0:
return nil, errors.New("gcal: secret key is required")
case cfg.Endpoint.AuthURL == "", cfg.Endpoint.TokenURL == "", cfg.RevokeURL == "":
return nil, errors.New("gcal: authorization, token and revoke endpoints are required")
}
return &Service{
st: st,
box: box,
oauth: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: cfg.Endpoint,
RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath,
Scopes: slices.Clone(Scopes),
},
revokeURL: cfg.RevokeURL,
client: &http.Client{Timeout: googleTimeout, CheckRedirect: auth.CheckRedirect},
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
now: time.Now,
pending: map[string]time.Time{},
}, nil
}
// connectState travels from Connect to Complete in the signed connect
// cookie.
type connectState struct {
State string `json:"s"`
Verifier string `json:"v"`
// Session binds the state to the session that started the connect: the
// SHA-256 of its CSRF token, which is unique to the session and never
// changes during it.
Session string `json:"b"`
auth.FlowExpiry
}
// sessionBinding is the value connectState.Session must hold for s.
func sessionBinding(s auth.Session) string {
sum := sha256.Sum256([]byte(s.CSRFToken))
return hex.EncodeToString(sum[:])
}
// Connect sets the connect cookie on w, bound to session, and returns
// Google's consent URL: offline access, consent always prompted so Google
// always issues a refresh token, and PKCE (S256).
func (s *Service) Connect(w http.ResponseWriter, session auth.Session) (string, error) {
if session.CSRFToken == "" {
return "", errors.New("gcal: connect needs a session")
}
state := connectState{
State: rand.Text(),
Verifier: oauth2.GenerateVerifier(),
Session: sessionBinding(session),
}
now := s.now()
if err := s.cookie.Set(w, &state, now); err != nil {
return "", fmt.Errorf("gcal: set connect cookie: %w", err)
}
s.addPending(state.State, now)
return s.oauth.AuthCodeURL(state.State,
oauth2.AccessTypeOffline,
oauth2.ApprovalForce,
oauth2.S256ChallengeOption(state.Verifier),
), nil
}
// Complete finishes the connect Google redirected r back with. It clears the
// connect cookie, checks the state against it and session, consumes the
// state, exchanges the code and stores the refresh token, sealed, with
// connected_at now. A reconnect replaces the stored token and keeps the
// chosen calendar.
func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.Session) error {
ctx := r.Context()
s.cookie.Clear(w)
var state connectState
if err := s.cookie.Read(r, &state, s.now()); err != nil {
return fmt.Errorf("%w: %v", ErrBadState, err)
}
q := r.URL.Query()
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state.State)) != 1 {
return fmt.Errorf("%w: state mismatch", ErrBadState)
}
if subtle.ConstantTimeCompare([]byte(sessionBinding(session)), []byte(state.Session)) != 1 {
return fmt.Errorf("%w: started by another session", ErrBadState)
}
// A copy of the cookie verifies until it expires, so the state is
// single-use only because it is consumed here.
if !s.consumePending(state.State) {
return fmt.Errorf("%w: already used", ErrBadState)
}
if q.Get("error") != "" {
// error and error_description are Google-supplied text; neither is
// kept.
return ErrDenied
}
code := q.Get("code")
if code == "" {
return ErrNoCode
}
s.account.Lock()
defer s.account.Unlock()
token, err := s.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, s.client), code,
oauth2.VerifierOption(state.Verifier))
if err != nil {
return fmt.Errorf("gcal: %w", auth.ExchangeError(err))
}
if token.RefreshToken == "" {
return ErrNoRefreshToken
}
if err := checkScopes(token); err != nil {
return err
}
sealed, err := s.box.Encrypt([]byte(token.RefreshToken))
if err != nil {
return fmt.Errorf("gcal: seal refresh token: %w", err)
}
return s.store(ctx, sealed)
}
// addPending records state, started at now, as pending until the connect
// cookie expires, and forgets expired ones.
func (s *Service) addPending(state string, now time.Time) {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
for k, expires := range s.pending {
if !now.Before(expires) {
delete(s.pending, k)
}
}
s.pending[state] = now.Add(connectCookieTTL)
}
// consumePending removes state from the pending states, reporting whether
// it was there.
func (s *Service) consumePending(state string) bool {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
_, ok := s.pending[state]
delete(s.pending, state)
return ok
}
// checkScopes fails with ErrScopes unless token's granted scopes include
// every scope in Scopes. Google lets the household untick a permission on
// the consent screen and reports what was granted in "scope".
func checkScopes(token *oauth2.Token) error {
granted, _ := token.Extra("scope").(string)
have := strings.Fields(granted)
for _, want := range Scopes {
if !slices.Contains(have, want) {
return ErrScopes
}
}
return nil
}
// store saves sealed as the refresh token with connected_at now, clearing
// any earlier sync error.
func (s *Service) store(ctx context.Context, sealed []byte) error {
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
row, err := q.GetIntegrationsGoogle(ctx)
exists := err == nil
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("gcal: read Google integration: %w", err)
}
now := store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
created := now
if exists {
created = row.CreatedAt
}
if _, err := q.UpsertIntegrationsGoogle(ctx, db.UpsertIntegrationsGoogleParams{
RefreshTokenCt: sealed,
CalendarID: row.CalendarID,
CalendarName: row.CalendarName,
ConnectedAt: now,
CreatedAt: created,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("gcal: save Google integration: %w", err)
}
return nil
})
}
// Disconnect revokes the stored refresh token at Google, then deletes the
// connection. A token Google no longer knows (the household already removed
// MealQuest's access in their Google account) counts as revoked. Any other
// revoke failure keeps the connection so the household can retry.
func (s *Service) Disconnect(ctx context.Context) error {
s.account.Lock()
defer s.account.Unlock()
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotConnected
}
if err != nil {
return fmt.Errorf("gcal: read Google integration: %w", err)
}
token, err := s.box.Decrypt(row.RefreshTokenCt)
if err != nil {
return fmt.Errorf("gcal: open refresh token: %w", err)
}
if err := s.revoke(ctx, string(token)); err != nil {
return err
}
if err := db.New(s.st.Writer).DeleteIntegrationsGoogle(ctx); err != nil {
return fmt.Errorf("gcal: delete Google integration: %w", err)
}
return nil
}
// RevokeError is a revoke Google refused or that failed in transit. Its text
// never holds the token or Google's response body.
type RevokeError struct {
msg string
}
func (e *RevokeError) Error() string { return e.msg }
// revoke revokes token at Google. HTTP 200 and HTTP 400 invalid_token (the
// token is already revoked or expired) both leave it unusable.
func (s *Service) revoke(ctx context.Context, token string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.revokeURL,
strings.NewReader(url.Values{"token": {token}}.Encode()))
if err != nil {
return fmt.Errorf("gcal: build revoke request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
var transport *url.Error
if errors.As(err, &transport) {
// The URL is dropped: after a refused redirect it is the
// Google-supplied Location.
return &RevokeError{msg: fmt.Sprintf("gcal: revoke request failed: %s: %v", transport.Op, transport.Err)}
}
return &RevokeError{msg: "gcal: revoke request failed"}
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxRevokeBody))
if err != nil {
return &RevokeError{msg: "gcal: read revoke response failed"}
}
if resp.StatusCode == http.StatusOK {
return nil
}
if resp.StatusCode == http.StatusBadRequest {
var reply struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &reply) == nil && reply.Error == "invalid_token" {
return nil
}
}
return &RevokeError{msg: fmt.Sprintf("gcal: Google refused the revoke (HTTP %d)", resp.StatusCode)}
}