Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
313 changes: 313 additions & 0 deletions authentication/oidc_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
})
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading