internal/auth/oidc_test.go
Rama main · réplica de lectura del repositorio original.
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)
}
}