Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
618edbf
feat: implement access token verification with Entra SDK container
Apr 14, 2026
198b390
feat: add Azure Entra SDK sidecar container support to installer
Apr 15, 2026
ed3d8b1
Fix E2E tests
Apr 15, 2026
26780b8
Pass custom guard image for E2E tests
Apr 16, 2026
d5a2435
Add local E2E testing script
Apr 16, 2026
4a08ae8
Add E2E installer test for Azure Entra SDK scenario
Apr 20, 2026
d60c035
Fix missing option in azure installer configuration
Apr 20, 2026
425634e
Send the Host header to Entra SDK when validating tokens
Apr 20, 2026
453c5e9
Ignore E2E test report files
Apr 20, 2026
e66af12
Add E2E test covering Azure Entra SDK token validation
Apr 20, 2026
aa2751d
Add E2E test covering Azure Entra SDK PoP token validation
Apr 20, 2026
c1ee28a
Document E2E local test runner and new Entra SDK tests
Apr 21, 2026
c7f40ab
Run make gen fmt
Apr 21, 2026
91146a7
Run go mod tidy
Apr 21, 2026
8d86cb3
Add missing license header
Apr 21, 2026
9efecea
Fix lint errors
Apr 21, 2026
10d199a
Merge branch 'master' into markdrobnak/entra-sdk-support
Apr 21, 2026
49d547e
Reduce some test code duplication
Apr 21, 2026
719700f
Support network proxy when installing with Entra SDK container
Apr 21, 2026
d83aa75
Fix lint errors
Apr 21, 2026
f2680da
Simplify cert init container script
Apr 23, 2026
f8d89b8
Fix test after recent change
Apr 23, 2026
3e77ed0
Merge branch 'master' into markdrobnak/entra-sdk-support
weinong May 6, 2026
8a29a11
PR comment suggested changes
May 7, 2026
f893c37
PR comment suggested changes
May 8, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ _testmain.go
/.idea
/dist
**/junit.xml
**/report.json
**/.env
/.vscode
/coverage.txt
Expand Down
4 changes: 1 addition & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,9 @@ e2e-tests: $(BUILD_DIRS)
ARCH=$(ARCH) \
OS=$(OS) \
VERSION=$(VERSION) \
DOCKER_REGISTRY=$(REGISTRY) \
TAG=$(TAG) \
KUBECONFIG=$${KUBECONFIG#$(HOME)} \
GINKGO_ARGS='$(GINKGO_ARGS)' \
TEST_ARGS='$(TEST_ARGS) --image-tag=$(TAG)' \
TEST_ARGS='$(TEST_ARGS)' \
./hack/e2e.sh \
"

Expand Down
62 changes: 38 additions & 24 deletions auth/providers/azure/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ type claims map[string]interface{}
type Authenticator struct {
Options
graphClient *graph.UserInfo
verifier *oidc.IDTokenVerifier
verifier AccessTokenVerifier
popTokenVerifier *PoPTokenVerifier
}

Expand Down Expand Up @@ -152,12 +152,11 @@ func New(ctx context.Context, opts Options) (auth.Interface, error) {

klog.V(3).Infof("Using issuer url: %v", cachedAuthInfo.Issuer)

provider, err := getOIDCIssuerProvider(cachedAuthInfo.Issuer, c.HttpClientRetryCount)
var err error
c.verifier, err = newAccessTokenVerifier(cachedAuthInfo.Issuer, opts)
if err != nil {
return nil, errors.Wrap(err, "failed to create provider for azure")
return nil, err
}

c.verifier = provider.Verifier(&oidc.Config{SkipClientIDCheck: !opts.VerifyClientID, ClientID: opts.ClientID})
if opts.EnablePOP {
c.popTokenVerifier = NewPoPVerifier(c.POPTokenHostname, c.PoPTokenValidityDuration)
}
Expand All @@ -178,6 +177,21 @@ func New(ctx context.Context, opts Options) (auth.Interface, error) {
return c, nil
}

func newAccessTokenVerifier(issuerURL string, opts Options) (AccessTokenVerifier, error) {
if opts.EntraSDKURL != "" {
return newEntraSDKTokenVerifier(opts.EntraSDKURL, opts.ClientID, opts.VerifyClientID, opts.HttpClientRetryCount)
}

provider, err := getOIDCIssuerProvider(issuerURL, opts.HttpClientRetryCount)
if err != nil {
return nil, errors.Wrap(err, "failed to create provider for azure")
}

return &OIDCAccessTokenVerifier{
verifier: provider.Verifier(&oidc.Config{SkipClientIDCheck: !opts.VerifyClientID, ClientID: opts.ClientID}),
}, nil
}

type metadataJSON struct {
Issuer string `json:"issuer"`
MsgraphHost string `json:"msgraph_host"`
Expand Down Expand Up @@ -233,7 +247,7 @@ func (s Authenticator) Check(ctx context.Context, token string) (*authv1.UserInf
}

ctx = azureutils.WithRetryableHttpClient(ctx, s.HttpClientRetryCount)
idToken, err := s.verifier.Verify(ctx, token)
verifiedToken, err := s.verifier.Verify(ctx, token)
if err != nil {
if klog.V(7).Enabled() {
if claims, err := extractTokenClaims(token); err == nil {
Expand All @@ -243,7 +257,7 @@ func (s Authenticator) Check(ctx context.Context, token string) (*authv1.UserInf
return nil, errors.Wrap(err, "failed to verify token for azure")
}

claims, err := getClaims(idToken)
claims, err := verifiedToken.Claims()
if err != nil {
return nil, errors.Wrap(err, "error parsing claims")
}
Expand Down Expand Up @@ -385,16 +399,6 @@ func marshalGenericTo(src interface{}, dst interface{}) error {
return json.Unmarshal(b, dst)
}

// GetClaims returns a Claims object
func getClaims(token *oidc.IDToken) (claims, error) {
c := claims{}
err := token.Claims(&c)
if err != nil {
return nil, fmt.Errorf("error unmarshalling claims: %s", err)
}
return c, nil
}

// ReviewFromClaims creates a new TokenReview object from the claims object
// the claims object
func (c claims) getUserInfo(usernameClaim, userObjectIDClaim string) (*authv1.UserInfo, error) {
Expand Down Expand Up @@ -434,13 +438,9 @@ func (c claims) string(key string) (string, error) {
type getMetadataFunc = func(context.Context, string, string, int) (*metadataJSON, error)

func getAuthInfo(ctx context.Context, environment, tenantID string, retryCount int, getMetadata getMetadataFunc) (*authInfo, error) {
var err error
env := azure.PublicCloud
if environment != "" {
env, err = azure.EnvironmentFromName(environment)
if err != nil {
return nil, errors.Wrap(err, "failed to parse environment for azure")
}
env, err := resolveAzureEnvironment(environment)
if err != nil {
return nil, errors.Wrap(err, "failed to parse environment for azure")
}

metadata, err := getMetadata(ctx, env.ActiveDirectoryEndpoint, tenantID, retryCount)
Expand All @@ -460,6 +460,20 @@ func getAuthInfo(ctx context.Context, environment, tenantID string, retryCount i
}, nil
}

func resolveAzureEnvironment(environment string) (azure.Environment, error) {
env := azure.PublicCloud
if environment == "" {
return env, nil
}

resolved, err := azure.EnvironmentFromName(environment)
if err != nil {
return azure.Environment{}, err
}

return resolved, nil
}

func extractTokenClaims(rawToken string) (string, error) {
token, _, err := new(jwt.Parser).ParseUnverified(rawToken, jwt.MapClaims{})
if err != nil {
Expand Down
178 changes: 176 additions & 2 deletions auth/providers/azure/azure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
"net"
"net/http"
Expand Down Expand Up @@ -105,6 +106,12 @@ func newRSAKey() (*signingKey, error) {
return &signingKey{"", priv, priv.Public(), jose.RS256}, nil
}

func resetOIDCIssuerProviderCache() {
cachedOIDCIssuerProvidersMutex.Lock()
defer cachedOIDCIssuerProvidersMutex.Unlock()
cachedOIDCIssuerProvider = nil
}

func clientSetup(clientID, clientSecret, tenantID, serverUrl string, useGroupUID, verifyClientID bool, authMode string) (*Authenticator, error) {
c := &Authenticator{
Options: Options{
Expand All @@ -127,11 +134,11 @@ func clientSetup(clientID, clientSecret, tenantID, serverUrl string, useGroupUID
return nil, fmt.Errorf("failed to create provider for azure. Reason: %v", err)
}

c.verifier = p.Verifier(&oidc.Config{
c.verifier = &OIDCAccessTokenVerifier{verifier: p.Verifier(&oidc.Config{
SkipClientIDCheck: !verifyClientID,
SkipExpiryCheck: true,
ClientID: clientID,
})
})}

c.graphClient, err = graph.TestUserInfo(clientID, clientSecret, serverUrl+"/login", serverUrl+"/api", useGroupUID)
if err != nil {
Expand All @@ -141,6 +148,33 @@ func clientSetup(clientID, clientSecret, tenantID, serverUrl string, useGroupUID
return c, nil
}

func entraClientSetup(clientID, clientSecret, tenantID, graphServerURL, sdkURL string, useGroupUID, verifyClientID bool) (*Authenticator, error) {
c := &Authenticator{
Options: Options{
ClientID: clientID,
ClientSecret: clientSecret,
TenantID: tenantID,
UseGroupUID: useGroupUID,
VerifyClientID: verifyClientID,
EntraSDKURL: sdkURL,
AzureRegion: "eastus",
HttpClientRetryCount: httpClientRetryCount,
},
}
verifier, err := newEntraSDKTokenVerifier(sdkURL, clientID, verifyClientID, httpClientRetryCount)
if err != nil {
return nil, err
}
c.verifier = verifier

c.graphClient, err = graph.TestUserInfo(clientID, clientSecret, graphServerURL+"/login", graphServerURL+"/api", useGroupUID)
if err != nil {
return nil, err
}

return c, nil
}

func serverSetup(loginResp string, loginStatus int, jwkResp, groupIds, groupList []byte, groupStatus ...int) (*httptest.Server, error) {
listener, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
Expand Down Expand Up @@ -688,6 +722,146 @@ func TestGetMetadata(t *testing.T) {
})
}

func TestNewAccessTokenVerifier(t *testing.T) {
t.Run("selects Entra SDK verifier when URL is provided", func(t *testing.T) {
verifier, err := newAccessTokenVerifier("https://issuer.example.com", Options{EntraSDKURL: "http://localhost:8080"})
assert.NoError(t, err)
assert.IsType(t, &EntraSDKTokenVerifier{}, verifier)
})

t.Run("returns error when Entra SDK URL includes a non-root path", func(t *testing.T) {
verifier, err := newAccessTokenVerifier("https://issuer.example.com", Options{EntraSDKURL: "http://localhost:8080/Validate"})
assert.Nil(t, verifier)
assert.EqualError(t, err, "Entra SDK endpoint must be a base URL")
})

t.Run("selects OIDC verifier when Entra SDK URL is not provided", func(t *testing.T) {
resetOIDCIssuerProviderCache()
defer resetOIDCIssuerProviderCache()

signKey, err := newRSAKey()
if err != nil {
t.Fatalf("Error when creating signing key: %v", err)
}
jwkResp, err := json.Marshal(signKey.jwk())
if err != nil {
t.Fatalf("Error when generating JWK response: %v", err)
}
groupIDs, groupList := getGroupsAndIds(t, 1)
srv, err := serverSetup(fmt.Sprintf(loginResp, "graph-access-token"), http.StatusOK, jwkResp, groupIDs, groupList)
if err != nil {
t.Fatalf("Error when creating OIDC server: %v", err)
}
defer srv.Close()

verifier, err := newAccessTokenVerifier(srv.URL, Options{ClientID: "client_id", VerifyClientID: true, HttpClientRetryCount: httpClientRetryCount})
assert.NoError(t, err)
assert.IsType(t, &OIDCAccessTokenVerifier{}, verifier)
})
}

func TestCheckAzureAuthenticationWithEntraSDK(t *testing.T) {
ctx := context.Background()

t.Run("uses SDK claims and keeps Guard-side audience and graph validations", func(t *testing.T) {
groupIDs, groupList := getGroupsAndIds(t, 5)
graphSrv, err := serverSetup(fmt.Sprintf(loginResp, "graph-access-token"), http.StatusOK, []byte(`{"keys":[]}`), groupIDs, groupList)
if !assert.NoError(t, err) {
return
}
defer graphSrv.Close()

sdkSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/Validate", r.URL.Path)
assert.Equal(t, "Bearer entra-access-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"protocol":"Bearer","token":"entra-access-token","claims":{"aud":"client_id","upn":"nahid","oid":"abc-123d4"}}`))
}))
defer sdkSrv.Close()

client, err := entraClientSetup("client_id", "client_secret", "tenant_id", graphSrv.URL, sdkSrv.URL, false, true)
if !assert.NoError(t, err) {
return
}

resp, err := client.Check(ctx, "entra-access-token")
if assert.NoError(t, err) && assert.NotNil(t, resp) {
assertUserInfo(t, resp, 5, client.UseGroupUID)
}
})

t.Run("rejects mismatched audience during SDK verification", func(t *testing.T) {
groupIDs, groupList := getGroupsAndIds(t, 5)
graphSrv, err := serverSetup(fmt.Sprintf(loginResp, "graph-access-token"), http.StatusOK, []byte(`{"keys":[]}`), groupIDs, groupList)
if !assert.NoError(t, err) {
return
}
defer graphSrv.Close()

sdkSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"protocol":"Bearer","token":"entra-access-token","claims":{"aud":"different-client","upn":"nahid","oid":"abc-123d4"}}`))
}))
defer sdkSrv.Close()

client, err := entraClientSetup("client_id", "client_secret", "tenant_id", graphSrv.URL, sdkSrv.URL, false, true)
if !assert.NoError(t, err) {
return
}

resp, err := client.Check(ctx, "entra-access-token")
assert.Nil(t, resp)
assert.ErrorContains(t, err, "failed to verify token for azure")
assert.ErrorContains(t, err, `expected audience "client_id" got different-client`)
})

t.Run("uses the inner PoP access token with the SDK verifier", func(t *testing.T) {
var validatedAuthorizationHeaders []string
sdkSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
validatedAuthorizationHeaders = append(validatedAuthorizationHeaders, r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"protocol":"Bearer","token":"inner-token","claims":{"aud":"client_id","upn":"nahid","oid":"abc-123d4","groups":["1","2","3"]}}`))
}))
defer sdkSrv.Close()

groupIDs, groupList := getGroupsAndIds(t, 0)
graphSrv, err := serverSetup(fmt.Sprintf(loginResp, "graph-access-token"), http.StatusOK, []byte(`{"keys":[]}`), groupIDs, groupList)
if !assert.NoError(t, err) {
return
}
defer graphSrv.Close()

client, err := entraClientSetup("client_id", "client_secret", "tenant_id", graphSrv.URL, sdkSrv.URL, true, true)
if !assert.NoError(t, err) {
return
}
client.Options.EnablePOP = true
client.Options.POPTokenHostname = "test-host"
client.Options.PoPTokenValidityDuration = 15 * time.Minute
client.Options.ResolveGroupMembershipOnlyOnOverageClaim = true
client.popTokenVerifier = NewPoPVerifier(client.POPTokenHostname, client.PoPTokenValidityDuration)

popToken, err := NewPoPTokenBuilder().
SetTimestamp(time.Now().Unix()).
SetHostName(client.POPTokenHostname).
GetToken()
if !assert.NoError(t, err) {
return
}

expectedInnerAccessToken, err := client.popTokenVerifier.ValidatePopToken(popToken)
if !assert.NoError(t, err) {
return
}

resp, err := client.Check(ctx, popToken)
if assert.NoError(t, err) && assert.NotNil(t, resp) {
assertUserInfo(t, resp, 3, client.UseGroupUID)
}
assert.Equal(t, []string{"Bearer " + expectedInnerAccessToken}, validatedAuthorizationHeaders)
})
}

func Test_getOIDCIssuerProvider_ErrorCase(t *testing.T) {
testServer := httptest.NewServer(http.NotFoundHandler())
defer testServer.Close()
Expand Down
Loading