initial commit
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

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

417
internal/auth/auth_test.go Normal file
View file

@ -0,0 +1,417 @@
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)
}
}