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 5b84df6f4..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 } @@ -242,6 +218,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. 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 + } + } + } + delegated, err := h.TokenProvider.CreateDelegatedAccessToken(&token.DelegationTokenConfig{ Subject: subject, Actor: act, @@ -253,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 } @@ -288,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{ @@ -299,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/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) 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`) }