From d8a11738e2abd90e1c2f64315584b2162240d6ae Mon Sep 17 00:00:00 2001 From: Chai Bot Date: Thu, 1 Oct 2026 09:50:19 +0000 Subject: [PATCH] Add comprehensive unit tests for OIDC checkAuth method Create authentication/oidc_test.go with table-driven tests covering 10 scenarios: both claims present, username-only (PR #61 behavior), group-only (PR #60 behavior), neither claim, empty username, wrong type username, group as string, group as []interface{}, no claims configured, and non-string group elements. Uses a test OIDC server with signed JWTs via go-jose/v4. Co-Authored-By: Claude Opus 4.6 --- authentication/oidc_test.go | 313 ++++++++++++++++++++++++++++++++++++ go.mod | 2 +- 2 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 authentication/oidc_test.go diff --git a/authentication/oidc_test.go b/authentication/oidc_test.go new file mode 100644 index 000000000..f2971f082 --- /dev/null +++ b/authentication/oidc_test.go @@ -0,0 +1,313 @@ +package authentication + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-jose/go-jose/v4" + "github.com/go-kit/log" +) + +// testOIDCEnv holds a test OIDC server, signing key, and helpers for +// building signed JWT tokens that the go-oidc verifier will accept. +type testOIDCEnv struct { + server *httptest.Server + issuerURL string + privateKey *rsa.PrivateKey +} + +// newTestOIDCEnv starts an httptest server that serves OIDC discovery and JWKS +// endpoints backed by a freshly-generated RSA key pair. +func newTestOIDCEnv(t *testing.T) *testOIDCEnv { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate RSA key: %v", err) + } + + jwks := jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{{ + Key: &key.PublicKey, + KeyID: "test-key", + Algorithm: string(jose.RS256), + Use: "sig", + }}, + } + + env := &testOIDCEnv{privateKey: key} + + env.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": env.issuerURL, + "jwks_uri": env.issuerURL + "/keys", + "authorization_endpoint": env.issuerURL + "/auth", + "token_endpoint": env.issuerURL + "/token", + }) + case "/keys": + _ = json.NewEncoder(w).Encode(jwks) + default: + http.NotFound(w, r) + } + })) + env.issuerURL = env.server.URL + + return env +} + +// newAuthenticator creates an oidcAuthenticator wired to the test OIDC server +// with the given username/group claim configuration. +func (env *testOIDCEnv) newAuthenticator(t *testing.T, usernameClaim, groupClaim string) *oidcAuthenticator { + t.Helper() + + ctx := oidc.ClientContext(context.Background(), env.server.Client()) + + provider, err := oidc.NewProvider(ctx, env.issuerURL) + if err != nil { + t.Fatalf("create OIDC provider: %v", err) + } + + verifier := provider.Verifier(&oidc.Config{ + ClientID: "test-client", + SkipClientIDCheck: true, + }) + + return &oidcAuthenticator{ + tenant: "test-tenant", + logger: log.NewNopLogger(), + config: oidcConfig{ + ClientID: "test-client", + IssuerURL: env.issuerURL, + UsernameClaim: usernameClaim, + GroupClaim: groupClaim, + }, + provider: provider, + verifier: verifier, + client: env.server.Client(), + } +} + +// signToken creates a signed JWT containing the supplied claims plus +// sensible defaults for the standard OIDC fields (iss, sub, aud, exp, iat). +func (env *testOIDCEnv) signToken(t *testing.T, extraClaims map[string]interface{}) string { + t.Helper() + + claims := map[string]interface{}{ + "iss": env.issuerURL, + "sub": "test-subject", + "aud": "test-client", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + } + + for k, v := range extraClaims { + claims[k] = v + } + + payload, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + + signer, err := jose.NewSigner( + jose.SigningKey{ + Algorithm: jose.RS256, + Key: jose.JSONWebKey{Key: env.privateKey, KeyID: "test-key"}, + }, + (&jose.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + t.Fatalf("create signer: %v", err) + } + + jws, err := signer.Sign(payload) + if err != nil { + t.Fatalf("sign token: %v", err) + } + + token, err := jws.CompactSerialize() + if err != nil { + t.Fatalf("serialize token: %v", err) + } + + return token +} + +func TestCheckAuth(t *testing.T) { + env := newTestOIDCEnv(t) + defer env.server.Close() + + tests := []struct { + name string + usernameClaim string + groupClaim string + extraClaims map[string]interface{} + wantCode int + wantSubject string + wantGroups []string + }{ + { + name: "both username and group claims present", + usernameClaim: "preferred_username", + groupClaim: "groups", + extraClaims: map[string]interface{}{ + "preferred_username": "testuser", + "groups": []interface{}{"group1", "group2"}, + }, + wantCode: http.StatusOK, + wantSubject: "testuser", + wantGroups: []string{"group1", "group2"}, + }, + { + name: "username present group claim missing proceeds with username", + usernameClaim: "preferred_username", + groupClaim: "groups", + extraClaims: map[string]interface{}{ + "preferred_username": "testuser", + // groups claim intentionally absent + }, + wantCode: http.StatusOK, + wantSubject: "testuser", + }, + { + name: "group present username claim missing proceeds with group", + usernameClaim: "preferred_username", + groupClaim: "groups", + extraClaims: map[string]interface{}{ + // username claim intentionally absent + "groups": []interface{}{"operators"}, + }, + wantCode: http.StatusOK, + wantSubject: "test-subject", // falls back to token subject + wantGroups: []string{"operators"}, + }, + { + name: "neither claim present returns 400", + usernameClaim: "preferred_username", + groupClaim: "groups", + extraClaims: map[string]interface{}{}, + wantCode: http.StatusBadRequest, + }, + { + name: "username claim configured but empty string value returns 400", + usernameClaim: "preferred_username", + groupClaim: "groups", + extraClaims: map[string]interface{}{ + "preferred_username": "", + "groups": []interface{}{"group1"}, + }, + wantCode: http.StatusBadRequest, + }, + { + name: "username claim configured wrong type returns 400", + usernameClaim: "preferred_username", + groupClaim: "groups", + extraClaims: map[string]interface{}{ + "preferred_username": 12345, + "groups": []interface{}{"group1"}, + }, + wantCode: http.StatusBadRequest, + }, + { + name: "group claim as string extracts single group", + usernameClaim: "preferred_username", + groupClaim: "groups", + extraClaims: map[string]interface{}{ + "preferred_username": "testuser", + "groups": "single-group", + }, + wantCode: http.StatusOK, + wantSubject: "testuser", + wantGroups: []string{"single-group"}, + }, + { + name: "group claim as slice of strings extracts multiple groups", + usernameClaim: "preferred_username", + groupClaim: "groups", + extraClaims: map[string]interface{}{ + "preferred_username": "testuser", + "groups": []interface{}{"alpha", "beta", "gamma"}, + }, + wantCode: http.StatusOK, + wantSubject: "testuser", + wantGroups: []string{"alpha", "beta", "gamma"}, + }, + { + name: "no username or group claims configured uses token subject", + usernameClaim: "", + groupClaim: "", + extraClaims: map[string]interface{}{}, + wantCode: http.StatusOK, + wantSubject: "test-subject", + }, + { + name: "group claim with non-string elements converted via Sprintf", + usernameClaim: "preferred_username", + groupClaim: "groups", + extraClaims: map[string]interface{}{ + "preferred_username": "testuser", + "groups": []interface{}{42, true}, + }, + wantCode: http.StatusOK, + wantSubject: "testuser", + wantGroups: []string{"42", "true"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth := env.newAuthenticator(t, tt.usernameClaim, tt.groupClaim) + token := env.signToken(t, tt.extraClaims) + + ctx, _, httpCode, _ := auth.checkAuth(context.Background(), token) + + if httpCode != tt.wantCode { + t.Fatalf("HTTP status = %d, want %d", httpCode, tt.wantCode) + } + + if tt.wantCode != http.StatusOK { + return + } + + if tt.wantSubject != "" { + gotSubject, ok := GetSubject(ctx) + if !ok { + t.Fatal("expected subject in context, got none") + } + + if gotSubject != tt.wantSubject { + t.Errorf("subject = %q, want %q", gotSubject, tt.wantSubject) + } + } + + if tt.wantGroups != nil { + gotGroups, ok := GetGroups(ctx) + if !ok { + t.Fatal("expected groups in context, got none") + } + + if len(gotGroups) != len(tt.wantGroups) { + t.Fatalf("got %d groups %v, want %d groups %v", + len(gotGroups), gotGroups, len(tt.wantGroups), tt.wantGroups) + } + + for i, g := range gotGroups { + if g != tt.wantGroups[i] { + t.Errorf("groups[%d] = %q, want %q", i, g, tt.wantGroups[i]) + } + } + } + }) + } +} diff --git a/go.mod b/go.mod index 5b811aabb..efa203c9f 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/ghodss/yaml v1.0.0 github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/httprate v0.14.1 + github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-kit/log v0.2.1 github.com/go-openapi/runtime v0.32.6 github.com/go-openapi/strfmt v0.27.0 @@ -67,7 +68,6 @@ require ( github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-ini/ini v1.67.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect