package auth import ( "context" "errors" "io" "log/slog" "net/http" "net/url" "slices" "strings" "testing" ) // signInWith completes a login whose ID token has claims edited by edit. func (h *harness) signInWith(t *testing.T, edit func(claims map[string]any)) *http.Response { t.Helper() l := h.login(t, LoginPath) c := h.provider.claims(l.query.Get("nonce")) edit(c) return h.callback(l, l.query.Get("state"), h.provider.signRS256(c)) } // assertRefused checks resp is the refused-sign-in page and starts no // session. func assertRefused(t *testing.T, resp *http.Response) { t.Helper() if resp.StatusCode != http.StatusForbidden { t.Fatalf("callback status = %d, want 403", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" { t.Errorf("Content-Type = %q, want text/html; charset=utf-8", ct) } body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } for _, want := range []string{ "This kitchen is for the household only", `
`, "Sign out", } { if !strings.Contains(string(body), want) { t.Errorf("refused page lacks %q:\n%s", want, body) } } for _, c := range resp.Cookies() { // Clearing a session cookie (negative Max-Age) is expected. if (c.Name == SessionCookieName || c.Name == "test_session") && c.MaxAge >= 0 { t.Errorf("refused sign-in set session cookie %s", c.Name) } } assertLoginCookieCleared(t, resp) } func TestGroupGateMember(t *testing.T) { t.Parallel() // Any one allowed group is enough, alongside groups that are not allowed. h := newHarness(t) resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests", "mealquest-family"} }) if resp.StatusCode != http.StatusSeeOther { t.Fatalf("callback status = %d, want 303", resp.StatusCode) } if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"guests", "mealquest-family"}) { t.Errorf("sessions started = %+v, want one with the ID token groups", started) } if n := h.provider.userinfoRequests(); n != 0 { t.Errorf("userinfo requested %d times, want 0 when the ID token has groups", n) } } func TestGroupGateRefused(t *testing.T) { t.Parallel() tests := []struct { name string groups any // ID token groups claim; nil removes it userinfo map[string]any }{ {name: "non-member", groups: []string{"guests", "MEALQUEST-ADMIN"}}, {name: "empty groups claim", groups: []string{}}, {name: "groups claim missing from ID token and userinfo", groups: nil}, { name: "non-member in userinfo", groups: nil, userinfo: map[string]any{"sub": "user-123", "groups": []string{"guests"}}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() h := newHarness(t) if tc.userinfo != nil { h.provider.setUserinfo(tc.userinfo) } resp := h.signInWith(t, func(c map[string]any) { if tc.groups == nil { delete(c, "groups") } else { c["groups"] = tc.groups } }) assertRefused(t, resp) if started := h.sessions.started(); len(started) != 0 { t.Errorf("sessions started = %+v, want none", started) } if !strings.Contains(h.logs.String(), "sign-in refused") { t.Errorf("refusal not logged:\n%s", h.logs.String()) } }) } } func TestGroupGateUsesUserinfoWhenIDTokenHasNoGroups(t *testing.T) { t.Parallel() h := newHarness(t) h.provider.setUserinfo(map[string]any{"sub": "user-123", "groups": []string{"mealquest-admin"}}) resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") }) if resp.StatusCode != http.StatusSeeOther { t.Fatalf("callback status = %d, want 303", resp.StatusCode) } if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"mealquest-admin"}) { t.Errorf("sessions started = %+v, want one with the userinfo groups", started) } if n := h.provider.userinfoRequests(); n != 1 { t.Errorf("userinfo requested %d times, want 1", n) } } func TestGroupGateUserinfoFailures(t *testing.T) { t.Parallel() tests := []struct { name string userinfo map[string]any }{ {"subject mismatch", map[string]any{"sub": "someone-else", "groups": []string{"mealquest-admin"}}}, {"malformed groups", map[string]any{"sub": "user-123", "groups": "mealquest-admin"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() h := newHarness(t) h.provider.setUserinfo(tc.userinfo) resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") }) if resp.StatusCode != http.StatusUnauthorized { t.Errorf("callback status = %d, want 401", resp.StatusCode) } if started := h.sessions.started(); len(started) != 0 { t.Errorf("sessions started = %+v, want none", started) } }) } } // A failed userinfo request fails the sign-in without logging the // endpoint's response, which may echo credentials. func TestGroupGateUserinfoErrorNotLogged(t *testing.T) { t.Parallel() h := newHarness(t) h.provider.mu.Lock() h.provider.userinfoReply = &tokenReply{status: http.StatusInternalServerError, body: `{"error":"leaked-s3cret"}`} h.provider.mu.Unlock() resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") }) if resp.StatusCode != http.StatusUnauthorized { t.Errorf("callback status = %d, want 401", resp.StatusCode) } if started := h.sessions.started(); len(started) != 0 { t.Errorf("sessions started = %+v, want none", started) } logs := h.logs.String() if !strings.Contains(logs, "userinfo") || strings.Contains(logs, "leaked-s3cret") { t.Errorf("logs = %s, want the userinfo failure without the response body", logs) } } // Membership is checked at every login: after removal from the group, the // next login is refused and starts no session. func TestGroupGateRemovedMemberRefusedAtNextLogin(t *testing.T) { t.Parallel() h := newSessionHarness(t) h.signIn(t) if n := len(h.rows(t)); n != 1 { t.Fatalf("sessions after first login = %d, want 1", n) } resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"former-household"} }) assertRefused(t, resp) if n := len(h.rows(t)); n != 1 { t.Errorf("sessions after refused login = %d, want still 1", n) } } // A refused sign-in from a browser that still holds a session ends that // session and clears its cookie. func TestGroupGateRefusalEndsExistingSession(t *testing.T) { t.Parallel() h := newSessionHarness(t) _, cookie := h.signIn(t) l := h.login(t, LoginPath) c := h.provider.claims(l.query.Get("nonce")) c["groups"] = []string{"former-household"} code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(c)) q := url.Values{"code": {code}, "state": {l.query.Get("state")}} resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie, cookie) if resp.StatusCode != http.StatusForbidden { t.Fatalf("callback status = %d, want 403", resp.StatusCode) } if n := len(h.rows(t)); n != 0 { t.Errorf("sessions after refused login = %d, want 0", n) } if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { t.Errorf("old session lookup error = %v, want ErrNoSession", err) } cleared := false for _, c := range resp.Cookies() { cleared = cleared || (c.Name == SessionCookieName && c.MaxAge < 0) } if !cleared { t.Error("refused sign-in did not clear the session cookie") } } func TestGroupGateRefusalEndSessionError(t *testing.T) { t.Parallel() h := newHarness(t) h.sessions.err = errors.New("database down") resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests"} }) if resp.StatusCode != http.StatusInternalServerError { t.Errorf("callback status = %d, want 500", resp.StatusCode) } } func TestNewRequiresAllowedGroups(t *testing.T) { t.Parallel() p := newFakeProvider(t) _, err := New(context.Background(), Config{ Issuer: p.issuer(), ClientID: testClientID, ClientSecret: testClientSecret, PublicURL: testPublicURL, SecretKey: []byte("k"), }, &memorySessions{}, slog.New(slog.DiscardHandler)) if err == nil || !strings.Contains(err.Error(), "allowed group") { t.Errorf("New error = %v, want missing allowed groups named", err) } } func TestNewRequiresUserinfoEndpoint(t *testing.T) { t.Parallel() p := newFakeProvider(t) p.discoveryOverrides = map[string]any{"userinfo_endpoint": nil} _, 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(), "userinfo endpoint") { t.Errorf("New error = %v, want the missing userinfo endpoint named", err) } }