CI / ci (push) Successful in 1m27s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
265 lines
8.6 KiB
Go
265 lines
8.6 KiB
Go
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",
|
|
`<form method="post" action="/auth/logout">`,
|
|
"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)
|
|
}
|
|
}
|