A consumed state left the map at once but stayed in the queue until it expired, so starting and completing flows grew the queue past the bound. The bound now counts the queue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1167 lines
35 KiB
Go
1167 lines
35 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
const (
|
|
testClientID = "mealquest"
|
|
testClientSecret = "client-s3cret"
|
|
testKID = "test-key"
|
|
)
|
|
|
|
var testPublicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
|
|
|
|
// testAllowedGroups are the household's groups in tests.
|
|
var testAllowedGroups = []string{"mealquest-admin", "mealquest-family"}
|
|
|
|
// fakeProvider is an OIDC provider serving discovery, JWKS and a token
|
|
// endpoint. Each issued code maps to the ID token the token endpoint returns.
|
|
type fakeProvider struct {
|
|
t *testing.T
|
|
srv *httptest.Server
|
|
key *rsa.PrivateKey
|
|
|
|
mu sync.Mutex
|
|
codes map[string]issuedCode
|
|
// discoveryOverrides replace fields of the discovery document.
|
|
discoveryOverrides map[string]any
|
|
// tokenReply, when set, is returned by the token endpoint instead of a
|
|
// token.
|
|
tokenReply *tokenReply
|
|
// userinfo is the userinfo endpoint's response; userinfoHits counts
|
|
// requests to it.
|
|
userinfo map[string]any
|
|
userinfoHits int
|
|
// userinfoReply, when set, is returned by the userinfo endpoint instead.
|
|
userinfoReply *tokenReply
|
|
// jwksReply, when set, is returned by the JWKS endpoint instead.
|
|
jwksReply *tokenReply
|
|
}
|
|
|
|
type tokenReply struct {
|
|
status int
|
|
body string
|
|
}
|
|
|
|
type issuedCode struct {
|
|
challenge string
|
|
idToken string
|
|
}
|
|
|
|
func newFakeProvider(t *testing.T) *fakeProvider {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := &fakeProvider{t: t, key: key, codes: map[string]issuedCode{}}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /.well-known/openid-configuration", p.discovery)
|
|
mux.HandleFunc("GET /jwks", p.jwks)
|
|
mux.HandleFunc("POST /token", p.token)
|
|
mux.HandleFunc("GET /userinfo", p.userinfoEndpoint)
|
|
p.srv = httptest.NewServer(mux)
|
|
t.Cleanup(p.srv.Close)
|
|
return p
|
|
}
|
|
|
|
func (p *fakeProvider) issuer() string { return p.srv.URL }
|
|
|
|
func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
|
|
doc := map[string]any{
|
|
"issuer": p.issuer(),
|
|
"authorization_endpoint": p.issuer() + "/authorize",
|
|
"token_endpoint": p.issuer() + "/token",
|
|
"jwks_uri": p.issuer() + "/jwks",
|
|
"userinfo_endpoint": p.issuer() + "/userinfo",
|
|
"end_session_endpoint": p.issuer() + "/end-session/",
|
|
"response_types_supported": []string{"code"},
|
|
"subject_types_supported": []string{"public"},
|
|
"id_token_signing_alg_values_supported": []string{"RS256", "HS256"},
|
|
}
|
|
p.mu.Lock()
|
|
maps.Copy(doc, p.discoveryOverrides)
|
|
p.mu.Unlock()
|
|
writeJSON(w, doc)
|
|
}
|
|
|
|
func (p *fakeProvider) jwks(w http.ResponseWriter, _ *http.Request) {
|
|
p.mu.Lock()
|
|
reply := p.jwksReply
|
|
p.mu.Unlock()
|
|
if reply != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(reply.status)
|
|
io.WriteString(w, reply.body)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"keys": []map[string]string{{
|
|
"kty": "RSA",
|
|
"kid": testKID,
|
|
"use": "sig",
|
|
"alg": "RS256",
|
|
"n": b64(p.key.N.Bytes()),
|
|
"e": b64(big.NewInt(int64(p.key.E)).Bytes()),
|
|
}}})
|
|
}
|
|
|
|
// token checks client authentication, the code and the PKCE verifier before
|
|
// returning the code's ID token.
|
|
func (p *fakeProvider) token(w http.ResponseWriter, r *http.Request) {
|
|
p.mu.Lock()
|
|
reply := p.tokenReply
|
|
p.mu.Unlock()
|
|
if reply != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(reply.status)
|
|
io.WriteString(w, reply.body)
|
|
return
|
|
}
|
|
id, secret, ok := r.BasicAuth()
|
|
if !ok || id != testClientID || secret != testClientSecret {
|
|
http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, `{"error":"invalid_request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
issued, ok := p.codes[r.PostForm.Get("code")]
|
|
delete(p.codes, r.PostForm.Get("code"))
|
|
p.mu.Unlock()
|
|
if r.PostForm.Get("grant_type") != "authorization_code" || !ok ||
|
|
challengeS256(r.PostForm.Get("code_verifier")) != issued.challenge {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
io.WriteString(w, `{"error":"invalid_grant"}`)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"access_token": "access-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"id_token": issued.idToken,
|
|
})
|
|
}
|
|
|
|
// userinfoEndpoint checks the access token and returns p.userinfo, which
|
|
// defaults to the test user without a groups claim.
|
|
func (p *fakeProvider) userinfoEndpoint(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != "Bearer access-token" {
|
|
http.Error(w, `{"error":"invalid_token"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
p.userinfoHits++
|
|
info, reply := p.userinfo, p.userinfoReply
|
|
p.mu.Unlock()
|
|
if reply != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(reply.status)
|
|
io.WriteString(w, reply.body)
|
|
return
|
|
}
|
|
if info == nil {
|
|
info = map[string]any{"sub": "user-123", "email": "cook@example.com"}
|
|
}
|
|
writeJSON(w, info)
|
|
}
|
|
|
|
func (p *fakeProvider) setUserinfo(info map[string]any) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.userinfo = info
|
|
}
|
|
|
|
func (p *fakeProvider) userinfoRequests() int {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.userinfoHits
|
|
}
|
|
|
|
// authorize stands in for the user signing in at the provider: it records a
|
|
// code bound to the PKCE challenge and returns it.
|
|
func (p *fakeProvider) authorize(challenge, idToken string) string {
|
|
code := rand.Text()
|
|
p.mu.Lock()
|
|
p.codes[code] = issuedCode{challenge: challenge, idToken: idToken}
|
|
p.mu.Unlock()
|
|
return code
|
|
}
|
|
|
|
// claims returns valid ID token claims for nonce.
|
|
func (p *fakeProvider) claims(nonce string) map[string]any {
|
|
now := time.Now()
|
|
return map[string]any{
|
|
"iss": p.issuer(),
|
|
"sub": "user-123",
|
|
"aud": testClientID,
|
|
"iat": now.Unix(),
|
|
"exp": now.Add(5 * time.Minute).Unix(),
|
|
"nonce": nonce,
|
|
"email": "cook@example.com",
|
|
"email_verified": true,
|
|
"name": "Home Cook",
|
|
"preferred_username": "cook",
|
|
"groups": []string{"mealquest-admin"},
|
|
}
|
|
}
|
|
|
|
func (p *fakeProvider) signRS256(claims map[string]any) string {
|
|
return signRS256(p.t, p.key, claims)
|
|
}
|
|
|
|
func signRS256(t *testing.T, key *rsa.PrivateKey, claims map[string]any) string {
|
|
t.Helper()
|
|
signing := jwtSigningInput(t, "RS256", claims)
|
|
digest := sha256.Sum256([]byte(signing))
|
|
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return signing + "." + b64(sig)
|
|
}
|
|
|
|
// signHS256 signs with the client secret, as in an algorithm-confusion attack.
|
|
func (p *fakeProvider) signHS256(claims map[string]any) string {
|
|
signing := jwtSigningInput(p.t, "HS256", claims)
|
|
mac := hmac.New(sha256.New, []byte(testClientSecret))
|
|
mac.Write([]byte(signing))
|
|
return signing + "." + b64(mac.Sum(nil))
|
|
}
|
|
|
|
func jwtSigningInput(t *testing.T, alg string, claims map[string]any) string {
|
|
t.Helper()
|
|
header, err := json.Marshal(map[string]string{"alg": alg, "typ": "JWT", "kid": testKID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := json.Marshal(claims)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b64(header) + "." + b64(body)
|
|
}
|
|
|
|
func challengeS256(verifier string) string {
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
return b64(sum[:])
|
|
}
|
|
|
|
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// memorySessions is an in-memory Sessions that records the claims it was
|
|
// handed and how many sessions were ended.
|
|
type memorySessions struct {
|
|
mu sync.Mutex
|
|
claims []Claims
|
|
ended int
|
|
err error
|
|
}
|
|
|
|
func (m *memorySessions) StartSession(w http.ResponseWriter, _ *http.Request, claims Claims) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
m.claims = append(m.claims, claims)
|
|
http.SetCookie(w, &http.Cookie{Name: "test_session", Value: claims.Subject})
|
|
return nil
|
|
}
|
|
|
|
func (m *memorySessions) EndSession(http.ResponseWriter, *http.Request) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
m.ended++
|
|
return nil
|
|
}
|
|
|
|
func (m *memorySessions) started() []Claims {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return slices.Clone(m.claims)
|
|
}
|
|
|
|
type harness struct {
|
|
auth *Auth
|
|
provider *fakeProvider
|
|
sessions *memorySessions
|
|
router http.Handler
|
|
logs *syncBuffer
|
|
}
|
|
|
|
// syncBuffer is a bytes.Buffer safe for concurrent log writes.
|
|
type syncBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *syncBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *syncBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|
|
|
|
func newHarness(t *testing.T) *harness {
|
|
t.Helper()
|
|
p := newFakeProvider(t)
|
|
sessions := &memorySessions{}
|
|
logs := &syncBuffer{}
|
|
a, err := New(context.Background(), Config{
|
|
Issuer: p.issuer(),
|
|
ClientID: testClientID,
|
|
ClientSecret: testClientSecret,
|
|
PublicURL: testPublicURL,
|
|
SecretKey: []byte("server-secret-key"),
|
|
AllowedGroups: testAllowedGroups,
|
|
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := chi.NewRouter()
|
|
a.Mount(r)
|
|
return &harness{auth: a, provider: p, sessions: sessions, router: r, logs: logs}
|
|
}
|
|
|
|
func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response {
|
|
req := httptest.NewRequest(http.MethodGet, target, nil)
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.router.ServeHTTP(rec, req)
|
|
return rec.Result()
|
|
}
|
|
|
|
// login is a started login: the authorize URL's query and the login cookie.
|
|
type login struct {
|
|
query url.Values
|
|
cookie *http.Cookie
|
|
}
|
|
|
|
func (h *harness) login(t *testing.T, target string) login {
|
|
t.Helper()
|
|
resp := h.get(target)
|
|
if resp.StatusCode != http.StatusFound {
|
|
t.Fatalf("GET %s status = %d, want 302", target, resp.StatusCode)
|
|
}
|
|
loc, err := url.Parse(resp.Header.Get("Location"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := loc.Scheme+"://"+loc.Host+loc.Path, h.provider.issuer()+"/authorize"; got != want {
|
|
t.Fatalf("redirect to %s, want %s", got, want)
|
|
}
|
|
var cookie *http.Cookie
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == loginCookieName {
|
|
cookie = c
|
|
}
|
|
}
|
|
if cookie == nil {
|
|
t.Fatalf("login set no %s cookie", loginCookieName)
|
|
}
|
|
return login{query: loc.Query(), cookie: cookie}
|
|
}
|
|
|
|
// callback completes l at the provider with idToken and calls the callback.
|
|
func (h *harness) callback(l login, state, idToken string) *http.Response {
|
|
code := h.provider.authorize(l.query.Get("code_challenge"), idToken)
|
|
q := url.Values{"code": {code}, "state": {state}}
|
|
return h.get(CallbackPath+"?"+q.Encode(), l.cookie)
|
|
}
|
|
|
|
func TestLoginRedirect(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
l := h.login(t, LoginPath+"?return=/plan")
|
|
|
|
want := map[string]string{
|
|
"client_id": testClientID,
|
|
"response_type": "code",
|
|
"scope": "openid profile email groups offline_access",
|
|
"redirect_uri": "https://meals.example.com/auth/callback",
|
|
"code_challenge_method": "S256",
|
|
}
|
|
for k, v := range want {
|
|
if got := l.query.Get(k); got != v {
|
|
t.Errorf("authorize %s = %q, want %q", k, got, v)
|
|
}
|
|
}
|
|
for _, k := range []string{"state", "nonce", "code_challenge"} {
|
|
if l.query.Get(k) == "" {
|
|
t.Errorf("authorize URL has no %s", k)
|
|
}
|
|
}
|
|
|
|
c := l.cookie
|
|
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" {
|
|
t.Errorf("login cookie HttpOnly=%v Secure=%v SameSite=%v Path=%q, want HttpOnly, Secure, Lax, /",
|
|
c.HttpOnly, c.Secure, c.SameSite, c.Path)
|
|
}
|
|
if c.MaxAge <= 0 || c.MaxAge > 600 {
|
|
t.Errorf("login cookie MaxAge = %d, want 1-600 seconds", c.MaxAge)
|
|
}
|
|
}
|
|
|
|
func TestLoginStateIsFreshEachTime(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
a, b := h.login(t, LoginPath), h.login(t, LoginPath)
|
|
for _, k := range []string{"state", "nonce", "code_challenge"} {
|
|
if a.query.Get(k) == b.query.Get(k) {
|
|
t.Errorf("%s repeated across logins", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCallbackSuccess(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
l := h.login(t, LoginPath+"?return="+url.QueryEscape("/plan?week=2026-W40"))
|
|
|
|
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
|
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
|
|
}
|
|
if got := resp.Header.Get("Location"); got != "/plan?week=2026-W40" {
|
|
t.Errorf("callback redirect = %q, want the return path", got)
|
|
}
|
|
started := h.sessions.started()
|
|
want := Claims{
|
|
Subject: "user-123",
|
|
Email: "cook@example.com",
|
|
EmailVerified: true,
|
|
Name: "Home Cook",
|
|
PreferredUsername: "cook",
|
|
Groups: []string{"mealquest-admin"},
|
|
}
|
|
if len(started) != 1 || started[0].Subject != want.Subject || started[0].Email != want.Email ||
|
|
started[0].Name != want.Name || started[0].PreferredUsername != want.PreferredUsername ||
|
|
!started[0].EmailVerified || !slices.Equal(started[0].Groups, want.Groups) {
|
|
t.Errorf("session started with %+v, want [%+v]", started, want)
|
|
}
|
|
assertLoginCookieCleared(t, resp)
|
|
}
|
|
|
|
func TestCallbackDefaultReturnPath(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
l := h.login(t, LoginPath)
|
|
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
|
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/" {
|
|
t.Errorf("callback = %d to %q, want 303 to /", resp.StatusCode, resp.Header.Get("Location"))
|
|
}
|
|
}
|
|
|
|
// A saved copy of the login cookie still verifies until it expires, so a
|
|
// replay with a second valid code is refused only because the server
|
|
// consumed the state on first use.
|
|
func TestCallbackRefusesReplayedLoginState(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
l := h.login(t, LoginPath)
|
|
idToken := h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))
|
|
|
|
if resp := h.callback(l, l.query.Get("state"), idToken); resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("first callback status = %d, want 303", resp.StatusCode)
|
|
}
|
|
resp := h.callback(l, l.query.Get("state"), idToken)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("replayed callback status = %d, want 400", resp.StatusCode)
|
|
}
|
|
if n := len(h.sessions.started()); n != 1 {
|
|
t.Errorf("%d sessions started, want 1", n)
|
|
}
|
|
assertLoginCookieCleared(t, resp)
|
|
}
|
|
|
|
// Pending login states live in memory, so a login started before a restart
|
|
// fails closed after it even though its cookie still verifies.
|
|
func TestCallbackRefusesLoginStartedBeforeRestart(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
l := h.login(t, LoginPath)
|
|
|
|
restarted, err := New(context.Background(), Config{
|
|
Issuer: h.provider.issuer(),
|
|
ClientID: testClientID,
|
|
ClientSecret: testClientSecret,
|
|
PublicURL: testPublicURL,
|
|
SecretKey: []byte("server-secret-key"),
|
|
AllowedGroups: testAllowedGroups,
|
|
}, h.sessions, slog.New(slog.DiscardHandler))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := chi.NewRouter()
|
|
restarted.Mount(r)
|
|
h.router = r
|
|
|
|
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("callback after restart status = %d, want 400", resp.StatusCode)
|
|
}
|
|
if n := len(h.sessions.started()); n != 0 {
|
|
t.Errorf("%d sessions started, want none", n)
|
|
}
|
|
}
|
|
|
|
func TestCallbackRejected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
// state returns the state sent to the callback.
|
|
state func(l login) string
|
|
// token returns the ID token the provider issues.
|
|
token func(p *fakeProvider, l login) string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "mismatched state",
|
|
state: func(login) string { return "attacker-state" },
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "wrong nonce",
|
|
token: func(p *fakeProvider, _ login) string {
|
|
return p.signRS256(p.claims("other-nonce"))
|
|
},
|
|
wantStatus: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "missing nonce",
|
|
token: func(p *fakeProvider, _ login) string {
|
|
c := p.claims("")
|
|
delete(c, "nonce")
|
|
return p.signRS256(c)
|
|
},
|
|
wantStatus: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "HS256-signed token",
|
|
token: func(p *fakeProvider, l login) string {
|
|
return p.signHS256(p.claims(l.query.Get("nonce")))
|
|
},
|
|
wantStatus: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "expired token",
|
|
token: func(p *fakeProvider, l login) string {
|
|
c := p.claims(l.query.Get("nonce"))
|
|
c["iat"] = time.Now().Add(-2 * time.Hour).Unix()
|
|
c["exp"] = time.Now().Add(-time.Hour).Unix()
|
|
return p.signRS256(c)
|
|
},
|
|
wantStatus: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "wrong issuer",
|
|
token: func(p *fakeProvider, l login) string {
|
|
c := p.claims(l.query.Get("nonce"))
|
|
c["iss"] = "https://evil.example.com"
|
|
return p.signRS256(c)
|
|
},
|
|
wantStatus: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "wrong audience",
|
|
token: func(p *fakeProvider, l login) string {
|
|
c := p.claims(l.query.Get("nonce"))
|
|
c["aud"] = "another-client"
|
|
return p.signRS256(c)
|
|
},
|
|
wantStatus: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "signed by an unknown key",
|
|
token: func(p *fakeProvider, l login) string {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
p.t.Fatal(err)
|
|
}
|
|
return signRS256(p.t, key, p.claims(l.query.Get("nonce")))
|
|
},
|
|
wantStatus: http.StatusUnauthorized,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
l := h.login(t, LoginPath)
|
|
state := l.query.Get("state")
|
|
if tc.state != nil {
|
|
state = tc.state(l)
|
|
}
|
|
token := h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))
|
|
if tc.token != nil {
|
|
token = tc.token(h.provider, l)
|
|
}
|
|
|
|
resp := h.callback(l, state, token)
|
|
|
|
if resp.StatusCode != tc.wantStatus {
|
|
t.Errorf("callback status = %d, want %d", resp.StatusCode, tc.wantStatus)
|
|
}
|
|
if n := len(h.sessions.started()); n != 0 {
|
|
t.Errorf("%d sessions started, want none", n)
|
|
}
|
|
assertLoginCookieCleared(t, resp)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCallbackWithoutLoginCookie(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
l := h.login(t, LoginPath)
|
|
l.cookie = &http.Cookie{Name: "unrelated", Value: "x"}
|
|
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("callback status = %d, want 400", resp.StatusCode)
|
|
}
|
|
if n := len(h.sessions.started()); n != 0 {
|
|
t.Errorf("%d sessions started, want none", n)
|
|
}
|
|
}
|
|
|
|
func TestCallbackTamperedLoginCookie(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
l := h.login(t, LoginPath)
|
|
payload, sig, _ := strings.Cut(l.cookie.Value, ".")
|
|
data, err := base64.RawURLEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var s loginState
|
|
if err := json.Unmarshal(data, &s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.Return = "/somewhere-else"
|
|
data, err = json.Marshal(s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l.cookie = &http.Cookie{Name: loginCookieName, Value: b64(data) + "." + sig}
|
|
|
|
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("callback status = %d, want 400", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// The provider's error and error_description are untrusted text and never
|
|
// reach the logs; only the fixed category does.
|
|
func TestCallbackProviderError(t *testing.T) {
|
|
t.Parallel()
|
|
const (
|
|
errorMarker = "provider-error-marker"
|
|
descriptionMarker = "description-marker-" + testClientSecret
|
|
)
|
|
h := newHarness(t)
|
|
l := h.login(t, LoginPath)
|
|
q := url.Values{
|
|
"error": {errorMarker},
|
|
"error_description": {descriptionMarker},
|
|
"state": {l.query.Get("state")},
|
|
}
|
|
resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie)
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
|
}
|
|
assertLoginCookieCleared(t, resp)
|
|
if n := len(h.sessions.started()); n != 0 {
|
|
t.Errorf("started %d sessions, want 0", n)
|
|
}
|
|
logs := h.logs.String()
|
|
for _, marker := range []string{errorMarker, descriptionMarker} {
|
|
if strings.Contains(logs, marker) {
|
|
t.Errorf("logs contain provider text %q: %s", marker, logs)
|
|
}
|
|
}
|
|
if !strings.Contains(logs, "identity provider returned an authorization error") {
|
|
t.Errorf("logs = %q, want the authorization error category", logs)
|
|
}
|
|
}
|
|
|
|
func TestCallbackSessionError(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
h.sessions.err = errors.New("database down")
|
|
l := h.login(t, LoginPath)
|
|
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Errorf("callback status = %d, want 500", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// A failed code exchange is logged by category and status only: the token
|
|
// endpoint's body and error_description may echo credentials.
|
|
func TestCallbackExchangeFailureDoesNotLogProviderResponse(t *testing.T) {
|
|
t.Parallel()
|
|
const leaked = "leaked-" + testClientSecret
|
|
tests := []struct {
|
|
name string
|
|
reply tokenReply
|
|
wantLogs string
|
|
}{
|
|
{
|
|
name: "error response",
|
|
reply: tokenReply{http.StatusBadRequest, `{"error":"invalid_grant","error_description":"` + leaked + `"}`},
|
|
wantLogs: "HTTP 400",
|
|
},
|
|
{
|
|
name: "unparseable error response",
|
|
reply: tokenReply{http.StatusInternalServerError, leaked},
|
|
wantLogs: "HTTP 500",
|
|
},
|
|
{
|
|
name: "success without access token",
|
|
reply: tokenReply{http.StatusOK, `{"id_token":"` + leaked + `"}`},
|
|
wantLogs: "unusable response",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
h.provider.mu.Lock()
|
|
h.provider.tokenReply = &tc.reply
|
|
h.provider.mu.Unlock()
|
|
l := h.login(t, LoginPath)
|
|
|
|
resp := h.callback(l, l.query.Get("state"), "unused")
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
|
}
|
|
logs := h.logs.String()
|
|
if strings.Contains(logs, leaked) {
|
|
t.Errorf("logs contain the provider response: %s", logs)
|
|
}
|
|
if !strings.Contains(logs, tc.wantLogs) {
|
|
t.Errorf("logs = %q, want %q", logs, tc.wantLogs)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCallbackKeySetFailureDoesNotLogProviderResponse(t *testing.T) {
|
|
t.Parallel()
|
|
const sentinel = "sentinel-jwks-body-5c1e"
|
|
tests := []struct {
|
|
name string
|
|
reply tokenReply
|
|
}{
|
|
{"error response", tokenReply{http.StatusInternalServerError, sentinel}},
|
|
{"malformed success", tokenReply{http.StatusOK, `{"keys":"` + sentinel + `"`}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
h.provider.mu.Lock()
|
|
h.provider.jwksReply = &tc.reply
|
|
h.provider.mu.Unlock()
|
|
|
|
// The returned error, checked through verify directly.
|
|
l := h.login(t, LoginPath)
|
|
req := httptest.NewRequest(http.MethodGet, CallbackPath, nil)
|
|
req.AddCookie(l.cookie)
|
|
state, err := h.auth.cookie.read(req, time.Now())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
|
_, err = h.auth.verify(context.Background(), code, state)
|
|
if err == nil {
|
|
t.Fatal("verify succeeded with an unusable key set")
|
|
}
|
|
if strings.Contains(err.Error(), sentinel) {
|
|
t.Errorf("verify error contains the JWKS response: %v", err)
|
|
}
|
|
|
|
// The logs and response, through the callback.
|
|
l = h.login(t, LoginPath)
|
|
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if strings.Contains(string(body), sentinel) {
|
|
t.Errorf("response contains the JWKS response: %s", body)
|
|
}
|
|
logs := h.logs.String()
|
|
if strings.Contains(logs, sentinel) {
|
|
t.Errorf("logs contain the JWKS response: %s", logs)
|
|
}
|
|
if want := "token or provider key set rejected"; !strings.Contains(logs, want) {
|
|
t.Errorf("logs = %q, want %q", logs, want)
|
|
}
|
|
if len(h.sessions.started()) != 0 {
|
|
t.Error("a session started")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoginCookieExpires(t *testing.T) {
|
|
t.Parallel()
|
|
c := newLoginCookie([]byte("server-secret-key"))
|
|
now := time.Now()
|
|
rec := httptest.NewRecorder()
|
|
if err := c.set(rec, loginState{State: "s", Nonce: "n", Verifier: "v", Return: "/"}, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, CallbackPath, nil)
|
|
req.AddCookie(rec.Result().Cookies()[0])
|
|
|
|
if _, err := c.read(req, now.Add(loginCookieTTL-time.Second)); err != nil {
|
|
t.Errorf("read before expiry: %v", err)
|
|
}
|
|
if _, err := c.read(req, now.Add(loginCookieTTL)); err == nil {
|
|
t.Error("read at expiry succeeded")
|
|
}
|
|
}
|
|
|
|
func TestPendingStates(t *testing.T) {
|
|
t.Parallel()
|
|
p := NewPendingStates(time.Minute)
|
|
now := time.Now()
|
|
if err := p.Add("first", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !p.Consume("first") {
|
|
t.Error("Consume of a pending state = false")
|
|
}
|
|
if p.Consume("first") {
|
|
t.Error("second Consume of a state = true")
|
|
}
|
|
if p.Consume("never-added") {
|
|
t.Error("Consume of an unknown state = true")
|
|
}
|
|
|
|
for i := range maxPendingStates - 1 {
|
|
if err := p.Add(fmt.Sprint("s", i), now); err != nil {
|
|
t.Fatalf("Add %d: %v", i, err)
|
|
}
|
|
}
|
|
// "first" was consumed but has not expired, so it still counts.
|
|
if err := p.Add("over", now); !errors.Is(err, ErrTooManyPending) {
|
|
t.Errorf("Add past the bound = %v, want ErrTooManyPending", err)
|
|
}
|
|
// Once the earlier states expire, they are forgotten and a new one fits.
|
|
if err := p.Add("later", now.Add(time.Minute)); err != nil {
|
|
t.Errorf("Add after expiry: %v", err)
|
|
}
|
|
if p.Consume("s0") {
|
|
t.Error("an expired state was still pending")
|
|
}
|
|
if !p.Consume("later") {
|
|
t.Error("the state added after expiry is not pending")
|
|
}
|
|
}
|
|
|
|
func TestLoginRejectsOffOriginReturn(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
for _, ret := range []string{
|
|
"https://evil.example.com/",
|
|
"http://meals.example.com/plan",
|
|
"//evil.example.com/",
|
|
"/\\evil.example.com",
|
|
"\\\\evil.example.com",
|
|
"javascript:alert(1)",
|
|
"plan",
|
|
"/plan\r\nSet-Cookie: x=y",
|
|
} {
|
|
resp := h.get(LoginPath + "?return=" + url.QueryEscape(ret))
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("return=%q: status = %d, want 400", ret, resp.StatusCode)
|
|
}
|
|
if loc := resp.Header.Get("Location"); loc != "" {
|
|
t.Errorf("return=%q: redirected to %q", ret, loc)
|
|
}
|
|
if len(resp.Cookies()) != 0 {
|
|
t.Errorf("return=%q: set cookies %v", ret, resp.Cookies())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewFailsWhenDiscoveryFails(t *testing.T) {
|
|
t.Parallel()
|
|
srv := httptest.NewServer(http.NotFoundHandler())
|
|
t.Cleanup(srv.Close)
|
|
_, err := New(context.Background(), Config{
|
|
Issuer: srv.URL,
|
|
ClientID: testClientID,
|
|
ClientSecret: testClientSecret,
|
|
PublicURL: testPublicURL,
|
|
SecretKey: []byte("k"),
|
|
AllowedGroups: testAllowedGroups,
|
|
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
|
if err == nil {
|
|
t.Fatal("New succeeded without a discovery document")
|
|
}
|
|
}
|
|
|
|
func TestNewDiscoveryErrorOmitsProviderBody(t *testing.T) {
|
|
t.Parallel()
|
|
const sentinel = "sentinel-discovery-body-7f3a"
|
|
for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} {
|
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
|
t.Parallel()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(status)
|
|
io.WriteString(w, sentinel)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
logs := &syncBuffer{}
|
|
_, err := New(context.Background(), Config{
|
|
Issuer: srv.URL,
|
|
ClientID: testClientID,
|
|
ClientSecret: testClientSecret,
|
|
PublicURL: testPublicURL,
|
|
SecretKey: []byte("k"),
|
|
AllowedGroups: testAllowedGroups,
|
|
}, &memorySessions{}, slog.New(slog.NewTextHandler(logs, nil)))
|
|
if err == nil {
|
|
t.Fatal("New succeeded without a discovery document")
|
|
}
|
|
// cmd/server logs the returned error at startup.
|
|
slog.New(slog.NewTextHandler(logs, nil)).Error("startup failed", "err", err)
|
|
if strings.Contains(logs.String(), sentinel) {
|
|
t.Errorf("logs contain the provider response body: %s", logs)
|
|
}
|
|
if want := fmt.Sprintf("auth: OIDC discovery: provider answered HTTP %d", status); err.Error() != want {
|
|
t.Errorf("error = %q, want %q", err, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewFailsOnIssuerMismatch(t *testing.T) {
|
|
t.Parallel()
|
|
p := newFakeProvider(t)
|
|
// Discovery trims the trailing slash, so this fetches the provider's
|
|
// document, which reports the issuer without it.
|
|
_, err := New(context.Background(), Config{
|
|
Issuer: p.issuer() + "/",
|
|
ClientID: testClientID,
|
|
ClientSecret: testClientSecret,
|
|
PublicURL: testPublicURL,
|
|
SecretKey: []byte("k"),
|
|
AllowedGroups: testAllowedGroups,
|
|
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
|
if err == nil {
|
|
t.Fatal("New accepted a provider reporting a different issuer")
|
|
}
|
|
}
|
|
|
|
func assertLoginCookieCleared(t *testing.T, resp *http.Response) {
|
|
t.Helper()
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == loginCookieName && c.MaxAge < 0 {
|
|
return
|
|
}
|
|
}
|
|
t.Errorf("callback did not clear the %s cookie", loginCookieName)
|
|
}
|
|
|
|
func TestNewRejectsRemoteHTTPIssuer(t *testing.T) {
|
|
t.Parallel()
|
|
for _, issuer := range []string{"http://auth.example.com/application/o/mealquest/", "http://192.0.2.10"} {
|
|
_, err := New(context.Background(), Config{
|
|
Issuer: issuer,
|
|
ClientID: testClientID,
|
|
ClientSecret: testClientSecret,
|
|
PublicURL: testPublicURL,
|
|
SecretKey: []byte("k"),
|
|
AllowedGroups: testAllowedGroups,
|
|
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
|
if err == nil || !strings.Contains(err.Error(), "issuer") {
|
|
t.Errorf("New(issuer %q) error = %v, want issuer rejected", issuer, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
field, name string
|
|
}{
|
|
{"authorization_endpoint", "authorization endpoint"},
|
|
{"token_endpoint", "token endpoint"},
|
|
{"jwks_uri", "JWKS endpoint"},
|
|
{"end_session_endpoint", "end session endpoint"},
|
|
{"userinfo_endpoint", "userinfo endpoint"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.field, func(t *testing.T) {
|
|
t.Parallel()
|
|
p := newFakeProvider(t)
|
|
p.discoveryOverrides = map[string]any{tc.field: "http://auth.example.com/" + tc.field}
|
|
_, err := New(context.Background(), Config{
|
|
Issuer: p.issuer(),
|
|
ClientID: testClientID,
|
|
ClientSecret: testClientSecret,
|
|
PublicURL: testPublicURL,
|
|
SecretKey: []byte("k"),
|
|
AllowedGroups: testAllowedGroups,
|
|
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
|
if err == nil || !strings.Contains(err.Error(), tc.name) {
|
|
t.Errorf("New error = %v, want %s rejected", err, tc.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A token endpoint on https:// that redirects the code exchange to plain
|
|
// http:// must not receive a follow-up request: Go would resend the client
|
|
// secret in the Authorization header. The plaintext target is loopback, so
|
|
// only the downgrade rule stops it.
|
|
func TestCallbackRefusesTokenRedirectToHTTP(t *testing.T) {
|
|
t.Parallel()
|
|
for _, status := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} {
|
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
|
t.Parallel()
|
|
var plaintextHits atomic.Int32
|
|
plaintext := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
plaintextHits.Add(1)
|
|
http.Error(w, "unreachable", http.StatusTeapot)
|
|
}))
|
|
t.Cleanup(plaintext.Close)
|
|
var tlsHits atomic.Int32
|
|
tlsToken := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
tlsHits.Add(1)
|
|
http.Redirect(w, r, plaintext.URL+"/token", status)
|
|
}))
|
|
t.Cleanup(tlsToken.Close)
|
|
|
|
p := newFakeProvider(t)
|
|
p.discoveryOverrides = map[string]any{"token_endpoint": tlsToken.URL + "/token"}
|
|
sessions := &memorySessions{}
|
|
logs := &syncBuffer{}
|
|
a, err := New(context.Background(), Config{
|
|
Issuer: p.issuer(),
|
|
ClientID: testClientID,
|
|
ClientSecret: testClientSecret,
|
|
PublicURL: testPublicURL,
|
|
SecretKey: []byte("server-secret-key"),
|
|
AllowedGroups: testAllowedGroups,
|
|
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Trust the test server's certificate; the redirect policy stays.
|
|
a.client.Transport = tlsToken.Client().Transport
|
|
r := chi.NewRouter()
|
|
a.Mount(r)
|
|
h := &harness{provider: p, sessions: sessions, router: r, logs: logs}
|
|
l := h.login(t, LoginPath)
|
|
|
|
resp := h.callback(l, l.query.Get("state"), "unused")
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
|
}
|
|
if n := tlsHits.Load(); n != 1 {
|
|
t.Errorf("https token endpoint hit %d times, want 1", n)
|
|
}
|
|
if n := plaintextHits.Load(); n != 0 {
|
|
t.Errorf("plaintext endpoint hit %d times, want 0", n)
|
|
}
|
|
if n := len(sessions.started()); n != 0 {
|
|
t.Errorf("started %d sessions, want 0", n)
|
|
}
|
|
if got := logs.String(); !strings.Contains(got, "downgrade") {
|
|
t.Errorf("logs = %q, want the downgrade refusal", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckRedirect(t *testing.T) {
|
|
t.Parallel()
|
|
request := func(raw string) *http.Request {
|
|
req, err := http.NewRequest(http.MethodPost, raw, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return req
|
|
}
|
|
tests := []struct {
|
|
from, to string
|
|
ok bool
|
|
}{
|
|
{"https://auth.example.com/token", "https://auth2.example.com/token", true},
|
|
{"http://127.0.0.1:9000/token", "http://localhost:9001/token", true},
|
|
{"http://127.0.0.1:9000/token", "https://auth.example.com/token", true},
|
|
{"https://auth.example.com/token", "http://auth.example.com/token", false},
|
|
{"https://127.0.0.1:9000/token", "http://127.0.0.1:9001/token", false},
|
|
{"http://127.0.0.1:9000/token", "http://auth.example.com/token", false},
|
|
{"https://auth.example.com/token", "ftp://auth.example.com/token", false},
|
|
}
|
|
for _, tc := range tests {
|
|
err := CheckRedirect(request(tc.to), []*http.Request{request(tc.from)})
|
|
if (err == nil) != tc.ok {
|
|
t.Errorf("redirect %s -> %s: error = %v, want ok=%v", tc.from, tc.to, err, tc.ok)
|
|
}
|
|
}
|
|
via := make([]*http.Request, maxRedirects)
|
|
for i := range via {
|
|
via[i] = request("https://auth.example.com/token")
|
|
}
|
|
if err := CheckRedirect(request("https://auth.example.com/token"), via); err == nil {
|
|
t.Errorf("redirect after %d hops: error = nil, want limit", maxRedirects)
|
|
}
|
|
}
|