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) } }