Add Google Calendar OAuth connect and disconnect
GET /api/v1/integrations/google/connect sends the browser to Google's consent screen (calendar.events and calendar.calendarlist.readonly, access_type=offline, prompt=consent, PKCE S256) with a state carried in a signed __Host-mealquest_google flow cookie bound to the session. GET /api/v1/integrations/google/callback checks the state and session, exchanges the code, refuses a response without a refresh token (reconnect_required) or without both scopes, seals the refresh token with secretbox into integrations_google and sets connected_at. DELETE /api/v1/integrations/google revokes the token at Google and clears it; a revoke failure keeps the connection so it can be retried. - internal/gcal: the connect service; Google endpoints are overridable and internal/gcal/gcaltest fakes the token and revoke endpoints. - internal/auth: the login cookie becomes the exported FlowCookie both flows use (same wire format); CheckRedirect and ExchangeError are exported for reuse. - internal/config: MEALQUEST_GOOGLE_CLIENT_ID and MEALQUEST_GOOGLE_CLIENT_SECRET_FILE, required at boot. - DeleteIntegrationsGoogle query (sqlc regenerated); no migration, the integrations_google table already exists. - deploy: compose mounts google_client_secret; .env.example and RUNBOOK document the Google OAuth client, its redirect URI and rotation. Maestro task 1e9d7850-3a09-4572-b8a1-cb18cd4774cb. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
// 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"
|
||||
"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")
|
||||
// ErrChanged means the connection changed while it was being
|
||||
// disconnected.
|
||||
ErrChanged = errors.New("gcal: Google Calendar connection changed during disconnect")
|
||||
)
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
|
||||
// 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,
|
||||
}, 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),
|
||||
}
|
||||
if err := s.cookie.Set(w, &state, s.now()); err != nil {
|
||||
return "", fmt.Errorf("gcal: set connect cookie: %w", err)
|
||||
}
|
||||
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, 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()
|
||||
// The connect cookie is single-use.
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
n, err := db.New(s.st.Writer).DeleteIntegrationsGoogle(ctx, row.RefreshTokenCt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: delete Google integration: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrChanged
|
||||
}
|
||||
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)}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package gcal_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const publicURL = "https://meals.example.com/app"
|
||||
|
||||
var (
|
||||
redirectURL = "https://meals.example.com" + gcal.CallbackPath
|
||||
session = auth.Session{Subject: "user-1", CSRFToken: "csrf-session-one"}
|
||||
other = auth.Session{Subject: "user-1", CSRFToken: "csrf-session-two"}
|
||||
)
|
||||
|
||||
type harness struct {
|
||||
svc *gcal.Service
|
||||
google *gcaltest.Google
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
clock time.Time
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
google := gcaltest.New(t, redirectURL)
|
||||
svc, err := gcal.NewService(st, box, gcal.Config{
|
||||
ClientID: gcaltest.ClientID,
|
||||
ClientSecret: gcaltest.ClientSecret,
|
||||
PublicURL: pub,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Endpoint: google.Endpoint(),
|
||||
RevokeURL: google.RevokeURL(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &harness{svc: svc, google: google, st: st, box: box, clock: time.Date(2026, 10, 7, 18, 30, 0, 0, time.UTC)}
|
||||
gcal.SetClock(svc, func() time.Time { return h.clock })
|
||||
return h
|
||||
}
|
||||
|
||||
// connect is a started connect: Google's consent URL and the connect
|
||||
// cookie.
|
||||
type connect struct {
|
||||
consent *url.URL
|
||||
cookie *http.Cookie
|
||||
}
|
||||
|
||||
func (h *harness) connect(t *testing.T, s auth.Session) connect {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
raw, err := h.svc.Connect(rec, s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
consent, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("Connect set %d cookies, want 1", len(cookies))
|
||||
}
|
||||
return connect{consent: consent, cookie: cookies[0]}
|
||||
}
|
||||
|
||||
// callback completes c as session s with query, returning the error and the
|
||||
// recorder Complete wrote to.
|
||||
func (h *harness) callback(c connect, s auth.Session, query url.Values) (*httptest.ResponseRecorder, error) {
|
||||
req := httptest.NewRequest(http.MethodGet, gcal.CallbackPath+"?"+query.Encode(), nil)
|
||||
if c.cookie != nil {
|
||||
req.AddCookie(c.cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
return rec, h.svc.Complete(rec, req, s)
|
||||
}
|
||||
|
||||
// consented is the query Google sends back after consent to c.
|
||||
func (h *harness) consented(c connect) url.Values {
|
||||
q := c.consent.Query()
|
||||
return url.Values{"state": {q.Get("state")}, "code": {h.google.Authorize(q.Get("code_challenge"))}}
|
||||
}
|
||||
|
||||
func (h *harness) row(t *testing.T) (db.IntegrationsGoogle, bool) {
|
||||
t.Helper()
|
||||
row, err := db.New(h.st.Reader).GetIntegrationsGoogle(context.Background())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return db.IntegrationsGoogle{}, false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row, true
|
||||
}
|
||||
|
||||
func TestConnectRedirectsToConsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
|
||||
if got, want := c.consent.Scheme+"://"+c.consent.Host+c.consent.Path, h.google.Endpoint().AuthURL; got != want {
|
||||
t.Errorf("consent URL = %s, want %s", got, want)
|
||||
}
|
||||
q := c.consent.Query()
|
||||
for name, want := range map[string]string{
|
||||
"client_id": gcaltest.ClientID,
|
||||
"redirect_uri": redirectURL,
|
||||
"response_type": "code",
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"code_challenge_method": "S256",
|
||||
} {
|
||||
if got := q.Get(name); got != want {
|
||||
t.Errorf("%s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
if got := strings.Fields(q.Get("scope")); !slices.Equal(got, []string{
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
|
||||
}) {
|
||||
t.Errorf("scope = %q", q.Get("scope"))
|
||||
}
|
||||
if q.Get("state") == "" || q.Get("code_challenge") == "" {
|
||||
t.Errorf("state %q, code_challenge %q: want both set", q.Get("state"), q.Get("code_challenge"))
|
||||
}
|
||||
if q.Get("client_secret") != "" || strings.Contains(c.consent.String(), gcaltest.ClientSecret) {
|
||||
t.Error("consent URL carries the client secret")
|
||||
}
|
||||
|
||||
ck := c.cookie
|
||||
if ck.Name != "__Host-mealquest_google" || ck.Path != "/" || !ck.Secure || !ck.HttpOnly ||
|
||||
ck.SameSite != http.SameSiteLaxMode || ck.MaxAge != 600 || ck.Domain != "" {
|
||||
t.Errorf("connect cookie = %+v, want __Host-, Path=/, Secure, HttpOnly, Lax, 10 minutes", ck)
|
||||
}
|
||||
// Two connects never share a state.
|
||||
if again := h.connect(t, session); again.consent.Query().Get("state") == q.Get("state") {
|
||||
t.Error("two connects share a state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteStoresSealedRefreshToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
rec, err := h.callback(c, session, h.consented(c))
|
||||
if err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
|
||||
row, ok := h.row(t)
|
||||
if !ok {
|
||||
t.Fatal("no integration stored")
|
||||
}
|
||||
if bytes.Contains(row.RefreshTokenCt, []byte(gcaltest.RefreshToken)) {
|
||||
t.Error("refresh token stored in plain text")
|
||||
}
|
||||
plain, err := h.box.Decrypt(row.RefreshTokenCt)
|
||||
if err != nil || string(plain) != gcaltest.RefreshToken {
|
||||
t.Errorf("stored token opens to %q, %v; want the refresh token", plain, err)
|
||||
}
|
||||
if want := "2026-10-07T18:30:00.000Z"; row.ConnectedAt != want || row.CreatedAt != want || row.UpdatedAt != want {
|
||||
t.Errorf("connected_at %q created_at %q updated_at %q, want %s", row.ConnectedAt, row.CreatedAt, row.UpdatedAt, want)
|
||||
}
|
||||
if row.CalendarID.Valid || row.CalendarName.Valid || row.LastSyncError.Valid {
|
||||
t.Errorf("new connection has calendar or sync error: %+v", row)
|
||||
}
|
||||
assertCookieCleared(t, rec)
|
||||
assertNoSecret(t, "response", rec.Body.String()+headerText(rec.Header()))
|
||||
}
|
||||
|
||||
func TestReconnectReplacesTokenAndKeepsCalendar(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
if _, err := db.New(h.st.Writer).UpsertIntegrationsGoogle(ctx, db.UpsertIntegrationsGoogleParams{
|
||||
RefreshTokenCt: []byte("old sealed token"),
|
||||
CalendarID: sql.NullString{String: "family@group.calendar.google.com", Valid: true},
|
||||
CalendarName: sql.NullString{String: "Family", Valid: true},
|
||||
ConnectedAt: "2026-09-01T00:00:00.000Z",
|
||||
LastSyncError: sql.NullString{String: "token revoked", Valid: true},
|
||||
CreatedAt: "2026-09-01T00:00:00.000Z",
|
||||
UpdatedAt: "2026-09-01T00:00:00.000Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
row, _ := h.row(t)
|
||||
if plain, err := h.box.Decrypt(row.RefreshTokenCt); err != nil || string(plain) != gcaltest.RefreshToken {
|
||||
t.Errorf("token after reconnect opens to %q, %v", plain, err)
|
||||
}
|
||||
if row.CalendarID.String != "family@group.calendar.google.com" || row.CalendarName.String != "Family" {
|
||||
t.Errorf("calendar after reconnect = %v %v, want it kept", row.CalendarID, row.CalendarName)
|
||||
}
|
||||
if row.LastSyncError.Valid {
|
||||
t.Errorf("last_sync_error = %q, want it cleared", row.LastSyncError.String)
|
||||
}
|
||||
if row.CreatedAt != "2026-09-01T00:00:00.000Z" || row.ConnectedAt != "2026-10-07T18:30:00.000Z" {
|
||||
t.Errorf("created_at %q connected_at %q", row.CreatedAt, row.ConnectedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRefusesBadState(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// edit changes the connect, the callback's session or its query.
|
||||
edit func(t *testing.T, h *harness, c *connect, s *auth.Session, q url.Values)
|
||||
}{
|
||||
{"missing state", func(_ *testing.T, _ *harness, _ *connect, _ *auth.Session, q url.Values) { q.Del("state") }},
|
||||
{"wrong state", func(_ *testing.T, _ *harness, _ *connect, _ *auth.Session, q url.Values) { q.Set("state", "forged") }},
|
||||
{"no connect cookie", func(_ *testing.T, _ *harness, c *connect, _ *auth.Session, _ url.Values) { c.cookie = nil }},
|
||||
{"tampered cookie", func(_ *testing.T, _ *harness, c *connect, _ *auth.Session, _ url.Values) {
|
||||
c.cookie = &http.Cookie{Name: c.cookie.Name, Value: "x" + c.cookie.Value}
|
||||
}},
|
||||
{"another session", func(_ *testing.T, _ *harness, _ *connect, s *auth.Session, _ url.Values) { *s = other }},
|
||||
{"expired", func(_ *testing.T, h *harness, _ *connect, _ *auth.Session, _ url.Values) {
|
||||
h.clock = h.clock.Add(10 * time.Minute)
|
||||
}},
|
||||
{"state of another connect", func(t *testing.T, h *harness, _ *connect, _ *auth.Session, q url.Values) {
|
||||
q.Set("state", h.connect(t, session).consent.Query().Get("state"))
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
s := session
|
||||
q := h.consented(c)
|
||||
tc.edit(t, h, &c, &s, q)
|
||||
|
||||
rec, err := h.callback(c, s, q)
|
||||
if !errors.Is(err, gcal.ErrBadState) {
|
||||
t.Fatalf("Complete error = %v, want ErrBadState", err)
|
||||
}
|
||||
if h.google.TokenCalls() != 0 {
|
||||
t.Error("the code was exchanged despite the bad state")
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("an integration was stored")
|
||||
}
|
||||
assertCookieCleared(t, rec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRefusesMissingRefreshToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.google.OmitRefreshToken()
|
||||
c := h.connect(t, session)
|
||||
_, err := h.callback(c, session, h.consented(c))
|
||||
if !errors.Is(err, gcal.ErrNoRefreshToken) {
|
||||
t.Fatalf("Complete error = %v, want ErrNoRefreshToken", err)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("an integration was stored without a refresh token")
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
}
|
||||
|
||||
func TestCompleteRefusesMissingScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.google.GrantScope("https://www.googleapis.com/auth/calendar.calendarlist.readonly")
|
||||
c := h.connect(t, session)
|
||||
_, err := h.callback(c, session, h.consented(c))
|
||||
if !errors.Is(err, gcal.ErrScopes) {
|
||||
t.Fatalf("Complete error = %v, want ErrScopes", err)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("an integration was stored without calendar.events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
q := url.Values{"state": {c.consent.Query().Get("state")}, "error": {"access_denied"}}
|
||||
if _, err := h.callback(c, session, q); !errors.Is(err, gcal.ErrDenied) {
|
||||
t.Fatalf("Complete error = %v, want ErrDenied", err)
|
||||
}
|
||||
if h.google.TokenCalls() != 0 {
|
||||
t.Error("token endpoint called after a denial")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteExchangeFailureIsRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.google.FailToken(http.StatusBadRequest)
|
||||
c := h.connect(t, session)
|
||||
_, err := h.callback(c, session, h.consented(c))
|
||||
if err == nil {
|
||||
t.Fatal("Complete succeeded despite the token endpoint failing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 400") {
|
||||
t.Errorf("error = %v, want the status", err)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("an integration was stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectRevokesAndClears(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.svc.Disconnect(context.Background()); err != nil {
|
||||
t.Fatalf("Disconnect: %v", err)
|
||||
}
|
||||
if got := h.google.Revoked(); !slices.Equal(got, []string{gcaltest.RefreshToken}) {
|
||||
t.Errorf("revoked %q, want the refresh token", got)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("integration still stored after disconnect")
|
||||
}
|
||||
if err := h.svc.Disconnect(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
|
||||
t.Errorf("second Disconnect = %v, want ErrNotConnected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectTokenAlreadyRevokedAtGoogle(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.google.AnswerRevoke(http.StatusBadRequest, `{"error":"invalid_token","error_description":"Token expired or revoked"}`)
|
||||
if err := h.svc.Disconnect(context.Background()); err != nil {
|
||||
t.Fatalf("Disconnect: %v", err)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("integration still stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectRevokeFailureKeepsConnection(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, status := range []int{http.StatusBadRequest, http.StatusServiceUnavailable} {
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.google.AnswerRevoke(status, "")
|
||||
err := h.svc.Disconnect(context.Background())
|
||||
var revoke *gcal.RevokeError
|
||||
if !errors.As(err, &revoke) {
|
||||
t.Fatalf("HTTP %d: Disconnect error = %v, want *RevokeError", status, err)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
if _, ok := h.row(t); !ok {
|
||||
t.Errorf("HTTP %d: connection cleared although Google did not revoke", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectNotConnected(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
if err := h.svc.Disconnect(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
|
||||
t.Fatalf("Disconnect = %v, want ErrNotConnected", err)
|
||||
}
|
||||
if len(h.google.Revoked()) != 0 {
|
||||
t.Error("revoke called with nothing connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
pub, _ := url.Parse(publicURL)
|
||||
valid := gcal.Config{
|
||||
ClientID: "id", ClientSecret: "secret", PublicURL: pub, SecretKey: []byte("k"),
|
||||
Endpoint: gcal.GoogleEndpoint, RevokeURL: gcal.GoogleRevokeURL,
|
||||
}
|
||||
if _, err := gcal.NewService(h.st, h.box, valid); err != nil {
|
||||
t.Fatalf("valid config: %v", err)
|
||||
}
|
||||
for name, edit := range map[string]func(*gcal.Config){
|
||||
"client ID": func(c *gcal.Config) { c.ClientID = "" },
|
||||
"client secret": func(c *gcal.Config) { c.ClientSecret = "" },
|
||||
"public URL": func(c *gcal.Config) { c.PublicURL = nil },
|
||||
"secret key": func(c *gcal.Config) { c.SecretKey = nil },
|
||||
"token URL": func(c *gcal.Config) { c.Endpoint.TokenURL = "" },
|
||||
"revoke URL": func(c *gcal.Config) { c.RevokeURL = "" },
|
||||
} {
|
||||
cfg := valid
|
||||
edit(&cfg)
|
||||
if _, err := gcal.NewService(h.st, h.box, cfg); err == nil {
|
||||
t.Errorf("NewService accepted a config without the %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertCookieCleared(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "__Host-mealquest_google" && c.MaxAge < 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("connect cookie not cleared")
|
||||
}
|
||||
|
||||
// assertNoSecret fails when text holds the refresh token, the access token
|
||||
// or the client secret.
|
||||
func assertNoSecret(t *testing.T, what, text string) {
|
||||
t.Helper()
|
||||
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
|
||||
if strings.Contains(text, secret) {
|
||||
t.Errorf("%s contains a secret: %s", what, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func headerText(h http.Header) string {
|
||||
var b strings.Builder
|
||||
for k, vs := range h {
|
||||
b.WriteString(k + ": " + strings.Join(vs, ", ") + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package gcal
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock replaces s's clock.
|
||||
func SetClock(s *Service, now func() time.Time) { s.now = now }
|
||||
@@ -0,0 +1,209 @@
|
||||
// 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 a revoke endpoint that records what it revoked.
|
||||
// It never touches the network beyond its own httptest server.
|
||||
package gcaltest
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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.
|
||||
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
|
||||
}
|
||||
|
||||
// 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{},
|
||||
}
|
||||
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...)
|
||||
}
|
||||
|
||||
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 {
|
||||
refresh = fmt.Sprintf(`"refresh_token":%q,`, RefreshToken)
|
||||
}
|
||||
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
|
||||
}
|
||||
token := r.PostForm.Get("token")
|
||||
g.revoked = append(g.revoked, token)
|
||||
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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user