From 4bb89cd2f73c956d06488d34e206883b0f86189a Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 15 Aug 2026 15:34:39 +0530 Subject: [PATCH 1/2] security(exchange): stop a logged-out subject seeding new delegations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token exchange verified a subject_token's signature, issuer and token_type and nothing else. delegationSessionIsLive then refused the RESULTING token — but only at Authorizer's own surfaces. A delegated token is bound to a third-party `resource` and validated by that server, which has no view of this session store. So after a logout the user's agent kept minting fresh, externally valid credentials on their behalf until the subject_token expired, and nothing downstream could tell. The probe produced a fully-formed delegated token for https://api.example.com/v1 from an already-logged-out subject. Checked only when there is something to check. A sid is either well-formed or absent — DelegationSessionID returns "" rather than a partial value — and its absence is a documented state meaning "this subject had no session" (CreateDelegatedAccessToken omits the claim entirely then, so its presence always means checkable). Rejecting that case too would delete a supported branch rather than close the gap. Service-account subjects stay exempt: a client_credentials token has no browser session, and its liveness comes from the IsActive check above. Enforcing there would break every agent-to-agent hop. The rejection reuses the opaque invalid_grant of the invalid-subject path, so the endpoint cannot be used to probe who is currently signed in. This changes what a third-party resource server observes, so it needs a delegation-design decision, not just a code review — see specs/AGENTIC_DELEGATION_DESIGN.md. TestTokenExchangeRejectsInvalidResourceIndicator minted its subject via CreateAuthToken without registering a session, making it revoked-shaped. Every production path that issues an access token registers it, so the test was the anomaly; it now registers one. --- internal/http_handlers/token_exchange.go | 37 +++++ .../token_exchange_session_liveness_test.go | 151 ++++++++++++++++++ .../token_grant_hardening_test.go | 13 ++ 3 files changed, 201 insertions(+) create mode 100644 internal/integration_tests/token_exchange_session_liveness_test.go diff --git a/internal/http_handlers/token_exchange.go b/internal/http_handlers/token_exchange.go index 5b84df6f4..039be5fb2 100644 --- a/internal/http_handlers/token_exchange.go +++ b/internal/http_handlers/token_exchange.go @@ -242,6 +242,43 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. sessionID = token.DelegationSessionID(loginMethod, subject, nonce) } + // The delegation has to be revocable at MINT time, not only at use time. + // + // delegationSessionIsLive already refuses a delegated token whose originating + // session is gone — but only at Authorizer's own surfaces. The token minted + // here is bound to a third-party `resource` and is validated by THAT server, + // which has no view of this session store. So without this check, a user who + // logged out could still have their agent mint fresh credentials against + // external resource servers for as long as the subject_token remained + // unexpired, and nothing downstream would notice. + // + // Checked only when there is something to check. A `sid` is either well-formed + // or absent — DelegationSessionID returns "" rather than a partial value — and + // its absence is a documented state meaning "this subject had no session" + // (see CreateDelegatedAccessToken, which omits the claim entirely in that + // case, so its presence always means checkable). Rejecting that case too would + // delete a supported branch rather than close the gap. + // + // Service-account subjects are exempt: a client_credentials token has no + // browser session, and its liveness was already established by the IsActive + // check above. Applying this to them would break the multi-hop agent chain. + if onBehalfOfType == constants.AuditActorTypeUser && sessionID != "" { + if sessionKey, sessionNonce, ok := token.ParseDelegationSessionID(sessionID); ok { + if _, sErr := h.MemoryStoreProvider.GetUserSession( + sessionKey, constants.TokenTypeAccessToken+"_"+sessionNonce); sErr != nil { + log.Debug().Msg("rejected: the subject's originating session is no longer live") + // Deliberately the same opaque invalid_grant the invalid-subject_token + // path returns, so this is not an oracle for whether a given user is + // currently signed in. + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_grant", + "error_description": "The subject_token is invalid or has expired", + }) + return + } + } + } + delegated, err := h.TokenProvider.CreateDelegatedAccessToken(&token.DelegationTokenConfig{ Subject: subject, Actor: act, diff --git a/internal/integration_tests/token_exchange_session_liveness_test.go b/internal/integration_tests/token_exchange_session_liveness_test.go new file mode 100644 index 000000000..4cbf853a1 --- /dev/null +++ b/internal/integration_tests/token_exchange_session_liveness_test.go @@ -0,0 +1,151 @@ +package integration_tests + +import ( + "encoding/json" + "net/http" + "net/url" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// RFC 8693 token exchange used to verify a subject_token's signature, issuer and +// token_type and nothing else. delegationSessionIsLive then refused the RESULTING +// token at Authorizer's own surfaces if the originating session had gone — but a +// delegated token is bound to a third-party `resource` and is validated by that +// server, which has no view of this session store. +// +// So a user could log out and their agent would keep minting fresh, externally +// valid credentials on their behalf until the subject_token expired, with nothing +// downstream able to tell. These tests pin the mint-time check and, just as +// importantly, the two cases it must NOT touch. + +// TestTokenExchangeRejectsAfterSubjectLogout is the regression test. +func TestTokenExchangeRejectsAfterSubjectLogout(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + clientID, secret := newDelegationAgent(t, ts, "openid,profile,email") + subjectToken := testAccessToken(t, ts) + actor := agentAccessToken(t, ts, router, clientID, secret) + + form := url.Values{} + form.Set("grant_type", tokenExchangeGrant) + form.Set("subject_token", subjectToken) + form.Set("subject_token_type", accessTokenType) + form.Set("actor_token", actor) + form.Set("actor_token_type", accessTokenType) + form.Set("resource", "https://api.example.com/v1") + + // Baseline: the exchange works while the user's session is live. + w := postTokenExchange(ts, router, form, clientID, secret) + require.Equal(t, http.StatusOK, w.Code, "baseline delegation must succeed: %s", w.Body.String()) + + userID, _ := decodeJWTPayload(t, subjectToken)["sub"].(string) + require.NotEmpty(t, userID) + + // Log the user out — the same store delete logout, password reset and admin + // revoke all perform. + require.NoError(t, ts.MemoryStoreProvider.DeleteAllUserSessions(userID)) + + w = postTokenExchange(ts, router, form, clientID, secret) + require.Equal(t, http.StatusBadRequest, w.Code, + "a logged-out subject must not seed a NEW delegation: %s", w.Body.String()) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "invalid_grant", resp["error"]) + // Opaque on purpose: identical to the invalid-subject_token response, so the + // endpoint cannot be used to probe whether a given user is currently signed in. + assert.Equal(t, "The subject_token is invalid or has expired", resp["error_description"]) +} + +// TestTokenExchangeServiceAccountSubjectIsExemptFromSessionCheck pins the +// exemption rather than leaving it incidental. +// +// A client_credentials token has no browser session, so a session check applied +// to it would reject every agent-to-agent hop — the multi-hop chain +// TestTokenExchangeMultiHopDelegation covers. Liveness for a service-account +// subject is established by the IsActive lookup instead, which +// TestDelegatedTokenForDeactivatedServiceAccountIsRejected covers. +func TestTokenExchangeServiceAccountSubjectIsExemptFromSessionCheck(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + // The SUBJECT is an agent's own machine token — no user, no session anywhere. + subjectAgentID, subjectAgentSecret := newDelegationAgent(t, ts, "openid,profile") + subjectToken := agentAccessToken(t, ts, router, subjectAgentID, subjectAgentSecret) + + callerID, callerSecret := newDelegationAgent(t, ts, "openid,profile") + actor := agentAccessToken(t, ts, router, callerID, callerSecret) + + form := url.Values{} + form.Set("grant_type", tokenExchangeGrant) + form.Set("subject_token", subjectToken) + form.Set("subject_token_type", accessTokenType) + form.Set("actor_token", actor) + form.Set("actor_token_type", accessTokenType) + form.Set("resource", "https://api.example.com/v1") + + w := postTokenExchange(ts, router, form, callerID, callerSecret) + assert.Equal(t, http.StatusOK, w.Code, + "a service-account subject has no session and must stay exempt: %s", w.Body.String()) +} + +// TestTokenExchangeChainedHopFollowsTheSubjectSession pins that the check reads +// the `sid` a chained exchange carries, not just a first-hop `nonce`. +// +// A delegated token deliberately carries no login_method or nonce claim, so hop 2 +// resolves its session through the `sid` hop 1 stamped. If the check only ever +// looked at `nonce`, hop 2 would silently skip it and a logout would stop the +// first hop while leaving every subsequent one working. +func TestTokenExchangeChainedHopFollowsTheSubjectSession(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + agent1ID, agent1Secret := newDelegationAgent(t, ts, "openid,email,profile") + agent2ID, agent2Secret := newDelegationAgent(t, ts, "openid,email") + + exchange := func(subjectToken, agentID, agentSecret string) *http.Response { + actor := agentAccessToken(t, ts, router, agentID, agentSecret) + form := url.Values{} + form.Set("grant_type", tokenExchangeGrant) + form.Set("subject_token", subjectToken) + form.Set("subject_token_type", accessTokenType) + form.Set("actor_token", actor) + form.Set("actor_token_type", accessTokenType) + form.Set("resource", "https://api.example.com/v1") + return postTokenExchange(ts, router, form, agentID, agentSecret).Result() + } + + userToken := testAccessToken(t, ts) + userID, _ := decodeJWTPayload(t, userToken)["sub"].(string) + require.NotEmpty(t, userID) + + // Hop 1 while the session is live. + res1 := exchange(userToken, agent1ID, agent1Secret) + require.Equal(t, http.StatusOK, res1.StatusCode, "hop 1 must succeed") + var resp1 map[string]interface{} + require.NoError(t, json.NewDecoder(res1.Body).Decode(&resp1)) + hop1Token, _ := resp1["access_token"].(string) + require.NotEmpty(t, hop1Token) + + // Hop 2 from the hop-1 token, still live. + res2 := exchange(hop1Token, agent2ID, agent2Secret) + require.Equal(t, http.StatusOK, res2.StatusCode, "hop 2 must succeed while the session is live") + + // Now log the user out and retry hop 2 with the same hop-1 token. + require.NoError(t, ts.MemoryStoreProvider.DeleteAllUserSessions(userID)) + + res3 := exchange(hop1Token, agent2ID, agent2Secret) + assert.Equal(t, http.StatusBadRequest, res3.StatusCode, + "a chained hop must resolve the session through `sid` and stop at logout too") +} diff --git a/internal/integration_tests/token_grant_hardening_test.go b/internal/integration_tests/token_grant_hardening_test.go index fd2c0cddd..aa9c70a26 100644 --- a/internal/integration_tests/token_grant_hardening_test.go +++ b/internal/integration_tests/token_grant_hardening_test.go @@ -120,6 +120,19 @@ func TestTokenExchangeRejectsInvalidResourceIndicator(t *testing.T) { Nonce: uuid.New().String(), HostName: testAuthorizerHost(ts), }) require.NoError(t, err) + // Register the session CreateAuthToken does not write. Every production path + // that mints an access token registers it (login, signup, verify_email, + // authorization_code, refresh, client_credentials), so a token without an + // entry is a token whose session has been revoked — and token exchange now + // refuses to seed a delegation from one. Without this the subject_token here + // is revoked-shaped, and this test would fail on the happy-path case it exists + // to protect for a reason that has nothing to do with resource indicators. + require.NoError(t, ts.MemoryStoreProvider.SetUserSession( + constants.AuthRecipeMethodBasicAuth+":"+user.ID, + constants.TokenTypeAccessToken+"_"+subjectTok.FingerPrint, + subjectTok.AccessToken.Token, + subjectTok.AccessToken.ExpiresAt, + )) agentID, agentSecret := newDelegationAgent(t, ts, "openid") actorTok := agentAccessToken(t, ts, router, agentID, agentSecret) From 220af5b3d0886ec8bfd26cea04a24ba201447d64 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 15 Aug 2026 15:49:39 +0530 Subject: [PATCH 2/2] feat(observability): audit and meter agent delegation activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent's activity has to be attributable — that is what the RFC 8693 act chain is for. Success was audited and counted; every one of the fourteen refusal paths on /oauth/token's exchange grant was silent. No audit row, no metric, only a Debug log. The sibling client_credentials grant already audits its failures, so machine-identity auth failures were attributable while delegation failures — carrying a user's authority, so the more sensitive of the two — were not. An agent probing the delegation endpoint left no trail. Every refusal now routes through one helper, which is also what keeps it true: a rejection path added later cannot be silent without deliberately bypassing it. - audit: token.exchange_failed, actor = the calling agent, metadata = a fixed reason constant naming the rule that refused. Never the subject id or the token — a refusal record must not be where an unverified subject's identity gets written. - metric: authorizer_auth_events_total{event=token_exchange} on both success and failure. EventTokenIssued is unchanged and still counts all issuance, but carries no grant label, so delegated issuance was indistinguishable from any other grant and had no failure count to form a rate against. - metric: security event token_exchange_rejected, reason=. The session-liveness refusal keeps returning the same opaque invalid_grant as a malformed token, so the endpoint is not an oracle for who is signed in — but its audit reason differs, because that row is written server-side and never reaches the caller. Also meters the agent scope-ceiling enforcement point, silent on every transport until now: delegated_insufficient_scope with separate labels for "not on the delegated allow-list" (a client bug, or probing) and "reachable but this token lacks the scope" (widen the ceiling, or don't). Operators had no way to see agents hitting their ceiling, which is the number needed before deciding whether to widen one. Method names are deliberately not labels — high cardinality on an internet-facing path. And meters delegated-token validation refusals, so revocation actually firing (logout, password reset, admin revoke taking an agent's access down with the user's session) is externally visible rather than a Debug line. AuditTokenExchangeEvent stays reserved and unused: success remains token.issued, which ROADMAP_V2.md documents, and renaming the action would silently break any query already filtering on it. --- internal/constants/audit_event.go | 15 ++ internal/grpcsrv/interceptors/auth.go | 10 + internal/http_handlers/graphql.go | 10 + internal/http_handlers/token_exchange.go | 174 +++++++++------ .../token_exchange_observability_test.go | 199 ++++++++++++++++++ internal/metrics/metrics.go | 7 + internal/token/delegated_access_token.go | 8 + 7 files changed, 357 insertions(+), 66 deletions(-) create mode 100644 internal/integration_tests/token_exchange_observability_test.go diff --git a/internal/constants/audit_event.go b/internal/constants/audit_event.go index 42f29e008..a9d82fc09 100644 --- a/internal/constants/audit_event.go +++ b/internal/constants/audit_event.go @@ -299,7 +299,22 @@ const ( // wrong secret) — mirrors AuditLoginFailedEvent for the human login path. AuditTokenClientCredentialsFailedEvent = "token.client_credentials_failed" // AuditTokenExchangeEvent is logged when a token exchange occurs (RFC 8693). + // + // Reserved, deliberately unused: a successful exchange is recorded as + // AuditTokenIssuedEvent with grant_type=token-exchange in its Metadata, and + // ROADMAP_V2.md documents "token.issued" as the issuance event. Switching the + // action would silently break any query already filtering on it. Failures use + // AuditTokenExchangeFailedEvent below, mirroring the client_credentials pair. AuditTokenExchangeEvent = "token.exchange" + // AuditTokenExchangeFailedEvent is logged when an RFC 8693 token exchange is + // REJECTED. + // + // Every rejection on that endpoint used to be silent — 14 refusal paths, none + // audited — while the client_credentials path already audited its failures. + // An agent probing the delegation endpoint therefore left no trail at all, + // which is the opposite of what a delegation surface needs: the whole point of + // the act chain is that an agent's activity is attributable. + AuditTokenExchangeFailedEvent = "token.exchange_failed" // AuditWorkloadAuthEvent is logged when a workload authenticates via client_assertion // (K8s SA token, SPIFFE JWT-SVID, or generic OIDC workload token). AuditWorkloadAuthEvent = "token.workload_auth" diff --git a/internal/grpcsrv/interceptors/auth.go b/internal/grpcsrv/interceptors/auth.go index ec0c1d5af..1bd571693 100644 --- a/internal/grpcsrv/interceptors/auth.go +++ b/internal/grpcsrv/interceptors/auth.go @@ -242,13 +242,23 @@ func enforceDelegatedScope(tokenData *token.SessionOrAccessTokenData, fullMethod if tokenData == nil || strings.TrimSpace(tokenData.ActorID) == "" { return nil } + // Both refusals below are metered, matching the GraphQL choke point. This was + // silent on every transport, so an operator could not see agents hitting + // their ceiling — the number needed before deciding whether to widen one. The + // labels distinguish the two, because they call for opposite actions: + // "not_delegatable" means the method is not on the delegated allow-list at + // all (a client bug, or probing), "scope_missing" means it is reachable but + // this token was not granted the scope. Method names are NOT included — they + // would be a high-cardinality label on an internet-facing path. required, ok := delegatedscope.RequiredForGRPC(fullMethod) if !ok { // Fail closed: an operation nobody has cleared for delegated callers is // out of reach for an agent, whatever scope it holds. + metrics.RecordSecurityEvent("delegated_insufficient_scope", "grpc_not_delegatable") return status.Error(codes.PermissionDenied, "insufficient_scope") } if !delegatedscope.Satisfied(tokenData.Scope, required) { + metrics.RecordSecurityEvent("delegated_insufficient_scope", "grpc_scope_missing") return status.Error(codes.PermissionDenied, "insufficient_scope") } return nil diff --git a/internal/http_handlers/graphql.go b/internal/http_handlers/graphql.go index f9f088dd3..7cbd2324d 100644 --- a/internal/http_handlers/graphql.go +++ b/internal/http_handlers/graphql.go @@ -268,13 +268,23 @@ func (h *httpProvider) gqlDelegatedScopeMiddleware() gql.FieldMiddleware { return next(ctx) // first-party token; see the package comment. } + // Both refusals below are metered. This is the agent scope-ceiling + // enforcement point, and it was previously silent on every transport: an + // operator had no way to see that agents were hitting their ceiling, which + // is exactly the number needed before deciding whether to widen one. The + // two outcomes are separate labels because they call for opposite actions + // — "not_delegatable" means the operation is not on the delegated + // allow-list at all (a client bug, or probing), while "scope_missing" + // means it is reachable but this token was not granted it. required, ok := delegatedscope.RequiredForGraphQL(fc.Field.Name) if !ok { // Fail closed: an operation nobody has cleared for delegated // callers is out of reach for an agent, whatever scope it holds. + metrics.RecordSecurityEvent("delegated_insufficient_scope", "graphql_not_delegatable") return nil, gqlerror.Errorf("insufficient_scope") } if !delegatedscope.Satisfied(token.ClaimScopes(claims), required) { + metrics.RecordSecurityEvent("delegated_insufficient_scope", "graphql_scope_missing") return nil, gqlerror.Errorf("insufficient_scope") } return next(ctx) diff --git a/internal/http_handlers/token_exchange.go b/internal/http_handlers/token_exchange.go index 039be5fb2..6bf546f08 100644 --- a/internal/http_handlers/token_exchange.go +++ b/internal/http_handlers/token_exchange.go @@ -44,11 +44,11 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. // RFC 8693 §2.1: subject_token and subject_token_type are REQUIRED. if subjectToken == "" || subjectTokenType == "" { - badTokenExchangeRequest(gc, "subject_token and subject_token_type are required") + h.badTokenExchangeRequest(gc, agent, reasonMissingSubject, "subject_token and subject_token_type are required") return } if !isSupportedExchangeTokenType(subjectTokenType) { - badTokenExchangeRequest(gc, "unsupported subject_token_type") + h.badTokenExchangeRequest(gc, agent, reasonUnsupportedSubject, "unsupported subject_token_type") return } @@ -56,11 +56,11 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. // exchange is impersonation — a separate, admin-gated design not served on // this endpoint. Reject fail-closed rather than silently impersonating. if actorToken == "" { - badTokenExchangeRequest(gc, "actor_token is required: only the delegation profile is supported here (impersonation is not permitted)") + h.badTokenExchangeRequest(gc, agent, reasonMissingActor, "actor_token is required: only the delegation profile is supported here (impersonation is not permitted)") return } if actorTokenType == "" || !isSupportedExchangeTokenType(actorTokenType) { - badTokenExchangeRequest(gc, "unsupported or missing actor_token_type") + h.badTokenExchangeRequest(gc, agent, reasonUnsupportedActor, "unsupported or missing actor_token_type") return } @@ -70,7 +70,7 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. // token-exchange path only; other grants are unaffected. resources := gc.PostFormArray("resource") if len(resources) != 1 || strings.TrimSpace(resources[0]) == "" { - badTokenExchangeRequest(gc, "exactly one resource parameter is required") + h.badTokenExchangeRequest(gc, agent, reasonResourceCount, "exactly one resource parameter is required") return } resource := strings.TrimSpace(resources[0]) @@ -81,10 +81,8 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. // server. Same helper, same rejection, so the two paths cannot drift. if !isValidResourceIndicator(resource) { log.Debug().Str("client_id", agent.ClientID).Str("resource", resource).Msg("rejected: invalid resource indicator") - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_target", - "error_description": "resource must be an absolute URI without a fragment", - }) + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonResourceInvalid, + "invalid_target", "resource must be an absolute URI without a fragment") return } @@ -95,19 +93,15 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. subjectClaims, err := h.validateExchangeToken(subjectToken, hostname) if err != nil { log.Debug().Err(err).Msg("invalid subject_token") - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_grant", - "error_description": "The subject_token is invalid or has expired", - }) + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonSubjectTokenInvalid, + "invalid_grant", "The subject_token is invalid or has expired") return } actorClaims, err := h.validateExchangeToken(actorToken, hostname) if err != nil { log.Debug().Err(err).Msg("invalid actor_token") - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_grant", - "error_description": "The actor_token is invalid or has expired", - }) + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonActorTokenInvalid, + "invalid_grant", "The actor_token is invalid or has expired") return } // RFC 8693 §1.1: the actor_token represents the acting party. Bind it to the @@ -117,19 +111,15 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. // so require the actor_token's subject to be this client. if actorSub, _ := actorClaims["sub"].(string); actorSub != agent.ID { log.Debug().Msg("actor_token does not belong to the authenticated client") - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_grant", - "error_description": "The actor_token must belong to the authenticated client", - }) + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonActorNotCaller, + "invalid_grant", "The actor_token must belong to the authenticated client") return } subject, _ := subjectClaims["sub"].(string) if subject == "" { - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_grant", - "error_description": "The subject_token has no subject", - }) + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonSubjectMissing, + "invalid_grant", "The subject_token has no subject") return } @@ -154,17 +144,13 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. subjectClient, cErr := h.StorageProvider.GetClientByID(gc, subject) if cErr != nil || subjectClient == nil { log.Debug().Err(cErr).Msg("subject service account could not be verified") - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_grant", - "error_description": "The subject could not be verified", - }) + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonSubjectUnverifiable, + "invalid_grant", "The subject could not be verified") return } if !subjectClient.IsActive { - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_grant", - "error_description": "The subject is no longer active", - }) + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonSubjectInactive, + "invalid_grant", "The subject is no longer active") return } } else { @@ -172,17 +158,13 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. if uErr != nil || user == nil { // A user authority we cannot load must not seed a delegation (fail closed). log.Debug().Err(uErr).Msg("subject user could not be verified") - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_grant", - "error_description": "The subject could not be verified", - }) + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonSubjectUnverifiable, + "invalid_grant", "The subject could not be verified") return } if user.RevokedTimestamp != nil { - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_grant", - "error_description": "The subject is no longer active", - }) + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonSubjectInactive, + "invalid_grant", "The subject is no longer active") return } } @@ -196,10 +178,8 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. if len(ceiling) == 0 { // Empty AllowedScopes is DENY-ALL (schema § AllowedScopes) — an agent with // no ceiling can delegate nothing. - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_scope", - "error_description": "The agent has no authorized scopes", - }) + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonAgentNoScopes, + "invalid_scope", "The agent has no authorized scopes") return } effective := intersectScopes(subjectScope, ceiling) @@ -207,10 +187,8 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. effective = intersectScopes(effective, requested) } if len(effective) == 0 { - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_scope", - "error_description": "The requested scope is empty after attenuation against the subject and the agent ceiling", - }) + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonScopeEmpty, + "invalid_scope", "The requested scope is empty after attenuation against the subject and the agent ceiling") return } @@ -223,10 +201,8 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. act["act"] = prior } if actChainDepth(act) > maxActChainDepth { - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_request", - "error_description": "The delegation chain exceeds the maximum allowed depth", - }) + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonChainTooDeep, + "invalid_request", "The delegation chain exceeds the maximum allowed depth") return } @@ -269,11 +245,11 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. log.Debug().Msg("rejected: the subject's originating session is no longer live") // Deliberately the same opaque invalid_grant the invalid-subject_token // path returns, so this is not an oracle for whether a given user is - // currently signed in. - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_grant", - "error_description": "The subject_token is invalid or has expired", - }) + // currently signed in. The AUDIT record distinguishes them (the + // reason constant differs) because that is written server-side and + // never reaches the caller. + h.rejectExchange(gc, agent, http.StatusBadRequest, reasonSubjectSessionGone, + "invalid_grant", "The subject_token is invalid or has expired") return } } @@ -290,10 +266,8 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. }) if err != nil { log.Debug().Err(err).Msg("failed to mint delegated token") - gc.JSON(http.StatusInternalServerError, gin.H{ - "error": "server_error", - "error_description": "Could not complete token issuance", - }) + h.rejectExchange(gc, agent, http.StatusInternalServerError, reasonMintFailed, + "server_error", "Could not complete token issuance") return } @@ -325,6 +299,10 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. }) metrics.RecordAuthEvent(metrics.EventTokenIssued, metrics.StatusSuccess) + // Delegation-specific, alongside the generic issuance counter above. Without + // it there is no denominator for the failure count rejectExchange records, and + // no way to see delegated issuance apart from any other grant. + metrics.RecordAuthEvent(metrics.EventTokenExchange, metrics.StatusSuccess) // RFC 8693 §2.2 token-exchange response. gc.JSON(http.StatusOK, gin.H{ @@ -336,14 +314,78 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. }) } -// badTokenExchangeRequest writes the RFC 6749 §5.2 invalid_request response. -func badTokenExchangeRequest(gc *gin.Context, desc string) { - gc.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid_request", +// Rejection reasons for rejectExchange. These become a Prometheus label and an +// audit Metadata value, so they are a FIXED, low-cardinality set — never a +// caller-derived string. Each names the rule that refused, which is the thing an +// operator needs in order to tell "this agent is misconfigured" from "this agent +// is probing". +const ( + reasonMissingSubject = "missing_subject_token" + reasonUnsupportedSubject = "unsupported_subject_token_type" + reasonMissingActor = "missing_actor_token" + reasonUnsupportedActor = "unsupported_actor_token_type" + reasonResourceCount = "resource_not_exactly_one" + reasonResourceInvalid = "invalid_resource_indicator" + reasonSubjectTokenInvalid = "subject_token_invalid" + reasonActorTokenInvalid = "actor_token_invalid" + reasonActorNotCaller = "actor_token_not_the_caller" + reasonSubjectMissing = "subject_token_has_no_subject" + reasonSubjectUnverifiable = "subject_unverifiable" + reasonSubjectInactive = "subject_inactive" + reasonSubjectSessionGone = "subject_session_not_live" + reasonAgentNoScopes = "agent_has_no_scopes" + reasonScopeEmpty = "scope_empty_after_attenuation" + reasonChainTooDeep = "delegation_chain_too_deep" + reasonMintFailed = "token_issuance_failed" +) + +// rejectExchange writes a token-exchange refusal AND records it. +// +// It exists because every one of the fourteen refusal paths on this endpoint was +// previously silent: no audit entry, no metric, only a Debug log. The +// client_credentials grant already audits its failures +// (AuditTokenClientCredentialsFailedEvent), so machine-identity auth failures +// were attributable while delegation failures — the more sensitive of the two, +// since a delegated token carries a user's authority — were not. An agent +// probing this endpoint left no trail. +// +// Routing every refusal through one function is also what keeps that true: a new +// rejection path added later cannot be silent without deliberately bypassing this. +func (h *httpProvider) rejectExchange(gc *gin.Context, agent *schemas.Client, status int, reason, errCode, desc string) { + metrics.RecordAuthEvent(metrics.EventTokenExchange, metrics.StatusFailure) + metrics.RecordSecurityEvent("token_exchange_rejected", reason) + + // agent is nil-safe: the caller is always an authenticated client by the time + // this handler runs, but an audit call must never be the thing that panics an + // auth endpoint. + actorID := "" + if agent != nil { + actorID = agent.ID + } + h.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditTokenExchangeFailedEvent, + ActorID: actorID, + ActorType: constants.AuditActorTypeServiceAccount, + ResourceType: constants.AuditResourceTypeToken, + // The reason constant only — never the subject id or the token. A refusal + // record must not become a place where an unverified subject's identity is + // written on the strength of a request that was rejected. + Metadata: reason, + IPAddress: utils.GetIP(gc.Request), + UserAgent: utils.GetUserAgent(gc.Request), + }) + + gc.JSON(status, gin.H{ + "error": errCode, "error_description": desc, }) } +// badTokenExchangeRequest writes the RFC 6749 §5.2 invalid_request response. +func (h *httpProvider) badTokenExchangeRequest(gc *gin.Context, agent *schemas.Client, reason, desc string) { + h.rejectExchange(gc, agent, http.StatusBadRequest, reason, "invalid_request", desc) +} + // isSupportedExchangeTokenType reports whether an RFC 8693 subject/actor token // type URN is one this delegation profile accepts (access token or generic JWT). func isSupportedExchangeTokenType(t string) bool { diff --git a/internal/integration_tests/token_exchange_observability_test.go b/internal/integration_tests/token_exchange_observability_test.go new file mode 100644 index 000000000..dfbede5f9 --- /dev/null +++ b/internal/integration_tests/token_exchange_observability_test.go @@ -0,0 +1,199 @@ +package integration_tests + +import ( + "net/http" + "net/url" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// An agent's activity has to be attributable — that is the entire point of the +// RFC 8693 act chain. Success was audited and counted; every one of the fourteen +// refusal paths was silent, with no audit row and no metric, while the sibling +// client_credentials grant already audited its failures. An agent probing the +// delegation endpoint therefore left no trail at all. + +func authEventCount(event, status string) float64 { + return testutil.ToFloat64(metrics.AuthEventsTotal.WithLabelValues(event, status)) +} + +func securityEventCount(event, reason string) float64 { + return testutil.ToFloat64(metrics.SecurityEventsTotal.WithLabelValues(event, reason)) +} + +// TestTokenExchangeRejectionIsAuditedAndMetered covers the rejection path end to +// end: audit row, delegation-specific failure counter, and a reason label that +// names the rule which refused. +func TestTokenExchangeRejectionIsAuditedAndMetered(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + clientID, secret := newDelegationAgent(t, ts, "openid,profile,email") + agent, err := ts.StorageProvider.GetClientByClientID(ctx, clientID) + require.NoError(t, err) + require.NotNil(t, agent) + + beforeFailures := authEventCount(metrics.EventTokenExchange, metrics.StatusFailure) + beforeReason := securityEventCount("token_exchange_rejected", "missing_actor_token") + + // A subject-only exchange: impersonation, which this profile refuses. + form := url.Values{} + form.Set("grant_type", tokenExchangeGrant) + form.Set("subject_token", testAccessToken(t, ts)) + form.Set("subject_token_type", accessTokenType) + form.Set("resource", "https://api.example.com/v1") + w := postTokenExchange(ts, router, form, clientID, secret) + require.Equal(t, http.StatusBadRequest, w.Code) + + assert.Equal(t, beforeFailures+1, authEventCount(metrics.EventTokenExchange, metrics.StatusFailure), + "a refused exchange must be counted, or there is no failure rate to alert on") + assert.Equal(t, beforeReason+1, securityEventCount("token_exchange_rejected", "missing_actor_token"), + "the reason label must name the rule that refused, so a misconfigured agent is "+ + "distinguishable from one probing the endpoint") + + // Audit writes are fire-and-forget (asyncutil.Go), so poll. + var logs []*schemas.AuditLog + require.Eventually(t, func() bool { + var lErr error + logs, _, lErr = ts.StorageProvider.ListAuditLogs(ctx, &model.Pagination{Limit: 50, Page: 1}, + map[string]interface{}{"action": constants.AuditTokenExchangeFailedEvent}) + return lErr == nil && len(logs) > 0 + }, 5*time.Second, 25*time.Millisecond, + "a refused delegation must leave an audit trail; the client_credentials grant already does") + + var found bool + for _, l := range logs { + if l.ActorID != agent.ID { + continue + } + found = true + assert.Equal(t, constants.AuditActorTypeServiceAccount, l.ActorType) + assert.Equal(t, "missing_actor_token", l.Metadata, + "the audit row records the rule that refused") + } + require.True(t, found, "no rejection audit entry attributed to the calling agent") +} + +// TestTokenExchangeSuccessIsMetered pins the denominator. A failure count with no +// success count beside it cannot express a rate, and the generic +// EventTokenIssued counter carries no grant label — delegated issuance was +// indistinguishable from authorization_code or client_credentials. +func TestTokenExchangeSuccessIsMetered(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + clientID, secret := newDelegationAgent(t, ts, "openid,profile,email") + form := url.Values{} + form.Set("grant_type", tokenExchangeGrant) + form.Set("subject_token", testAccessToken(t, ts)) + form.Set("subject_token_type", accessTokenType) + form.Set("actor_token", agentAccessToken(t, ts, router, clientID, secret)) + form.Set("actor_token_type", accessTokenType) + form.Set("resource", "https://api.example.com/v1") + + before := authEventCount(metrics.EventTokenExchange, metrics.StatusSuccess) + w := postTokenExchange(ts, router, form, clientID, secret) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + assert.Equal(t, before+1, authEventCount(metrics.EventTokenExchange, metrics.StatusSuccess), + "delegated issuance must be separately countable from every other grant") +} + +// TestTokenExchangeSessionRejectionCarriesItsOwnReason pins that the audit +// record distinguishes a logged-out subject from a malformed one even though the +// HTTP response deliberately does not. +// +// The response is intentionally the same opaque invalid_grant, so the endpoint +// cannot be used to probe who is signed in. That opacity is the right call for +// the caller and the wrong one for the operator — the audit row is written +// server-side and never reaches the agent, so it can and must be specific. +func TestTokenExchangeSessionRejectionCarriesItsOwnReason(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + clientID, secret := newDelegationAgent(t, ts, "openid,profile,email") + subjectToken := testAccessToken(t, ts) + form := url.Values{} + form.Set("grant_type", tokenExchangeGrant) + form.Set("subject_token", subjectToken) + form.Set("subject_token_type", accessTokenType) + form.Set("actor_token", agentAccessToken(t, ts, router, clientID, secret)) + form.Set("actor_token_type", accessTokenType) + form.Set("resource", "https://api.example.com/v1") + + userID, _ := decodeJWTPayload(t, subjectToken)["sub"].(string) + require.NotEmpty(t, userID) + require.NoError(t, ts.MemoryStoreProvider.DeleteAllUserSessions(userID)) + + before := securityEventCount("token_exchange_rejected", "subject_session_not_live") + w := postTokenExchange(ts, router, form, clientID, secret) + require.Equal(t, http.StatusBadRequest, w.Code) + + assert.Equal(t, before+1, + securityEventCount("token_exchange_rejected", "subject_session_not_live"), + "a logout-driven refusal must be distinguishable in telemetry from a malformed token, "+ + "even though both return the same opaque invalid_grant to the caller") +} + +// TestDelegatedInsufficientScopeIsMetered covers the agent scope-ceiling +// enforcement point, which was silent on every transport. Without it an operator +// has no way to see agents hitting their ceiling — the number needed before +// deciding whether widening one is justified. +func TestDelegatedInsufficientScopeIsMetered(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + + tokenRouter := gin.New() + tokenRouter.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + delegated, _, _ := mintDelegatedViaEndpoint(t, ts, tokenRouter, testAuthorizerHost(ts)) + + router := setupTestRouter(ts) + post := func(query string) string { + body := `{"query":` + jsonQuote(query) + `}` + w := sendTestRequest(t, router, "POST", "/graphql", body, map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer " + delegated, + "Origin": "http://localhost:3000", + "X-Authorizer-URL": testAuthorizerHost(ts), + }) + return w.Body.String() + } + + t.Run("an operation the agent lacks the scope for", func(t *testing.T) { + before := securityEventCount("delegated_insufficient_scope", "graphql_scope_missing") + out := post(`mutation { update_profile(params: {given_name: "mutated-by-agent"}) { message } }`) + require.Contains(t, out, "insufficient_scope") + assert.Equal(t, before+1, + securityEventCount("delegated_insufficient_scope", "graphql_scope_missing"), + "an agent refused for want of scope must be counted") + }) + + t.Run("an operation not delegatable at all", func(t *testing.T) { + // Separate label: this one means the operation is not on the delegated + // allow-list, which is a client bug or probing — a different action for + // the operator than "grant this agent more scope". + before := securityEventCount("delegated_insufficient_scope", "graphql_not_delegatable") + out := post(`query { webauthn_credentials { id } }`) + require.Contains(t, out, "insufficient_scope") + assert.Equal(t, before+1, + securityEventCount("delegated_insufficient_scope", "graphql_not_delegatable"), + "a fail-closed refusal must be counted separately from a scope shortfall") + }) +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 69face0a3..a4657bc02 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -30,6 +30,13 @@ const ( EventTokenIssued = "token_issued" EventTokenRefresh = "token_refresh" EventTokenRevoke = "token_revoke" + // EventTokenExchange counts RFC 8693 delegation exchanges specifically, + // success and failure. EventTokenIssued stays the count of ALL issuance + // (unchanged, so existing dashboards keep working) — but it carries no grant + // label, so before this there was no way to see delegated issuance apart from + // authorization_code or client_credentials, and no failure count at all to + // compute a rate against. + EventTokenExchange = "token_exchange" StatusSuccess = "success" StatusFailure = "failure" diff --git a/internal/token/delegated_access_token.go b/internal/token/delegated_access_token.go index a4f05127b..16aa906d5 100644 --- a/internal/token/delegated_access_token.go +++ b/internal/token/delegated_access_token.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/storage" "github.com/authorizerdev/authorizer/internal/storage/schemas" @@ -179,6 +180,7 @@ func (p *provider) validateDelegatedForAudience(gc *gin.Context, accessToken str p.dependencies.Log.Debug().Str("aud", aud).Str("expected", expectedAud). Msg("delegated token rejected: audience names a different resource server") } + metrics.RecordSecurityEvent("delegated_token_rejected", "audience_mismatch") return res, fmt.Errorf(`unauthorized: token audience is not this server`) } @@ -187,10 +189,16 @@ func (p *provider) validateDelegatedForAudience(gc *gin.Context, accessToken str // token whose delegation has already been revoked is rejected without // spending a DB read. if !p.delegationSessionIsLive(res) { + // The revocation lever firing. Metered because it is the ONLY externally + // visible signal that logout / password reset / admin revoke is actually + // taking an agent's access down with the user's session — everything else + // about it is a Debug log. + metrics.RecordSecurityEvent("delegated_token_rejected", "session_revoked") return res, fmt.Errorf(`unauthorized: originating session is no longer valid`) } if !p.subjectIsLive(gc, userID) { + metrics.RecordSecurityEvent("delegated_token_rejected", "subject_inactive") return res, fmt.Errorf(`unauthorized: delegation subject is not active`) }