Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
417 lines
13 KiB
Go
417 lines
13 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/urpi/go-bananas/internal/config"
|
|
"github.com/urpi/go-bananas/internal/domain"
|
|
)
|
|
|
|
const testSecret = "test-secret-that-is-at-least-32-bytes-long"
|
|
|
|
// ---------------------------------------------------------------- sessions --
|
|
|
|
func TestSessionRoundTrip(t *testing.T) {
|
|
store, err := NewSessionStore(testSecret, time.Hour, false)
|
|
if err != nil {
|
|
t.Fatalf("NewSessionStore: %v", err)
|
|
}
|
|
|
|
identity := &domain.Identity{
|
|
Subject: "alice",
|
|
Email: "alice@example.com",
|
|
Name: "Alice",
|
|
Roles: []domain.Role{domain.RoleEditor},
|
|
Method: "oidc",
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
if err := store.Issue(rec, identity); err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
|
for _, c := range rec.Result().Cookies() {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
got, err := store.Read(req)
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if got.Subject != "alice" || got.Email != "alice@example.com" {
|
|
t.Errorf("identity = %+v", got)
|
|
}
|
|
if !got.HasRole(domain.RoleEditor) {
|
|
t.Errorf("roles = %v, want editor", got.Roles)
|
|
}
|
|
}
|
|
|
|
func TestSessionCookieIsHttpOnly(t *testing.T) {
|
|
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
|
rec := httptest.NewRecorder()
|
|
if err := store.Issue(rec, &domain.Identity{Subject: "a"}); err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
cookies := rec.Result().Cookies()
|
|
if len(cookies) != 1 {
|
|
t.Fatalf("got %d cookies, want 1", len(cookies))
|
|
}
|
|
if !cookies[0].HttpOnly {
|
|
t.Error("session cookie must be HttpOnly so script cannot read it")
|
|
}
|
|
if !cookies[0].Secure {
|
|
t.Error("session cookie must be Secure")
|
|
}
|
|
}
|
|
|
|
func TestSessionRejectsShortSecret(t *testing.T) {
|
|
if _, err := NewSessionStore("too-short", time.Hour, false); err == nil {
|
|
t.Fatal("expected a short secret to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestSessionRejectsTamperedPayload(t *testing.T) {
|
|
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
|
rec := httptest.NewRecorder()
|
|
if err := store.Issue(rec, &domain.Identity{Subject: "alice", Roles: []domain.Role{domain.RoleViewer}}); err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
|
|
var issued *http.Cookie
|
|
for _, c := range rec.Result().Cookies() {
|
|
issued = c
|
|
}
|
|
|
|
// Re-encode the payload with an escalated role but keep the old signature.
|
|
body, signature, _ := strings.Cut(issued.Value, ".")
|
|
forged := base64RawURL(`{"sub":"alice","roles":["admin"],"method":"oidc","iat":1,"exp":9999999999}`)
|
|
_ = body
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
|
req.AddCookie(&http.Cookie{Name: issued.Name, Value: forged + "." + signature})
|
|
|
|
if _, err := store.Read(req); err == nil {
|
|
t.Fatal("a re-signed payload must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestSessionRejectsForeignSecret(t *testing.T) {
|
|
mine, _ := NewSessionStore(testSecret, time.Hour, false)
|
|
theirs, _ := NewSessionStore("a-completely-different-32-byte-secret!", time.Hour, false)
|
|
|
|
rec := httptest.NewRecorder()
|
|
if err := theirs.Issue(rec, &domain.Identity{Subject: "mallory", Roles: []domain.Role{domain.RoleAdmin}}); err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
|
for _, c := range rec.Result().Cookies() {
|
|
req.AddCookie(c)
|
|
}
|
|
if _, err := mine.Read(req); err == nil {
|
|
t.Fatal("a cookie signed with another key must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestSessionExpires(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
|
store.WithClock(func() time.Time { return now })
|
|
|
|
rec := httptest.NewRecorder()
|
|
if err := store.Issue(rec, &domain.Identity{Subject: "alice"}); err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
|
for _, c := range rec.Result().Cookies() {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
// Two hours later the session must be gone.
|
|
store.WithClock(func() time.Time { return now.Add(2 * time.Hour) })
|
|
if _, err := store.Read(req); err == nil {
|
|
t.Fatal("expected an expired session to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestSessionReadRejectsMissingCookie(t *testing.T) {
|
|
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
|
if _, err := store.Read(req); err == nil {
|
|
t.Fatal("expected ErrNoSession")
|
|
}
|
|
}
|
|
|
|
func TestSessionWithRoleDefaultsToViewer(t *testing.T) {
|
|
// A cookie carrying no roles must never be treated as having privileges.
|
|
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
|
encoded, err := store.encode(sessionPayload{Subject: "a", Method: "oidc", ExpiresAt: time.Now().Add(time.Hour).Unix()})
|
|
if err != nil {
|
|
t.Fatalf("encode: %v", err)
|
|
}
|
|
payload, err := store.decode(encoded)
|
|
if err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(payload.Roles) != 0 {
|
|
t.Errorf("payload roles = %v, want none", payload.Roles)
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- oidc state --
|
|
|
|
func TestOAuthStateRoundTrip(t *testing.T) {
|
|
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
|
|
|
|
rec := httptest.NewRecorder()
|
|
if err := state.Put(rec, "verifier-value", "/return", "nonce-value", "state-value"); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
|
|
for _, c := range rec.Result().Cookies() {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
tx, err := state.Get(req)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if tx.Verifier != "verifier-value" || tx.ReturnTo != "/return" ||
|
|
tx.Nonce != "nonce-value" || tx.State != "state-value" {
|
|
t.Errorf("round trip lost data: %+v", tx)
|
|
}
|
|
}
|
|
|
|
func TestOAuthStateRejectsTampering(t *testing.T) {
|
|
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
|
|
|
|
rec := httptest.NewRecorder()
|
|
if err := state.Put(rec, "verifier", "/", "nonce", "state"); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
var issued *http.Cookie
|
|
for _, c := range rec.Result().Cookies() {
|
|
issued = c
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
|
|
req.AddCookie(&http.Cookie{Name: issued.Name, Value: base64RawURL(`{"v":"attacker","r":"/evil","n":"x","iat":1}`) + "." + strings.Split(issued.Value, ".")[1]})
|
|
|
|
if _, err := state.Get(req); err == nil {
|
|
t.Fatal("a tampered state cookie must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestOAuthStateExpires(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
|
|
|
|
rec := httptest.NewRecorder()
|
|
if err := state.Put(rec, "verifier", "/", "nonce", "state"); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
|
|
for _, c := range rec.Result().Cookies() {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
// The StateStore uses time.Now directly; simulate staleness by minting an
|
|
// old state value through the exported encoder path.
|
|
stale := oauthState{Verifier: "v", ReturnTo: "/", IssuedAt: now.Add(-time.Hour).Unix()}
|
|
body := base64RawURL(`{"v":"v","r":"/","n":"","iat":` + itoa(stale.IssuedAt) + `}`)
|
|
mac := hmacSHA256([]byte(testSecret), []byte(body))
|
|
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
|
|
req2.AddCookie(&http.Cookie{Name: "bananas-oauth", Value: body + "." + base64RawURLBytes(mac[:])})
|
|
|
|
if _, err := state.Get(req2); err == nil {
|
|
t.Fatal("an expired state cookie must be rejected")
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------- sanitising URLs --
|
|
|
|
func TestSafeReturnToBlocksOpenRedirects(t *testing.T) {
|
|
dangerous := []string{
|
|
"https://evil.example.com/steal",
|
|
"//evil.example.com/steal",
|
|
"/\\evil.example.com",
|
|
"javascript:alert(1)",
|
|
"admin",
|
|
"",
|
|
}
|
|
for _, input := range dangerous {
|
|
got := SafeReturnTo(input)
|
|
if got != "/" {
|
|
t.Errorf("SafeReturnTo(%q) = %q, want /", input, got)
|
|
}
|
|
}
|
|
|
|
safe := map[string]string{
|
|
"/admin": "/admin",
|
|
"/admin/themes?x=1": "/admin/themes?x=1",
|
|
"/": "/",
|
|
}
|
|
for input, want := range safe {
|
|
if got := SafeReturnTo(input); got != want {
|
|
t.Errorf("SafeReturnTo(%q) = %q, want %q", input, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoginURLIsNotAnOpenRedirect(t *testing.T) {
|
|
// The redirect URI must always be one the provider has registered.
|
|
cfg := config.Defaults()
|
|
cfg.PublicBaseURL = "https://themes.example.com"
|
|
if got := cfg.RedirectURL(); got != "https://themes.example.com/auth/callback" {
|
|
t.Errorf("RedirectURL = %q", got)
|
|
}
|
|
|
|
cfg.BasePath = "/bananas"
|
|
if got := cfg.RedirectURL(); got != "https://themes.example.com/bananas/auth/callback" {
|
|
t.Errorf("RedirectURL with base path = %q", got)
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------- authenticator --
|
|
|
|
func TestDevBypassGrantsConfiguredRole(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.AuthMode = config.AuthModeDev
|
|
cfg.Environment = "development"
|
|
cfg.DevFallbackRole = "viewer"
|
|
cfg.DevUsers = map[string]string{"boss@example.com": "admin"}
|
|
|
|
a := newTestAuthenticator(t, cfg, nil)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes", nil)
|
|
identity, err := a.Authenticate(req)
|
|
if err != nil {
|
|
t.Fatalf("Authenticate: %v", err)
|
|
}
|
|
if identity.HighestRole() != domain.RoleViewer {
|
|
t.Errorf("role = %v, want viewer", identity.Roles)
|
|
}
|
|
|
|
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes?dev-user=boss@example.com", nil)
|
|
identity, err = a.Authenticate(req)
|
|
if err != nil {
|
|
t.Fatalf("Authenticate: %v", err)
|
|
}
|
|
if identity.HighestRole() != domain.RoleAdmin {
|
|
t.Errorf("role = %v, want admin", identity.Roles)
|
|
}
|
|
}
|
|
|
|
func TestDevBypassRefusedOutsideDevelopment(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.AuthMode = config.AuthModeDev
|
|
cfg.Environment = "production"
|
|
cfg.SessionSecret = testSecret
|
|
|
|
if _, err := New(cfg, nil, nil); err == nil {
|
|
t.Fatal("the development bypass must not be constructible in production")
|
|
}
|
|
}
|
|
|
|
func TestAuthNoneRefusedOutsideDevelopment(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.AuthMode = config.AuthModeNone
|
|
cfg.Environment = "production"
|
|
cfg.SessionSecret = testSecret
|
|
|
|
if _, err := New(cfg, nil, nil); err == nil {
|
|
t.Fatal("AUTH_MODE=none must not be constructible in production")
|
|
}
|
|
}
|
|
|
|
func TestUnauthenticatedRequestIsRejected(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.AuthMode = config.AuthModeOIDC
|
|
cfg.Environment = "test"
|
|
a := newTestAuthenticator(t, cfg, nil)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes", nil)
|
|
if _, err := a.Authenticate(req); err == nil {
|
|
t.Fatal("expected an unauthenticated request to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestRoleMappingFailsClosed(t *testing.T) {
|
|
// A user in no configured group gets viewer, never more.
|
|
p := &OIDCProvider{config: config.Config{
|
|
OIDCAdminGroups: []string{"platform-admins"},
|
|
OIDCEditorGroups: []string{"designers"},
|
|
}}
|
|
|
|
if got := p.rolesFor([]string{"some-other-group"}, nil); got[0] != domain.RoleViewer {
|
|
t.Errorf("roles = %v, want viewer", got)
|
|
}
|
|
if got := p.rolesFor([]string{"designers"}, nil); got[0] != domain.RoleEditor {
|
|
t.Errorf("roles = %v, want editor", got)
|
|
}
|
|
if got := p.rolesFor([]string{"designers", "platform-admins"}, nil); got[0] != domain.RoleAdmin {
|
|
t.Errorf("roles = %v, want admin; the most privileged group must win", got)
|
|
}
|
|
if got := p.rolesFor(nil, []string{"platform-admins"}); got[0] != domain.RoleAdmin {
|
|
t.Errorf("roles = %v, want admin from realm roles", got)
|
|
}
|
|
}
|
|
|
|
func TestRoleMappingIsCaseInsensitive(t *testing.T) {
|
|
p := &OIDCProvider{config: config.Config{OIDCAdminGroups: []string{"Platform-Admins"}}}
|
|
if got := p.rolesFor([]string{"PLATFORM-ADMINS"}, nil); got[0] != domain.RoleAdmin {
|
|
t.Errorf("roles = %v, want admin", got)
|
|
}
|
|
}
|
|
|
|
func TestCodeChallengeIsS256(t *testing.T) {
|
|
// RFC 7636 test vector: verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
// must produce challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".
|
|
got := codeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
|
if got != "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" {
|
|
t.Errorf("codeChallenge = %q, want the RFC 7636 value", got)
|
|
}
|
|
}
|
|
|
|
func TestInsecureTLSConfigIsUsedOnlyWhenRequested(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.OIDCSkipTLSVerify = true
|
|
if client := newProviderHTTPClient(cfg); client == nil {
|
|
t.Fatal("expected a client")
|
|
}
|
|
if tlsCfg := newProviderHTTPClient(cfg).Transport.(*http.Transport).TLSClientConfig; tlsCfg == nil || !tlsCfg.InsecureSkipVerify {
|
|
t.Error("expected TLS verification to be skipped when configured")
|
|
}
|
|
|
|
cfg.OIDCSkipTLSVerify = false
|
|
if tlsCfg := newProviderHTTPClient(cfg).Transport.(*http.Transport).TLSClientConfig; tlsCfg != nil && tlsCfg.InsecureSkipVerify {
|
|
t.Error("TLS verification must be on by default")
|
|
}
|
|
var _ *tls.Config
|
|
}
|
|
|
|
func TestProviderHTTPClientHasTimeout(t *testing.T) {
|
|
// An unresponsive identity provider must not be able to hang a request
|
|
// indefinitely.
|
|
client := newProviderHTTPClient(config.Defaults())
|
|
if client.Timeout == 0 {
|
|
t.Error("provider HTTP client must have a timeout")
|
|
}
|
|
}
|
|
|
|
func TestURLQueryEscape(t *testing.T) {
|
|
if got := urlQueryEscape("https://x.example/y?a=1&b=2"); got != "https%3A%2F%2Fx.example%2Fy%3Fa%3D1%26b%3D2" {
|
|
t.Errorf("urlQueryEscape = %q", got)
|
|
}
|
|
if _, err := url.Parse("https://x.example"); err != nil {
|
|
t.Fatalf("unexpected: %v", err)
|
|
}
|
|
}
|