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>
103 lines
4.4 KiB
Go
103 lines
4.4 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
|
)
|
|
|
|
// googleConnectedPath is where a completed connect sends the browser: the
|
|
// Google Calendar settings screen.
|
|
const googleConnectedPath = "/settings/calendar"
|
|
|
|
// getGoogleConnect sends the browser to Google's consent screen with a state
|
|
// bound to its session. The household opens it as a top-level navigation;
|
|
// the session cookie is SameSite=Lax, so it rides along.
|
|
func getGoogleConnect(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
session, ok := auth.SessionFrom(r.Context())
|
|
if !ok {
|
|
logger.ErrorContext(r.Context(), "Google connect without a session")
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
return
|
|
}
|
|
consent, err := svc.Connect(w, session)
|
|
if err != nil {
|
|
logger.ErrorContext(r.Context(), "start Google connect", "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
return
|
|
}
|
|
http.Redirect(w, r, consent, http.StatusFound)
|
|
}
|
|
}
|
|
|
|
// getGoogleCallback completes the connect Google redirects back with, then
|
|
// sends the browser to the Google Calendar settings screen. Every failure is
|
|
// an API error; none holds a token or Google's response text.
|
|
func getGoogleCallback(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
ctx := r.Context()
|
|
session, ok := auth.SessionFrom(ctx)
|
|
if !ok {
|
|
logger.ErrorContext(ctx, "Google callback without a session")
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
return
|
|
}
|
|
err := svc.Complete(w, r, session)
|
|
switch {
|
|
case err == nil:
|
|
http.Redirect(w, r, googleConnectedPath, http.StatusSeeOther)
|
|
case errors.Is(err, gcal.ErrBadState):
|
|
logger.WarnContext(ctx, "Google callback refused", "error", err)
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_state",
|
|
"this Google Calendar connection expired or was not started here; connect again")
|
|
case errors.Is(err, gcal.ErrDenied):
|
|
logger.WarnContext(ctx, "Google returned an authorization error")
|
|
writeError(w, r, logger, http.StatusForbidden, "google_denied",
|
|
"Google did not grant access to your calendar; connect again to retry")
|
|
case errors.Is(err, gcal.ErrNoCode):
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "missing authorization code")
|
|
case errors.Is(err, gcal.ErrNoRefreshToken):
|
|
logger.WarnContext(ctx, "Google connect refused", "error", err)
|
|
writeError(w, r, logger, http.StatusBadGateway, "reconnect_required",
|
|
"Google did not grant lasting access to your calendar; connect Google Calendar again")
|
|
case errors.Is(err, gcal.ErrScopes):
|
|
logger.WarnContext(ctx, "Google connect refused", "error", err)
|
|
writeError(w, r, logger, http.StatusForbidden, "scopes_missing",
|
|
"allow MealQuest to see your calendars and manage their events, then connect Google Calendar again")
|
|
default:
|
|
logger.WarnContext(ctx, "Google connect failed", "error", err)
|
|
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
|
|
"Google Calendar could not be connected; connect again")
|
|
}
|
|
}
|
|
}
|
|
|
|
// deleteGoogle revokes the connected account's access at Google and forgets
|
|
// it: 204 on success, 409 not_connected when nothing is connected, 502 when
|
|
// Google could not revoke (the connection is kept, so it can be retried).
|
|
func deleteGoogle(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
err := svc.Disconnect(r.Context())
|
|
var revoke *gcal.RevokeError
|
|
switch {
|
|
case err == nil:
|
|
w.WriteHeader(http.StatusNoContent)
|
|
case errors.Is(err, gcal.ErrNotConnected):
|
|
writeError(w, r, logger, http.StatusConflict, "not_connected", "Google Calendar is not connected")
|
|
case errors.As(err, &revoke):
|
|
logger.WarnContext(r.Context(), "revoke Google access", "error", err)
|
|
writeError(w, r, logger, http.StatusBadGateway, "revoke_failed",
|
|
"Google could not revoke MealQuest's access; try again")
|
|
default:
|
|
logger.ErrorContext(r.Context(), "disconnect Google Calendar", "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
}
|
|
}
|
|
}
|