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
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:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
417
internal/auth/auth_test.go
Normal file
417
internal/auth/auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue