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>
296 lines
8.8 KiB
Go
296 lines
8.8 KiB
Go
// Package gcaltest provides a fake Google OAuth server for tests of the
|
|
// Google Calendar connection: a token endpoint that checks the client, the
|
|
// redirect URI and PKCE and issues a distinct refresh token per exchange, and
|
|
// a revoke endpoint that, like Google's, revokes the whole grant: every token
|
|
// issued so far. It never touches the network beyond its own httptest
|
|
// server.
|
|
package gcaltest
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
|
)
|
|
|
|
// Fixed credentials and tokens the fake issues and expects. The first
|
|
// exchange issues RefreshToken; exchange n after it issues RefreshToken
|
|
// followed by "-n", so every issued refresh token contains RefreshToken.
|
|
const (
|
|
ClientID = "mealquest-test.apps.googleusercontent.com"
|
|
ClientSecret = "google-client-s3cret-4f1c"
|
|
RefreshToken = "1//google-refresh-t0ken-9a8b7c"
|
|
AccessToken = "ya29.google-access-t0ken-5d6e"
|
|
)
|
|
|
|
// Google is the fake. Its exported fields script the next responses; set
|
|
// them before the request they affect.
|
|
type Google struct {
|
|
Server *httptest.Server
|
|
// RedirectURL is the redirect URI the token endpoint requires.
|
|
RedirectURL string
|
|
|
|
mu sync.Mutex
|
|
// omitRefresh leaves refresh_token out of the token response.
|
|
omitRefresh bool
|
|
// scope is the token response's granted scopes.
|
|
scope string
|
|
// tokenStatus, when not 200, fails the token endpoint with a body that
|
|
// quotes the refresh token and client secret.
|
|
tokenStatus int
|
|
// revokeStatus and revokeBody answer the revoke endpoint; revokeBody
|
|
// quotes the token when revokeStatus is not 200 or 400.
|
|
revokeStatus int
|
|
revokeBody string
|
|
codes map[string]string // code -> PKCE challenge
|
|
tokenCalls int
|
|
revoked []string
|
|
// issued is every refresh token issued, in order; dead is those a
|
|
// revoke has invalidated.
|
|
issued []string
|
|
dead map[string]bool
|
|
// tokenHold and revokeHold, when set, stop the next request to that
|
|
// endpoint.
|
|
tokenHold, revokeHold *hold
|
|
}
|
|
|
|
// hold stops one request: arrived is closed when it arrives, and it goes on
|
|
// once release is closed.
|
|
type hold struct {
|
|
arrived, release chan struct{}
|
|
}
|
|
|
|
// New starts a fake Google that requires redirectURL at its token endpoint.
|
|
// It grants every scope in gcal.Scopes with a refresh token by default.
|
|
func New(t *testing.T, redirectURL string) *Google {
|
|
t.Helper()
|
|
g := &Google{
|
|
RedirectURL: redirectURL,
|
|
scope: strings.Join(gcal.Scopes, " "),
|
|
tokenStatus: http.StatusOK,
|
|
revokeStatus: http.StatusOK,
|
|
codes: map[string]string{},
|
|
dead: map[string]bool{},
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST /token", g.token)
|
|
mux.HandleFunc("POST /revoke", g.revoke)
|
|
g.Server = httptest.NewServer(mux)
|
|
t.Cleanup(g.Server.Close)
|
|
return g
|
|
}
|
|
|
|
// Endpoint is the fake's authorization and token endpoint, with Google's
|
|
// client authentication style.
|
|
func (g *Google) Endpoint() oauth2.Endpoint {
|
|
return oauth2.Endpoint{
|
|
AuthURL: g.Server.URL + "/auth",
|
|
TokenURL: g.Server.URL + "/token",
|
|
AuthStyle: gcal.GoogleEndpoint.AuthStyle,
|
|
}
|
|
}
|
|
|
|
// RevokeURL is the fake's revocation endpoint.
|
|
func (g *Google) RevokeURL() string { return g.Server.URL + "/revoke" }
|
|
|
|
// Authorize plays the consent screen for an authorization request with PKCE
|
|
// challenge and returns the code Google would send back.
|
|
func (g *Google) Authorize(challenge string) string {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
code := fmt.Sprintf("code-%d", len(g.codes)+1)
|
|
g.codes[code] = challenge
|
|
return code
|
|
}
|
|
|
|
// OmitRefreshToken makes the token endpoint answer without a refresh token,
|
|
// as Google does when it reuses an earlier grant.
|
|
func (g *Google) OmitRefreshToken() {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.omitRefresh = true
|
|
}
|
|
|
|
// GrantScope sets the scopes the token endpoint reports as granted.
|
|
func (g *Google) GrantScope(scope string) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.scope = scope
|
|
}
|
|
|
|
// FailToken makes the token endpoint answer status with a body that quotes
|
|
// the refresh token and client secret.
|
|
func (g *Google) FailToken(status int) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.tokenStatus = status
|
|
}
|
|
|
|
// AnswerRevoke makes the revoke endpoint answer status with body. A body of
|
|
// "" with a status other than 200 quotes the token it was sent.
|
|
func (g *Google) AnswerRevoke(status int, body string) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.revokeStatus, g.revokeBody = status, body
|
|
}
|
|
|
|
// TokenCalls is how many requests reached the token endpoint.
|
|
func (g *Google) TokenCalls() int {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.tokenCalls
|
|
}
|
|
|
|
// Revoked lists the tokens the revoke endpoint received, in order.
|
|
func (g *Google) Revoked() []string {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return append([]string(nil), g.revoked...)
|
|
}
|
|
|
|
// Live reports whether token was issued by the fake and no revoke has
|
|
// invalidated it since.
|
|
func (g *Google) Live(token string) bool {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return slices.Contains(g.issued, token) && !g.dead[token]
|
|
}
|
|
|
|
// HoldToken stops the next token request after its refresh token is issued
|
|
// and before it is answered, so a revoke can land in between. It returns a
|
|
// channel closed when the request arrives and the function that lets it go
|
|
// on.
|
|
func (g *Google) HoldToken() (arrived <-chan struct{}, release func()) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.tokenHold = newHold()
|
|
return g.tokenHold.arrived, g.tokenHold.releaseFunc()
|
|
}
|
|
|
|
// HoldRevoke stops the next revoke request before Google acts on it. It
|
|
// returns a channel closed when the request arrives and the function that
|
|
// lets it go on.
|
|
func (g *Google) HoldRevoke() (arrived <-chan struct{}, release func()) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.revokeHold = newHold()
|
|
return g.revokeHold.arrived, g.revokeHold.releaseFunc()
|
|
}
|
|
|
|
func newHold() *hold {
|
|
return &hold{arrived: make(chan struct{}), release: make(chan struct{})}
|
|
}
|
|
|
|
func (h *hold) releaseFunc() func() {
|
|
var once sync.Once
|
|
return func() { once.Do(func() { close(h.release) }) }
|
|
}
|
|
|
|
// wait stops the request at h, if any, without holding g.mu.
|
|
func (g *Google) wait(h *hold) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
g.mu.Unlock()
|
|
close(h.arrived)
|
|
<-h.release
|
|
g.mu.Lock()
|
|
}
|
|
|
|
func (g *Google) token(w http.ResponseWriter, r *http.Request) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.tokenCalls++
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
f := r.PostForm
|
|
challenge, known := g.codes[f.Get("code")]
|
|
delete(g.codes, f.Get("code"))
|
|
switch {
|
|
case f.Get("grant_type") != "authorization_code":
|
|
oauthError(w, http.StatusBadRequest, "unsupported_grant_type")
|
|
return
|
|
case f.Get("client_id") != ClientID || f.Get("client_secret") != ClientSecret:
|
|
oauthError(w, http.StatusUnauthorized, "invalid_client")
|
|
return
|
|
case !known, f.Get("redirect_uri") != g.RedirectURL, s256(f.Get("code_verifier")) != challenge:
|
|
oauthError(w, http.StatusBadRequest, "invalid_grant")
|
|
return
|
|
}
|
|
if g.tokenStatus != http.StatusOK {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(g.tokenStatus)
|
|
fmt.Fprintf(w, `{"error":"invalid_grant","error_description":"echo %s %s"}`, RefreshToken, ClientSecret)
|
|
return
|
|
}
|
|
refresh := ""
|
|
if !g.omitRefresh {
|
|
issue := RefreshToken
|
|
if n := len(g.issued); n > 0 {
|
|
issue = fmt.Sprintf("%s-%d", RefreshToken, n+1)
|
|
}
|
|
g.issued = append(g.issued, issue)
|
|
refresh = fmt.Sprintf(`"refresh_token":%q,`, issue)
|
|
}
|
|
h := g.tokenHold
|
|
g.tokenHold = nil
|
|
g.wait(h)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"access_token":%q,%s"expires_in":3599,"scope":%q,"token_type":"Bearer"}`,
|
|
AccessToken, refresh, g.scope)
|
|
}
|
|
|
|
func (g *Google) revoke(w http.ResponseWriter, r *http.Request) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
|
|
http.Error(w, "want a form", http.StatusUnsupportedMediaType)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
h := g.revokeHold
|
|
g.revokeHold = nil
|
|
g.wait(h)
|
|
token := r.PostForm.Get("token")
|
|
g.revoked = append(g.revoked, token)
|
|
if g.revokeStatus == http.StatusOK && slices.Contains(g.issued, token) {
|
|
// Google revokes the grant, not just the token: every token issued
|
|
// to it so far stops working.
|
|
for _, t := range g.issued {
|
|
g.dead[t] = true
|
|
}
|
|
}
|
|
body := g.revokeBody
|
|
if body == "" && g.revokeStatus != http.StatusOK {
|
|
body = "cannot revoke " + token
|
|
}
|
|
w.WriteHeader(g.revokeStatus)
|
|
fmt.Fprint(w, body)
|
|
}
|
|
|
|
func oauthError(w http.ResponseWriter, status int, code string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
fmt.Fprintf(w, `{"error":%q}`, code)
|
|
}
|
|
|
|
// s256 is the PKCE S256 challenge for verifier.
|
|
func s256(verifier string) string {
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:])
|
|
}
|