Public source validation / validate (push) Failing after 3m8s
228 lines
7.7 KiB
Go
228 lines
7.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
func TestBeginAuthorizationUsesStateNonceAndPKCE(t *testing.T) {
|
|
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
flow, err := BeginAuthorization(oauth2.Endpoint{AuthURL: "https://auth.example/authorize"}, OIDCConfig{ClientID: "pulse", RedirectURL: "https://pulse.example/callback"}, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parsed, err := url.Parse(flow.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
query := parsed.Query()
|
|
for _, key := range []string{"state", "nonce", "code_challenge", "code_challenge_method"} {
|
|
if query.Get(key) == "" {
|
|
t.Fatalf("authorization URL missing %s", key)
|
|
}
|
|
}
|
|
if query.Get("state") != flow.State || query.Get("nonce") != flow.Nonce || query.Get("code_challenge_method") != "S256" {
|
|
t.Fatalf("authorization URL does not match flow: %s", flow.URL)
|
|
}
|
|
if query.Get("code_challenge") != pkceChallenge(flow.CodeVerifier) {
|
|
t.Fatal("authorization URL has incorrect PKCE challenge")
|
|
}
|
|
}
|
|
|
|
func TestValidateCallbackRejectsStateNonceFlowAbuse(t *testing.T) {
|
|
now := time.Now()
|
|
flow := Authorization{State: "expected", Nonce: "nonce", CodeVerifier: "verifier", ExpiresAt: now.Add(time.Minute)}
|
|
if err := ValidateCallback(flow, "wrong", "code", now); err == nil {
|
|
t.Fatal("wrong state was accepted")
|
|
}
|
|
if err := ValidateCallback(flow, flow.State, "", now); err == nil {
|
|
t.Fatal("empty code was accepted")
|
|
}
|
|
flow.ExpiresAt = now.Add(-time.Second)
|
|
if err := ValidateCallback(flow, flow.State, "code", now); err == nil {
|
|
t.Fatal("expired flow was accepted")
|
|
}
|
|
}
|
|
|
|
func TestRoleMappingAndAuthorizationMatrix(t *testing.T) {
|
|
mapping := map[string]Role{"pulse-view": RoleViewer, "pulse-operator": RoleOperator, "pulse-admin": RoleAdministrator}
|
|
role, err := MapRoles([]string{"unrelated", "pulse-operator", "pulse-view"}, mapping)
|
|
if err != nil || role != RoleOperator {
|
|
t.Fatalf("role mapping = %q, %v", role, err)
|
|
}
|
|
if _, err := MapRoles([]string{"unrelated"}, mapping); err == nil {
|
|
t.Fatal("unmapped claims were authorized")
|
|
}
|
|
for _, test := range []struct {
|
|
role Role
|
|
permission Permission
|
|
allowed bool
|
|
}{
|
|
{RoleViewer, PermissionView, true}, {RoleViewer, PermissionEdit, false},
|
|
{RoleOperator, PermissionOperate, true}, {RoleOperator, PermissionAdmin, false},
|
|
{RoleEditor, PermissionEdit, true}, {RoleEditor, PermissionAdmin, false},
|
|
{RoleAdministrator, PermissionAdmin, true},
|
|
} {
|
|
if got := Allows(test.role, test.permission); got != test.allowed {
|
|
t.Errorf("Allows(%s, %s) = %v, want %v", test.role, test.permission, got, test.allowed)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnauthorizedPathsAreDenied(t *testing.T) {
|
|
handler := Require(PermissionEdit, http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusNoContent) }))
|
|
for _, test := range []struct {
|
|
name string
|
|
ctx context.Context
|
|
status int
|
|
}{
|
|
{"anonymous", context.Background(), http.StatusUnauthorized},
|
|
{"viewer", WithPrincipal(context.Background(), Principal{Subject: "user-1", Role: RoleViewer}), http.StatusForbidden},
|
|
{"editor", WithPrincipal(context.Background(), Principal{Subject: "user-1", Role: RoleEditor}), http.StatusNoContent},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
request := httptest.NewRequest(http.MethodGet, "/protected", nil).WithContext(test.ctx)
|
|
response := httptest.NewRecorder()
|
|
handler.ServeHTTP(response, request)
|
|
if response.Code != test.status {
|
|
t.Fatalf("status = %d, want %d", response.Code, test.status)
|
|
}
|
|
if test.status == http.StatusUnauthorized || test.status == http.StatusForbidden {
|
|
if response.Header().Get("Cache-Control") != "private, no-store" {
|
|
t.Fatalf("cache control = %q", response.Header().Get("Cache-Control"))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBreakGlassIsDisabledByDefault(t *testing.T) {
|
|
if (BreakGlassPolicy{}).Allows() {
|
|
t.Fatal("break-glass unexpectedly enabled")
|
|
}
|
|
}
|
|
|
|
func TestDiscoverResolvesEndpointAndVerifier(t *testing.T) {
|
|
var issuer string
|
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
if request.URL.Path != "/.well-known/openid-configuration" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
response.Header().Set("Content-Type", "application/json")
|
|
_, _ = response.Write([]byte(`{"issuer":"` + issuer + `","authorization_endpoint":"` + issuer + `/authorize","token_endpoint":"` + issuer + `/token","jwks_uri":"` + issuer + `/jwks","id_token_signing_alg_values_supported":["RS256"]}`))
|
|
}))
|
|
defer server.Close()
|
|
issuer = server.URL
|
|
|
|
discovery, err := Discover(context.Background(), OIDCConfig{Issuer: issuer, ClientID: "pulse"})
|
|
if err != nil {
|
|
t.Fatalf("Discover: %v", err)
|
|
}
|
|
if discovery.Endpoint.AuthURL != issuer+"/authorize" || discovery.Endpoint.TokenURL != issuer+"/token" || discovery.Verifier == nil {
|
|
t.Fatalf("discovery = %#v", discovery.Endpoint)
|
|
}
|
|
if _, err := NewVerifier(context.Background(), OIDCConfig{Issuer: issuer, ClientID: "pulse"}); err != nil {
|
|
t.Fatalf("NewVerifier: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDiscoverRejectsIncompleteOrUnreachableIssuer(t *testing.T) {
|
|
unreachable := httptest.NewServer(http.NewServeMux())
|
|
unreachable.Close()
|
|
for _, test := range []struct {
|
|
name string
|
|
config OIDCConfig
|
|
}{
|
|
{"missing issuer", OIDCConfig{ClientID: "pulse"}},
|
|
{"missing client id", OIDCConfig{Issuer: "https://idp.example"}},
|
|
{"unreachable issuer", OIDCConfig{Issuer: unreachable.URL, ClientID: "pulse"}},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
discovery, err := Discover(context.Background(), test.config)
|
|
if err == nil {
|
|
t.Fatal("incomplete configuration was accepted")
|
|
}
|
|
if discovery.Verifier != nil {
|
|
t.Fatal("a verifier was returned with an error")
|
|
}
|
|
if strings.Contains(err.Error(), test.config.Issuer) && test.config.Issuer != "" {
|
|
t.Fatalf("error leaks the issuer: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractIdentityRequiresToken(t *testing.T) {
|
|
if _, err := ExtractIdentity(nil, "groups"); err == nil {
|
|
t.Fatal("nil token was accepted")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeGroupClaimBoundsAndShapes(t *testing.T) {
|
|
many, err := json.Marshal(make([]string, maxIdentityGroups+50))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, test := range []struct {
|
|
name string
|
|
raw string
|
|
want []string
|
|
wantErr bool
|
|
}{
|
|
{name: "list", raw: `["pulse-admin"," pulse-view ",""]`, want: []string{"pulse-admin", "pulse-view"}},
|
|
{name: "single string", raw: `"pulse-admin"`, want: []string{"pulse-admin"}},
|
|
{name: "empty list", raw: `[]`, want: []string{}},
|
|
{name: "object", raw: `{"groups":["pulse-admin"]}`, wantErr: true},
|
|
{name: "number", raw: `7`, wantErr: true},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
groups, err := normalizeGroupClaim(json.RawMessage(test.raw))
|
|
if (err != nil) != test.wantErr {
|
|
t.Fatalf("err = %v, wantErr = %v", err, test.wantErr)
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
if len(groups) != len(test.want) {
|
|
t.Fatalf("groups = %#v, want %#v", groups, test.want)
|
|
}
|
|
for index, value := range test.want {
|
|
if groups[index] != value {
|
|
t.Fatalf("groups = %#v, want %#v", groups, test.want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
bounded, err := normalizeGroupClaim(many)
|
|
if err != nil {
|
|
t.Fatalf("normalizeGroupClaim: %v", err)
|
|
}
|
|
if len(bounded) != 0 {
|
|
t.Fatalf("blank group values were kept: %d", len(bounded))
|
|
}
|
|
filled := make([]string, maxIdentityGroups+50)
|
|
for index := range filled {
|
|
filled[index] = "group"
|
|
}
|
|
encoded, err := json.Marshal(filled)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
capped, err := normalizeGroupClaim(encoded)
|
|
if err != nil {
|
|
t.Fatalf("normalizeGroupClaim: %v", err)
|
|
}
|
|
if len(capped) != maxIdentityGroups {
|
|
t.Fatalf("groups = %d, want %d", len(capped), maxIdentityGroups)
|
|
}
|
|
}
|