diff --git a/internal/gmail/gmail.go b/internal/gmail/gmail.go index a93613d..f112dfc 100644 --- a/internal/gmail/gmail.go +++ b/internal/gmail/gmail.go @@ -1,5 +1,5 @@ -// Package gmail implements the OAuth2 authorisation-code flow with PKCE against -// Google, and the small slice of the Gmail API verdande needs. +// Package gmail is the small slice of the Gmail API verdande needs, and the scope +// it asks for. // // The connection is one-way on purpose: a starred or labelled message becomes a // task, and nothing verdande does afterwards touches the mailbox. Unstarring the @@ -9,200 +9,51 @@ // // Scope is gmail.readonly. verdande never needs to send, modify or delete, and // asking for less is what makes the consent screen something a person can agree to. +// +// The OAuth flow itself moved to internal/google when Calendar needed the same one. +// The names below are aliases so that every call site — and this package's own +// tests — still read as Gmail's, which is what they are: one registration, one +// flow, one scope each. package gmail import ( "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" "fmt" "net/http" "net/url" "strings" "time" + + "github.com/kristianwind/verdande/internal/google" ) -const ( - authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth" - defaultTokenEndpoint = "https://oauth2.googleapis.com/token" - defaultAPIBase = "https://gmail.googleapis.com/gmail/v1" +// Read-only, and only mail. Not profile, not contacts, not send. +const Scope = "https://www.googleapis.com/auth/gmail.readonly" - // Read-only, and only mail. Not profile, not contacts, not send. - Scope = "https://www.googleapis.com/auth/gmail.readonly" +const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1" + +// The OAuth flow, which is Google's rather than Gmail's. +type ( + Config = google.Config + PKCE = google.PKCE + Token = google.Token ) -// Endpoints 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 Gmail is slow or refuses — -// and "the sync comes back before whatever sits in front of this server gives up -// on it" is exactly the kind of promise worth a test and impossible to make -// against the real thing. -// -// The zero value is Google, so nothing outside a test has to know it exists. -type Endpoints struct { - Token string - API string -} - -func (e Endpoints) token() string { - if e.Token != "" { - return e.Token - } - return defaultTokenEndpoint -} - -func (e Endpoints) api() string { - if e.API != "" { - return e.API - } - return defaultAPIBase -} - -// Config is the OAuth client the operator registered in Google Cloud. It belongs to -// the instance rather than to a person: one registration, any number of users. -type Config struct { - ClientID string - ClientSecret string - RedirectURL string - // Endpoints is Google unless a test says otherwise. - Endpoints Endpoints -} - -func (c Config) Configured() bool { - return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != "" -} - -// 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", 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.Endpoints.token(), - 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("gmail: reading token response: %w", err) - } - if parsed.Error != "" { - return Token{}, fmt.Errorf("gmail: %s: %s", parsed.Error, parsed.ErrorDescription) - } - if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" { - return Token{}, fmt.Errorf("gmail: 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 -} +var ( + NewPKCE = google.NewPKCE + ErrUnauthorized = google.ErrUnauthorized +) // --- the API --------------------------------------------------------------------- type Client struct { accessToken string - endpoints Endpoints - http *http.Client + // base is where the Gmail API is. A field rather than a constant so a test can + // point it at a server it controls — and "the sync comes back before whatever + // sits in front of this server gives up on it" is exactly the kind of promise + // worth a test and impossible to make against the real thing. Empty is Google. + base string + http *http.Client } func NewClient(accessToken string) *Client { @@ -210,11 +61,18 @@ func NewClient(accessToken string) *Client { } // At points the client at another server. Tests only; the zero value is Google. -func (c *Client) At(e Endpoints) *Client { - c.endpoints = e +func (c *Client) At(base string) *Client { + c.base = base return c } +func (c *Client) api() string { + if c.base != "" { + return c.base + } + return defaultAPIBase +} + // Message is the part of a Gmail message a task is made from. type Message struct { ID string @@ -233,7 +91,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) { var parsed struct { EmailAddress string `json:"emailAddress"` } - if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil { + if err := c.get(ctx, c.api()+"/users/me/profile", &parsed); err != nil { return "", err } return parsed.EmailAddress, nil @@ -257,7 +115,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err ID string `json:"id"` } `json:"messages"` } - if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil { + if err := c.get(ctx, c.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil { return nil, err } @@ -288,7 +146,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) { } `json:"headers"` } `json:"payload"` } - if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil { + if err := c.get(ctx, c.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil { return Message{}, err } @@ -308,31 +166,9 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) { } func (c *Client) get(ctx context.Context, url string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.accessToken) - - resp, err := c.http.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("gmail: %s", resp.Status) - } - return json.NewDecoder(resp.Body).Decode(out) + return google.Get(ctx, c.http, c.accessToken, url, out) } -// 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("gmail: authorisation was refused") - // SenderName pulls "Anders Jensen" out of "Anders Jensen ", // falling back to the address when there is no display name. func SenderName(from string) string { @@ -375,11 +211,3 @@ func Query(trigger, label string) string { terms = append(terms, "newer_than:30d") return strings.Join(terms, " ") } - -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 -} diff --git a/internal/gmail/gmail_test.go b/internal/gmail/gmail_test.go index 59f1d26..b1d53bf 100644 --- a/internal/gmail/gmail_test.go +++ b/internal/gmail/gmail_test.go @@ -1,113 +1,19 @@ package gmail import ( - "crypto/sha256" - "encoding/base64" - "net/url" "strings" "testing" ) -// The challenge has to be the SHA-256 of the verifier, base64url without padding. -// Getting this wrong means Google refuses every exchange with a message that does -// not say which end is at fault. -func TestPKCEChallengeMatchesItsVerifier(t *testing.T) { - p, err := NewPKCE() - if err != nil { - t.Fatal(err) - } - - sum := sha256.Sum256([]byte(p.Verifier)) - want := base64.RawURLEncoding.EncodeToString(sum[:]) - if p.Challenge != want { - t.Errorf("challenge = %q, want %q", p.Challenge, want) - } - - // RFC 7636 requires 43–128 characters, and no padding. - if n := len(p.Verifier); n < 43 || n > 128 { - t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n) - } - if strings.ContainsAny(p.Verifier, "=+/") { - t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier) - } - if p.State == "" { - t.Error("no state was generated") - } - - // Two attempts must not share anything. - other, err := NewPKCE() - if err != nil { - t.Fatal(err) - } - if other.Verifier == p.Verifier || other.State == p.State { - t.Error("two authorisation attempts produced the same secrets") - } -} - -func TestAuthURL(t *testing.T) { - cfg := Config{ - ClientID: "client-123", - ClientSecret: "secret", - RedirectURL: "https://todo.example.dk/oauth/gmail/callback", - } - p, err := NewPKCE() - if err != nil { - t.Fatal(err) - } - - raw := cfg.AuthURL(p) - parsed, err := url.Parse(raw) - if err != nil { - t.Fatalf("the URL does not parse: %v", err) - } - q := parsed.Query() - - checks := map[string]string{ - "client_id": cfg.ClientID, - "redirect_uri": cfg.RedirectURL, - "response_type": "code", - "scope": Scope, - "code_challenge": p.Challenge, - "code_challenge_method": "S256", - "state": p.State, - // Without both of these Google returns no refresh token on a - // re-authorisation, and the connection dies silently an hour later. - "access_type": "offline", - "prompt": "consent", - } - for key, want := range checks { - if got := q.Get(key); got != want { - t.Errorf("%s = %q, want %q", key, got, want) - } - } - - // The verifier itself must never leave the server. - if strings.Contains(raw, p.Verifier) { - t.Error("the code verifier was put in the authorisation URL") - } - // Read-only, and only mail. +// The scope is the one thing verdande chooses about the connection, and it is the +// one the person reads on the consent screen. The flow itself is exercised in +// internal/google; what belongs here is that Gmail still asks for read-only mail. +func TestTheScopeIsReadOnlyMail(t *testing.T) { if !strings.HasSuffix(Scope, "gmail.readonly") { t.Errorf("scope = %q; verdande never needs to send or modify", Scope) } } -func TestConfigured(t *testing.T) { - full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"} - if !full.Configured() { - t.Error("a complete config is not reported as configured") - } - for _, partial := range []Config{ - {}, - {ClientID: "a"}, - {ClientID: "a", ClientSecret: "b"}, - {ClientSecret: "b", RedirectURL: "c"}, - } { - if partial.Configured() { - t.Errorf("%+v was reported as configured", partial) - } - } -} - func TestQuery(t *testing.T) { cases := []struct { trigger, label string diff --git a/internal/google/google.go b/internal/google/google.go new file mode 100644 index 0000000..af8dabe --- /dev/null +++ b/internal/google/google.go @@ -0,0 +1,224 @@ +// 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) +} + +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 +} diff --git a/internal/google/google_test.go b/internal/google/google_test.go new file mode 100644 index 0000000..9df67ba --- /dev/null +++ b/internal/google/google_test.go @@ -0,0 +1,127 @@ +package google + +import ( + "crypto/sha256" + "encoding/base64" + "net/url" + "strings" + "testing" +) + +// The challenge has to be the SHA-256 of the verifier, base64url without padding. +// Getting this wrong means Google refuses every exchange with a message that does +// not say which end is at fault. +func TestPKCEChallengeMatchesItsVerifier(t *testing.T) { + p, err := NewPKCE() + if err != nil { + t.Fatal(err) + } + + sum := sha256.Sum256([]byte(p.Verifier)) + want := base64.RawURLEncoding.EncodeToString(sum[:]) + if p.Challenge != want { + t.Errorf("challenge = %q, want %q", p.Challenge, want) + } + + // RFC 7636 requires 43–128 characters, and no padding. + if n := len(p.Verifier); n < 43 || n > 128 { + t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n) + } + if strings.ContainsAny(p.Verifier, "=+/") { + t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier) + } + if p.State == "" { + t.Error("no state was generated") + } + + // Two attempts must not share anything. + other, err := NewPKCE() + if err != nil { + t.Fatal(err) + } + if other.Verifier == p.Verifier || other.State == p.State { + t.Error("two authorisation attempts produced the same secrets") + } +} + +func TestAuthURL(t *testing.T) { + cfg := Config{ + ClientID: "client-123", + ClientSecret: "secret", + RedirectURL: "https://todo.example.dk/oauth/gmail/callback", + Scope: "https://www.googleapis.com/auth/gmail.readonly", + } + p, err := NewPKCE() + if err != nil { + t.Fatal(err) + } + + raw := cfg.AuthURL(p) + parsed, err := url.Parse(raw) + if err != nil { + t.Fatalf("the URL does not parse: %v", err) + } + q := parsed.Query() + + checks := map[string]string{ + "client_id": cfg.ClientID, + "redirect_uri": cfg.RedirectURL, + "response_type": "code", + "scope": cfg.Scope, + "code_challenge": p.Challenge, + "code_challenge_method": "S256", + "state": p.State, + // Without both of these Google returns no refresh token on a + // re-authorisation, and the connection dies silently an hour later. + "access_type": "offline", + "prompt": "consent", + } + for key, want := range checks { + if got := q.Get(key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + + // The verifier itself must never leave the server. + if strings.Contains(raw, p.Verifier) { + t.Error("the code verifier was put in the authorisation URL") + } +} + +// The scope is the one thing that differs between the two features signing in +// through this, so it has to come out of the config rather than out of a constant +// somebody could forget to change. +func TestTheScopeComesFromTheConfig(t *testing.T) { + p, err := NewPKCE() + if err != nil { + t.Fatal(err) + } + calendar := "https://www.googleapis.com/auth/calendar.readonly" + raw := Config{ClientID: "a", Scope: calendar}.AuthURL(p) + + parsed, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + if got := parsed.Query().Get("scope"); got != calendar { + t.Errorf("scope = %q, want %q — asking for the wrong one is a consent screen "+ + "that grants the wrong thing", got, calendar) + } +} + +func TestConfigured(t *testing.T) { + full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"} + if !full.Configured() { + t.Error("a complete config is not reported as configured") + } + for _, partial := range []Config{ + {}, + {ClientID: "a"}, + {ClientID: "a", ClientSecret: "b"}, + {ClientSecret: "b", RedirectURL: "c"}, + } { + if partial.Configured() { + t.Errorf("%+v was reported as configured", partial) + } + } +} diff --git a/internal/httpapi/gmail_handlers.go b/internal/httpapi/gmail_handlers.go index 74dc370..a05ba23 100644 --- a/internal/httpapi/gmail_handlers.go +++ b/internal/httpapi/gmail_handlers.go @@ -25,18 +25,16 @@ const defaultGmailSyncBudget = 25 * time.Second // to redeploy a container to paste a client id. // gmailClient is the API client, pointed wherever the config says Google is. func (s *Server) gmailClient(accessToken string) *gmail.Client { - return gmail.NewClient(accessToken).At(gmail.Endpoints{ - Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL, - }) + return gmail.NewClient(accessToken).At(s.cfg.GmailAPIURL) } func (s *Server) gmailConfig(ctx context.Context) gmail.Config { - endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL} cfg := gmail.Config{ ClientID: s.cfg.GmailClientID, ClientSecret: s.cfg.GmailClientSecret, RedirectURL: s.cfg.GmailRedirectURL(), - Endpoints: endpoints, + Scope: gmail.Scope, + TokenURL: s.cfg.GmailTokenURL, } if cfg.ClientID != "" && cfg.ClientSecret != "" { return cfg