Ctrl K
ComunidadGuías…

internal/auth/oidc_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package auth

import (
	"bytes"
	"encoding/pem"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync"
	"testing"
	"time"
)

type startedLogin struct {
	cookies []*http.Cookie
	state   string
	nonce   string
	away    *url.URL
}

func startLogin(t *testing.T, held *Authenticator, next string) startedLogin {
	t.Helper()
	recorded := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/auth/login?"+url.Values{returnParam: []string{next}}.Encode(), http.NoBody)

	held.Login(recorded, req)

	if recorded.Code != http.StatusFound {
		t.Fatalf("status = %d, want %d with the location of the provider", recorded.Code, http.StatusFound)
	}
	away, err := url.Parse(recorded.Header().Get("Location"))
	if err != nil {
		t.Fatalf("the login redirect was not a url: %v", err)
	}
	return startedLogin{
		cookies: recorded.Result().Cookies(),
		state:   away.Query().Get("state"),
		nonce:   away.Query().Get("nonce"),
		away:    away,
	}
}

func callback(t *testing.T, held *Authenticator, login startedLogin, query url.Values) *httptest.ResponseRecorder {
	t.Helper()
	recorded := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/auth/callback?"+query.Encode(), http.NoBody)
	for _, cookie := range login.cookies {
		req.AddCookie(cookie)
	}
	held.Callback(recorded, req)
	return recorded
}

func signedIn(t *testing.T, held *Authenticator, idp *fakeIDP) *http.Request {
	t.Helper()
	login := startLogin(t, held, "/checks")
	idp.claims = idp.standardClaims(login.nonce)
	recorded := callback(t, held, login, url.Values{"state": {login.state}, "code": {"abc"}})
	if recorded.Header().Get("Location") != "/checks" {
		t.Fatalf("landed on %q, want the page the login started from", recorded.Header().Get("Location"))
	}
	req := httptest.NewRequest(http.MethodGet, "/api/overview", http.NoBody)
	for _, cookie := range recorded.Result().Cookies() {
		req.AddCookie(cookie)
	}
	return req
}

func errorFrom(t *testing.T, held *Authenticator, recorded *httptest.ResponseRecorder) string {
	t.Helper()
	if got := recorded.Header().Get("Location"); got != "/" {
		t.Fatalf("landed on %q, want the sign-in page with nothing in the url", got)
	}
	req := httptest.NewRequest(http.MethodGet, "/api/auth/me", http.NoBody)
	for _, cookie := range recorded.Result().Cookies() {
		req.AddCookie(cookie)
	}
	return held.WhyNot(httptest.NewRecorder(), req)
}

func TestALoginSendsTheBrowserToTheProviderWithEverythingTheSpecWants(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, nil)

	login := startLogin(t, held, "/checks")

	if login.away.Path != "/authorize" {
		t.Fatalf("path = %q, want the provider's authorize endpoint", login.away.Path)
	}
	query := login.away.Query()
	if query.Get("client_id") != "spinoza" {
		t.Fatalf("client_id = %q, want spinoza", query.Get("client_id"))
	}
	if query.Get("code_challenge_method") != "S256" || query.Get("code_challenge") == "" {
		t.Fatalf("pkce = %q/%q, want an S256 challenge", query.Get("code_challenge_method"), query.Get("code_challenge"))
	}
	if login.state == "" || login.nonce == "" {
		t.Fatal("the login carried no state or no nonce")
	}
	if query.Get("scope") != "openid profile email groups" {
		t.Fatalf("scope = %q, want the four spinoza asks for", query.Get("scope"))
	}
	if query.Get("redirect_uri") != "https://spinoza.example.com/auth/callback" {
		t.Fatalf("redirect_uri = %q, want the configured one", query.Get("redirect_uri"))
	}
	if len(login.cookies) != 1 || login.cookies[0].Name != flowCookie {
		t.Fatalf("cookies = %v, want only the login state", login.cookies)
	}
}

func TestALoginAfterSigningOutAsksTheProviderToShowItsForm(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, nil)
	recorded := httptest.NewRecorder()

	held.Login(recorded, httptest.NewRequest(http.MethodGet, "/auth/login?prompt=login", http.NoBody))

	away, _ := url.Parse(recorded.Header().Get("Location"))
	if away.Query().Get("prompt") != "login" {
		t.Fatal("the provider was not asked to show its login form, so it would sign the same person straight back in")
	}
}

func TestAFinishedLoginBecomesASessionWithTheProvidersClaims(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.EditorGroups = []string{"platform"}
	})

	who, ok := held.Identify(httptest.NewRecorder(), signedIn(t, held, idp))
	if !ok {
		t.Fatal("the session spinoza issued did not read back")
	}
	if who.User != "alice" {
		t.Fatalf("user = %q, want the preferred_username claim", who.User)
	}
	if strings.Join(who.Groups, ",") != "platform" {
		t.Fatalf("groups = %v, want the groups claim", who.Groups)
	}
	if who.Role != RoleEditor {
		t.Fatalf("role = %q, want %q from the group list", who.Role, RoleEditor)
	}
	if who.Session != "session-7" {
		t.Fatalf("session = %q, want the sid claim", who.Session)
	}
	if idp.received("code_verifier") == "" {
		t.Fatal("the token exchange sent no pkce verifier")
	}
}

func TestParallelCallbackReplayCausesOneTokenExchange(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, nil)
	login := startLogin(t, held, "/")
	idp.claims = idp.standardClaims(login.nonce)
	const attempts = 16
	start := make(chan struct{})
	var group sync.WaitGroup
	for range attempts {
		group.Go(func() {
			<-start
			callback(t, held, login, url.Values{"state": {login.state}, "code": {"abc"}})
		})
	}
	close(start)
	group.Wait()
	if got := idp.exchanges(); got != 1 {
		t.Fatalf("token exchanges = %d, want 1", got)
	}
	replayed := callback(t, held, login, url.Values{"state": {login.state}, "code": {"abc"}})
	if message := errorFrom(t, held, replayed); !strings.Contains(message, "already used") {
		t.Fatalf("replay message = %q", message)
	}
}

func TestSaturatedTokenExchangeReturns429BeforeContactingTheProvider(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, nil)
	held.exchanges = newExchangeBudget(1, 1)
	probe := httptest.NewRequest(http.MethodGet, "/auth/callback", http.NoBody)
	release, claimed := held.exchanges.claim(callbackSource(probe))
	if !claimed {
		t.Fatal("the setup token exchange was refused")
	}
	login := startLogin(t, held, "/")
	refused := callback(t, held, login, url.Values{"state": {login.state}, "code": {"abc"}})
	if refused.Code != http.StatusTooManyRequests {
		t.Fatalf("status = %d, want %d", refused.Code, http.StatusTooManyRequests)
	}
	if !strings.Contains(refused.Body.String(), "login exchange is busy; try again") {
		t.Fatalf("body = %q", refused.Body.String())
	}
	if got := idp.exchanges(); got != 0 {
		t.Fatalf("provider exchanges = %d, want 0", got)
	}
	release()
	next := startLogin(t, held, "/")
	idp.claims = idp.standardClaims(next.nonce)
	accepted := callback(t, held, next, url.Values{"state": {next.state}, "code": {"abc"}})
	if accepted.Code != http.StatusFound {
		t.Fatalf("status after release = %d, want %d", accepted.Code, http.StatusFound)
	}
	if got := idp.exchanges(); got != 1 {
		t.Fatalf("provider exchanges after release = %d, want 1", got)
	}
}

func TestPrefixesGoOnToMatchWhatTheApiserverBinds(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.OIDC.UsernamePrefix = "oidc:"
		cfg.OIDC.GroupsPrefix = "oidc:"
	})

	who, _ := held.Identify(httptest.NewRecorder(), signedIn(t, held, idp))

	if who.User != "oidc:alice" {
		t.Fatalf("user = %q, want the prefix in front", who.User)
	}
	if who.Groups[0] != "oidc:platform" {
		t.Fatalf("group = %q, want the prefix in front", who.Groups[0])
	}
}

func TestALoginThatComesBackWrongEndsOnTheSignInPage(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, nil)

	cases := []struct {
		name  string
		build func(t *testing.T) *httptest.ResponseRecorder
		want  string
	}{
		{
			name: "with a state spinoza never sent",
			build: func(t *testing.T) *httptest.ResponseRecorder {
				t.Helper()
				login := startLogin(t, held, "/")
				return callback(t, held, login, url.Values{"state": {"forged"}, "code": {"abc"}})
			},
			want: "did not come back with the state",
		},
		{
			name: "with no login in progress at all",
			build: func(t *testing.T) *httptest.ResponseRecorder {
				t.Helper()
				return callback(t, held, startedLogin{}, url.Values{"state": {"forged"}, "code": {"abc"}})
			},
			want: "did not come back with the state",
		},
		{
			name: "with the provider refusing",
			build: func(t *testing.T) *httptest.ResponseRecorder {
				t.Helper()
				login := startLogin(t, held, "/")
				return callback(t, held, login, url.Values{
					"state":             {login.state},
					"error":             {"access_denied"},
					"error_description": {"the user said no"},
				})
			},
			want: "the user said no",
		},
		{
			name: "with the provider refusing and saying nothing useful",
			build: func(t *testing.T) *httptest.ResponseRecorder {
				t.Helper()
				login := startLogin(t, held, "/")
				return callback(t, held, login, url.Values{"state": {login.state}, "error": {"access_denied"}})
			},
			want: "access_denied",
		},
		{
			name: "with no code and no reason",
			build: func(t *testing.T) *httptest.ResponseRecorder {
				t.Helper()
				login := startLogin(t, held, "/")
				return callback(t, held, login, url.Values{"state": {login.state}})
			},
			want: "without an authorization code",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := errorFrom(t, held, tc.build(t))
			if !strings.Contains(got, tc.want) {
				t.Fatalf("message = %q, want it to mention %q", got, tc.want)
			}
		})
	}
}

func TestATokenSpinozaCannotTrustNeverBecomesASession(t *testing.T) {
	cases := []struct {
		name    string
		arrange func(idp *fakeIDP, nonce string)
		want    string
	}{
		{
			name:    "the exchange failed",
			arrange: func(idp *fakeIDP, _ string) { idp.tokenCode = http.StatusBadRequest },
			want:    "refused the login",
		},
		{
			name:    "there was no id token",
			arrange: func(idp *fakeIDP, _ string) { idp.noIDToken = true },
			want:    "returned no id token",
		},
		{
			name: "the nonce was somebody else's",
			arrange: func(idp *fakeIDP, _ string) {
				idp.claims = idp.standardClaims("another-login")
			},
			want: "nonce spinoza did not send",
		},
		{
			name: "it was signed for another client",
			arrange: func(idp *fakeIDP, nonce string) {
				claims := idp.standardClaims(nonce)
				claims["aud"] = "grafana"
				idp.claims = claims
			},
			want: "did not verify",
		},
		{
			name: "it carried no username spinoza reads",
			arrange: func(idp *fakeIDP, nonce string) {
				claims := idp.standardClaims(nonce)
				delete(claims, "preferred_username")
				delete(claims, "email")
				delete(claims, "sub")
				idp.claims = claims
			},
			want: "none of the claims spinoza reads a username from",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			idp := newIDP(t)
			held := authFor(t, idp, nil)
			login := startLogin(t, held, "/")
			tc.arrange(idp, login.nonce)

			got := errorFrom(t, held, callback(t, held, login, url.Values{"state": {login.state}, "code": {"abc"}}))
			if !strings.Contains(got, tc.want) {
				t.Fatalf("message = %q, want it to mention %q", got, tc.want)
			}
		})
	}
}

func TestAValidLoginTooLargeForASessionCookieIsRefused(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, nil)
	login := startLogin(t, held, "/")
	claims := idp.standardClaims(login.nonce)
	claims["groups"] = []string{strings.Repeat("g", maxCookieBytes)}
	idp.claims = claims

	recorded := callback(t, held, login, url.Values{"state": {login.state}, "code": {"abc"}})

	if recorded.Code != http.StatusInternalServerError {
		t.Fatalf("status = %d, want %d", recorded.Code, http.StatusInternalServerError)
	}
	for _, cookie := range recorded.Result().Cookies() {
		if cookie.Name == SessionCookie && cookie.Value != "" {
			t.Fatal("an identity too large to retain still became a session")
		}
	}
	if !strings.Contains(recorded.Body.String(), "too large") {
		t.Fatalf("body = %q, want the cookie limit named", recorded.Body.String())
	}
}

func TestSigningOutEndsTheProvidersSessionToo(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.OIDC.PostLogoutURL = "https://spinoza.example.com/"
	})
	req := signedIn(t, held, idp)
	recorded := httptest.NewRecorder()

	held.Logout(recorded, req)

	away, err := url.Parse(recorded.Header().Get("Location"))
	if err != nil {
		t.Fatalf("the logout redirect was not a url: %v", err)
	}
	if away.Path != "/logout" {
		t.Fatalf("path = %q, want the provider's end session endpoint", away.Path)
	}
	if away.Query().Get("client_id") != "spinoza" {
		t.Fatal("the provider was not told which client is signing out")
	}
	if away.Query().Get("post_logout_redirect_uri") != "https://spinoza.example.com/" {
		t.Fatal("the provider was not told where to send the browser back")
	}
	if _, still := held.Identify(httptest.NewRecorder(), req); still {
		t.Fatal("the session was still good after signing out")
	}
}

func TestSigningOutOfAProviderWithNoLogoutEndpointForcesTheFormNextTime(t *testing.T) {
	idp := newIDP(t)
	idp.noLogout = true
	held := authFor(t, idp, nil)
	recorded := httptest.NewRecorder()

	held.Logout(recorded, httptest.NewRequest(http.MethodPost, "/auth/logout", http.NoBody))

	if recorded.Header().Get("Location") != "/?prompt=login" {
		t.Fatalf("location = %q, want the sign-in page asking for the form", recorded.Header().Get("Location"))
	}
}

func TestAProviderCanEndASessionOverTheBackChannel(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.OIDC.BackchannelLogout = true
	})
	req := signedIn(t, held, idp)
	token := signJWT(map[string]any{
		"iss":    idp.issuer,
		"aud":    "spinoza",
		"iat":    idp.standardClaims("")["iat"],
		"exp":    idp.standardClaims("")["exp"],
		"sid":    "session-7",
		"events": map[string]any{backchannelEvent: map[string]any{}},
	})
	recorded := httptest.NewRecorder()

	held.BackchannelLogout(recorded, logoutRequest(token))

	if recorded.Code != http.StatusOK {
		t.Fatalf("status = %d, want %d", recorded.Code, http.StatusOK)
	}
	if _, still := held.Identify(httptest.NewRecorder(), req); still {
		t.Fatal("the session the provider ended still worked")
	}
}

func logoutRequest(token string) *http.Request {
	body := strings.NewReader(url.Values{"logout_token": {token}}.Encode())
	req := httptest.NewRequest(http.MethodPost, "/auth/backchannel-logout", body)
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	return req
}

func TestABackChannelLogoutIsRefusedWhenItCannotBeTrusted(t *testing.T) {
	idp := newIDP(t)
	base := idp.standardClaims("")

	cases := []struct {
		name  string
		on    bool
		token string
		want  int
	}{
		{name: "the feature is off", on: false, token: "anything", want: http.StatusNotFound},
		{name: "no token at all", on: true, token: "", want: http.StatusBadRequest},
		{name: "a token that is not a token", on: true, token: "not.a.jwt", want: http.StatusBadRequest},
		{
			name: "a token that is not a logout",
			on:   true,
			token: signJWT(map[string]any{
				"iss": idp.issuer, "aud": "spinoza", "iat": base["iat"], "exp": base["exp"], "sid": "session-7",
			}),
			want: http.StatusBadRequest,
		},
		{
			name: "a token whose logout claims have the wrong shape",
			on:   true,
			token: signJWT(map[string]any{
				"iss": idp.issuer, "aud": "spinoza", "iat": base["iat"], "exp": base["exp"], "sid": "session-7",
				"events": "back-channel logout",
			}),
			want: http.StatusBadRequest,
		},
		{
			name: "a logout naming nobody",
			on:   true,
			token: signJWT(map[string]any{
				"iss": idp.issuer, "aud": "spinoza", "iat": base["iat"], "exp": base["exp"],
				"events": map[string]any{backchannelEvent: map[string]any{}},
			}),
			want: http.StatusBadRequest,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			held := authFor(t, idp, func(cfg *Config) {
				cfg.OIDC.BackchannelLogout = tc.on
			})
			recorded := httptest.NewRecorder()

			held.BackchannelLogout(recorded, logoutRequest(tc.token))

			if recorded.Code != tc.want {
				t.Fatalf("status = %d, want %d", recorded.Code, tc.want)
			}
		})
	}
}

func TestAnOversizedBackChannelLogoutIsRefusedBeforeItIsParsed(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.OIDC.BackchannelLogout = true
	})
	body := strings.NewReader("logout_token=" + strings.Repeat("x", maxBackchannelLogoutBytes))
	req := httptest.NewRequest(http.MethodPost, "/auth/backchannel-logout", body)
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	recorded := httptest.NewRecorder()

	held.BackchannelLogout(recorded, req)

	if recorded.Code != http.StatusRequestEntityTooLarge {
		t.Fatalf("status = %d, want %d", recorded.Code, http.StatusRequestEntityTooLarge)
	}
}

func TestBackChannelLogoutTokenLimitAcceptsTheBoundaryAndRejectsOneMoreByte(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.OIDC.BackchannelLogout = true
	})
	recorded := httptest.NewRecorder()

	held.BackchannelLogout(recorded, logoutRequest(strings.Repeat("x", maxLogoutTokenBytes)))

	if recorded.Code != http.StatusBadRequest {
		t.Fatalf("boundary status = %d, want %d", recorded.Code, http.StatusBadRequest)
	}
	other := authFor(t, idp, func(cfg *Config) {
		cfg.OIDC.BackchannelLogout = true
	})
	recorded = httptest.NewRecorder()
	other.BackchannelLogout(recorded, logoutRequest(strings.Repeat("x", maxLogoutTokenBytes+1)))
	if recorded.Code != http.StatusRequestEntityTooLarge {
		t.Fatalf("over-limit status = %d, want %d", recorded.Code, http.StatusRequestEntityTooLarge)
	}
	if !strings.Contains(recorded.Body.String(), "the logout token is too large") {
		t.Fatalf("body = %q", recorded.Body.String())
	}
}

func TestFailedLogoutVerificationStartsAJWKSRefreshCooldown(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.OIDC.BackchannelLogout = true
	})
	now := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC)
	held.logouts.now = func() time.Time { return now }
	claims := idp.standardClaims("")
	claims["events"] = map[string]any{backchannelEvent: map[string]any{}}

	first := httptest.NewRecorder()
	held.BackchannelLogout(first, logoutRequest(signJWTWithKeyID(claims, "unknown-one")))
	if first.Code != http.StatusBadRequest {
		t.Fatalf("first status = %d, want %d", first.Code, http.StatusBadRequest)
	}
	second := httptest.NewRecorder()
	held.BackchannelLogout(second, logoutRequest(signJWTWithKeyID(claims, "unknown-two")))
	if second.Code != http.StatusTooManyRequests {
		t.Fatalf("cooldown status = %d, want %d", second.Code, http.StatusTooManyRequests)
	}
	if second.Header().Get("Retry-After") != "5" {
		t.Fatalf("retry-after = %q", second.Header().Get("Retry-After"))
	}
	if idp.keyReads() != 1 {
		t.Fatalf("jwks reads inside cooldown = %d, want 1", idp.keyReads())
	}
	now = now.Add(logoutFailureCooldown)
	third := httptest.NewRecorder()
	held.BackchannelLogout(third, logoutRequest(signJWTWithKeyID(claims, "unknown-three")))
	if third.Code != http.StatusBadRequest {
		t.Fatalf("post-cooldown status = %d, want %d", third.Code, http.StatusBadRequest)
	}
	if idp.keyReads() != 2 {
		t.Fatalf("jwks reads at cooldown boundary = %d, want 2", idp.keyReads())
	}
}

func TestBackChannelLogoutSaturationDoesNotContactTheProvider(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.OIDC.BackchannelLogout = true
	})
	releases := make([]func(), 0, logoutGlobalConcurrent)
	for at := range logoutGlobalConcurrent {
		release, ok := held.logouts.claim(fmt.Sprintf("source-%d", at))
		if !ok {
			t.Fatalf("claim %d was refused", at)
		}
		releases = append(releases, release)
	}
	claims := idp.standardClaims("")
	claims["events"] = map[string]any{backchannelEvent: map[string]any{}}
	recorded := httptest.NewRecorder()

	held.BackchannelLogout(recorded, logoutRequest(signJWT(claims)))

	if recorded.Code != http.StatusTooManyRequests {
		t.Fatalf("status = %d, want %d", recorded.Code, http.StatusTooManyRequests)
	}
	if idp.keyReads() != 0 {
		t.Fatalf("jwks reads = %d, want 0", idp.keyReads())
	}
	for _, release := range releases {
		release()
	}
	recorded = httptest.NewRecorder()
	held.BackchannelLogout(recorded, logoutRequest(signJWT(claims)))
	if recorded.Code != http.StatusOK {
		t.Fatalf("status after release = %d, want %d", recorded.Code, http.StatusOK)
	}
}

func TestAMalformedBackChannelLogoutIsRefused(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.OIDC.BackchannelLogout = true
	})
	req := httptest.NewRequest(http.MethodPost, "/auth/backchannel-logout", strings.NewReader("logout_token=%zz"))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	recorded := httptest.NewRecorder()

	held.BackchannelLogout(recorded, req)

	if recorded.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want %d", recorded.Code, http.StatusBadRequest)
	}
	if !strings.Contains(recorded.Body.String(), "could not be read") {
		t.Fatalf("body = %q, want the malformed form named", recorded.Body.String())
	}
}

func TestALogoutNamingOnlyASubjectIsAcceptedAndSaidOutLoud(t *testing.T) {
	idp := newIDP(t)
	idp.noSIDs = true
	held := authFor(t, idp, func(cfg *Config) {
		cfg.OIDC.BackchannelLogout = true
	})
	base := idp.standardClaims("")
	token := signJWT(map[string]any{
		"iss": idp.issuer, "aud": "spinoza", "iat": base["iat"], "exp": base["exp"], "sub": "1a2b",
		"events": map[string]any{backchannelEvent: map[string]any{}},
	})
	recorded := httptest.NewRecorder()

	held.BackchannelLogout(recorded, logoutRequest(token))

	if recorded.Code != http.StatusOK {
		t.Fatalf("status = %d, want %d; the provider retries anything else", recorded.Code, http.StatusOK)
	}
}

func TestAnUnreachableProviderStopsSpinozaStarting(t *testing.T) {
	cfg := Config{
		Mode:      ModeOIDC,
		PublicURL: "https://spinoza.example.com",
		OIDC: OIDCConfig{
			IssuerURL:   "http://127.0.0.1:1/realms/main",
			ClientID:    "spinoza",
			RedirectURL: "https://spinoza.example.com/auth/callback",
		},
	}

	_, err := New(t.Context(), cfg)
	if err == nil {
		t.Fatal("spinoza started against a provider it could not reach")
	}
	if !strings.Contains(err.Error(), "oidc discovery") {
		t.Fatalf("error = %q, want it to name discovery", err.Error())
	}
}

func TestAnUnreachableInternalProviderStopsSpinozaStarting(t *testing.T) {
	_, _, err := discover(t.Context(), OIDCConfig{
		IssuerURL:         "https://login.example.com/realms/main",
		InternalIssuerURL: "http://127.0.0.1:1/realms/main",
	})

	if err == nil {
		t.Fatal("an unreachable internal issuer produced an OIDC provider")
	}
	if !strings.Contains(err.Error(), "oidc discovery") {
		t.Fatalf("error = %v, want internal discovery named", err)
	}
}

func TestAMalformedDiscoveryDocumentIsRejected(t *testing.T) {
	issuer := ""
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{
			"issuer":"` + issuer + `",
			"authorization_endpoint":"` + issuer + `/authorize",
			"token_endpoint":"` + issuer + `/token",
			"jwks_uri":"` + issuer + `/keys",
			"scopes_supported":"openid"
		}`))
	}))
	issuer = server.URL
	t.Cleanup(server.Close)

	_, _, err := discoverAt(t.Context(), issuer)

	if err == nil {
		t.Fatal("a discovery document with a malformed scope list was accepted")
	}
	if !strings.Contains(err.Error(), "discovery document") {
		t.Fatalf("error = %v, want the malformed document named", err)
	}
}

func TestTheBrowserGetsThePublicIssuerWhileThePodUsesTheInternalOne(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.OIDC.IssuerURL = "https://keycloak.example.com/realms/main"
		cfg.OIDC.InternalIssuerURL = idp.issuer
	})

	login := startLogin(t, held, "/")

	if login.away.Host != "keycloak.example.com" {
		t.Fatalf("the browser was sent to %q, which it cannot reach", login.away.Host)
	}
	idp.claims = map[string]any{
		"iss": "https://keycloak.example.com/realms/main", "aud": "spinoza",
		"exp": idp.standardClaims("")["exp"], "iat": idp.standardClaims("")["iat"],
		"nonce": login.nonce, "sub": "1a2b", "preferred_username": "alice",
	}

	recorded := callback(t, held, login, url.Values{"state": {login.state}, "code": {"abc"}})
	if errorFrom(t, held, recorded) != "" {
		t.Fatalf("the login failed: %s", errorFrom(t, held, recorded))
	}
}

func TestAnEndpointOnAHostSpinozaWasNotToldAboutIsLeftAlone(t *testing.T) {
	if got := swapBase("https://elsewhere/authorize", "https://inner", "https://outer"); got != "https://elsewhere/authorize" {
		t.Fatalf("endpoint = %q, want it untouched", got)
	}
	if got := swapBase("", "https://inner", "https://outer"); got != "" {
		t.Fatalf("endpoint = %q, want it empty", got)
	}
}

func TestAnEndpointWithOnlyAHostOrPathPrefixMatchIsLeftAlone(t *testing.T) {
	from := "https://inner.example.com/realms/main"
	to := "https://outer.example.com/realms/main"
	for _, endpoint := range []string{
		"https://inner.example.com.attacker.test/realms/main/authorize",
		"https://inner.example.com/realms/mainland/authorize",
	} {
		if got := swapBase(endpoint, from, to); got != endpoint {
			t.Fatalf("endpoint = %q, want %q untouched", got, endpoint)
		}
	}
}

func TestACertificateAuthorityHasToBeReadable(t *testing.T) {
	_, err := httpClientFor(OIDCConfig{CACertFile: "/no/such/file"})
	if err == nil {
		t.Fatal("a missing ca certificate was accepted")
	}
	if !strings.Contains(err.Error(), "oidc ca certificate") {
		t.Fatalf("error = %q, want it to name the certificate", err.Error())
	}

	path := writeFile(t, "not a certificate")
	_, badErr := httpClientFor(OIDCConfig{CACertFile: path})
	if badErr == nil {
		t.Fatal("a file with no certificate in it was accepted")
	}
}

func TestACustomCertificateAuthorityTrustsItsProvider(t *testing.T) {
	provider := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNoContent)
	}))
	t.Cleanup(provider.Close)

	certificate := pem.EncodeToMemory(&pem.Block{
		Type:  "CERTIFICATE",
		Bytes: provider.Certificate().Raw,
	})
	path := writeFile(t, string(certificate))
	client, err := httpClientFor(OIDCConfig{CACertFile: path})
	if err != nil {
		t.Fatalf("building the client: %v", err)
	}
	response, err := client.Get(provider.URL)
	if err != nil {
		t.Fatalf("calling the provider: %v", err)
	}
	t.Cleanup(func() {
		response.Body.Close()
	})
	if response.StatusCode != http.StatusNoContent {
		t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusNoContent)
	}
}

func TestAnOversizedCertificateAuthorityIsRefused(t *testing.T) {
	provider := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNoContent)
	}))
	t.Cleanup(provider.Close)
	certificate := pem.EncodeToMemory(&pem.Block{
		Type:  "CERTIFICATE",
		Bytes: provider.Certificate().Raw,
	})
	copies := maxCACertBytes/len(certificate) + 1
	path := writeFile(t, string(bytes.Repeat(certificate, copies)))

	_, err := httpClientFor(OIDCConfig{CACertFile: path})

	if err == nil || !strings.Contains(err.Error(), "larger than") {
		t.Fatalf("building the client = %v, want the ca size limit", err)
	}
}

func TestTheDefaultTransportIsUsedWhenTlsNeedsNothingSpecial(t *testing.T) {
	client, err := httpClientFor(OIDCConfig{})
	if err != nil {
		t.Fatalf("building the client: %v", err)
	}
	if client.Transport != http.DefaultTransport {
		t.Fatal("a plain provider got a transport of its own")
	}
}

func TestSkippingVerificationIsPossibleForALab(t *testing.T) {
	client, err := httpClientFor(OIDCConfig{InsecureSkipVerify: true})
	if err != nil {
		t.Fatalf("building the client: %v", err)
	}
	if client.Transport == http.DefaultTransport {
		t.Fatal("skipping verification left the default transport in place")
	}
}

func TestCustomTLSNeedsTheStandardHTTPTransportShape(t *testing.T) {
	original := http.DefaultTransport
	http.DefaultTransport = http.NewFileTransport(http.Dir(t.TempDir()))
	t.Cleanup(func() {
		http.DefaultTransport = original
	})

	_, err := newProvider(t.Context(), OIDCConfig{InsecureSkipVerify: true})

	if err == nil {
		t.Fatal("custom TLS was added to a transport that cannot carry it")
	}
	if !strings.Contains(err.Error(), "default http transport was replaced") {
		t.Fatalf("error = %v, want the replaced transport named", err)
	}
}

func TestAProviderWithNoLogoutEndpointOffersNoLogoutUrl(t *testing.T) {
	held := &provider{}

	if got := held.logoutURL(); got != "" {
		t.Fatalf("url = %q, want none", got)
	}
	broken := &provider{endSession: "://nope"}
	if got := broken.logoutURL(); got != "" {
		t.Fatalf("url = %q, want none from an endpoint that is not a url", got)
	}
}

func TestAScopeTheProviderDoesNotDefineIsNotAskedFor(t *testing.T) {
	cases := []struct {
		name      string
		wanted    []string
		supported []string
		want      string
	}{
		{
			name:      "stock keycloak, which has no groups scope",
			wanted:    DefaultScopes,
			supported: []string{"openid", "profile", "email", "roles", "address"},
			want:      "openid profile email",
		},
		{
			name:      "dex, which needs it",
			wanted:    DefaultScopes,
			supported: []string{"openid", "profile", "email", "groups"},
			want:      "openid profile email groups",
		},
		{
			name:      "a provider that lists none",
			wanted:    DefaultScopes,
			supported: nil,
			want:      "openid profile email groups",
		},
		{
			name:      "a provider that forgot to list openid",
			wanted:    []string{"openid", "email"},
			supported: []string{"email"},
			want:      "openid email",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := strings.Join(askableScopes(tc.wanted, tc.supported), " "); got != tc.want {
				t.Fatalf("scopes = %q, want %q", got, tc.want)
			}
		})
	}
}

func TestTheLoginAsksOnlyForScopesTheProviderAdvertises(t *testing.T) {
	idp := newIDP(t)
	idp.scopes = []string{"openid", "profile", "email"}
	held := authFor(t, idp, nil)

	login := startLogin(t, held, "/")

	if got := login.away.Query().Get("scope"); got != "openid profile email" {
		t.Fatalf("scope = %q, want the groups scope left out", got)
	}
}

func TestWorkingAllDayNeverBouncesYouBackToTheProvider(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.SessionTTL = time.Hour
	})
	req := signedIn(t, held, idp)
	held.sessions.now = func() time.Time { return time.Now().Add(50 * time.Minute) }
	recorded := httptest.NewRecorder()

	who, ok := held.Identify(recorded, req)
	if !ok {
		t.Fatal("a session still inside its life was turned away")
	}
	if who.User != "alice" {
		t.Fatalf("user = %q, want alice", who.User)
	}
	renewed := recorded.Result().Cookies()
	if len(renewed) != 1 || renewed[0].Name != SessionCookie {
		t.Fatalf("cookies = %v, want the session renewed", renewed)
	}
}

func TestASessionWellInsideItsLifeIsLeftAlone(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, nil)
	req := signedIn(t, held, idp)
	recorded := httptest.NewRecorder()

	held.Identify(recorded, req)

	if len(recorded.Result().Cookies()) != 0 {
		t.Fatal("a fresh session was written back on every request")
	}
}

func TestABrowserLeftOpenAllWeekGoesBackToTheProvider(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.SessionTTL = time.Hour
		cfg.SessionMaxAge = 2 * time.Hour
	})
	req := signedIn(t, held, idp)

	held.sessions.now = func() time.Time { return time.Now().Add(50 * time.Minute) }
	renewed := httptest.NewRecorder()
	if _, ok := held.Identify(renewed, req); !ok {
		t.Fatal("a session inside the cap was turned away")
	}
	if len(renewed.Result().Cookies()) != 1 {
		t.Fatal("a session inside the cap was not renewed")
	}

	held.sessions.now = func() time.Time { return time.Now().Add(3 * time.Hour) }
	past := httptest.NewRecorder()
	if _, ok := held.Identify(past, req); ok {
		t.Fatal("a session past the cap still worked, so groups lost at the provider would too")
	}
	if len(past.Result().Cookies()) != 0 {
		t.Fatal("a session past the cap was renewed anyway")
	}
}

func TestASessionPastTheCapIsNotRenewedWhileItStillReads(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, func(cfg *Config) {
		cfg.SessionTTL = time.Hour
		cfg.SessionMaxAge = 90 * time.Minute
	})
	req := signedIn(t, held, idp)
	held.sessions.now = func() time.Time { return time.Now().Add(95 * time.Minute) }
	recorded := httptest.NewRecorder()

	if _, ok := held.Identify(recorded, req); ok {
		t.Fatal("the cookie outlived both its own life and the cap")
	}
	if len(recorded.Result().Cookies()) != 0 {
		t.Fatal("a session past the cap was renewed")
	}
}

func TestWhyALoginFailedIsNotSomethingAnyoneCanPutOnThatPage(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, nil)

	planted := httptest.NewRequest(http.MethodGet, "/api/auth/me?authError=call+555+for+support", http.NoBody)
	if got := held.WhyNot(httptest.NewRecorder(), planted); got != "" {
		t.Fatalf("the sign-in page would have shown %q, which came from the address bar", got)
	}

	login := startLogin(t, held, "/")
	refused := callback(t, held, login, url.Values{"state": {login.state}, "error": {"access_denied"}})
	if got := errorFrom(t, held, refused); !strings.Contains(got, "access_denied") {
		t.Fatalf("the provider's own reason was lost: %q", got)
	}
}

func TestAnOversizedReturnPathCannotCreateAFlowCookie(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, nil)
	next := "/" + strings.Repeat("x", maxCookieBytes)
	req := httptest.NewRequest(http.MethodGet, "/auth/login?"+url.Values{returnParam: {next}}.Encode(), http.NoBody)
	recorded := httptest.NewRecorder()

	held.Login(recorded, req)

	if recorded.Code != http.StatusInternalServerError {
		t.Fatalf("status = %d, want %d", recorded.Code, http.StatusInternalServerError)
	}
	if recorded.Header().Get("Location") != "" {
		t.Fatalf("location = %q, want no provider redirect without a flow cookie", recorded.Header().Get("Location"))
	}
	if len(recorded.Result().Cookies()) != 0 {
		t.Fatalf("cookies = %v, want no oversized flow state", recorded.Result().Cookies())
	}
}

func TestAnOversizedProviderRefusalStillReturnsToSignIn(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, nil)
	login := startLogin(t, held, "/")
	recorded := callback(t, held, login, url.Values{
		"state":             {login.state},
		"error_description": {strings.Repeat("x", maxCookieBytes)},
	})

	if recorded.Code != http.StatusFound || recorded.Header().Get("Location") != "/" {
		t.Fatalf("status/location = %d/%q, want the sign-in page", recorded.Code, recorded.Header().Get("Location"))
	}
	if got := errorFrom(t, held, recorded); got != "" {
		t.Fatalf("stored refusal = %q, want an oversized reason left out", got)
	}
}

func TestTheReasonALoginFailedIsShownOnceAndThenForgotten(t *testing.T) {
	idp := newIDP(t)
	held := authFor(t, idp, nil)
	login := startLogin(t, held, "/")
	refused := callback(t, held, login, url.Values{"state": {login.state}, "error": {"access_denied"}})

	req := httptest.NewRequest(http.MethodGet, "/api/auth/me", http.NoBody)
	for _, cookie := range refused.Result().Cookies() {
		req.AddCookie(cookie)
	}
	cleared := httptest.NewRecorder()
	if held.WhyNot(cleared, req) == "" {
		t.Fatal("the reason was not kept for the sign-in page at all")
	}

	again := httptest.NewRequest(http.MethodGet, "/api/auth/me", http.NoBody)
	for _, cookie := range cleared.Result().Cookies() {
		again.AddCookie(cookie)
	}
	if got := held.WhyNot(httptest.NewRecorder(), again); got != "" {
		t.Fatalf("the reason came back a second time as %q, so a reload would keep showing it", got)
	}
}