Gmail og Google Calendar logger ind gennem den samme registrering med den samme PKCE og den samme fornyelse. Det eneste, der adskiller dem, er ét ord: scopet. En kopi mere ville være et sted mere, hvor `access_type=offline` kan blive glemt — og det er dén fejl, der får en forbindelse til at dø lydløst en time efter, den blev lavet. internal/gmail beholder sit API og sit scope; navnene på flowet er aliaser, så hverken kaldere eller prøver ændrer sig. Prøverne for PKCE, AuthURL og Configured flytter med koden.
128 lines
3.5 KiB
Go
128 lines
3.5 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|