Skip to content

auth.m2m: Add token revocation (RFC 7009) and introspection (RFC 7662) endpoints#246

Merged
intel352 merged 6 commits intomainfrom
copilot/add-token-revocation-introspection
Mar 3, 2026
Merged

auth.m2m: Add token revocation (RFC 7009) and introspection (RFC 7662) endpoints#246
intel352 merged 6 commits intomainfrom
copilot/add-token-revocation-introspection

Conversation

Copy link
Contributor

Copilot AI commented Mar 3, 2026

auth.m2m was missing token revocation and introspection, leaving the OAuth2 implementation incomplete for clients that depend on these flows.

Changes

New endpoints

  • POST /oauth/revoke (RFC 7009) — accepts token form param, requires client authentication (HTTP Basic Auth or form client_id/client_secret per RFC 7009 §2.1), adds the token's JTI to a bounded in-memory blacklist. Returns 200 per spec for valid/unknown tokens; returns 401 invalid_client when credentials are missing or invalid.
  • POST /oauth/introspect (RFC 7662) — accepts token form param, returns active status + claims (client_id, scope, exp, iat, iss), or {"active": false} for invalid/revoked tokens. The endpoint requires caller authentication and enforces a configurable access-control policy.

JTI support

  • issueToken now generates a cryptographically random jti (16-byte base64url) for every issued token. jti is added to the protected-claims set so extraClaims cannot override it.
  • Authenticate checks the JTI blacklist after signature validation — revoked tokens are rejected.
  • The in-memory JTI blacklist is bounded: each entry stores the token's exp time and purgeExpiredJTIsLocked() removes stale entries on every write, preventing unbounded growth in long-running processes.

Introspection access control

The /oauth/introspect endpoint requires the caller to authenticate before any token data is revealed. Two authentication methods are supported:

  • HTTP Basic Auth (client_id + client_secret): treated as admin-level
  • ******** (`Authorization: ****** the caller's own valid, non-revoked token

Two access-control modes are available via SetIntrospectPolicy (or the introspect YAML config block):

  1. Self-only (default, allowOthers: false): a caller may only introspect its own token (the inspected token's sub must match the caller's identity). Returns 403 for cross-token inspection.

  2. Allow-others (allowOthers: true): any authenticated caller may inspect any token. Optional prerequisites for ****** callers:

    • requiredScope: the caller's token must contain this scope (e.g. introspect:admin)
    • requiredClaim / requiredClaimVal: the caller's token must have this claim, optionally with a specific value

    HTTP Basic Auth callers always bypass scope/claim prerequisites (admin-level).

Pluggable DB-backed revocation store

Added a TokenRevocationStore interface so revocations can be persisted across process restarts:

type TokenRevocationStore interface {
    RevokeToken(ctx context.Context, jti string, expiry time.Time) error
    IsRevoked(ctx context.Context, jti string) (bool, error)
}

Wire in a store via SetRevocationStore(store). When set, POST /oauth/revoke also calls store.RevokeToken, and Authenticate/introspect fall back to store.IsRevoked for tokens absent from the in-memory blacklist — enabling revocations to survive process restarts.

Refactor

  • Extracted parseTokenClaims() to consolidate signature-validation logic, reused by Authenticate, handleRevoke, and handleIntrospect.
  • Added authenticateIntrospectCaller, callerMeetsIntrospectPolicy, and isJTIRevoked helpers.
  • Fixed duplicate error-wrapping: generateJTI returns raw errors; issueToken wraps once with context.

Usage

POST /oauth/revoke
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

token=<access_token>
# Self-inspection via ******
POST /oauth/introspect
Authorization: ******
Content-Type: application/x-www-form-urlencoded

token=<your_own_token>

# Response:
{"active":true,"client_id":"my-client","scope":"read write","exp":1735689600,"iat":1735686000,"iss":"chimera"}
# Admin inspection via HTTP Basic Auth
POST /oauth/introspect
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

token=<any_token>
# YAML config example with introspect policy and DB-backed revocation store
- name: m2m-auth
  type: auth.m2m
  config:
    algorithm: ES256
    introspect:
      allowOthers: true
      requiredScope: "introspect:admin"  # ****** must have this scope to inspect others
      # requiredClaim: "role"           # or a claim-based check
      # requiredClaimVal: "admin"
Original prompt

This section details on the original issue you should resolve

<issue_title>auth.m2m: Add token revocation and introspection endpoints</issue_title>
<issue_description>## Use Case

The auth.m2m module currently supports token issuance (POST /oauth/token) and JWKS (GET /oauth/jwks). For a complete OAuth2 implementation, token revocation and introspection are also needed.

Proposed Endpoints

Token Revocation (RFC 7009)

POST /oauth/revoke
Content-Type: application/x-www-form-urlencoded

token=<access_token>&token_type_hint=access_token

Response: 200 OK (empty body)

This would add the token's JTI to the blacklist (the oauth2_jti_blacklist table pattern is already used in Chimera's cleanup jobs).

Token Introspection (RFC 7662)

POST /oauth/introspect
Content-Type: application/x-www-form-urlencoded

token=<access_token>

Response:

{
  "active": true,
  "client_id": "my-client",
  "scope": "admin read write",
  "exp": 1735689600,
  "iat": 1735686000,
  "iss": "chimera"
}

Context

Chimera's main branch uses Fosite for full OAuth2 with revocation and introspection. The workflow engine branch uses auth.m2m which lacks these endpoints. While not blocking for current use cases, they would improve OAuth2 completeness.

🤖 Generated with Claude Code</issue_description>

Comments on the Issue (you are @copilot in this section)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

…) endpoints

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copilot AI changed the title [WIP] Add token revocation and introspection endpoints for auth.m2m auth.m2m: Add token revocation (RFC 7009) and introspection (RFC 7662) endpoints Mar 3, 2026
@intel352 intel352 marked this pull request as ready for review March 3, 2026 19:24
Copilot AI review requested due to automatic review settings March 3, 2026 19:24
@intel352
Copy link
Contributor

intel352 commented Mar 3, 2026

@copilot Introspect is supposed to have some gating around it, to prevent anyone from inspecting any token. we may not want to get overly prescriptive here, but best practices would be to limit this to a user inspecting their own token, or an elevated role (admin type) being able to inspect their own token and others in their domain, or another approach is simply to wrap the endpoint with HTTP Basic Auth.

If we don't have support for Basic Auth already, we should add it as it's own step, and then example documentation should show how to use basic auth + introspection.

The introspection step itself should have a flag that allows a token to inspect just self or self + others. if it supports inspecting others, we should optionally support a condition match for a claim or scope in the token as a prerequisite to inspect others.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds RFC-compliant OAuth2 token revocation and introspection support to the auth.m2m module, completing common OAuth2 management flows for issued JWT access tokens.

Changes:

  • Added POST /oauth/revoke (RFC 7009) and POST /oauth/introspect (RFC 7662) routes to auth.m2m.
  • Introduced per-token jti generation and an in-memory JTI blacklist checked during authentication and introspection.
  • Refactored signature validation into a shared parseTokenClaims() helper and added comprehensive tests for the new endpoints and JTI behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
module/auth_m2m.go Adds revoke/introspect handlers, JTI generation + blacklist, and refactors token parsing/validation.
module/auth_m2m_test.go Adds tests covering revocation behavior, introspection responses, and JTI presence/uniqueness.
Comments suppressed due to low confidence (1)

module/auth_m2m.go:658

  • parseTokenClaims uses jwt.Parse without options like jwt.WithExpirationRequired() and only checks the signing method type (HMAC/ECDSA), not the specific algorithm (HS256/ES256). This means a token without exp could be treated as valid, and HS384/HS512 (or ES384/ES512) could be accepted when the module is configured for HS256/ES256. Consider enforcing the exact algorithm (e.g., t.Method.Alg() == "HS256"/"ES256" or using jwt.WithValidMethods`) and requiring expiration.
// 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 nil, false
		}
		token, err = jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
			if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
				return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
			}
			return m.publicKey, nil
		})
	default: // HS256
		token, err = jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
			if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
			}
			return m.hmacSecret, nil
		})

Comment on lines +366 to +371
if claims, ok := m.parseTokenClaims(tokenStr); ok {
if jti, _ := claims["jti"].(string); jti != "" {
m.mu.Lock()
m.jtiBlacklist[jti] = struct{}{}
m.mu.Unlock()
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JTI blacklist is an unbounded in-memory map; revoked JTIs are never expired or garbage-collected. In long-running processes (or under misuse), this can grow without bound and become a memory/DoS risk. Consider storing expiry alongside the JTI (using the token's exp) and periodically purging expired entries, or enforcing a max size / LRU.

Copilot uses AI. Check for mistakes.
now := time.Now()
jti, err := generateJTI()
if err != nil {
return "", fmt.Errorf("generate JTI: %w", err)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issueToken wraps errors from generateJTI with fmt.Errorf("generate JTI: %w", err), but generateJTI already wraps its own errors with the same prefix. This results in duplicated context like "generate JTI: generate JTI: ...". Consider removing the extra wrapping at one of these layers (typically let generateJTI return the raw rand.Read error context and wrap once at the call site).

Suggested change
return "", fmt.Errorf("generate JTI: %w", err)
return "", err

Copilot uses AI. Check for mistakes.
Comment on lines +352 to +363
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
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/oauth/revoke currently accepts just a token and performs revocation without any client authentication. RFC 7009 expects the revocation endpoint to be protected (typically via the same client authentication as the token endpoint). As implemented, anyone who obtains a token can revoke it, and anonymous callers can also use this endpoint as a CPU oracle for signature checks. Consider requiring client_id/client_secret (Basic auth or form, reusing extractClientCredentials + authenticateClient) and returning 401 invalid_client when missing/invalid.

Copilot uses AI. Check for mistakes.
Comment on lines +376 to +389
// 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
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/oauth/introspect is exposed without any authentication/authorization checks. RFC 7662 requires the introspection endpoint to be protected because it leaks token metadata and provides a token-validity oracle to anonymous callers. Consider requiring client authentication (Basic auth or form, reusing extractClientCredentials/authenticateClient) and failing with 401 invalid_client when the caller is not authorized.

Copilot uses AI. Check for mistakes.
…l policy

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 3, 2026

@copilot Introspect is supposed to have some gating around it, to prevent anyone from inspecting any token. we may not want to get overly prescriptive here, but best practices would ...

Implemented in commit 5f900e7.

What changed:

The /oauth/introspect endpoint now requires caller authentication. Two methods are supported:

  • HTTP Basic Auth (client_id + client_secret): treated as admin-level — can inspect any token when allowOthers: true
  • ******** (`Authorization: ****** the caller's own valid (non-revoked) token

Access-control modes (via SetIntrospectPolicy / YAML config):

  1. Self-only (default, allowOthers: false): a caller may only introspect its own token (the inspected token's sub must match the caller's identity). Returns 403 otherwise.

  2. Allow-others (allowOthers: true): any authenticated caller may inspect any token. Two optional prerequisites for ****** callers:

    • requiredScope: the caller's token must contain this scope (e.g. "introspect:admin")
    • requiredClaim + requiredClaimVal: the caller's token must have this claim (with optional value match)

    Basic Auth callers always bypass these prerequisites (admin-level).

YAML config example:

- name: m2m-auth
  type: auth.m2m
  config:
    algorithm: ES256
    introspect:
      allowOthers: true
      requiredScope: "introspect:admin"   # ****** must have this scope
      # requiredClaim: "role"            # or a claim-based check
      # requiredClaimVal: "admin"

Unauthenticated requests → 401. Invalid credentials → 401. Self-inspection is always allowed regardless of policy.

@intel352
Copy link
Contributor

intel352 commented Mar 3, 2026

@copilot apply changes based on the comments in this thread

Additionally, craft a test scenario that backs the JWT token storage with a database, so we can model an example of how such a revocation would also revoke an entry in the database. If the step doesn't provide the necessary functionality/info to support this, adapt the step accordingly so it's flexible enough for this use case.

…list, TokenRevocationStore interface, SQLite tests

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 3, 2026

@copilot apply changes based on the comments in this thread

Additionally, craft a test scenario th...

Addressed in commit 186650f. Here's what was done:

Review thread changes applied:

  1. Duplicate error wrappinggenerateJTI now returns the raw error; issueToken wraps it once with "generate JTI: ...".
  2. Unbounded JTI blacklist — changed to map[string]time.Time storing each JTI's expiry. purgeExpiredJTIsLocked() removes stale entries on every write, keeping the map bounded.
  3. /oauth/revoke requires client auth (RFC 7009 §2.1) — now requires HTTP Basic Auth or form client_id/client_secret; returns 401 invalid_client when missing/invalid.
  4. /oauth/introspect auth — already addressed in the previous commit.

DB-backed revocation store:
Added a TokenRevocationStore interface:

type TokenRevocationStore interface {
    RevokeToken(ctx context.Context, jti string, expiry time.Time) error
    IsRevoked(ctx context.Context, jti string) (bool, error)
}

SetRevocationStore(store) wires in a persistent backend. When set, POST /oauth/revoke writes the JTI to the store in addition to the in-memory blacklist, and Authenticate/handleIntrospect fall back to the store for revocation checks — so revocations survive process restarts.

New tests (SQLite-backed): TestM2M_Revoke_DBStore_PersistsRevocation, TestM2M_Revoke_DBStore_ReloadedModuleRespects (simulates restart with empty in-memory blacklist — DB store still rejects the token), TestM2M_Revoke_DBStore_MultipleTokens. The sqliteRevocationStore in the test file serves as a reference implementation showing how to swap in any database/sql-compatible backend (PostgreSQL, MySQL, etc.).

@intel352 intel352 merged commit 6dd4a83 into main Mar 3, 2026
14 checks passed
@intel352 intel352 deleted the copilot/add-token-revocation-introspection branch March 3, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

auth.m2m: Add token revocation and introspection endpoints

3 participants