diff --git a/auth/middleware/m2m.go b/auth/middleware/m2m.go new file mode 100644 index 0000000..2d00354 --- /dev/null +++ b/auth/middleware/m2m.go @@ -0,0 +1,239 @@ +package middleware + +import ( + "context" + "crypto/rsa" + "errors" + "fmt" + stdlog "log" + "net/http" + + observability "github.com/LerianStudio/lib-observability/v2" + "github.com/LerianStudio/lib-observability/v2/log" + "github.com/LerianStudio/lib-observability/v2/tracing" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "github.com/LerianStudio/lib-commons/v6/commons" + libHTTP "github.com/LerianStudio/lib-commons/v6/commons/net/http" + "github.com/gofiber/fiber/v3" + jwt "github.com/golang-jwt/jwt/v5" +) + +// Fiber locals keys under which RequireM2M stores the authenticated application +// identity for downstream handlers. Read them via the typed helpers +// (M2MSubjectFromContext / M2MClientIDFromContext), not the raw keys. +const ( + contextKeyM2MSubject = "lib_auth.m2m.subject" + contextKeyM2MClientID = "lib_auth.m2m.client_id" +) + +// M2MAuthenticator authenticates machine-to-machine (application) callers by +// verifying a Casdoor-issued RS256 access token offline against an injected +// public key (the issuer's certificate). +// +// It authenticates only; it does NOT authorize. There is no RBAC round-trip and +// no binding of the caller to a target resource or slug: possession of a valid +// M2M token — whose issuing application is provisioned only by a trusted +// operator — is the trust boundary. Callers that need per-resource ownership +// must enforce it themselves downstream. +// +// Verification is fully local (no network call, no Casdoor SDK dependency), so +// any service can reuse this by supplying its own issuer certificate. +type M2MAuthenticator struct { + publicKey *rsa.PublicKey + // expectedIssuer, when non-empty, pins the accepted token "iss" claim so a + // token minted by a different issuer sharing the same signing key is + // rejected. Empty means the issuer is not checked (acceptable only in a + // single-issuer deployment). + expectedIssuer string + enabled bool + logger log.Logger +} + +// NewM2MAuthenticator builds an M2MAuthenticator from the issuer's certificate +// (a PEM-encoded X.509 certificate or RSA public key, e.g. Casdoor's +// token_jwt_key.pem). +// +// When enabled is false the authenticator is a pass-through (RequireM2M calls +// c.Next() without inspecting the request) and the certificate may be empty — +// this mirrors AuthClient behavior for local/dev where auth is disabled. When +// enabled is true a parseable certificate is required; an empty or invalid PEM +// returns an error so a misconfigured service fails to start rather than +// silently accepting unverified tokens. +// +// expectedIssuer, when non-empty, pins the token "iss" claim (defense in depth +// against reuse of a token minted by a different issuer that shares the same +// signing key). Pass "" to skip the issuer check; this is only safe in a +// single-issuer deployment and setting it is recommended otherwise. +func NewM2MAuthenticator(certificatePEM, expectedIssuer string, enabled bool, logger *log.Logger) (*M2MAuthenticator, error) { + var l log.Logger + + if logger != nil { + l = *logger + } else { + var err error + + l, err = initializeDefaultLogger() + if err != nil { + stdlog.Printf("failed to initialize logger, using NopLogger: %v", err) + + l = log.NewNop() + } + } + + if !enabled { + return &M2MAuthenticator{publicKey: nil, expectedIssuer: expectedIssuer, enabled: false, logger: l}, nil + } + + publicKey, err := jwt.ParseRSAPublicKeyFromPEM([]byte(certificatePEM)) + if err != nil { + return nil, fmt.Errorf("failed to parse issuer certificate: %w", err) + } + + return &M2MAuthenticator{publicKey: publicKey, expectedIssuer: expectedIssuer, enabled: true, logger: l}, nil +} + +// RequireM2M is a Fiber middleware that rejects any request not carrying a +// valid, unexpired, RS256-signed Casdoor application (M2M) token. On success the +// application identity is stored in the request locals (see +// M2MSubjectFromContext / M2MClientIDFromContext) and the request proceeds. +// +// All failure modes fail closed: +// - missing token -> 401 Unauthorized +// - unparseable / bad signature / expired -> 401 Unauthorized +// - authentic token whose type is not +// "application" (e.g. a normal-user) -> 403 Forbidden +func (m *M2MAuthenticator) RequireM2M() fiber.Handler { + return func(c fiber.Ctx) error { + if !m.enabled { + return c.Next() + } + + ctx := tracing.ExtractHTTPContext(c.Context(), c) + + _, tracer, reqID, _ := observability.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "lib_auth.require_m2m") + defer span.End() + + span.SetAttributes( + attribute.String("app.request.request_id", reqID), + ) + + accessToken := libHTTP.ExtractTokenFromHeader(c) + + if commons.IsNilOrEmpty(&accessToken) { + return c.Status(http.StatusUnauthorized).SendString("Missing Token") + } + + claims, statusCode, err := m.verify(ctx, span, accessToken) + if err != nil { + return c.Status(statusCode).SendString(http.StatusText(statusCode)) + } + + subject, _ := claims["sub"].(string) + clientID, _ := claims["azp"].(string) + + c.Locals(contextKeyM2MSubject, subject) + c.Locals(contextKeyM2MClientID, clientID) + + span.SetAttributes( + attribute.String("app.auth.m2m.subject", subject), + attribute.String("app.auth.m2m.client_id", clientID), + ) + + return c.Next() + } +} + +// verify parses and cryptographically validates the access token, enforcing +// RS256 against the injected public key and the default expiry/not-before +// checks, then requires the whitelisted "application" token type. It never logs +// the token itself. +func (m *M2MAuthenticator) verify(ctx context.Context, span trace.Span, accessToken string) (jwt.MapClaims, int, error) { + if m.publicKey == nil { + err := errors.New("m2m authenticator has no verification key") + + logErrorf(ctx, m.logger, "M2M authentication misconfigured: missing verification key") + tracing.HandleSpanError(span, "M2M authentication misconfigured", err) + + 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 + }) + 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 + } + + userType, _ := claims["type"].(string) + if userType != application { + err := errors.New("token is not an application token") + + logErrorf(ctx, m.logger, "Rejected non-application token on M2M-only route: type=%q", userType) + tracing.HandleSpanError(span, "Non-application token on M2M route", err) + + return nil, http.StatusForbidden, err + } + + subject, _ := claims["sub"].(string) + if subject == "" { + err := errors.New("missing sub claim in application token") + + logErrorf(ctx, m.logger, "Missing sub claim in application token") + tracing.HandleSpanError(span, "Missing sub claim in application token", err) + + return nil, http.StatusUnauthorized, err + } + + return claims, http.StatusOK, nil +} + +// M2MSubjectFromContext returns the authenticated application subject (the "sub" +// claim, "/" form) stored by RequireM2M, or ("", false) when absent. +func M2MSubjectFromContext(c fiber.Ctx) (string, bool) { + v, ok := c.Locals(contextKeyM2MSubject).(string) + + return v, ok && v != "" +} + +// M2MClientIDFromContext returns the authenticated application client id (the +// "azp" claim) stored by RequireM2M, or ("", false) when absent. +func M2MClientIDFromContext(c fiber.Ctx) (string, bool) { + v, ok := c.Locals(contextKeyM2MClientID).(string) + + return v, ok && v != "" +} diff --git a/auth/middleware/m2m_test.go b/auth/middleware/m2m_test.go new file mode 100644 index 0000000..629c996 --- /dev/null +++ b/auth/middleware/m2m_test.go @@ -0,0 +1,429 @@ +package middleware + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/LerianStudio/lib-observability/v2/log" + "github.com/gofiber/fiber/v3" + jwt "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +// noopSpan returns a non-recording span for exercising verify() directly. +func noopSpan() trace.Span { + return trace.SpanFromContext(context.Background()) +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// newTestRSAKeyPEM generates an RSA key pair and returns the private key plus +// the PKIX PEM encoding of its public half (the form NewM2MAuthenticator parses). +func newTestRSAKeyPEM(t *testing.T) (*rsa.PrivateKey, string) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + der, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + require.NoError(t, err) + + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) + + return key, string(pemBytes) +} + +// signRS256 signs claims with the given RSA private key using RS256. +func signRS256(t *testing.T, key *rsa.PrivateKey, claims jwt.MapClaims) string { + t.Helper() + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + + signed, err := token.SignedString(key) + require.NoError(t, err) + + return signed +} + +// applicationClaims mirrors a real Casdoor client_credentials (M2M) token. +func applicationClaims() jwt.MapClaims { + return jwt.MapClaims{ + "type": "application", + "sub": "admin/3a09ac44-1faf-4e66-843c-5152b09b19dc", + "name": "3a09ac44-1faf-4e66-843c-5152b09b19dc", + "azp": "66bac70fbea746daa760", + "exp": float64(time.Now().Add(time.Hour).Unix()), + "iat": float64(time.Now().Add(-time.Minute).Unix()), + } +} + +func newTestM2MAuthenticator(t *testing.T, pubPEM string) *M2MAuthenticator { + t.Helper() + + logger := log.Logger(&testLogger{}) + + m, err := NewM2MAuthenticator(pubPEM, "", true, &logger) + require.NoError(t, err) + + return m +} + +func newTestM2MAuthenticatorWithIssuer(t *testing.T, pubPEM, issuer string) *M2MAuthenticator { + t.Helper() + + logger := log.Logger(&testLogger{}) + + m, err := NewM2MAuthenticator(pubPEM, issuer, true, &logger) + require.NoError(t, err) + + return m +} + +// newTestM2MApp builds a Fiber app whose single route is gated by RequireM2M and +// echoes the authenticated identity back through response headers. +func newTestM2MApp(m *M2MAuthenticator) *fiber.App { + app := fiber.New() + + app.Put("/declarations/:slug", m.RequireM2M(), func(c fiber.Ctx) error { + sub, _ := M2MSubjectFromContext(c) + clientID, _ := M2MClientIDFromContext(c) + + c.Set("X-M2M-Subject", sub) + c.Set("X-M2M-Client-Id", clientID) + + return c.SendStatus(http.StatusOK) + }) + + return app +} + +// --------------------------------------------------------------------------- +// NewM2MAuthenticator +// --------------------------------------------------------------------------- + +func TestNewM2MAuthenticator_DisabledAllowsEmptyCert(t *testing.T) { + t.Parallel() + + logger := log.Logger(&testLogger{}) + + m, err := NewM2MAuthenticator("", "", false, &logger) + require.NoError(t, err) + require.NotNil(t, m) + assert.False(t, m.enabled) + assert.Nil(t, m.publicKey) +} + +func TestNewM2MAuthenticator_EnabledRejectsInvalidCert(t *testing.T) { + t.Parallel() + + logger := log.Logger(&testLogger{}) + + m, err := NewM2MAuthenticator("not-a-pem", "", true, &logger) + require.Error(t, err) + assert.Nil(t, m) +} + +func TestNewM2MAuthenticator_EnabledParsesValidCert(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + + logger := log.Logger(&testLogger{}) + + m, err := NewM2MAuthenticator(pubPEM, "", true, &logger) + require.NoError(t, err) + require.NotNil(t, m) + assert.True(t, m.enabled) + assert.NotNil(t, m.publicKey) +} + +// --------------------------------------------------------------------------- +// verify - cryptographic core +// --------------------------------------------------------------------------- + +func TestVerify_ValidApplicationToken(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + token := signRS256(t, key, applicationClaims()) + + claims, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, statusCode) + assert.Equal(t, "admin/3a09ac44-1faf-4e66-843c-5152b09b19dc", claims["sub"]) + assert.Equal(t, "66bac70fbea746daa760", claims["azp"]) +} + +func TestVerify_WrongKey_FailsClosed(t *testing.T) { + t.Parallel() + + // Token signed by an attacker key that is NOT the authenticator's key. + attackerKey, _ := newTestRSAKeyPEM(t) + _, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + token := signRS256(t, attackerKey, applicationClaims()) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_AlgConfusionHS256_Rejected(t *testing.T) { + t.Parallel() + + // Classic RS/HS confusion: attacker forges an HS256 token using the public + // PEM bytes as the shared secret. WithValidMethods([]string{"RS256"}) must + // reject it before the keyfunc runs. + _, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + forged := jwt.NewWithClaims(jwt.SigningMethodHS256, applicationClaims()) + + signed, err := forged.SignedString([]byte(pubPEM)) + require.NoError(t, err) + + _, statusCode, verr := m.verify(context.Background(), noopSpan(), signed) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_AlgNone_Rejected(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + unsigned := jwt.NewWithClaims(jwt.SigningMethodNone, applicationClaims()) + + signed, err := unsigned.SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + + _, statusCode, verr := m.verify(context.Background(), noopSpan(), signed) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_ExpiredToken_Rejected(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + claims := applicationClaims() + claims["exp"] = float64(time.Now().Add(-time.Hour).Unix()) + + token := signRS256(t, key, claims) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_MissingExp_Rejected(t *testing.T) { + t.Parallel() + + // A validly-signed token that omits "exp" must be rejected (WithExpirationRequired). + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + claims := applicationClaims() + delete(claims, "exp") + + token := signRS256(t, key, claims) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_ApplicationMissingSub_Rejected(t *testing.T) { + t.Parallel() + + // A correctly-signed application token without a "sub" claim fails closed: + // the stashed identity would otherwise be empty. + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + claims := applicationClaims() + delete(claims, "sub") + + token := signRS256(t, key, claims) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_MatchingIssuer_Allows(t *testing.T) { + t.Parallel() + + const issuer = "http://plugin-access-manager-auth-backend:8000" + + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticatorWithIssuer(t, pubPEM, issuer) + + claims := applicationClaims() + claims["iss"] = issuer + + token := signRS256(t, key, claims) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, statusCode) +} + +func TestVerify_WrongIssuer_Rejected(t *testing.T) { + t.Parallel() + + // A validly-signed application token from a different issuer (sharing the same + // signing key) must be rejected when an expected issuer is pinned. + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticatorWithIssuer(t, pubPEM, "http://expected-issuer:8000") + + claims := applicationClaims() + claims["iss"] = "http://attacker-issuer:8000" + + token := signRS256(t, key, claims) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_NonApplicationType_Forbidden(t *testing.T) { + t.Parallel() + + // An authentic, correctly-signed token that is a normal-user (not M2M) must + // be rejected with 403: authentication succeeds, but the route is M2M-only. + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + claims := applicationClaims() + claims["type"] = "normal-user" + + token := signRS256(t, key, claims) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, statusCode) +} + +func TestVerify_MalformedToken_Rejected(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), "not-a-jwt") + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_NilKey_FailsClosed(t *testing.T) { + t.Parallel() + + // Defensive: an enabled authenticator with no key must deny, never allow. + m := &M2MAuthenticator{publicKey: nil, enabled: true, logger: &testLogger{}} + + _, statusCode, err := m.verify(context.Background(), noopSpan(), "any-token") + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +// --------------------------------------------------------------------------- +// RequireM2M - Fiber middleware +// --------------------------------------------------------------------------- + +func TestRequireM2M_Disabled_PassesThrough(t *testing.T) { + t.Parallel() + + logger := log.Logger(&testLogger{}) + + m, err := NewM2MAuthenticator("", "", false, &logger) + require.NoError(t, err) + + app := newTestM2MApp(m) + + // No Authorization header at all: a disabled authenticator must not block. + req := httptest.NewRequest(http.MethodPut, "/declarations/plugin-fees", nil) + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestRequireM2M_MissingToken_Unauthorized(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + app := newTestM2MApp(newTestM2MAuthenticator(t, pubPEM)) + + req := httptest.NewRequest(http.MethodPut, "/declarations/plugin-fees", nil) + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestRequireM2M_ValidToken_AllowsAndExposesIdentity(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + app := newTestM2MApp(newTestM2MAuthenticator(t, pubPEM)) + + token := signRS256(t, key, applicationClaims()) + + req := httptest.NewRequest(http.MethodPut, "/declarations/plugin-fees", nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "admin/3a09ac44-1faf-4e66-843c-5152b09b19dc", resp.Header.Get("X-M2M-Subject")) + assert.Equal(t, "66bac70fbea746daa760", resp.Header.Get("X-M2M-Client-Id")) +} + +func TestRequireM2M_NormalUserToken_Forbidden(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + app := newTestM2MApp(newTestM2MAuthenticator(t, pubPEM)) + + claims := applicationClaims() + claims["type"] = "normal-user" + + token := signRS256(t, key, claims) + + req := httptest.NewRequest(http.MethodPut, "/declarations/plugin-fees", nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +}