A starred Gmail message became a task and kept its star, so a starred inbox only grew — the IMAP side has always unflagged what it imports, and Gmail could not, because the scope was gmail.readonly. This gives Gmail the same one write: after the task is made, the STARRED label comes off the message, in one batchModify for the whole sweep. Nothing else is touched — not the body, not other labels, and a label trigger's label stays where it is, since that label is the person's own. The scope goes from gmail.readonly to gmail.modify, which is the narrowest scope Google offers that can remove a label. An existing connection keeps working on its old read-only grant — the sync still reads, the unstar is a best-effort 403 that is logged, not fatal — and starts clearing stars once the account is reconnected and the new grant is in place. It self-heals: a mail that could not be unstarred stays starred, stays in the next query, and is tried again. The consent screen now asks for more than it used to; the settings copy, the docs and the scope table all say modify rather than read-only in the same change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
259 lines
8.1 KiB
Go
259 lines
8.1 KiB
Go
// Package google implements the OAuth2 authorisation-code flow with PKCE against
|
|
// Google.
|
|
//
|
|
// It was written inside internal/gmail, because Gmail was the only thing that
|
|
// needed it. Calendar needs the same flow — the same endpoints, the same PKCE, the
|
|
// same refresh — and the only thing that differs between them is one string: the
|
|
// scope. A second copy would be a second place for "access_type=offline" to be
|
|
// forgotten, which is the mistake that makes a connection die silently an hour
|
|
// after it was made.
|
|
//
|
|
// What is *not* here is the API calls. Those differ completely, so gmail and gcal
|
|
// keep their own clients and share only the way in.
|
|
package google
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
|
)
|
|
|
|
// Config is the OAuth client the operator registered in Google Cloud, plus the
|
|
// scope one feature is asking for.
|
|
//
|
|
// The client belongs to the instance rather than to a person: one registration,
|
|
// any number of users. The scope belongs to the feature: Gmail and Calendar sign
|
|
// in through the same registration and ask for different things, and Google issues
|
|
// a refresh token per authorisation — so connecting one does not disturb the other.
|
|
type Config struct {
|
|
ClientID string
|
|
ClientSecret string
|
|
RedirectURL string
|
|
Scope string
|
|
|
|
// TokenURL is where Google is. A field rather than a constant so a test can
|
|
// point it at a server it controls: there is no other way to exercise what
|
|
// happens when the token endpoint is slow or refuses. Empty is Google, so
|
|
// nothing outside a test has to know it exists.
|
|
TokenURL string
|
|
}
|
|
|
|
func (c Config) Configured() bool {
|
|
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
|
|
}
|
|
|
|
func (c Config) tokenURL() string {
|
|
if c.TokenURL != "" {
|
|
return c.TokenURL
|
|
}
|
|
return defaultTokenEndpoint
|
|
}
|
|
|
|
// PKCE is the proof-key pair for one authorisation attempt.
|
|
//
|
|
// PKCE on a server-side client is not strictly required — the client secret already
|
|
// authenticates the token exchange — but it costs nothing and closes the window in
|
|
// which an authorisation code intercepted from the redirect could be replayed
|
|
// before verdande gets to it.
|
|
type PKCE struct {
|
|
Verifier string
|
|
Challenge string
|
|
State string
|
|
}
|
|
|
|
func NewPKCE() (PKCE, error) {
|
|
verifier, err := randomString(64)
|
|
if err != nil {
|
|
return PKCE{}, err
|
|
}
|
|
state, err := randomString(32)
|
|
if err != nil {
|
|
return PKCE{}, err
|
|
}
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
return PKCE{
|
|
Verifier: verifier,
|
|
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
|
|
State: state,
|
|
}, nil
|
|
}
|
|
|
|
// AuthURL is where the browser is sent to ask the person for consent.
|
|
func (c Config) AuthURL(p PKCE) string {
|
|
v := url.Values{}
|
|
v.Set("client_id", c.ClientID)
|
|
v.Set("redirect_uri", c.RedirectURL)
|
|
v.Set("response_type", "code")
|
|
v.Set("scope", c.Scope)
|
|
v.Set("state", p.State)
|
|
v.Set("code_challenge", p.Challenge)
|
|
v.Set("code_challenge_method", "S256")
|
|
// Without both of these Google returns no refresh token on a re-authorisation,
|
|
// and the connection silently stops working an hour later.
|
|
v.Set("access_type", "offline")
|
|
v.Set("prompt", "consent")
|
|
return authEndpoint + "?" + v.Encode()
|
|
}
|
|
|
|
type Token struct {
|
|
AccessToken string
|
|
RefreshToken string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// Exchange trades the authorisation code for tokens.
|
|
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
|
|
form := url.Values{}
|
|
form.Set("code", code)
|
|
form.Set("client_id", c.ClientID)
|
|
form.Set("client_secret", c.ClientSecret)
|
|
form.Set("redirect_uri", c.RedirectURL)
|
|
form.Set("grant_type", "authorization_code")
|
|
form.Set("code_verifier", p.Verifier)
|
|
|
|
return c.token(ctx, form)
|
|
}
|
|
|
|
// Refresh gets a fresh access token. Google does not return a new refresh token
|
|
// here, so the caller keeps the one it has.
|
|
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
|
|
form := url.Values{}
|
|
form.Set("refresh_token", refreshToken)
|
|
form.Set("client_id", c.ClientID)
|
|
form.Set("client_secret", c.ClientSecret)
|
|
form.Set("grant_type", "refresh_token")
|
|
|
|
token, err := c.token(ctx, form)
|
|
if err != nil {
|
|
return Token{}, err
|
|
}
|
|
token.RefreshToken = refreshToken
|
|
return token, nil
|
|
}
|
|
|
|
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL(),
|
|
strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return Token{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return Token{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var parsed struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Error string `json:"error"`
|
|
ErrorDescription string `json:"error_description"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
|
return Token{}, fmt.Errorf("google: reading token response: %w", err)
|
|
}
|
|
if parsed.Error != "" {
|
|
return Token{}, fmt.Errorf("google: %s: %s", parsed.Error, parsed.ErrorDescription)
|
|
}
|
|
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
|
|
return Token{}, fmt.Errorf("google: token exchange failed with %s", resp.Status)
|
|
}
|
|
|
|
return Token{
|
|
AccessToken: parsed.AccessToken,
|
|
RefreshToken: parsed.RefreshToken,
|
|
// A minute of slack, so a token that is about to expire is refreshed
|
|
// rather than used and rejected.
|
|
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
|
|
}, nil
|
|
}
|
|
|
|
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
|
|
// or changed their password. The caller disconnects rather than retrying.
|
|
var ErrUnauthorized = fmt.Errorf("google: authorisation was refused")
|
|
|
|
// Get performs an authorised GET and decodes the JSON body into out.
|
|
//
|
|
// Shared by the two API clients because the interesting part is the same in both:
|
|
// a 401 is not a failure to retry, it is a connection that has ended, and telling
|
|
// those apart is what stops a revoked account being polled every ten minutes for
|
|
// ever.
|
|
func Get(ctx context.Context, client *http.Client, accessToken, url string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
return ErrUnauthorized
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("google: %s", resp.Status)
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
// Post sends a JSON body to a Google API and, unlike Get, does not insist on a
|
|
// body coming back. A write call — modifying labels on a message — answers with
|
|
// 204 No Content when it worked, so anything 2xx is success and only the body is
|
|
// decoded, and only when the caller asked for one.
|
|
func Post(ctx context.Context, client *http.Client, accessToken, url string, body, out any) error {
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
return ErrUnauthorized
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("google: %s", resp.Status)
|
|
}
|
|
if out == nil || resp.StatusCode == http.StatusNoContent {
|
|
return nil
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
func randomString(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
|
|
}
|