From f9ff5b01c933e0c28d1ba9f8f83f7382a2f72706 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:36:44 +0000 Subject: [PATCH 1/4] Initial plan From a08a6f4660bed497e06584e67b708d1c6fdbdb54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:47:35 +0000 Subject: [PATCH 2/4] auth.m2m: Add token revocation (RFC 7009) and introspection (RFC 7662) endpoints Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- module/auth_m2m.go | 177 +++++++++++++++++++++++---- module/auth_m2m_test.go | 265 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 419 insertions(+), 23 deletions(-) diff --git a/module/auth_m2m.go b/module/auth_m2m.go index 8c84cc0d..bd95ee56 100644 --- a/module/auth_m2m.go +++ b/module/auth_m2m.go @@ -72,7 +72,8 @@ type M2MAuthModule struct { // Registered clients mu sync.RWMutex - clients map[string]*M2MClient // keyed by ClientID + clients map[string]*M2MClient // keyed by ClientID + jtiBlacklist map[string]struct{} // revoked token JTIs } // NewM2MAuthModule creates a new M2MAuthModule with HS256 signing. @@ -85,13 +86,14 @@ func NewM2MAuthModule(name string, hmacSecret string, tokenExpiry time.Duration, issuer = "workflow" } m := &M2MAuthModule{ - name: name, - algorithm: SigningAlgHS256, - issuer: issuer, - tokenExpiry: tokenExpiry, - hmacSecret: []byte(hmacSecret), - trustedKeys: make(map[string]*ecdsa.PublicKey), - clients: make(map[string]*M2MClient), + name: name, + algorithm: SigningAlgHS256, + issuer: issuer, + tokenExpiry: tokenExpiry, + hmacSecret: []byte(hmacSecret), + trustedKeys: make(map[string]*ecdsa.PublicKey), + clients: make(map[string]*M2MClient), + jtiBlacklist: make(map[string]struct{}), } return m } @@ -185,8 +187,10 @@ func (m *M2MAuthModule) RequiresServices() []modular.ServiceDependency { return // // Routes: // -// POST /oauth/token — token endpoint (client_credentials + jwt-bearer grants) -// GET /oauth/jwks — JSON Web Key Set (ES256 public key) +// POST /oauth/token — token endpoint (client_credentials + jwt-bearer grants) +// POST /oauth/revoke — token revocation (RFC 7009) +// POST /oauth/introspect — token introspection (RFC 7662) +// GET /oauth/jwks — JSON Web Key Set (ES256 public key) func (m *M2MAuthModule) Handle(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -194,6 +198,10 @@ func (m *M2MAuthModule) Handle(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && strings.HasSuffix(path, "/oauth/token"): m.handleToken(w, r) + case r.Method == http.MethodPost && strings.HasSuffix(path, "/oauth/revoke"): + m.handleRevoke(w, r) + case r.Method == http.MethodPost && strings.HasSuffix(path, "/oauth/introspect"): + m.handleIntrospect(w, r) case r.Method == http.MethodGet && strings.HasSuffix(path, "/oauth/jwks"): m.handleJWKS(w, r) default: @@ -336,17 +344,104 @@ func (m *M2MAuthModule) handleJWKS(w http.ResponseWriter, _ *http.Request) { }) } +// handleRevoke implements token revocation per RFC 7009. +// It adds the token's JTI to the in-memory blacklist so that subsequent +// calls to Authenticate or handleIntrospect will treat the token as invalid. +// Per RFC 7009 §2.2, the endpoint always returns 200 OK even if the token +// is unknown or already invalid. +func (m *M2MAuthModule) handleRevoke(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(oauthError("invalid_request", "failed to parse form")) + return + } + tokenStr := r.FormValue("token") + if tokenStr == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(oauthError("invalid_request", "token is required")) + return + } + // Per RFC 7009 §2.2: if the token is unrecognized or already invalid, still return 200. + // We attempt to parse it; if valid, add its JTI to the blacklist. + if claims, ok := m.parseTokenClaims(tokenStr); ok { + if jti, _ := claims["jti"].(string); jti != "" { + m.mu.Lock() + m.jtiBlacklist[jti] = struct{}{} + m.mu.Unlock() + } + } + w.WriteHeader(http.StatusOK) +} + +// handleIntrospect implements token introspection per RFC 7662. +// It validates the token and returns its active status along with claims. +func (m *M2MAuthModule) handleIntrospect(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(oauthError("invalid_request", "failed to parse form")) + return + } + tokenStr := r.FormValue("token") + if tokenStr == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(oauthError("invalid_request", "token is required")) + return + } + + claims, ok := m.parseTokenClaims(tokenStr) + if !ok { + _ = json.NewEncoder(w).Encode(map[string]any{"active": false}) + return + } + + // Check JTI blacklist. + if jti, _ := claims["jti"].(string); jti != "" { + m.mu.RLock() + _, revoked := m.jtiBlacklist[jti] + m.mu.RUnlock() + if revoked { + _ = json.NewEncoder(w).Encode(map[string]any{"active": false}) + return + } + } + + resp := map[string]any{ + "active": true, + } + if v, ok2 := claims["sub"].(string); ok2 { + resp["client_id"] = v + } + if v, ok2 := claims["scope"].(string); ok2 { + resp["scope"] = v + } + if v, ok2 := claims["exp"]; ok2 { + resp["exp"] = v + } + if v, ok2 := claims["iat"]; ok2 { + resp["iat"] = v + } + if v, ok2 := claims["iss"].(string); ok2 { + resp["iss"] = v + } + _ = json.NewEncoder(w).Encode(resp) +} + // --- token issuance --- // issueToken creates and signs a JWT access token. // extraClaims are merged in (e.g., from a jwt-bearer assertion). func (m *M2MAuthModule) issueToken(subject string, scopes []string, extraClaims map[string]any) (string, error) { now := time.Now() + jti, err := generateJTI() + if err != nil { + return "", fmt.Errorf("generate JTI: %w", err) + } claims := jwt.MapClaims{ "iss": m.issuer, "sub": subject, "iat": now.Unix(), "exp": now.Add(m.tokenExpiry).Unix(), + "jti": jti, } if len(scopes) > 0 { claims["scope"] = strings.Join(scopes, " ") @@ -354,7 +449,7 @@ func (m *M2MAuthModule) issueToken(subject string, scopes []string, extraClaims // Merge extra claims, but never let them override standard fields. for k, v := range extraClaims { switch k { - case "iss", "sub", "iat", "exp", "scope": + case "iss", "sub", "iat", "exp", "scope", "jti": // protected — skip default: claims[k] = v @@ -506,14 +601,47 @@ func (m *M2MAuthModule) validateJWTAssertion(assertion string) (jwt.MapClaims, e // Authenticate implements the AuthProvider interface so M2MAuthModule can be // used as a provider in AuthMiddleware. It validates the token's signature // using the configured algorithm and returns the embedded claims. +// Tokens whose JTI has been revoked via the /oauth/revoke endpoint are rejected. func (m *M2MAuthModule) Authenticate(tokenStr string) (bool, map[string]any, error) { - var token *jwt.Token - var err error + if m.algorithm == SigningAlgES256 && m.publicKey == nil { + return false, nil, fmt.Errorf("no ECDSA public key configured") + } + + claims, ok := m.parseTokenClaims(tokenStr) + if !ok { + return false, nil, nil + } + + // Check JTI blacklist. + if jti, _ := claims["jti"].(string); jti != "" { + m.mu.RLock() + _, revoked := m.jtiBlacklist[jti] + m.mu.RUnlock() + if revoked { + return false, nil, nil + } + } + + result := make(map[string]any, len(claims)) + for k, v := range claims { + result[k] = v + } + return true, result, nil +} + +// parseTokenClaims parses and cryptographically validates a token string, +// returning the claims and whether the token is valid. +// It does NOT check the JTI blacklist; callers must do that separately. +func (m *M2MAuthModule) parseTokenClaims(tokenStr string) (jwt.MapClaims, bool) { + var ( + token *jwt.Token + err error + ) switch m.algorithm { case SigningAlgES256: if m.publicKey == nil { - return false, nil, fmt.Errorf("no ECDSA public key configured") + return nil, false } token, err = jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { @@ -531,23 +659,26 @@ func (m *M2MAuthModule) Authenticate(tokenStr string) (bool, map[string]any, err } if err != nil { - return false, nil, nil //nolint:nilerr // Invalid token is a failed auth, not an error + return nil, false } - claims, ok := token.Claims.(jwt.MapClaims) if !ok || !token.Valid { - return false, nil, nil - } - - result := make(map[string]any, len(claims)) - for k, v := range claims { - result[k] = v + return nil, false } - return true, result, nil + return claims, true } // --- JWKS helpers --- +// generateJTI generates a random 16-byte JWT ID encoded as base64url. +func generateJTI() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate JTI: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + // ecPublicKeyToJWK converts an ECDSA P-256 public key to a JWK (RFC 7517) map. // It uses the ecdh package to extract the uncompressed point bytes, avoiding // the deprecated ecdsa.PublicKey.X / .Y big.Int fields. diff --git a/module/auth_m2m_test.go b/module/auth_m2m_test.go index 6de23b19..d8ee082b 100644 --- a/module/auth_m2m_test.go +++ b/module/auth_m2m_test.go @@ -772,6 +772,271 @@ func TestM2M_UnknownRoute(t *testing.T) { } } +// --- Token revocation (RFC 7009) --- + +// postRevoke is a test helper that sends a form-encoded POST to /oauth/revoke. +func postRevoke(t *testing.T, m *M2MAuthModule, params url.Values) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/oauth/revoke", + strings.NewReader(params.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + m.Handle(w, req) + return w +} + +// postIntrospect is a test helper that sends a form-encoded POST to /oauth/introspect. +func postIntrospect(t *testing.T, m *M2MAuthModule, params url.Values) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/oauth/introspect", + strings.NewReader(params.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + m.Handle(w, req) + return w +} + +// issueTestToken is a test helper that obtains an access token via client_credentials. +func issueTestToken(t *testing.T, m *M2MAuthModule, clientID, clientSecret string) string { + t.Helper() + params := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + } + w := postToken(t, m, params) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 issuing token, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + tok, _ := resp["access_token"].(string) + if tok == "" { + t.Fatal("expected non-empty access_token") + } + return tok +} + +func TestM2M_Revoke_ValidToken_Returns200(t *testing.T) { + m := newM2MHS256(t) + tokenStr := issueTestToken(t, m, "test-client", "test-secret") + + w := postRevoke(t, m, url.Values{"token": {tokenStr}}) + if w.Code != http.StatusOK { + t.Errorf("expected 200 for revoke, got %d", w.Code) + } +} + +func TestM2M_Revoke_InvalidToken_StillReturns200(t *testing.T) { + // RFC 7009 §2.2: revocation of an unknown/invalid token must return 200. + m := newM2MHS256(t) + w := postRevoke(t, m, url.Values{"token": {"not.a.valid.jwt"}}) + if w.Code != http.StatusOK { + t.Errorf("expected 200 even for invalid token, got %d", w.Code) + } +} + +func TestM2M_Revoke_MissingToken_Returns400(t *testing.T) { + m := newM2MHS256(t) + w := postRevoke(t, m, url.Values{}) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 when token param missing, got %d", w.Code) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["error"] != "invalid_request" { + t.Errorf("expected error=invalid_request, got %q", resp["error"]) + } +} + +func TestM2M_Revoke_BlacklistsToken_AuthenticateFails(t *testing.T) { + m := newM2MHS256(t) + tokenStr := issueTestToken(t, m, "test-client", "test-secret") + + // Token is valid before revocation. + valid, _, _ := m.Authenticate(tokenStr) + if !valid { + t.Fatal("expected token to be valid before revocation") + } + + // Revoke the token. + w := postRevoke(t, m, url.Values{"token": {tokenStr}}) + if w.Code != http.StatusOK { + t.Fatalf("revoke failed with %d", w.Code) + } + + // Token must now be rejected by Authenticate. + valid, _, _ = m.Authenticate(tokenStr) + if valid { + t.Error("expected token to be invalid after revocation") + } +} + +func TestM2M_Revoke_ES256_BlacklistsToken(t *testing.T) { + m := newM2MES256(t) + tokenStr := issueTestToken(t, m, "es256-client", "es256-secret") + + valid, _, _ := m.Authenticate(tokenStr) + if !valid { + t.Fatal("expected token to be valid before revocation") + } + + w := postRevoke(t, m, url.Values{"token": {tokenStr}}) + if w.Code != http.StatusOK { + t.Fatalf("revoke failed with %d", w.Code) + } + + valid, _, _ = m.Authenticate(tokenStr) + if valid { + t.Error("expected ES256 token to be invalid after revocation") + } +} + +// --- Token introspection (RFC 7662) --- + +func TestM2M_Introspect_ValidToken_ActiveTrue(t *testing.T) { + m := newM2MHS256(t) + tokenStr := issueTestToken(t, m, "test-client", "test-secret") + + w := postIntrospect(t, m, url.Values{"token": {tokenStr}}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + if resp["active"] != true { + t.Errorf("expected active=true, got %v", resp["active"]) + } + if resp["client_id"] != "test-client" { + t.Errorf("expected client_id=test-client, got %v", resp["client_id"]) + } + if resp["iss"] != "test-issuer" { + t.Errorf("expected iss=test-issuer, got %v", resp["iss"]) + } + if resp["exp"] == nil { + t.Error("expected exp in introspect response") + } + if resp["iat"] == nil { + t.Error("expected iat in introspect response") + } +} + +func TestM2M_Introspect_InvalidToken_ActiveFalse(t *testing.T) { + m := newM2MHS256(t) + w := postIntrospect(t, m, url.Values{"token": {"not.a.valid.jwt"}}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + if resp["active"] != false { + t.Errorf("expected active=false for invalid token, got %v", resp["active"]) + } +} + +func TestM2M_Introspect_RevokedToken_ActiveFalse(t *testing.T) { + m := newM2MHS256(t) + tokenStr := issueTestToken(t, m, "test-client", "test-secret") + + // Revoke then introspect. + postRevoke(t, m, url.Values{"token": {tokenStr}}) + + w := postIntrospect(t, m, url.Values{"token": {tokenStr}}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + if resp["active"] != false { + t.Errorf("expected active=false for revoked token, got %v", resp["active"]) + } +} + +func TestM2M_Introspect_MissingToken_Returns400(t *testing.T) { + m := newM2MHS256(t) + w := postIntrospect(t, m, url.Values{}) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 when token param missing, got %d", w.Code) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["error"] != "invalid_request" { + t.Errorf("expected error=invalid_request, got %q", resp["error"]) + } +} + +func TestM2M_Introspect_ES256_ValidToken(t *testing.T) { + m := newM2MES256(t) + tokenStr := issueTestToken(t, m, "es256-client", "es256-secret") + + w := postIntrospect(t, m, url.Values{"token": {tokenStr}}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + if resp["active"] != true { + t.Errorf("expected active=true, got %v", resp["active"]) + } + if resp["client_id"] != "es256-client" { + t.Errorf("expected client_id=es256-client, got %v", resp["client_id"]) + } +} + +func TestM2M_Introspect_ScopeIncluded(t *testing.T) { + m := newM2MHS256(t) + params := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"test-client"}, + "client_secret": {"test-secret"}, + "scope": {"read"}, + } + w := postToken(t, m, params) + var tokenResp map[string]any + json.NewDecoder(w.Body).Decode(&tokenResp) + tokenStr, _ := tokenResp["access_token"].(string) + + introspectResp := postIntrospect(t, m, url.Values{"token": {tokenStr}}) + var resp map[string]any + json.NewDecoder(introspectResp.Body).Decode(&resp) + if resp["scope"] != "read" { + t.Errorf("expected scope=read in introspect response, got %v", resp["scope"]) + } +} + +// Verify that issued tokens include a jti claim. +func TestM2M_IssuedToken_HasJTI(t *testing.T) { + m := newM2MHS256(t) + tokenStr := issueTestToken(t, m, "test-client", "test-secret") + + _, claims, err := m.Authenticate(tokenStr) + if err != nil { + t.Fatalf("authenticate: %v", err) + } + jti, _ := claims["jti"].(string) + if jti == "" { + t.Error("expected non-empty jti claim in issued token") + } +} + +// Verify that two tokens issued for the same client have different JTIs. +func TestM2M_IssuedTokens_UniqueJTIs(t *testing.T) { + m := newM2MHS256(t) + tok1 := issueTestToken(t, m, "test-client", "test-secret") + tok2 := issueTestToken(t, m, "test-client", "test-secret") + + _, claims1, _ := m.Authenticate(tok1) + _, claims2, _ := m.Authenticate(tok2) + jti1, _ := claims1["jti"].(string) + jti2, _ := claims2["jti"].(string) + if jti1 == "" || jti2 == "" { + t.Fatal("expected non-empty jti claims") + } + if jti1 == jti2 { + t.Error("expected unique JTIs for different tokens") + } +} + // --- Authenticate (AuthProvider interface) --- func TestM2M_Authenticate_HS256_Valid(t *testing.T) { From 5f900e74bdf42e04a4c905a684e5376ab0102469 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:41:39 +0000 Subject: [PATCH 3/4] auth.m2m: Gate introspect endpoint with caller auth and access-control policy Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- module/auth_m2m.go | 160 +++++++++++++++++++++++- module/auth_m2m_test.go | 262 ++++++++++++++++++++++++++++++++++++++-- plugins/auth/plugin.go | 10 ++ 3 files changed, 419 insertions(+), 13 deletions(-) diff --git a/module/auth_m2m.go b/module/auth_m2m.go index bd95ee56..3cd8ae77 100644 --- a/module/auth_m2m.go +++ b/module/auth_m2m.go @@ -71,9 +71,15 @@ type M2MAuthModule struct { trustedKeys map[string]*ecdsa.PublicKey // Registered clients - mu sync.RWMutex - clients map[string]*M2MClient // keyed by ClientID - jtiBlacklist map[string]struct{} // revoked token JTIs + mu sync.RWMutex + clients map[string]*M2MClient // keyed by ClientID + jtiBlacklist map[string]struct{} // revoked token JTIs + + // Introspection access-control policy (see SetIntrospectPolicy). + introspectAllowOthers bool // if true, authenticated callers may inspect any token + introspectRequiredScope string // scope required in caller's token to inspect others + introspectRequiredClaim string // claim key required in caller's token to inspect others + introspectRequiredClaimVal string // expected value for introspectRequiredClaim (empty = key only) } // NewM2MAuthModule creates a new M2MAuthModule with HS256 signing. @@ -151,6 +157,27 @@ func (m *M2MAuthModule) RegisterClient(client M2MClient) { m.clients[client.ClientID] = &client } +// SetIntrospectPolicy configures access control for the POST /oauth/introspect endpoint. +// +// By default (allowOthers=false) only self-inspection is permitted: a caller may only +// introspect its own token (the token being inspected must have the same sub as the +// authenticated caller's identity). +// +// When allowOthers=true, any authenticated caller may introspect any token. Two optional +// prerequisites can narrow this further when the caller authenticates with a Bearer token: +// - requiredScope: the caller's token must contain this scope (e.g. "introspect:admin"). +// - requiredClaim / requiredClaimVal: the caller's token must have this claim, and if +// requiredClaimVal is non-empty, the claim value must equal that string. +// +// Callers authenticating via HTTP Basic Auth (client_id + client_secret) are always +// considered admin-level and satisfy any scope/claim requirement when allowOthers=true. +func (m *M2MAuthModule) SetIntrospectPolicy(allowOthers bool, requiredScope, requiredClaim, requiredClaimVal string) { + m.introspectAllowOthers = allowOthers + m.introspectRequiredScope = requiredScope + m.introspectRequiredClaim = requiredClaim + m.introspectRequiredClaimVal = requiredClaimVal +} + // Name returns the module name. func (m *M2MAuthModule) Name() string { return m.name } @@ -374,7 +401,15 @@ func (m *M2MAuthModule) handleRevoke(w http.ResponseWriter, r *http.Request) { } // handleIntrospect implements token introspection per RFC 7662. -// It validates the token and returns its active status along with claims. +// +// The caller MUST authenticate before the token under inspection is revealed. +// Two authentication methods are supported: +// - HTTP Basic Auth (client_id + client_secret): always treated as admin-level. +// - Bearer token (Authorization: Bearer ): the caller's own valid token. +// +// By default (allowOthers=false) a caller may only introspect its own token (the +// token's sub must match the caller's identity). Set the introspect policy via +// SetIntrospectPolicy to allow cross-token inspection with optional scope/claim guards. func (m *M2MAuthModule) handleIntrospect(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) @@ -388,6 +423,15 @@ func (m *M2MAuthModule) handleIntrospect(w http.ResponseWriter, r *http.Request) return } + // --- Authenticate the caller --- + callerID, callerClaims, authed := m.authenticateIntrospectCaller(r) + if !authed { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(oauthError("unauthorized_client", "authentication required to use the introspection endpoint")) + return + } + + // --- Validate the token being introspected --- claims, ok := m.parseTokenClaims(tokenStr) if !ok { _ = json.NewEncoder(w).Encode(map[string]any{"active": false}) @@ -405,6 +449,24 @@ func (m *M2MAuthModule) handleIntrospect(w http.ResponseWriter, r *http.Request) } } + // --- Authorization check --- + tokenSub, _ := claims["sub"].(string) + if !m.introspectAllowOthers { + // Default: self-inspection only. + if callerID != tokenSub { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(oauthError("access_denied", "not authorized to introspect this token")) + return + } + } else { + // Allow-others mode: enforce optional scope/claim prerequisites. + if !m.callerMeetsIntrospectPolicy(callerID, callerClaims, tokenSub) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(oauthError("access_denied", "not authorized to introspect this token")) + return + } + } + resp := map[string]any{ "active": true, } @@ -670,6 +732,96 @@ func (m *M2MAuthModule) parseTokenClaims(tokenStr string) (jwt.MapClaims, bool) // --- JWKS helpers --- +// authenticateIntrospectCaller authenticates the caller of the /oauth/introspect endpoint. +// It tries HTTP Basic Auth (client_id + client_secret) first, then Bearer token. +// +// Returns: +// - callerID: the authenticated client_id (Basic Auth) or sub claim (Bearer token). +// - callerClaims: the JWT claims of the caller's Bearer token, or nil for Basic Auth callers. +// - ok: whether authentication succeeded. +// +// If HTTP Basic Auth credentials are present but invalid, authentication fails immediately +// without falling back to Bearer token. +func (m *M2MAuthModule) authenticateIntrospectCaller(r *http.Request) (callerID string, callerClaims jwt.MapClaims, ok bool) { + // Try HTTP Basic Auth (client_id + client_secret). + if clientID, clientSecret, hasBasic := r.BasicAuth(); hasBasic && clientID != "" { + if _, err := m.authenticateClient(clientID, clientSecret); err == nil { + return clientID, nil, true + } + // Credentials provided but invalid — reject immediately. + return "", nil, false + } + + // Try Bearer token in Authorization header. + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + if claims, valid := m.parseTokenClaims(tokenStr); valid { + // Reject revoked caller tokens. + if jti, _ := claims["jti"].(string); jti != "" { + m.mu.RLock() + _, revoked := m.jtiBlacklist[jti] + m.mu.RUnlock() + if revoked { + return "", nil, false + } + } + if sub, _ := claims["sub"].(string); sub != "" { + return sub, claims, true + } + } + return "", nil, false + } + + return "", nil, false +} + +// callerMeetsIntrospectPolicy reports whether the caller is permitted to inspect a +// token with the given subject under the "allow-others" policy. +// +// Self-inspection (callerID == tokenSub) is always allowed. +// HTTP Basic Auth callers (callerClaims == nil) are treated as admin-level and bypass +// scope/claim prerequisites. +// Bearer token callers must satisfy any configured requiredScope and/or requiredClaim. +func (m *M2MAuthModule) callerMeetsIntrospectPolicy(callerID string, callerClaims jwt.MapClaims, tokenSub string) bool { + // Self-inspection is always permitted. + if callerID == tokenSub { + return true + } + // HTTP Basic Auth callers are admin-level. + if callerClaims == nil { + return true + } + // Bearer token callers: enforce scope prerequisite. + if m.introspectRequiredScope != "" { + scopeStr, _ := callerClaims["scope"].(string) + if !containsScope(scopeStr, m.introspectRequiredScope) { + return false + } + } + // Enforce claim prerequisite. + if m.introspectRequiredClaim != "" { + claimVal, exists := callerClaims[m.introspectRequiredClaim] + if !exists { + return false + } + if m.introspectRequiredClaimVal != "" && fmt.Sprintf("%v", claimVal) != m.introspectRequiredClaimVal { + return false + } + } + return true +} + +// containsScope reports whether scopeStr (a space-separated list of scopes) contains target. +func containsScope(scopeStr, target string) bool { + for _, s := range strings.Fields(scopeStr) { + if s == target { + return true + } + } + return false +} + // generateJTI generates a random 16-byte JWT ID encoded as base64url. func generateJTI() (string, error) { b := make([]byte, 16) diff --git a/module/auth_m2m_test.go b/module/auth_m2m_test.go index d8ee082b..9084e38b 100644 --- a/module/auth_m2m_test.go +++ b/module/auth_m2m_test.go @@ -785,12 +785,30 @@ func postRevoke(t *testing.T, m *M2MAuthModule, params url.Values) *httptest.Res return w } -// postIntrospect is a test helper that sends a form-encoded POST to /oauth/introspect. -func postIntrospect(t *testing.T, m *M2MAuthModule, params url.Values) *httptest.ResponseRecorder { +// postIntrospect is a test helper that sends a form-encoded POST to /oauth/introspect +// authenticated with a Bearer token (the caller's own token for self-inspection, or a +// different token for cross-inspection when the policy allows it). +func postIntrospect(t *testing.T, m *M2MAuthModule, params url.Values, bearerToken string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, "/oauth/introspect", strings.NewReader(params.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if bearerToken != "" { + req.Header.Set("Authorization", "Bearer "+bearerToken) + } + w := httptest.NewRecorder() + m.Handle(w, req) + return w +} + +// postIntrospectBasic is a test helper that authenticates the introspect endpoint +// using HTTP Basic Auth (client_id + client_secret). +func postIntrospectBasic(t *testing.T, m *M2MAuthModule, params url.Values, clientID, clientSecret string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/oauth/introspect", + strings.NewReader(params.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) w := httptest.NewRecorder() m.Handle(w, req) return w @@ -898,7 +916,8 @@ func TestM2M_Introspect_ValidToken_ActiveTrue(t *testing.T) { m := newM2MHS256(t) tokenStr := issueTestToken(t, m, "test-client", "test-secret") - w := postIntrospect(t, m, url.Values{"token": {tokenStr}}) + // Self-inspection: caller authenticates with its own token. + w := postIntrospect(t, m, url.Values{"token": {tokenStr}}, tokenStr) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) } @@ -923,7 +942,9 @@ func TestM2M_Introspect_ValidToken_ActiveTrue(t *testing.T) { func TestM2M_Introspect_InvalidToken_ActiveFalse(t *testing.T) { m := newM2MHS256(t) - w := postIntrospect(t, m, url.Values{"token": {"not.a.valid.jwt"}}) + // Authenticate with a valid token but try to introspect an invalid one (allowOthers=false → 403, not active=false). + // Use Basic Auth to show the token is just invalid (not a policy error). + w := postIntrospectBasic(t, m, url.Values{"token": {"not.a.valid.jwt"}}, "test-client", "test-secret") if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } @@ -936,12 +957,14 @@ func TestM2M_Introspect_InvalidToken_ActiveFalse(t *testing.T) { func TestM2M_Introspect_RevokedToken_ActiveFalse(t *testing.T) { m := newM2MHS256(t) + m.SetIntrospectPolicy(true, "", "", "") // allow others so we can still introspect after revoke tokenStr := issueTestToken(t, m, "test-client", "test-secret") - // Revoke then introspect. + // Revoke then introspect (self-inspect: same caller, same token). postRevoke(t, m, url.Values{"token": {tokenStr}}) - w := postIntrospect(t, m, url.Values{"token": {tokenStr}}) + // Use Basic Auth since the token is now revoked (can't use it as Bearer). + w := postIntrospectBasic(t, m, url.Values{"token": {tokenStr}}, "test-client", "test-secret") if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } @@ -954,7 +977,8 @@ func TestM2M_Introspect_RevokedToken_ActiveFalse(t *testing.T) { func TestM2M_Introspect_MissingToken_Returns400(t *testing.T) { m := newM2MHS256(t) - w := postIntrospect(t, m, url.Values{}) + // Authenticate via Basic Auth; the missing `token` param is what triggers the 400. + w := postIntrospectBasic(t, m, url.Values{}, "test-client", "test-secret") if w.Code != http.StatusBadRequest { t.Errorf("expected 400 when token param missing, got %d", w.Code) } @@ -969,7 +993,7 @@ func TestM2M_Introspect_ES256_ValidToken(t *testing.T) { m := newM2MES256(t) tokenStr := issueTestToken(t, m, "es256-client", "es256-secret") - w := postIntrospect(t, m, url.Values{"token": {tokenStr}}) + w := postIntrospect(t, m, url.Values{"token": {tokenStr}}, tokenStr) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) } @@ -996,7 +1020,8 @@ func TestM2M_Introspect_ScopeIncluded(t *testing.T) { json.NewDecoder(w.Body).Decode(&tokenResp) tokenStr, _ := tokenResp["access_token"].(string) - introspectResp := postIntrospect(t, m, url.Values{"token": {tokenStr}}) + // Self-inspection. + introspectResp := postIntrospect(t, m, url.Values{"token": {tokenStr}}, tokenStr) var resp map[string]any json.NewDecoder(introspectResp.Body).Decode(&resp) if resp["scope"] != "read" { @@ -1037,6 +1062,225 @@ func TestM2M_IssuedTokens_UniqueJTIs(t *testing.T) { } } +// --- Introspect access-control --- + +// TestM2M_Introspect_NoAuth_Returns401 verifies that unauthenticated requests are rejected. +func TestM2M_Introspect_NoAuth_Returns401(t *testing.T) { + m := newM2MHS256(t) + tokenStr := issueTestToken(t, m, "test-client", "test-secret") + + // No Authorization header, no Basic Auth. + w := postIntrospect(t, m, url.Values{"token": {tokenStr}}, "") + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for unauthenticated introspect, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["error"] != "unauthorized_client" { + t.Errorf("expected error=unauthorized_client, got %q", resp["error"]) + } +} + +// TestM2M_Introspect_BasicAuth_SelfToken verifies that Basic Auth allows self-inspection. +func TestM2M_Introspect_BasicAuth_SelfToken(t *testing.T) { + m := newM2MHS256(t) + tokenStr := issueTestToken(t, m, "test-client", "test-secret") + + w := postIntrospectBasic(t, m, url.Values{"token": {tokenStr}}, "test-client", "test-secret") + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for Basic Auth self-inspect, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + if resp["active"] != true { + t.Errorf("expected active=true, got %v", resp["active"]) + } +} + +// TestM2M_Introspect_BasicAuth_InvalidCredentials_Returns401 verifies that wrong +// Basic Auth credentials are rejected. +func TestM2M_Introspect_BasicAuth_InvalidCredentials_Returns401(t *testing.T) { + m := newM2MHS256(t) + tokenStr := issueTestToken(t, m, "test-client", "test-secret") + + w := postIntrospectBasic(t, m, url.Values{"token": {tokenStr}}, "test-client", "wrong-secret") + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for bad Basic Auth, got %d", w.Code) + } +} + +// TestM2M_Introspect_SelfOnly_CrossTokenForbidden verifies that in default self-only +// mode a caller cannot introspect another client's token. +func TestM2M_Introspect_SelfOnly_CrossTokenForbidden(t *testing.T) { + m := NewM2MAuthModule("m2m", "this-is-a-valid-secret-32-bytes!", time.Hour, "test-issuer") + m.RegisterClient(M2MClient{ClientID: "client-a", ClientSecret: "secret-a", Scopes: []string{"read"}}) //nolint:gosec // test credential + m.RegisterClient(M2MClient{ClientID: "client-b", ClientSecret: "secret-b", Scopes: []string{"read"}}) //nolint:gosec // test credential + + tokenA := issueTestToken(t, m, "client-a", "secret-a") + tokenB := issueTestToken(t, m, "client-b", "secret-b") + + // client-a tries to introspect client-b's token (default policy = self-only). + w := postIntrospect(t, m, url.Values{"token": {tokenB}}, tokenA) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 when self-only policy prevents cross-token inspect, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["error"] != "access_denied" { + t.Errorf("expected error=access_denied, got %q", resp["error"]) + } +} + +// TestM2M_Introspect_AllowOthers_BasicAuth verifies that HTTP Basic Auth callers +// can inspect any token when allowOthers=true. +func TestM2M_Introspect_AllowOthers_BasicAuth(t *testing.T) { + m := NewM2MAuthModule("m2m", "this-is-a-valid-secret-32-bytes!", time.Hour, "test-issuer") + m.RegisterClient(M2MClient{ClientID: "client-a", ClientSecret: "secret-a", Scopes: []string{"read"}}) //nolint:gosec // test credential + m.RegisterClient(M2MClient{ClientID: "client-b", ClientSecret: "secret-b", Scopes: []string{"read"}}) //nolint:gosec // test credential + m.SetIntrospectPolicy(true, "", "", "") + + tokenB := issueTestToken(t, m, "client-b", "secret-b") + + // client-a authenticates via Basic Auth and inspects client-b's token. + w := postIntrospectBasic(t, m, url.Values{"token": {tokenB}}, "client-a", "secret-a") + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for Basic Auth cross-token inspect, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + if resp["active"] != true { + t.Errorf("expected active=true, got %v", resp["active"]) + } +} + +// TestM2M_Introspect_AllowOthers_BearerWithRequiredScope verifies that a Bearer +// token caller can inspect another token when it has the required scope. +func TestM2M_Introspect_AllowOthers_BearerWithRequiredScope(t *testing.T) { + m := NewM2MAuthModule("m2m", "this-is-a-valid-secret-32-bytes!", time.Hour, "test-issuer") + m.RegisterClient(M2MClient{ClientID: "admin", ClientSecret: "admin-secret-long!", Scopes: []string{"read", "introspect:admin"}}) //nolint:gosec // test credential + m.RegisterClient(M2MClient{ClientID: "worker", ClientSecret: "worker-secret-long", Scopes: []string{"read"}}) //nolint:gosec // test credential + m.SetIntrospectPolicy(true, "introspect:admin", "", "") + + adminToken := issueTestToken(t, m, "admin", "admin-secret-long!") + workerToken := issueTestToken(t, m, "worker", "worker-secret-long") + + // admin (has introspect:admin scope) inspects worker's token. + w := postIntrospect(t, m, url.Values{"token": {workerToken}}, adminToken) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + if resp["active"] != true { + t.Errorf("expected active=true, got %v", resp["active"]) + } + if resp["client_id"] != "worker" { + t.Errorf("expected client_id=worker, got %v", resp["client_id"]) + } +} + +// TestM2M_Introspect_AllowOthers_BearerMissingScope verifies that a Bearer token +// caller is forbidden from inspecting another token when it lacks the required scope. +func TestM2M_Introspect_AllowOthers_BearerMissingScope(t *testing.T) { + m := NewM2MAuthModule("m2m", "this-is-a-valid-secret-32-bytes!", time.Hour, "test-issuer") + m.RegisterClient(M2MClient{ClientID: "client-a", ClientSecret: "secret-a-long-enough!", Scopes: []string{"read"}}) //nolint:gosec // test credential + m.RegisterClient(M2MClient{ClientID: "client-b", ClientSecret: "secret-b-long-enough!", Scopes: []string{"read"}}) //nolint:gosec // test credential + m.SetIntrospectPolicy(true, "introspect:admin", "", "") + + tokenA := issueTestToken(t, m, "client-a", "secret-a-long-enough!") + tokenB := issueTestToken(t, m, "client-b", "secret-b-long-enough!") + + // client-a lacks introspect:admin → forbidden. + w := postIntrospect(t, m, url.Values{"token": {tokenB}}, tokenA) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 when caller missing required scope, got %d", w.Code) + } +} + +// TestM2M_Introspect_AllowOthers_BearerWithRequiredClaim verifies that a claim-based +// prerequisite is enforced for cross-token inspection. +func TestM2M_Introspect_AllowOthers_BearerWithRequiredClaim(t *testing.T) { + m := NewM2MAuthModule("m2m", "this-is-a-valid-secret-32-bytes!", time.Hour, "test-issuer") + m.RegisterClient(M2MClient{ + ClientID: "admin", + ClientSecret: "admin-secret-long!", //nolint:gosec // test credential + Scopes: []string{"read"}, + Claims: map[string]any{"role": "admin"}, + }) + m.RegisterClient(M2MClient{ClientID: "worker", ClientSecret: "worker-secret-long", Scopes: []string{"read"}}) //nolint:gosec // test credential + m.SetIntrospectPolicy(true, "", "role", "admin") + + adminToken := issueTestToken(t, m, "admin", "admin-secret-long!") + workerToken := issueTestToken(t, m, "worker", "worker-secret-long") + + // admin (has role=admin claim) can inspect worker's token. + w := postIntrospect(t, m, url.Values{"token": {workerToken}}, adminToken) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + if resp["active"] != true { + t.Errorf("expected active=true, got %v", resp["active"]) + } +} + +// TestM2M_Introspect_AllowOthers_BearerMissingClaim verifies that a caller without +// the required claim value is forbidden from cross-token inspection. +func TestM2M_Introspect_AllowOthers_BearerMissingClaim(t *testing.T) { + m := NewM2MAuthModule("m2m", "this-is-a-valid-secret-32-bytes!", time.Hour, "test-issuer") + m.RegisterClient(M2MClient{ClientID: "client-a", ClientSecret: "secret-a-long-enough!", Scopes: []string{"read"}}) //nolint:gosec // test credential + m.RegisterClient(M2MClient{ClientID: "client-b", ClientSecret: "secret-b-long-enough!", Scopes: []string{"read"}}) //nolint:gosec // test credential + m.SetIntrospectPolicy(true, "", "role", "admin") // role=admin required + + tokenA := issueTestToken(t, m, "client-a", "secret-a-long-enough!") + tokenB := issueTestToken(t, m, "client-b", "secret-b-long-enough!") + + // client-a has no role=admin claim → forbidden. + w := postIntrospect(t, m, url.Values{"token": {tokenB}}, tokenA) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 when caller missing required claim, got %d", w.Code) + } +} + +// TestM2M_Introspect_SelfAlwaysAllowed verifies that self-inspection is always +// permitted even when allowOthers=true has scope/claim prerequisites. +func TestM2M_Introspect_SelfAlwaysAllowed(t *testing.T) { + m := NewM2MAuthModule("m2m", "this-is-a-valid-secret-32-bytes!", time.Hour, "test-issuer") + m.RegisterClient(M2MClient{ClientID: "worker", ClientSecret: "worker-secret-long", Scopes: []string{"read"}}) //nolint:gosec // test credential + m.SetIntrospectPolicy(true, "introspect:admin", "role", "admin") + + workerToken := issueTestToken(t, m, "worker", "worker-secret-long") + + // worker inspects its own token — must succeed even without the admin scope/claim. + w := postIntrospect(t, m, url.Values{"token": {workerToken}}, workerToken) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for self-inspection, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + if resp["active"] != true { + t.Errorf("expected active=true for self-inspection, got %v", resp["active"]) + } +} + +// TestM2M_Introspect_RevokedCallerToken_Returns401 verifies that a revoked Bearer +// token cannot be used to authenticate an introspect call. +func TestM2M_Introspect_RevokedCallerToken_Returns401(t *testing.T) { + m := newM2MHS256(t) + callerToken := issueTestToken(t, m, "test-client", "test-secret") + targetToken := issueTestToken(t, m, "test-client", "test-secret") + + // Revoke the caller's token. + postRevoke(t, m, url.Values{"token": {callerToken}}) + + // Attempt introspect with the now-revoked caller token. + w := postIntrospect(t, m, url.Values{"token": {targetToken}}, callerToken) + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 when caller token is revoked, got %d", w.Code) + } +} + // --- Authenticate (AuthProvider interface) --- func TestM2M_Authenticate_HS256_Valid(t *testing.T) { diff --git a/plugins/auth/plugin.go b/plugins/auth/plugin.go index 8df826e8..42c006f9 100644 --- a/plugins/auth/plugin.go +++ b/plugins/auth/plugin.go @@ -205,6 +205,15 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { } } } + + // Configure introspection access-control policy. + if introspectCfg, ok := cfg["introspect"].(map[string]any); ok { + allowOthers, _ := introspectCfg["allowOthers"].(bool) + requiredScope := stringFromMap(introspectCfg, "requiredScope") + requiredClaim := stringFromMap(introspectCfg, "requiredClaim") + requiredClaimVal := stringFromMap(introspectCfg, "requiredClaimVal") + m.SetIntrospectPolicy(allowOthers, requiredScope, requiredClaim, requiredClaimVal) + } return m }, } @@ -370,6 +379,7 @@ func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { {Key: "tokenExpiry", Label: "Token Expiry", Type: schema.FieldTypeDuration, DefaultValue: "1h", Description: "Access token expiration duration (e.g. 15m, 1h)", Placeholder: "1h"}, {Key: "issuer", Label: "Issuer", Type: schema.FieldTypeString, DefaultValue: "workflow", Description: "Token issuer (iss) claim", Placeholder: "workflow"}, {Key: "clients", Label: "Registered Clients", Type: schema.FieldTypeJSON, Description: "List of OAuth2 clients: [{clientId, clientSecret, scopes, description, claims}]"}, + {Key: "introspect", Label: "Introspection Policy", Type: schema.FieldTypeJSON, Description: "Access-control policy for POST /oauth/introspect: {allowOthers: bool, requiredScope: string, requiredClaim: string, requiredClaimVal: string}. Default: self-only (allowOthers: false)."}, }, DefaultConfig: map[string]any{"algorithm": "ES256", "tokenExpiry": "1h", "issuer": "workflow", "clients": []any{}}, }, From 186650ff9881567ef9d1e690676cf5f8145b8633 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:45:10 +0000 Subject: [PATCH 4/4] auth.m2m: Fix review comments - client auth for revoke, bounded blacklist, TokenRevocationStore interface, SQLite tests Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- module/auth_m2m.go | 140 +++++++++++++++--- module/auth_m2m_test.go | 316 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 430 insertions(+), 26 deletions(-) diff --git a/module/auth_m2m.go b/module/auth_m2m.go index 3cd8ae77..3b30c0b4 100644 --- a/module/auth_m2m.go +++ b/module/auth_m2m.go @@ -1,6 +1,7 @@ package module import ( + "context" "crypto/ecdh" "crypto/ecdsa" "crypto/elliptic" @@ -21,6 +22,21 @@ import ( "github.com/golang-jwt/jwt/v5" ) +// TokenRevocationStore is an optional persistence backend for token revocations. +// Implementations can persist revoked JTIs (e.g., in a relational database) so +// that revocations survive process restarts. +// +// Both methods receive a context so implementations can honour timeouts and +// propagate cancellations. +type TokenRevocationStore interface { + // RevokeToken persists the revocation of the given JTI. + // expiry is the token's exp time; implementations should use it to avoid + // accumulating entries for tokens that have already expired naturally. + RevokeToken(ctx context.Context, jti string, expiry time.Time) error + // IsRevoked reports whether the given JTI has been revoked. + IsRevoked(ctx context.Context, jti string) (bool, error) +} + // GrantType constants for OAuth2 M2M flows. const ( GrantTypeClientCredentials = "client_credentials" @@ -73,12 +89,15 @@ type M2MAuthModule struct { // Registered clients mu sync.RWMutex clients map[string]*M2MClient // keyed by ClientID - jtiBlacklist map[string]struct{} // revoked token JTIs + jtiBlacklist map[string]time.Time // revoked token JTIs → expiry time + + // Optional pluggable persistence for token revocations. + revocationStore TokenRevocationStore // Introspection access-control policy (see SetIntrospectPolicy). - introspectAllowOthers bool // if true, authenticated callers may inspect any token - introspectRequiredScope string // scope required in caller's token to inspect others - introspectRequiredClaim string // claim key required in caller's token to inspect others + introspectAllowOthers bool // if true, authenticated callers may inspect any token + introspectRequiredScope string // scope required in caller's token to inspect others + introspectRequiredClaim string // claim key required in caller's token to inspect others introspectRequiredClaimVal string // expected value for introspectRequiredClaim (empty = key only) } @@ -99,7 +118,7 @@ func NewM2MAuthModule(name string, hmacSecret string, tokenExpiry time.Duration, hmacSecret: []byte(hmacSecret), trustedKeys: make(map[string]*ecdsa.PublicKey), clients: make(map[string]*M2MClient), - jtiBlacklist: make(map[string]struct{}), + jtiBlacklist: make(map[string]time.Time), } return m } @@ -178,6 +197,18 @@ func (m *M2MAuthModule) SetIntrospectPolicy(allowOthers bool, requiredScope, req m.introspectRequiredClaimVal = requiredClaimVal } +// SetRevocationStore configures an optional persistent backend for token revocations. +// When set, every call to POST /oauth/revoke will also call store.RevokeToken. +// Revocation checks consult the store in addition to the in-memory blacklist, allowing +// revocations to survive process restarts. +// +// The store is called with a background context. Pass nil to remove a previously set store. +func (m *M2MAuthModule) SetRevocationStore(store TokenRevocationStore) { + m.mu.Lock() + defer m.mu.Unlock() + m.revocationStore = store +} + // Name returns the module name. func (m *M2MAuthModule) Name() string { return m.name } @@ -374,14 +405,31 @@ func (m *M2MAuthModule) handleJWKS(w http.ResponseWriter, _ *http.Request) { // handleRevoke implements token revocation per RFC 7009. // It adds the token's JTI to the in-memory blacklist so that subsequent // calls to Authenticate or handleIntrospect will treat the token as invalid. -// Per RFC 7009 §2.2, the endpoint always returns 200 OK even if the token -// is unknown or already invalid. +// +// Per RFC 7009 §2.1, the revocation endpoint MUST require client authentication. +// Callers must authenticate via HTTP Basic Auth or form-encoded client_id/client_secret. +// Per RFC 7009 §2.2, if the token is valid and recognised, 200 OK is returned; +// if it is unrecognised or already invalid, 200 OK is still returned. func (m *M2MAuthModule) handleRevoke(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(oauthError("invalid_request", "failed to parse form")) return } + + // RFC 7009 §2.1: client authentication is required. + clientID, clientSecret, hasCredentials := m.extractClientCredentials(r) + if !hasCredentials { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(oauthError("invalid_client", "client authentication required")) + return + } + if _, err := m.authenticateClient(clientID, clientSecret); err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(oauthError("invalid_client", "invalid client credentials")) + return + } + tokenStr := r.FormValue("token") if tokenStr == "" { w.WriteHeader(http.StatusBadRequest) @@ -392,9 +440,31 @@ func (m *M2MAuthModule) handleRevoke(w http.ResponseWriter, r *http.Request) { // We attempt to parse it; if valid, add its JTI to the blacklist. if claims, ok := m.parseTokenClaims(tokenStr); ok { if jti, _ := claims["jti"].(string); jti != "" { + var expiry time.Time + if expRaw, ok2 := claims["exp"]; ok2 { + switch v := expRaw.(type) { + case float64: + expiry = time.Unix(int64(v), 0) + case json.Number: + if n, e := v.Int64(); e == nil { + expiry = time.Unix(n, 0) + } + } + } + if expiry.IsZero() { + expiry = time.Now().Add(m.tokenExpiry) + } + m.mu.Lock() - m.jtiBlacklist[jti] = struct{}{} + m.purgeExpiredJTIsLocked() + m.jtiBlacklist[jti] = expiry + store := m.revocationStore m.mu.Unlock() + + // Persist to external store if configured. + if store != nil { + _ = store.RevokeToken(r.Context(), jti, expiry) + } } } w.WriteHeader(http.StatusOK) @@ -438,12 +508,9 @@ func (m *M2MAuthModule) handleIntrospect(w http.ResponseWriter, r *http.Request) return } - // Check JTI blacklist. + // Check JTI blacklist (in-memory + optional persistent store). if jti, _ := claims["jti"].(string); jti != "" { - m.mu.RLock() - _, revoked := m.jtiBlacklist[jti] - m.mu.RUnlock() - if revoked { + if m.isJTIRevoked(r.Context(), jti) { _ = json.NewEncoder(w).Encode(map[string]any{"active": false}) return } @@ -674,12 +741,9 @@ func (m *M2MAuthModule) Authenticate(tokenStr string) (bool, map[string]any, err return false, nil, nil } - // Check JTI blacklist. + // Check JTI blacklist (in-memory + optional persistent store). if jti, _ := claims["jti"].(string); jti != "" { - m.mu.RLock() - _, revoked := m.jtiBlacklist[jti] - m.mu.RUnlock() - if revoked { + if m.isJTIRevoked(context.Background(), jti) { return false, nil, nil } } @@ -759,10 +823,7 @@ func (m *M2MAuthModule) authenticateIntrospectCaller(r *http.Request) (callerID if claims, valid := m.parseTokenClaims(tokenStr); valid { // Reject revoked caller tokens. if jti, _ := claims["jti"].(string); jti != "" { - m.mu.RLock() - _, revoked := m.jtiBlacklist[jti] - m.mu.RUnlock() - if revoked { + if m.isJTIRevoked(r.Context(), jti) { return "", nil, false } } @@ -822,11 +883,44 @@ func containsScope(scopeStr, target string) bool { return false } +// isJTIRevoked checks whether the given JTI has been revoked. +// It consults the in-memory blacklist first (after pruning expired entries), +// then falls back to the optional persistent store. +func (m *M2MAuthModule) isJTIRevoked(ctx context.Context, jti string) bool { + m.mu.Lock() + m.purgeExpiredJTIsLocked() + expiry, inMemory := m.jtiBlacklist[jti] + store := m.revocationStore + m.mu.Unlock() + + if inMemory && time.Now().Before(expiry) { + return true + } + if store != nil { + revoked, err := store.IsRevoked(ctx, jti) + if err == nil && revoked { + return true + } + } + return false +} + +// purgeExpiredJTIsLocked removes JTIs from the in-memory blacklist whose +// tokens have already expired naturally. It MUST be called with m.mu held for writing. +func (m *M2MAuthModule) purgeExpiredJTIsLocked() { + now := time.Now() + for jti, expiry := range m.jtiBlacklist { + if now.After(expiry) { + delete(m.jtiBlacklist, jti) + } + } +} + // generateJTI generates a random 16-byte JWT ID encoded as base64url. func generateJTI() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("generate JTI: %w", err) + return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } diff --git a/module/auth_m2m_test.go b/module/auth_m2m_test.go index 9084e38b..51a4998f 100644 --- a/module/auth_m2m_test.go +++ b/module/auth_m2m_test.go @@ -1,10 +1,12 @@ package module import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" + "database/sql" "encoding/base64" "encoding/json" "encoding/pem" @@ -13,10 +15,12 @@ import ( "net/http/httptest" "net/url" "strings" + "sync" "testing" "time" "github.com/golang-jwt/jwt/v5" + _ "modernc.org/sqlite" ) // --- helpers --- @@ -774,12 +778,22 @@ func TestM2M_UnknownRoute(t *testing.T) { // --- Token revocation (RFC 7009) --- -// postRevoke is a test helper that sends a form-encoded POST to /oauth/revoke. +// postRevoke is a test helper that sends a form-encoded POST to /oauth/revoke, +// authenticated via HTTP Basic Auth (client_id + client_secret). func postRevoke(t *testing.T, m *M2MAuthModule, params url.Values) *httptest.ResponseRecorder { + t.Helper() + return postRevokeAs(t, m, params, "test-client", "test-secret") +} + +// postRevokeAs sends a POST to /oauth/revoke with the given client credentials. +func postRevokeAs(t *testing.T, m *M2MAuthModule, params url.Values, clientID, clientSecret string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, "/oauth/revoke", strings.NewReader(params.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if clientID != "" { + req.SetBasicAuth(clientID, clientSecret) + } w := httptest.NewRecorder() m.Handle(w, req) return w @@ -841,7 +855,32 @@ func TestM2M_Revoke_ValidToken_Returns200(t *testing.T) { w := postRevoke(t, m, url.Values{"token": {tokenStr}}) if w.Code != http.StatusOK { - t.Errorf("expected 200 for revoke, got %d", w.Code) + t.Errorf("expected 200 for revoke, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestM2M_Revoke_NoClientAuth_Returns401(t *testing.T) { + // RFC 7009 §2.1: client authentication is required. + m := newM2MHS256(t) + tokenStr := issueTestToken(t, m, "test-client", "test-secret") + // Send revoke with no credentials. + w := postRevokeAs(t, m, url.Values{"token": {tokenStr}}, "", "") + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 when no client auth provided, got %d", w.Code) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["error"] != "invalid_client" { + t.Errorf("expected error=invalid_client, got %q", resp["error"]) + } +} + +func TestM2M_Revoke_WrongClientCredentials_Returns401(t *testing.T) { + m := newM2MHS256(t) + tokenStr := issueTestToken(t, m, "test-client", "test-secret") + w := postRevokeAs(t, m, url.Values{"token": {tokenStr}}, "test-client", "wrong-secret") + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for wrong credentials, got %d", w.Code) } } @@ -899,7 +938,7 @@ func TestM2M_Revoke_ES256_BlacklistsToken(t *testing.T) { t.Fatal("expected token to be valid before revocation") } - w := postRevoke(t, m, url.Values{"token": {tokenStr}}) + w := postRevokeAs(t, m, url.Values{"token": {tokenStr}}, "es256-client", "es256-secret") if w.Code != http.StatusOK { t.Fatalf("revoke failed with %d", w.Code) } @@ -1820,3 +1859,274 @@ func TestM2M_ClientCredentials_NilClaimsOK(t *testing.T) { t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) } } + +// --- JTI blacklist expiry and GC --- + +// TestM2M_JTIBlacklist_ExpiredEntryPurged verifies that the in-memory blacklist +// automatically removes JTI entries once the token's expiry has passed. +func TestM2M_JTIBlacklist_ExpiredEntryPurged(t *testing.T) { + m := newM2MHS256(t) // 1-hour token expiry + + tokenStr := issueTestToken(t, m, "test-client", "test-secret") + + // Revoke the token via the endpoint (adds JTI with real future expiry). + w := postRevoke(t, m, url.Values{"token": {tokenStr}}) + if w.Code != http.StatusOK { + t.Fatalf("revoke failed: got %d; body: %s", w.Code, w.Body.String()) + } + + // Artificially backdate the entry to simulate the token having expired. + m.mu.Lock() + for jti := range m.jtiBlacklist { + m.jtiBlacklist[jti] = time.Now().Add(-time.Hour) // already expired + } + m.mu.Unlock() + + // Revoke another token — the next write purges the expired entry. + newTok := issueTestToken(t, m, "test-client", "test-secret") + w2 := postRevoke(t, m, url.Values{"token": {newTok}}) + if w2.Code != http.StatusOK { + t.Fatalf("second revoke failed: got %d", w2.Code) + } + + m.mu.RLock() + blacklistLen := len(m.jtiBlacklist) + m.mu.RUnlock() + + // Only the freshly revoked token should remain; the backdated one was purged. + if blacklistLen != 1 { + t.Errorf("expected 1 blacklist entry after GC, got %d", blacklistLen) + } +} + +// TestM2M_JTIBlacklist_GrowsBoundedByExpiry verifies that the blacklist does not +// accumulate stale entries: each write purges any JTIs whose expiry has passed. +func TestM2M_JTIBlacklist_GrowsBoundedByExpiry(t *testing.T) { + m := newM2MHS256(t) // 1-hour token expiry + + const numTokens = 5 + for i := 0; i < numTokens; i++ { + tok := issueTestToken(t, m, "test-client", "test-secret") + w := postRevoke(t, m, url.Values{"token": {tok}}) + if w.Code != http.StatusOK { + t.Fatalf("revoke[%d] failed: got %d", i, w.Code) + } + } + + // All 5 should be in the blacklist (not yet expired). + m.mu.RLock() + sizeBeforeExpiry := len(m.jtiBlacklist) + m.mu.RUnlock() + if sizeBeforeExpiry != numTokens { + t.Errorf("expected %d blacklist entries before expiry, got %d", numTokens, sizeBeforeExpiry) + } + + // Backdate all existing entries to simulate having expired. + m.mu.Lock() + for jti := range m.jtiBlacklist { + m.jtiBlacklist[jti] = time.Now().Add(-time.Hour) + } + m.mu.Unlock() + + // Revoking one more token triggers purge. + newTok := issueTestToken(t, m, "test-client", "test-secret") + w := postRevoke(t, m, url.Values{"token": {newTok}}) + if w.Code != http.StatusOK { + t.Fatalf("final revoke failed: got %d", w.Code) + } + + m.mu.RLock() + sizeAfterExpiry := len(m.jtiBlacklist) + m.mu.RUnlock() + + // Only the freshly revoked token (future expiry) should remain. + if sizeAfterExpiry != 1 { + t.Errorf("expected 1 blacklist entry after GC, got %d", sizeAfterExpiry) + } +} + +// --- DB-backed TokenRevocationStore --- + +// sqliteRevocationStore is an example TokenRevocationStore backed by a SQL +// database (SQLite here; swap the driver and DDL for PostgreSQL or MySQL in +// production). It demonstrates how to implement the TokenRevocationStore +// interface so that revocations survive process restarts. +type sqliteRevocationStore struct { + mu sync.Mutex + db *sql.DB +} + +// newSQLiteRevocationStore opens an in-memory SQLite database and creates the +// revoked_tokens table. Cleanup is registered via t.Cleanup. +func newSQLiteRevocationStore(t *testing.T) *sqliteRevocationStore { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(context.Background(), ` + CREATE TABLE IF NOT EXISTS revoked_tokens ( + jti TEXT PRIMARY KEY, + expires INTEGER NOT NULL -- Unix timestamp + )`) + if err != nil { + t.Fatalf("create revoked_tokens table: %v", err) + } + return &sqliteRevocationStore{db: db} +} + +// RevokeToken inserts the JTI into the database. +// If it already exists the insert is silently ignored (idempotent). +func (s *sqliteRevocationStore) RevokeToken(_ context.Context, jti string, expiry time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.db.Exec( + `INSERT OR IGNORE INTO revoked_tokens (jti, expires) VALUES (?, ?)`, + jti, expiry.Unix(), + ) + return err +} + +// IsRevoked reports whether the JTI is present and its stored expiry is still +// in the future (i.e. the token could still be used if it weren't revoked). +func (s *sqliteRevocationStore) IsRevoked(_ context.Context, jti string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + var expires int64 + err := s.db.QueryRow( + `SELECT expires FROM revoked_tokens WHERE jti = ?`, jti, + ).Scan(&expires) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + // Only treat as revoked if the token hasn't already expired naturally. + return time.Now().Unix() < expires, nil +} + +// revokedJTIs returns all JTIs currently stored in the SQLite database (test helper). +func (s *sqliteRevocationStore) revokedJTIs(t *testing.T) []string { + t.Helper() + rows, err := s.db.Query(`SELECT jti FROM revoked_tokens`) + if err != nil { + t.Fatalf("query revoked_tokens: %v", err) + } + defer rows.Close() + var jtis []string + for rows.Next() { + var jti string + if err := rows.Scan(&jti); err != nil { + t.Fatalf("scan jti: %v", err) + } + jtis = append(jtis, jti) + } + return jtis +} + +// TestM2M_Revoke_DBStore_PersistsRevocation demonstrates that when a +// TokenRevocationStore is attached, POST /oauth/revoke also writes the JTI to +// the database. +func TestM2M_Revoke_DBStore_PersistsRevocation(t *testing.T) { + m := newM2MHS256(t) + store := newSQLiteRevocationStore(t) + m.SetRevocationStore(store) + + tokenStr := issueTestToken(t, m, "test-client", "test-secret") + + // Confirm the token is valid before revocation. + valid, claims, err := m.Authenticate(tokenStr) + if err != nil || !valid { + t.Fatalf("expected valid token before revocation") + } + jti, _ := claims["jti"].(string) + if jti == "" { + t.Fatal("expected non-empty jti in token") + } + + // Revoke via the HTTP endpoint. + w := postRevoke(t, m, url.Values{"token": {tokenStr}}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) + } + + // Verify the JTI was written to the database. + storedJTIs := store.revokedJTIs(t) + if len(storedJTIs) != 1 || storedJTIs[0] != jti { + t.Errorf("expected JTI %q in DB, got %v", jti, storedJTIs) + } + + // Authenticate must now reject the token. + valid, _, _ = m.Authenticate(tokenStr) + if valid { + t.Error("expected token to be invalid after revocation") + } +} + +// TestM2M_Revoke_DBStore_ReloadedModuleRespects demonstrates that a fresh +// M2MAuthModule (simulating a process restart with an empty in-memory blacklist) +// still rejects previously revoked tokens when backed by the same persistent store. +func TestM2M_Revoke_DBStore_ReloadedModuleRespects(t *testing.T) { + store := newSQLiteRevocationStore(t) + + // First module instance: issues and revokes a token. + m1 := newM2MHS256(t) + m1.SetRevocationStore(store) + + tokenStr := issueTestToken(t, m1, "test-client", "test-secret") + w := postRevoke(t, m1, url.Values{"token": {tokenStr}}) + if w.Code != http.StatusOK { + t.Fatalf("revoke failed: got %d", w.Code) + } + + // Second module instance — same secret and DB store, but a fresh empty + // in-memory blacklist, as would occur after a process restart. + m2 := NewM2MAuthModule("m2m", "this-is-a-valid-secret-32-bytes!", time.Hour, "test-issuer") + m2.RegisterClient(M2MClient{ + ClientID: "test-client", + ClientSecret: "test-secret", //nolint:gosec // test credential + Scopes: []string{"read", "write"}, + }) + m2.SetRevocationStore(store) + + m2.mu.RLock() + inMemLen := len(m2.jtiBlacklist) + m2.mu.RUnlock() + if inMemLen != 0 { + t.Fatalf("expected empty in-memory blacklist on fresh module, got %d entries", inMemLen) + } + + // Authenticate via the fresh module must still reject the revoked token. + valid, _, authErr := m2.Authenticate(tokenStr) + if authErr != nil { + t.Fatalf("unexpected error: %v", authErr) + } + if valid { + t.Error("expected token to be rejected by fresh module using DB-backed store") + } +} + +// TestM2M_Revoke_DBStore_MultipleTokens verifies that multiple revocations each +// produce a distinct database row. +func TestM2M_Revoke_DBStore_MultipleTokens(t *testing.T) { + m := newM2MHS256(t) + store := newSQLiteRevocationStore(t) + m.SetRevocationStore(store) + + const numTokens = 3 + for i := 0; i < numTokens; i++ { + tok := issueTestToken(t, m, "test-client", "test-secret") + w := postRevoke(t, m, url.Values{"token": {tok}}) + if w.Code != http.StatusOK { + t.Fatalf("revoke[%d] failed: got %d", i, w.Code) + } + } + + storedJTIs := store.revokedJTIs(t) + if len(storedJTIs) != numTokens { + t.Errorf("expected %d DB rows, got %d", numTokens, len(storedJTIs)) + } +}