package traefikoidc import ( "bytes" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "log" "math/big" "net/http" "testing" "time" "github.com/gorilla/sessions" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/time/rate" ) // signGraphStyleAccessToken builds a JWT in Microsoft's Graph proprietary // nonce-header form: bytes that get signed contain the SHA256 hash of the // nonce, while the wire token ships the original nonce. A standard JWS // verifier always rejects these with `crypto/rsa: verification error`, which // is why Microsoft documents Graph access tokens as opaque to client apps: // // https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens // "you can't validate tokens for Microsoft Graph according to these rules // due to their proprietary format" func signGraphStyleAccessToken(t *testing.T, key *rsa.PrivateKey, kid, originalNonce string, claims map[string]any) string { t.Helper() wireHeader := map[string]any{ "alg": "RS256", "kid": kid, "typ": "JWT", "nonce": originalNonce, } wireHeaderJSON, err := json.Marshal(wireHeader) require.NoError(t, err) hashed := sha256.Sum256([]byte(originalNonce)) signedHeader := map[string]any{ "alg": "RS256", "kid": kid, "typ": "JWT", "nonce": fmt.Sprintf("%x", hashed), } signedHeaderJSON, err := json.Marshal(signedHeader) require.NoError(t, err) claimsJSON, err := json.Marshal(claims) require.NoError(t, err) wireHeaderB64 := base64.RawURLEncoding.EncodeToString(wireHeaderJSON) signedHeaderB64 := base64.RawURLEncoding.EncodeToString(signedHeaderJSON) claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) signedInput := signedHeaderB64 + "." + claimsB64 hSign := sha256.Sum256([]byte(signedInput)) sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hSign[:]) require.NoError(t, err) return wireHeaderB64 + "." + claimsB64 + "." + base64.RawURLEncoding.EncodeToString(sig) } // newAzureFollowupOIDC produces a TraefikOidc instance wired for an Azure // AD tenant with a captured error log buffer. Used by the issue #134 followup // tests to assert log behavior during validateAzureTokens flows. func newAzureFollowupOIDC(t *testing.T, jwks *JWKSet) (*TraefikOidc, *bytes.Buffer) { t.Helper() tc := newTestCleanup(t) errBuf := &bytes.Buffer{} logger := &Logger{ logError: log.New(errBuf, "", 0), logInfo: log.New(io.Discard, "", 0), logDebug: log.New(io.Discard, "", 0), } tokenCache := tc.addTokenCache(NewTokenCache()) tokenBlacklist := tc.addCache(NewCache()) oidc := &TraefikOidc{ issuerURL: "https://login.microsoftonline.com/tenant-id/v2.0", clientID: "test-client-id", audience: "test-client-id", jwksURL: "https://login.microsoftonline.com/tenant-id/discovery/v2.0/keys", limiter: rate.NewLimiter(rate.Every(time.Second), 100), logger: logger, httpClient: &http.Client{Timeout: 10 * time.Second}, jwkCache: &MockJWKCache{JWKS: jwks}, tokenCache: tokenCache, tokenBlacklist: tokenBlacklist, extractClaimsFunc: extractClaims, } oidc.tokenVerifier = oidc oidc.jwtVerifier = oidc require.True(t, oidc.isAzureProvider(), "fixture must be detected as Azure provider") return oidc, errBuf } // authedSessionWithTokens returns a SessionData populated with the supplied // access and ID tokens, marked authenticated and recently created. The // SessionManager carries a real ChunkManager so that GetAccessToken / // GetIDToken / GetRefreshToken behave like the production code path. func authedSessionWithTokens(t *testing.T, accessToken, idToken string) *SessionData { t.Helper() chunkLogger := NewLogger("error") chunkManager := NewChunkManager(chunkLogger) t.Cleanup(chunkManager.Shutdown) sd := CreateMockSessionData() sd.manager = &SessionManager{ sessionMaxAge: 24 * time.Hour, chunkManager: chunkManager, logger: chunkLogger, } sd.mainSession = sessions.NewSession(nil, "main") sd.mainSession.Values["authenticated"] = true sd.mainSession.Values["created_at"] = time.Now().Unix() sd.accessSession = sessions.NewSession(nil, "access") sd.accessSession.Values["token"] = accessToken sd.accessSession.Values["compressed"] = false sd.idTokenSession = sessions.NewSession(nil, "id") sd.idTokenSession.Values["token"] = idToken sd.idTokenSession.Values["compressed"] = false sd.refreshSession = sessions.NewSession(nil, "refresh") sd.refreshSession.Values["token"] = "" sd.refreshSession.Values["compressed"] = false return sd } // TestIssue134_Followup_GraphAccessTokenReproducesUsersError sanity-checks // that our crafted Graph-style token reproduces the exact rsa error string // quoted on the issue thread (dada-engineer 2026-05-08, friek 2026-05-11). // // Sanity test: must always pass, regardless of the issue #134 followup fix. // It exists so a future contributor does not accidentally weaken the // reproducer and assume the followup fix is no longer needed. func TestIssue134_Followup_GraphAccessTokenReproducesUsersError(t *testing.T) { rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) const kid = "azure-followup-kid" graphToken := signGraphStyleAccessToken(t, rsaKey, kid, "wire-only-nonce", map[string]any{ "iss": "https://login.microsoftonline.com/tenant-id/v2.0", "aud": "00000003-0000-0000-c000-000000000000", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "sub": "user-azure-id", "scp": "User.Read", }) parsedJWT, err := parseJWT(graphToken) require.NoError(t, err) pubKey := &rsaKey.PublicKey alg, _ := parsedJWT.Header["alg"].(string) verifyErr := verifySignatureWithKey(graphToken, pubKey, alg) require.Error(t, verifyErr) assert.Contains(t, verifyErr.Error(), "crypto/rsa: verification error", "reproducer must emit the exact error string reported on issue #134") } // TestIssue134_Followup_ValidateAzureTokensSkipsGraphAccessToken is the // failing-then-passing test for the followup fix. // // Symptom (before fix): validateAzureTokens calls verifyToken on every // JWT-shaped access token. For Microsoft Graph access tokens (the default // when no custom resource is registered), verification always fails with // `crypto/rsa: verification error`, generating two error log lines per // request: // // UNKNOWN token verification failed: signature verification failed: // crypto/rsa: verification error // DIAGNOSTIC: Signature verification failed for kid=, alg=RS256: // crypto/rsa: verification error // // Microsoft's own documentation tells client apps not to validate Graph // access tokens. The fix matches that guidance: when an Azure access token // carries Microsoft's proprietary `nonce` JWT header, treat it as opaque // (skip JWT verification, fall through to ID token validation). func TestIssue134_Followup_ValidateAzureTokensSkipsGraphAccessToken(t *testing.T) { rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) const kid = "azure-followup-kid" jwk := JWK{ Kty: "RSA", Use: "sig", Alg: "RS256", Kid: kid, N: base64.RawURLEncoding.EncodeToString(rsaKey.N.Bytes()), E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaKey.E)).Bytes()), } jwks := &JWKSet{Keys: []JWK{jwk}} now := time.Now() exp := now.Add(time.Hour).Unix() graphAccessToken := signGraphStyleAccessToken(t, rsaKey, kid, "wire-only-nonce-azure-graph", map[string]any{ "iss": "https://login.microsoftonline.com/tenant-id/v2.0", "aud": "00000003-0000-0000-c000-000000000000", "exp": exp, "iat": now.Unix(), "sub": "user-azure-id", "appid": "test-client-id", "scp": "User.Read", }) idToken, err := createTestJWT(rsaKey, "RS256", kid, map[string]any{ "iss": "https://login.microsoftonline.com/tenant-id/v2.0", "aud": "test-client-id", "exp": exp, "iat": now.Add(-2 * time.Minute).Unix(), "nbf": now.Add(-2 * time.Minute).Unix(), "sub": "user-azure-id", "email": "user@example.com", "nonce": "id-token-oidc-nonce", "jti": "id-token-jti-followup", }) require.NoError(t, err) oidc, errBuf := newAzureFollowupOIDC(t, jwks) session := authedSessionWithTokens(t, graphAccessToken, idToken) rs := (&requestState{}).captureSession(session) authenticated, needsRefresh, expired := oidc.validateAzureTokensRS(rs) output := errBuf.String() assert.NotContains(t, output, "crypto/rsa: verification error", "validateAzureTokens must not log rsa verification error for Graph-style access tokens; got: %q", output) assert.NotContains(t, output, "DIAGNOSTIC: Signature verification failed", "DIAGNOSTIC line must not fire for Graph-style access tokens; got: %q", output) assert.NotContains(t, output, "UNKNOWN token verification failed", "UNKNOWN classification log must not fire for Graph-style access tokens; got: %q", output) assert.True(t, authenticated, "session must remain authenticated via the ID token fallback") assert.False(t, needsRefresh, "valid ID token must not signal a refresh need") assert.False(t, expired, "valid ID token must not be reported as expired") } // TestIssue134_Followup_IsUnverifiableAzureAccessToken_Detection covers the // classifier added by the followup fix. Pure-function unit test for the // Microsoft proprietary marker we rely on (nonce in JWT header). func TestIssue134_Followup_IsUnverifiableAzureAccessToken_Detection(t *testing.T) { rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) const kid = "azure-detection-kid" standardToken, err := createTestJWT(rsaKey, "RS256", kid, map[string]any{ "iss": "https://login.microsoftonline.com/tenant-id/v2.0", "aud": "test-client-id", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "sub": "user-azure-id", }) require.NoError(t, err) graphToken := signGraphStyleAccessToken(t, rsaKey, kid, "wire-only-nonce", map[string]any{ "iss": "https://login.microsoftonline.com/tenant-id/v2.0", "aud": "00000003-0000-0000-c000-000000000000", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "sub": "user-azure-id", "scp": "User.Read", }) oidc, _ := newAzureFollowupOIDC(t, &JWKSet{}) cases := []struct { name string token string wantUnverified bool }{ {name: "standard JWT without nonce header", token: standardToken, wantUnverified: false}, {name: "Microsoft proprietary token (nonce in header)", token: graphToken, wantUnverified: true}, {name: "garbage token treated as unverifiable", token: "not-a-jwt-at-all", wantUnverified: true}, {name: "empty token treated as unverifiable", token: "", wantUnverified: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := oidc.isUnverifiableAzureAccessToken(tc.token) assert.Equal(t, tc.wantUnverified, got) }) } } // TestIssue134_Followup_StandardAzureAccessTokenStillVerifies guards against // regression in the happy path: an access token issued for our own clientID // (custom Azure-registered API) — no proprietary nonce header, signed normally // — must still flow through the standard verification path and authenticate. func TestIssue134_Followup_StandardAzureAccessTokenStillVerifies(t *testing.T) { rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) const kid = "azure-standard-kid" jwk := JWK{ Kty: "RSA", Use: "sig", Alg: "RS256", Kid: kid, N: base64.RawURLEncoding.EncodeToString(rsaKey.N.Bytes()), E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaKey.E)).Bytes()), } jwks := &JWKSet{Keys: []JWK{jwk}} now := time.Now() exp := now.Add(time.Hour).Unix() // Custom-resource access token: aud points to the app, no nonce header. accessToken, err := createTestJWT(rsaKey, "RS256", kid, map[string]any{ "iss": "https://login.microsoftonline.com/tenant-id/v2.0", "aud": "test-client-id", "exp": exp, "iat": now.Add(-2 * time.Minute).Unix(), "nbf": now.Add(-2 * time.Minute).Unix(), "sub": "user-azure-id", "scp": "api.read", "jti": "standard-access-jti", }) require.NoError(t, err) idToken, err := createTestJWT(rsaKey, "RS256", kid, map[string]any{ "iss": "https://login.microsoftonline.com/tenant-id/v2.0", "aud": "test-client-id", "exp": exp, "iat": now.Add(-2 * time.Minute).Unix(), "nbf": now.Add(-2 * time.Minute).Unix(), "sub": "user-azure-id", "email": "user@example.com", "nonce": "id-token-oidc-nonce", "jti": "standard-id-jti", }) require.NoError(t, err) oidc, errBuf := newAzureFollowupOIDC(t, jwks) session := authedSessionWithTokens(t, accessToken, idToken) rs := (&requestState{}).captureSession(session) authenticated, needsRefresh, expired := oidc.validateAzureTokensRS(rs) assert.True(t, authenticated, "standard Azure access token must verify and authenticate") assert.False(t, needsRefresh) assert.False(t, expired) assert.NotContains(t, errBuf.String(), "crypto/rsa: verification error", "standard Azure token must not produce signature errors") } // TestIssue134_Followup_GraphAccessTokenWithoutIDToken covers the edge where // the session has only a Graph access token (no ID token). The classifier must // preserve the existing "treat as opaque" semantics for backward compatibility: // authenticated=true even when there is no ID token to verify. func TestIssue134_Followup_GraphAccessTokenWithoutIDToken(t *testing.T) { rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) const kid = "azure-no-idt-kid" jwk := JWK{ Kty: "RSA", Use: "sig", Alg: "RS256", Kid: kid, N: base64.RawURLEncoding.EncodeToString(rsaKey.N.Bytes()), E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaKey.E)).Bytes()), } jwks := &JWKSet{Keys: []JWK{jwk}} graphAccessToken := signGraphStyleAccessToken(t, rsaKey, kid, "wire-only-nonce-no-idt", map[string]any{ "iss": "https://login.microsoftonline.com/tenant-id/v2.0", "aud": "00000003-0000-0000-c000-000000000000", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "sub": "user-azure-id", "scp": "User.Read", }) oidc, errBuf := newAzureFollowupOIDC(t, jwks) session := authedSessionWithTokens(t, graphAccessToken, "") rs := (&requestState{}).captureSession(session) authenticated, needsRefresh, expired := oidc.validateAzureTokensRS(rs) assert.True(t, authenticated, "Graph token without ID token must remain authenticated (matches existing opaque-token semantics)") assert.False(t, needsRefresh) assert.False(t, expired) assert.NotContains(t, errBuf.String(), "crypto/rsa: verification error") } // TestIssue134_Followup_ConfusedDeputyAttackDoesNotBypassVerification proves // the classifier is not a security regression. An attacker who forges a JWT // with a `nonce` JWT header (Microsoft's proprietary marker) but a payload // claiming `aud=our-clientID` should NOT gain authenticated status simply by // triggering the "treat as opaque" branch. // // This is the confused-deputy guardrail Microsoft warns about // (https://cwe.mitre.org/data/definitions/441.html): we treat the access token // as opaque, which means we DO NOT authorize from it — authorization comes // only from a separately verifiable ID token. An attacker without a valid ID // token must not be authenticated. func TestIssue134_Followup_ConfusedDeputyAttackDoesNotBypassVerification(t *testing.T) { rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) attackerKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) const kid = "azure-attack-kid" jwk := JWK{ Kty: "RSA", Use: "sig", Alg: "RS256", Kid: kid, N: base64.RawURLEncoding.EncodeToString(rsaKey.N.Bytes()), E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaKey.E)).Bytes()), } jwks := &JWKSet{Keys: []JWK{jwk}} // Forged: attacker uses their OWN key, sets aud = our clientID, plants a // `nonce` header to trip the opaque-detection path. forgedAccessToken := signGraphStyleAccessToken(t, attackerKey, kid, "attacker-nonce", map[string]any{ "iss": "https://login.microsoftonline.com/tenant-id/v2.0", "aud": "test-client-id", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "sub": "attacker", "scp": "admin", }) // Forged ID token signed with the attacker's key — must fail verification // against the tenant JWKS. forgedIDToken, err := createTestJWT(attackerKey, "RS256", kid, map[string]any{ "iss": "https://login.microsoftonline.com/tenant-id/v2.0", "aud": "test-client-id", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Add(-2 * time.Minute).Unix(), "nbf": time.Now().Add(-2 * time.Minute).Unix(), "sub": "attacker", "email": "attacker@evil.example", "nonce": "id-token-oidc-nonce", "jti": "attacker-id-jti", }) require.NoError(t, err) oidc, _ := newAzureFollowupOIDC(t, jwks) session := authedSessionWithTokens(t, forgedAccessToken, forgedIDToken) rs := (&requestState{}).captureSession(session) authenticated, _, _ := oidc.validateAzureTokensRS(rs) assert.False(t, authenticated, "attacker's forged tokens must not authenticate even when the access token has a nonce header — ID token verification rejects the wrong-key signature") }