Files
MealQuest/internal/auth/auth_test.go
T
dev_agentandClaude Opus 5.5 0f7f8014f8
CI / ci (push) Successful in 9m31s
Bound pending flow states
The login route is unauthenticated, so its pending states could grow
without limit and every Add scanned them all. Expired states now leave
from the front of an add-ordered queue, and Add refuses a new flow with
ErrTooManyPending once 10000 are pending; login answers 503. Both flows
record the state before setting the cookie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:40:18 -04:00

1166 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 {
if err := p.Add(fmt.Sprint("s", i), now); err != nil {
t.Fatalf("Add %d: %v", i, err)
}
}
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)
}
}