diff --git a/README.md b/README.md index bbed4c0..7359161 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,23 @@ PLUGIN_AUTH_ENABLED=true # false, preserving the previous behavior of sending no product for M2M. AUTH_M2M_PRODUCT_FORWARD_ENABLED=false +# Optional. Opt-in local JWT signature verification for the general authorization +# path. When unset, tokens are parsed without signature verification (the +# authorization service remains the trust anchor) — the previous behavior, +# unchanged. When set, the bearer token is cryptographically verified (RS256, +# expiry required, and issuer when AUTH_JWT_ISSUER is set) BEFORE its claims are +# trusted; any failure denies the request (401, fail closed). +# +# AUTH_JWT_VERIFY_CERT holds the issuer's PEM certificate(s) or RSA public key(s). +# Newline-join multiple PEMs to carry the old and new certs simultaneously across +# a key rotation (zero-downtime: a token verified by ANY listed key is accepted). +# AUTH_JWT_VERIFY_CERT_PATH points to a mounted PEM file instead (used only when +# AUTH_JWT_VERIFY_CERT is empty). A configured-but-unparseable cert is logged at +# ERROR and leaves verification disabled; it is never silently accepted. +AUTH_JWT_VERIFY_CERT= +AUTH_JWT_VERIFY_CERT_PATH= +AUTH_JWT_ISSUER= + # Optional. When "true", the middleware fails closed: if auth is disabled # (PLUGIN_AUTH_ENABLED=false) or misconfigured (empty PLUGIN_AUTH_ADDRESS), # every protected route refuses to serve (HTTP 503 / gRPC Unavailable) instead diff --git a/auth/middleware/m2m.go b/auth/middleware/m2m.go index 853fa9e..cbc257d 100644 --- a/auth/middleware/m2m.go +++ b/auth/middleware/m2m.go @@ -170,42 +170,18 @@ func (m *M2MAuthenticator) verify(ctx context.Context, span trace.Span, accessTo return nil, http.StatusUnauthorized, err } - claims := jwt.MapClaims{} - - // WithValidMethods pins the accepted signing algorithm to RS256, closing the - // alg-substitution hole (a token forged with "none" or with HS256 using the - // public key as the shared secret is rejected before the keyfunc runs). - // WithExpirationRequired rejects a validly-signed token that omits "exp", so a - // non-expiring credential cannot slip through (Casdoor always emits exp). - // WithIssuer, when an expected issuer is configured, rejects a token minted by - // a different issuer that shares the same signing key. - opts := []jwt.ParserOption{ - jwt.WithValidMethods([]string{"RS256"}), - jwt.WithExpirationRequired(), - } - if m.expectedIssuer != "" { - opts = append(opts, jwt.WithIssuer(m.expectedIssuer)) - } - - parser := jwt.NewParser(opts...) - - token, err := parser.ParseWithClaims(accessToken, claims, func(_ *jwt.Token) (any, error) { - return m.publicKey, nil - }) + // verifyToken is the shared hardened RS256 verifier: it pins RS256 (closing the + // alg-substitution hole — a token forged with "none" or with HS256 using the + // public key as the shared secret is rejected before the keyfunc runs), requires + // "exp" (Casdoor always emits it, so a non-expiring credential cannot slip + // through), and pins "iss" when expectedIssuer is set. A single-key set is passed + // here; the general path may pass several for rotation overlap. + claims, statusCode, err := verifyToken([]*rsa.PublicKey{m.publicKey}, m.expectedIssuer, accessToken) if err != nil { logErrorf(ctx, m.logger, "Failed to verify M2M token: %v", err) tracing.HandleSpanError(span, "Failed to verify M2M token", err) - return nil, http.StatusUnauthorized, errors.New("invalid token") - } - - if !token.Valid { - err := errors.New("invalid token") - - logErrorf(ctx, m.logger, "M2M token failed validation") - tracing.HandleSpanError(span, "M2M token failed validation", err) - - return nil, http.StatusUnauthorized, err + return nil, statusCode, err } userType, _ := claims["type"].(string) diff --git a/auth/middleware/middleware.go b/auth/middleware/middleware.go index 498a16c..80d68ee 100644 --- a/auth/middleware/middleware.go +++ b/auth/middleware/middleware.go @@ -3,6 +3,7 @@ package middleware import ( "bytes" "context" + "crypto/rsa" "encoding/json" "errors" "fmt" @@ -73,6 +74,18 @@ type AuthClient struct { // failures (network/timeout/5xx). Read once from AUTH_RETRY_MAX; 0 disables // retry (opt-in). Authoritative decisions (401/403) are never retried. retryMax uint + + // verifyKeys holds the RSA public keys used to cryptographically verify the + // bearer token locally before its claims are trusted. When empty (the default) + // the general path preserves the historical ParseUnverified behavior and the + // authz round-trip remains the trust anchor. It is populated from + // AUTH_JWT_VERIFY_CERT / AUTH_JWT_VERIFY_CERT_PATH at construction; carrying more + // than one key supports zero-downtime cert rotation (any key may verify). + verifyKeys []*rsa.PublicKey + + // verifyIssuer, when non-empty, pins the accepted token "iss" claim during local + // verification. Read once from AUTH_JWT_ISSUER; inert when verifyKeys is empty. + verifyIssuer string } type AuthResponse struct { @@ -208,6 +221,8 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient } } + verifyKeys, verifyIssuer := loadVerification(l) + // Build the client once with all env-derived config, then return it from every // path below; the health check only logs, it never changes these fields. c := &AuthClient{ @@ -220,6 +235,8 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient cache: newDecisionCacheFromEnv(), breaker: newBreakerFromEnv(), retryMax: parseRetryMax(), + verifyKeys: verifyKeys, + verifyIssuer: verifyIssuer, } if !enabled || address == "" { @@ -427,24 +444,9 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resourc ctx, cancel := context.WithTimeout(ctx, auth.requestTimeout()) defer cancel() - token, _, err := new(jwt.Parser).ParseUnverified(accessToken, jwt.MapClaims{}) + claims, statusCode, err := auth.extractClaims(ctx, span, accessToken) if err != nil { - logErrorf(ctx, auth.Logger, "Failed to parse token: %v", err) - - tracing.HandleSpanError(span, "Failed to parse token", err) - - return false, http.StatusUnauthorized, err - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - logErrorf(ctx, auth.Logger, "Failed to parse claims: token.Claims is not of type jwt.MapClaims") - - err := errors.New("token claims are not in the expected format") - - tracing.HandleSpanError(span, "Failed to parse claims", err) - - return false, http.StatusUnauthorized, err + return false, statusCode, err } userType, _ := claims["type"].(string) diff --git a/auth/middleware/verification.go b/auth/middleware/verification.go new file mode 100644 index 0000000..95870dd --- /dev/null +++ b/auth/middleware/verification.go @@ -0,0 +1,206 @@ +package middleware + +import ( + "context" + "crypto/rsa" + "encoding/pem" + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/LerianStudio/lib-observability/v2/log" + "github.com/LerianStudio/lib-observability/v2/tracing" + jwt "github.com/golang-jwt/jwt/v5" + "go.opentelemetry.io/otel/trace" +) + +// verifyToken cryptographically verifies a JWT and returns its claims. It pins +// RS256 (closing the alg-substitution hole: a token forged with "none" or with +// HS256 using the public key as the shared secret is rejected before the keyfunc +// runs), requires "exp", and — when expectedIssuer is non-empty — pins the "iss" +// claim. +// +// pubKeys is a rotation-overlap set: the token is accepted if it verifies against +// ANY key, so an operator can carry the old and new certs simultaneously across a +// key rotation without downtime (the static-keyset-with-overlap pattern). It +// performs NO application-level claim checks (token type, sub); the caller applies +// those. Every failure returns http.StatusUnauthorized so callers fail closed. It +// never logs the token. +// +// This is the shared hardened verifier extracted from the M2M gate: both +// RequireM2M (via M2MAuthenticator.verify) and the general checkAuthorization path +// route their signature verification through here so there is one audited RS256 +// verifier, not two. +func verifyToken(pubKeys []*rsa.PublicKey, expectedIssuer, accessToken string) (jwt.MapClaims, int, error) { + if len(pubKeys) == 0 { + return nil, http.StatusUnauthorized, errors.New("no verification key configured") + } + + opts := []jwt.ParserOption{ + jwt.WithValidMethods([]string{"RS256"}), + jwt.WithExpirationRequired(), + } + if expectedIssuer != "" { + opts = append(opts, jwt.WithIssuer(expectedIssuer)) + } + + keySet := jwt.VerificationKeySet{Keys: make([]jwt.VerificationKey, len(pubKeys))} + for i, key := range pubKeys { + keySet.Keys[i] = key + } + + claims := jwt.MapClaims{} + + token, err := jwt.NewParser(opts...).ParseWithClaims(accessToken, claims, func(_ *jwt.Token) (any, error) { + return keySet, nil + }) + if err != nil { + return nil, http.StatusUnauthorized, fmt.Errorf("token verification failed: %w", err) + } + + if !token.Valid { + return nil, http.StatusUnauthorized, errors.New("invalid token") + } + + return claims, http.StatusOK, nil +} + +// extractClaims returns the token claims used to build the authorization subject. +// +// When local verification is configured (verifyKeys present, from +// AUTH_JWT_VERIFY_CERT / AUTH_JWT_VERIFY_CERT_PATH) it cryptographically verifies +// the token first (signature/alg/exp/iss) and fails closed on any error before the +// claims are used. When it is NOT configured it preserves the historical +// ParseUnverified behavior exactly — the network authorization call remains the +// trust anchor — so the feature is opt-in and backward compatible. Verification is +// gated by presence of a cert, mirroring the M2M cert gate. +func (auth *AuthClient) extractClaims(ctx context.Context, span trace.Span, accessToken string) (jwt.MapClaims, int, error) { + if len(auth.verifyKeys) > 0 { + claims, statusCode, err := verifyToken(auth.verifyKeys, auth.verifyIssuer, accessToken) + if err != nil { + logErrorf(ctx, auth.Logger, "Failed to verify token: %v", err) + + tracing.HandleSpanError(span, "Failed to verify token", err) + + return nil, statusCode, err + } + + return claims, http.StatusOK, nil + } + + token, _, err := new(jwt.Parser).ParseUnverified(accessToken, jwt.MapClaims{}) + if err != nil { + logErrorf(ctx, auth.Logger, "Failed to parse token: %v", err) + + tracing.HandleSpanError(span, "Failed to parse token", err) + + return nil, http.StatusUnauthorized, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + logErrorf(ctx, auth.Logger, "Failed to parse claims: token.Claims is not of type jwt.MapClaims") + + err := errors.New("token claims are not in the expected format") + + tracing.HandleSpanError(span, "Failed to parse claims", err) + + return nil, http.StatusUnauthorized, err + } + + return claims, http.StatusOK, nil +} + +// loadVerification reads the opt-in local JWT verification config from the +// environment: AUTH_JWT_VERIFY_CERT (inline, newline-joined PEMs for rotation +// overlap) or, when it is empty, AUTH_JWT_VERIFY_CERT_PATH (a mounted file), plus +// AUTH_JWT_ISSUER (optional "iss" pin). Absence of both cert sources leaves +// verification off (nil keys), preserving the historical ParseUnverified behavior. +// +// A configured-but-unparseable cert is logged loud at ERROR and verification is +// left disabled (the authz round-trip stays the trust anchor); it never silently +// accepts a bad cert and never panics, keeping NewAuthClient's no-error signature. +func loadVerification(logger log.Logger) (keys []*rsa.PublicKey, issuer string) { + issuer = strings.TrimSpace(os.Getenv("AUTH_JWT_ISSUER")) + + pemData, err := loadVerifyCertPEM() + if err != nil { + logErrorf(context.Background(), logger, + "AUTH_JWT_VERIFY_CERT_PATH unreadable, local JWT verification disabled: %v", err) + + return nil, issuer + } + + if len(pemData) == 0 { + return nil, issuer + } + + keys, err = parseRSAPublicKeys(pemData) + if err != nil { + logErrorf(context.Background(), logger, + "failed to parse verification certificate, local JWT verification disabled: %v", err) + + return nil, issuer + } + + return keys, issuer +} + +// loadVerifyCertPEM returns the PEM bytes for local JWT verification, preferring +// the inline AUTH_JWT_VERIFY_CERT over the file referenced by +// AUTH_JWT_VERIFY_CERT_PATH. It returns (nil, nil) when neither is set, which +// leaves verification off (opt-in by presence, like the M2M cert gate). +func loadVerifyCertPEM() ([]byte, error) { + if inline := strings.TrimSpace(os.Getenv("AUTH_JWT_VERIFY_CERT")); inline != "" { + return []byte(inline), nil + } + + path := strings.TrimSpace(os.Getenv("AUTH_JWT_VERIFY_CERT_PATH")) + if path == "" { + return nil, nil + } + + data, err := os.ReadFile(path) // #nosec G304 G703 -- path is operator-supplied deployment config (a mounted secret), not request/user input + if err != nil { + return nil, fmt.Errorf("failed to read AUTH_JWT_VERIFY_CERT_PATH %q: %w", path, err) + } + + return data, nil +} + +// parseRSAPublicKeys parses one or more PEM blocks (concatenated / newline-joined) +// into RSA public keys. Each block may be a PKIX public key or an X.509 +// certificate (e.g. Casdoor's token_jwt_key.pem). Supporting multiple blocks is +// what makes zero-downtime key rotation possible: the operator carries the old and +// new certs together for the overlap window. It errors if any block fails to parse +// or if no PEM block is found, so a misconfiguration is caught rather than silently +// yielding an empty (deny-everything) key set. +func parseRSAPublicKeys(pemData []byte) ([]*rsa.PublicKey, error) { + var keys []*rsa.PublicKey + + rest := pemData + + for { + block, remainder := pem.Decode(rest) + if block == nil { + break + } + + rest = remainder + + key, err := jwt.ParseRSAPublicKeyFromPEM(pem.EncodeToMemory(block)) + if err != nil { + return nil, fmt.Errorf("failed to parse verification key: %w", err) + } + + keys = append(keys, key) + } + + if len(keys) == 0 { + return nil, errors.New("no PEM blocks found in verification certificate") + } + + return keys, nil +} diff --git a/auth/middleware/verification_test.go b/auth/middleware/verification_test.go new file mode 100644 index 0000000..1fcfc8a --- /dev/null +++ b/auth/middleware/verification_test.go @@ -0,0 +1,491 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/LerianStudio/lib-observability/v2/log" + jwt "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// normalUserClaims mirrors a real Casdoor normal-user token (the general path). +func normalUserClaims() jwt.MapClaims { + return jwt.MapClaims{ + "type": "normal-user", + "owner": "acme-org", + "sub": "user-123", + "exp": float64(time.Now().Add(time.Hour).Unix()), + "iat": float64(time.Now().Add(-time.Minute).Unix()), + } +} + +// --------------------------------------------------------------------------- +// verifyToken - shared hardened verifier +// --------------------------------------------------------------------------- + +func TestVerifyToken_ValidSignedToken_Passes(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + token := signRS256(t, key, normalUserClaims()) + + claims, statusCode, verr := verifyToken(pubKeys, "", token) + + require.NoError(t, verr) + assert.Equal(t, http.StatusOK, statusCode) + assert.Equal(t, "acme-org", claims["owner"]) + assert.Equal(t, "user-123", claims["sub"]) +} + +func TestVerifyToken_WrongKey_FailsClosed(t *testing.T) { + t.Parallel() + + attackerKey, _ := newTestRSAKeyPEM(t) + _, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + token := signRS256(t, attackerKey, normalUserClaims()) + + _, statusCode, verr := verifyToken(pubKeys, "", token) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_ExpiredToken_FailsClosed(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + claims := normalUserClaims() + claims["exp"] = float64(time.Now().Add(-time.Hour).Unix()) + + token := signRS256(t, key, claims) + + _, statusCode, verr := verifyToken(pubKeys, "", token) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_MissingExp_FailsClosed(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + claims := normalUserClaims() + delete(claims, "exp") + + token := signRS256(t, key, claims) + + _, statusCode, verr := verifyToken(pubKeys, "", token) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_AlgConfusionHS256_Rejected(t *testing.T) { + t.Parallel() + + // Attacker forges an HS256 token using the public PEM bytes as the shared + // secret. WithValidMethods([]string{"RS256"}) must reject it. + _, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + forged := jwt.NewWithClaims(jwt.SigningMethodHS256, normalUserClaims()) + + signed, err := forged.SignedString([]byte(pubPEM)) + require.NoError(t, err) + + _, statusCode, verr := verifyToken(pubKeys, "", signed) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_AlgNone_Rejected(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + unsigned := jwt.NewWithClaims(jwt.SigningMethodNone, normalUserClaims()) + + signed, err := unsigned.SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + + _, statusCode, verr := verifyToken(pubKeys, "", signed) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_MatchingIssuer_Allows(t *testing.T) { + t.Parallel() + + const issuer = "http://plugin-access-manager-auth-backend:8000" + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + claims := normalUserClaims() + claims["iss"] = issuer + + token := signRS256(t, key, claims) + + _, statusCode, verr := verifyToken(pubKeys, issuer, token) + + require.NoError(t, verr) + assert.Equal(t, http.StatusOK, statusCode) +} + +func TestVerifyToken_WrongIssuer_Rejected(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + claims := normalUserClaims() + claims["iss"] = "http://attacker-issuer:8000" + + token := signRS256(t, key, claims) + + _, statusCode, verr := verifyToken(pubKeys, "http://expected-issuer:8000", token) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_NoKeys_FailsClosed(t *testing.T) { + t.Parallel() + + key, _ := newTestRSAKeyPEM(t) + token := signRS256(t, key, normalUserClaims()) + + _, statusCode, err := verifyToken(nil, "", token) + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_MalformedToken_Rejected(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + _, statusCode, verr := verifyToken(pubKeys, "", "not-a-jwt") + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +// TestVerifyToken_RotationBundle_AcceptsEitherKey proves the zero-downtime +// rotation contract: with the old and new certs both present, a token signed by +// EITHER key verifies, while a token signed by a key outside the set is rejected. +func TestVerifyToken_RotationBundle_AcceptsEitherKey(t *testing.T) { + t.Parallel() + + oldKey, oldPEM := newTestRSAKeyPEM(t) + newKey, newPEM := newTestRSAKeyPEM(t) + strangerKey, _ := newTestRSAKeyPEM(t) + + bundle := oldPEM + "\n" + newPEM + pubKeys, err := parseRSAPublicKeys([]byte(bundle)) + require.NoError(t, err) + require.Len(t, pubKeys, 2) + + t.Run("old_key_accepted", func(t *testing.T) { + t.Parallel() + + _, statusCode, verr := verifyToken(pubKeys, "", signRS256(t, oldKey, normalUserClaims())) + require.NoError(t, verr) + assert.Equal(t, http.StatusOK, statusCode) + }) + + t.Run("new_key_accepted", func(t *testing.T) { + t.Parallel() + + _, statusCode, verr := verifyToken(pubKeys, "", signRS256(t, newKey, normalUserClaims())) + require.NoError(t, verr) + assert.Equal(t, http.StatusOK, statusCode) + }) + + t.Run("stranger_key_rejected", func(t *testing.T) { + t.Parallel() + + _, statusCode, verr := verifyToken(pubKeys, "", signRS256(t, strangerKey, normalUserClaims())) + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) + }) +} + +// --------------------------------------------------------------------------- +// parseRSAPublicKeys +// --------------------------------------------------------------------------- + +func TestParseRSAPublicKeys_Single(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + + keys, err := parseRSAPublicKeys([]byte(pubPEM)) + + require.NoError(t, err) + assert.Len(t, keys, 1) +} + +func TestParseRSAPublicKeys_MultipleNewlineJoined(t *testing.T) { + t.Parallel() + + _, pem1 := newTestRSAKeyPEM(t) + _, pem2 := newTestRSAKeyPEM(t) + _, pem3 := newTestRSAKeyPEM(t) + + keys, err := parseRSAPublicKeys([]byte(pem1 + "\n" + pem2 + "\n" + pem3)) + + require.NoError(t, err) + assert.Len(t, keys, 3) +} + +func TestParseRSAPublicKeys_NoPEMBlock_Errors(t *testing.T) { + t.Parallel() + + keys, err := parseRSAPublicKeys([]byte("not a pem")) + + require.Error(t, err) + assert.Nil(t, keys) +} + +func TestParseRSAPublicKeys_BadBlock_Errors(t *testing.T) { + t.Parallel() + + badPEM := "-----BEGIN PUBLIC KEY-----\nZm9vYmFy\n-----END PUBLIC KEY-----\n" + + keys, err := parseRSAPublicKeys([]byte(badPEM)) + + require.Error(t, err) + assert.Nil(t, keys) +} + +// --------------------------------------------------------------------------- +// checkAuthorization - verification hooked in via extractClaims +// --------------------------------------------------------------------------- + +func TestCheckAuthorization_VerificationEnabled_ValidToken_ReachesAuthz(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + server := mockAuthServer(t, true, http.StatusOK) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + verifyKeys: pubKeys, + } + + token := signRS256(t, key, normalUserClaims()) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "midaz", "resource", "read", token, + ) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) +} + +func TestCheckAuthorization_VerificationEnabled_ForgedToken_DeniedBeforeAuthz(t *testing.T) { + t.Parallel() + + attackerKey, _ := newTestRSAKeyPEM(t) + _, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + // The authz backend must never be reached: verification fails closed first. + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Errorf("auth backend must not be called when local verification fails") + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + verifyKeys: pubKeys, + } + + token := signRS256(t, attackerKey, normalUserClaims()) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "midaz", "resource", "read", token, + ) + + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestCheckAuthorization_VerificationEnabled_ExpiredToken_Denied(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Errorf("auth backend must not be called when the token is expired") + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + verifyKeys: pubKeys, + } + + claims := normalUserClaims() + claims["exp"] = float64(time.Now().Add(-time.Hour).Unix()) + + token := signRS256(t, key, claims) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "midaz", "resource", "read", token, + ) + + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +// TestCheckAuthorization_VerificationDisabled_UnsignedTokenStillWorks proves the +// opt-in contract: with no verifyKeys, the historical ParseUnverified path is +// preserved exactly — an HS256 test token (not RS256-signed by any trusted key) +// still authorizes through the authz round-trip. +func TestCheckAuthorization_VerificationDisabled_UnsignedTokenStillWorks(t *testing.T) { + t.Parallel() + + server := mockAuthServer(t, true, http.StatusOK) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "acme-org", + "sub": "user-123", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "midaz", "resource", "read", token, + ) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) +} + +// --------------------------------------------------------------------------- +// NewAuthClient - verification config wiring (AUTH_JWT_VERIFY_CERT*) +// --------------------------------------------------------------------------- + +func TestNewAuthClient_VerificationConfig(t *testing.T) { + // Cannot use t.Parallel(): subtests use t.Setenv. enabled=false returns early + // without any network call, exercising the config wiring in isolation. + logger := log.Logger(&testLogger{}) + + t.Run("inline_cert_populates_keys", func(t *testing.T) { + _, pubPEM := newTestRSAKeyPEM(t) + t.Setenv("AUTH_JWT_VERIFY_CERT", pubPEM) + t.Setenv("AUTH_JWT_ISSUER", "http://issuer:8000") + + client := NewAuthClient("", false, &logger) + assert.Len(t, client.verifyKeys, 1) + assert.Equal(t, "http://issuer:8000", client.verifyIssuer) + }) + + t.Run("inline_bundle_populates_multiple_keys", func(t *testing.T) { + _, pem1 := newTestRSAKeyPEM(t) + _, pem2 := newTestRSAKeyPEM(t) + t.Setenv("AUTH_JWT_VERIFY_CERT", pem1+"\n"+pem2) + + client := NewAuthClient("", false, &logger) + assert.Len(t, client.verifyKeys, 2) + }) + + t.Run("absent_cert_leaves_verification_off", func(t *testing.T) { + t.Setenv("AUTH_JWT_VERIFY_CERT", "") + t.Setenv("AUTH_JWT_VERIFY_CERT_PATH", "") + + client := NewAuthClient("", false, &logger) + assert.Nil(t, client.verifyKeys) + }) + + t.Run("bad_cert_disables_verification_without_panic", func(t *testing.T) { + t.Setenv("AUTH_JWT_VERIFY_CERT", "-----BEGIN PUBLIC KEY-----\ngarbage\n-----END PUBLIC KEY-----") + + client := NewAuthClient("", false, &logger) + assert.Nil(t, client.verifyKeys) + }) + + t.Run("cert_path_reads_file", func(t *testing.T) { + _, pubPEM := newTestRSAKeyPEM(t) + dir := t.TempDir() + path := filepath.Join(dir, "cert.pem") + require.NoError(t, os.WriteFile(path, []byte(pubPEM), 0o600)) + + t.Setenv("AUTH_JWT_VERIFY_CERT", "") + t.Setenv("AUTH_JWT_VERIFY_CERT_PATH", path) + + client := NewAuthClient("", false, &logger) + assert.Len(t, client.verifyKeys, 1) + }) + + t.Run("cert_path_missing_file_disables_without_panic", func(t *testing.T) { + t.Setenv("AUTH_JWT_VERIFY_CERT", "") + t.Setenv("AUTH_JWT_VERIFY_CERT_PATH", filepath.Join(t.TempDir(), "does-not-exist.pem")) + + client := NewAuthClient("", false, &logger) + assert.Nil(t, client.verifyKeys) + }) + + t.Run("inline_cert_takes_precedence_over_path", func(t *testing.T) { + _, inlinePEM := newTestRSAKeyPEM(t) + t.Setenv("AUTH_JWT_VERIFY_CERT", inlinePEM) + t.Setenv("AUTH_JWT_VERIFY_CERT_PATH", filepath.Join(t.TempDir(), "unused.pem")) + + client := NewAuthClient("", false, &logger) + assert.Len(t, client.verifyKeys, 1) + }) +}