Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 8 additions & 32 deletions auth/middleware/m2m.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 19 additions & 17 deletions auth/middleware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package middleware
import (
"bytes"
"context"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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{
Expand All @@ -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 == "" {
Expand Down Expand Up @@ -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)
Expand Down
206 changes: 206 additions & 0 deletions auth/middleware/verification.go
Original file line number Diff line number Diff line change
@@ -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,
"AUTH_JWT_VERIFY_CERT unparseable, local JWT verification disabled: %v", err)

return nil, issuer
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 := os.Getenv("AUTH_JWT_VERIFY_CERT"); inline != "" {
return []byte(inline), nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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
}
Loading
Loading