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")) } } 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 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) } }