From 1589867a1d03b3f919f948f7ec2a8bb63af4416a Mon Sep 17 00:00:00 2001 From: Indeewai Wijesiri Date: Thu, 23 Jul 2026 21:25:38 +0530 Subject: [PATCH] Add token family id (tfid) for grant-scoped revocation Mint a token family id (tfid) per login flow and carry it across the authorization grant (authorization code, access and refresh tokens, preserved across refresh rotation) so a whole grant can be revoked at once. Add a criteria-based revocation deny list (REVOCATION_CRITERIA) with a token_family criterion and a CriteriaRevoker write seam, enforced on both the authorization-server hot path and the resource-server cache. Revocation triggers: refresh-token reuse, RFC 7009 explicit revoke, authorization-code replay, and SSO sign-out. Configurable via oauth.revocation.token_family.* and oauth.token_exchange.token_family. Refs #3321 --- backend/.mockery.public.yml | 6 + backend/cmd/server/config/default.json | 10 + backend/cmd/server/servicemanager.go | 26 +- .../runtime_persistent/postgres-cleanup.sql | 10 + .../dbscripts/runtime_persistent/postgres.sql | 23 + .../dbscripts/runtime_persistent/sqlite.sql | 23 + backend/internal/flow/common/constants.go | 6 + .../flow/executor/auth_assert_executor.go | 6 + .../flow/executor/session_executor.go | 38 +- .../flow/executor/session_executor_test.go | 6 +- .../flow/session/CriteriaRevoker_mock_test.go | 95 ++++ .../flow/session/Service_mock_test.go | 34 +- backend/internal/flow/session/init.go | 13 +- backend/internal/flow/session/model.go | 5 + .../flow/session/participant_store_test.go | 17 +- backend/internal/flow/session/service.go | 79 ++- backend/internal/flow/session/service_test.go | 66 ++- backend/internal/flow/session/store.go | 3 +- .../internal/flow/session/store_constants.go | 14 +- backend/internal/oauth/init.go | 14 +- ...thorizationCodeStoreInterface_mock_test.go | 142 ++++++ .../oauth/oauth2/authz/auth_code_store.go | 54 ++ .../oauth2/authz/auth_code_store_test.go | 28 ++ backend/internal/oauth/oauth2/authz/init.go | 4 +- .../internal/oauth/oauth2/authz/init_test.go | 6 +- backend/internal/oauth/oauth2/authz/model.go | 5 + .../internal/oauth/oauth2/authz/service.go | 64 ++- .../oauth/oauth2/authz/service_test.go | 59 +++ .../oauth/oauth2/constants/constants.go | 14 + .../granthandlers/authorization_code.go | 1 + .../oauth2/granthandlers/grant_handler.go | 1 + .../oauth/oauth2/granthandlers/init.go | 2 + .../oauth/oauth2/granthandlers/provider.go | 5 +- .../oauth2/granthandlers/provider_test.go | 2 + .../oauth2/granthandlers/refresh_token.go | 38 +- .../granthandlers/refresh_token_test.go | 51 +- .../oauth2/granthandlers/token_exchange.go | 18 + .../granthandlers/token_exchange_test.go | 4 +- backend/internal/oauth/oauth2/model/token.go | 3 + .../CriteriaRevokerInterface_mock_test.go | 101 ++++ .../EnforcementServiceInterface_mock_test.go | 22 +- .../RevocationServiceInterface_mock_test.go | 63 +++ .../RevokedTokenStoreInterface_mock_test.go | 161 ------ .../oauth2/revocation/enforcement_service.go | 73 ++- .../revocation/enforcement_service_test.go | 60 ++- .../internal/oauth/oauth2/revocation/init.go | 32 +- .../oauth/oauth2/revocation/init_test.go | 13 +- .../internal/oauth/oauth2/revocation/model.go | 10 + .../revocationStoreInterface_mock_test.go | 290 +++++++++++ .../oauth/oauth2/revocation/service.go | 109 +++- .../oauth/oauth2/revocation/service_test.go | 104 +++- .../internal/oauth/oauth2/revocation/store.go | 100 +++- .../oauth2/revocation/store_constants.go | 19 + .../oauth/oauth2/revocation/store_test.go | 129 ++++- .../token/TokenServiceInterface_mock_test.go | 3 +- .../internal/oauth/oauth2/token/service.go | 1 + .../oauth/oauth2/token/service_test.go | 22 +- .../oauth/oauth2/tokenservice/builder.go | 9 + .../oauth/oauth2/tokenservice/model.go | 13 + .../oauth/oauth2/tokenservice/validator.go | 23 +- .../oauth2/tokenservice/validator_test.go | 15 +- backend/internal/system/config/config.go | 3 + .../internal/system/revocationcache/cache.go | 51 +- .../system/revocationcache/cache_test.go | 40 +- .../system/revocationcache/enforcer.go | 28 +- .../system/revocationcache/enforcer_test.go | 21 +- .../system/revocationcache/init_test.go | 8 +- .../internal/system/revocationcache/model.go | 16 +- .../system/revocationcache/query_constants.go | 12 +- .../internal/system/revocationcache/source.go | 5 +- .../system/revocationcache/source_db.go | 52 +- .../system/revocationcache/source_db_test.go | 57 ++- .../internal/system/revocationcache/syncer.go | 4 +- .../system/revocationcache/syncer_test.go | 22 +- .../RevocationEnforcerInterface_mock_test.go | 24 +- backend/internal/system/security/context.go | 13 +- .../system/security/jwt_authenticator.go | 6 + backend/internal/system/security/service.go | 15 +- .../internal/system/security/service_test.go | 30 +- backend/pkg/thunderidengine/config/config.go | 29 ++ .../pkg/thunderidengine/config/validate.go | 11 + .../mocks/flow/sessionmock/Service_mock.go | 34 +- .../RefreshTokenGrantHandlerInterface_mock.go | 22 +- .../CriteriaRevokerInterface_mock.go | 102 ++++ .../EnforcementServiceInterface_mock.go | 22 +- tests/integration/oauth/sso/rp_logout_test.go | 23 + tests/integration/oauth/sso/suite_test.go | 35 +- tests/integration/oauth/token/tfid_test.go | 464 ++++++++++++++++++ 88 files changed, 2970 insertions(+), 546 deletions(-) create mode 100644 backend/internal/flow/session/CriteriaRevoker_mock_test.go create mode 100644 backend/internal/oauth/oauth2/revocation/CriteriaRevokerInterface_mock_test.go delete mode 100644 backend/internal/oauth/oauth2/revocation/RevokedTokenStoreInterface_mock_test.go create mode 100644 backend/internal/oauth/oauth2/revocation/revocationStoreInterface_mock_test.go create mode 100644 backend/tests/mocks/oauth/oauth2/revocationmock/CriteriaRevokerInterface_mock.go create mode 100644 tests/integration/oauth/token/tfid_test.go diff --git a/backend/.mockery.public.yml b/backend/.mockery.public.yml index 6dbb137622..dce2c8d0cb 100644 --- a/backend/.mockery.public.yml +++ b/backend/.mockery.public.yml @@ -131,6 +131,12 @@ packages: structname: '{{.InterfaceName}}Mock' pkgname: revocationmock filename: "{{.InterfaceName}}_mock.go" + CriteriaRevokerInterface: + config: + dir: tests/mocks/oauth/oauth2/revocationmock + structname: '{{.InterfaceName}}Mock' + pkgname: revocationmock + filename: "{{.InterfaceName}}_mock.go" github.com/thunder-id/thunderid/internal/oauth/oauth2/granthandlers: config: diff --git a/backend/cmd/server/config/default.json b/backend/cmd/server/config/default.json index fffd3e787f..d37deef43f 100644 --- a/backend/cmd/server/config/default.json +++ b/backend/cmd/server/config/default.json @@ -156,6 +156,16 @@ }, "logout" : { "enabled" : true + }, + "revocation": { + "token_family": { + "on_refresh_replay": true, + "on_explicit_revoke": true, + "on_code_replay": true + } + }, + "token_exchange": { + "token_family": "none" } }, "flow": { diff --git a/backend/cmd/server/servicemanager.go b/backend/cmd/server/servicemanager.go index a764b7a959..b6ee5c382f 100644 --- a/backend/cmd/server/servicemanager.go +++ b/backend/cmd/server/servicemanager.go @@ -75,6 +75,7 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/dcr" "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" "github.com/thunder-id/thunderid/internal/oauth/oauth2/jti" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/revocation" "github.com/thunder-id/thunderid/internal/openid4vci" "github.com/thunder-id/thunderid/internal/ou" "github.com/thunder-id/thunderid/internal/resource" @@ -323,7 +324,15 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa resourceServerProvider := resource.NewDefaultAwareResourceServerProvider(resourceService, serverConfigService) flowConfig := flowconfig.FromServerRuntime() - sessionService, sessionCfg := initSessionService(ctx, serverConfigService, runtime.Config.Server.Identifier, logger) + // The SSO session service revokes a session's token families on sign-out. The criteria revoker is + // built here (the OAuth engine's own revoker is created later, so it cannot be shared) and adapted + // to the session service's consumer interface with the sign-out reason fixed. + tokenFamilyRevocationTTL := time.Duration(runtime.Config.OAuth.RefreshToken.ValidityPeriod) * time.Second + sessionCriteriaRev := sessionCriteriaRevoker{ + revoker: revocation.InitializeCriteriaRevoker(tokenFamilyRevocationTTL), + } + sessionService, sessionCfg := initSessionService(ctx, serverConfigService, + runtime.Config.Server.Identifier, sessionCriteriaRev, logger) flowConfig.Session = sessionCfg flowFactory, execRegistry, interceptorRegistry, graphBuilder := initializeFlowCoreAndExecutor(ctx, logger, cacheManager, executor.ExecutorDependencies{ @@ -526,14 +535,25 @@ func unregisterServices() { // initSessionService reads the effective SSO session configuration from the server-config section and // builds the session service, returning both so the caller can thread the config into flowexec too. func initSessionService(ctx context.Context, svc serverconfig.ServerConfigService, deploymentID string, - logger *log.Logger) (flowsession.Service, flowsession.Config) { + criteriaRevoker flowsession.CriteriaRevoker, logger *log.Logger) (flowsession.Service, flowsession.Config) { cfg := readSessionConfig(ctx, svc, logger) sessionService, err := flowsession.Initialize(dbprovider.GetDBProvider(), deploymentID, - flowsession.NewTimeouts(cfg.IdleTimeoutSeconds, cfg.AbsoluteTimeoutSeconds)) + flowsession.NewTimeouts(cfg.IdleTimeoutSeconds, cfg.AbsoluteTimeoutSeconds), criteriaRevoker) fatalOnError(ctx, logger, err, "Failed to initialize SSO session service") return sessionService, cfg } +// sessionCriteriaRevoker adapts the OAuth criteria revoker to the SSO session service's consumer +// interface, fixing the revocation reason to session sign-out. +type sessionCriteriaRevoker struct { + revoker revocation.CriteriaRevokerInterface +} + +// RevokeTokenFamily revokes the given token family with the session-logout reason. +func (a sessionCriteriaRevoker) RevokeTokenFamily(ctx context.Context, tokenFamilyID string) error { + return a.revoker.RevokeTokenFamily(ctx, tokenFamilyID, revocation.RevocationReasonSessionLogout) +} + // readSessionConfig reads the effective SSO session lifetime configuration from the server-config // "session" section. An unset section resolves to the zero Config, which NewTimeouts turns into the // built-in defaults; a read error is non-fatal for the same reason, so it logs and falls back. diff --git a/backend/dbscripts/runtime_persistent/postgres-cleanup.sql b/backend/dbscripts/runtime_persistent/postgres-cleanup.sql index 517fadb237..7d84d09ae7 100644 --- a/backend/dbscripts/runtime_persistent/postgres-cleanup.sql +++ b/backend/dbscripts/runtime_persistent/postgres-cleanup.sql @@ -117,5 +117,15 @@ BEGIN COMMIT; EXIT WHEN v_deleted = 0; END LOOP; + + LOOP + DELETE FROM "REVOCATION_CRITERIA" + WHERE ctid IN ( + SELECT ctid FROM "REVOCATION_CRITERIA" WHERE EXPIRY_TIME < v_now LIMIT p_batch_size + ); + GET DIAGNOSTICS v_deleted = ROW_COUNT; + COMMIT; + EXIT WHEN v_deleted = 0; + END LOOP; END; $$; diff --git a/backend/dbscripts/runtime_persistent/postgres.sql b/backend/dbscripts/runtime_persistent/postgres.sql index 084c08239c..b5954745f9 100644 --- a/backend/dbscripts/runtime_persistent/postgres.sql +++ b/backend/dbscripts/runtime_persistent/postgres.sql @@ -33,6 +33,28 @@ CREATE UNIQUE INDEX idx_revoked_token_jti_deployment ON "REVOKED_TOKEN" (DEPLOYM -- Index for expiry time on REVOKED_TOKEN (supports cleanup and expiry checks). CREATE INDEX idx_revoked_token_expiry_time ON "REVOKED_TOKEN" (EXPIRY_TIME); +-- Table to store criteria-based (many-token) revocations: a generalized attribute deny list. +-- CRITERION_TYPE names the dimension ('token_family' today; subject/client/consent are future types) +-- and CRITERION_VALUE holds the revoked value (the tfid for 'token_family'). Part of the +-- database.runtime_persistent classification: authoritative enforcement state that must survive a +-- runtime database flush. +CREATE TABLE "REVOCATION_CRITERIA" ( + DEPLOYMENT_ID VARCHAR(255) NOT NULL, + ID VARCHAR(36) NOT NULL PRIMARY KEY, + CRITERION_TYPE VARCHAR(30) NOT NULL, + CRITERION_VALUE VARCHAR(255) NOT NULL, + REASON VARCHAR(30) NOT NULL, + REVOKED_AT TIMESTAMP NOT NULL, + EXPIRY_TIME TIMESTAMP NOT NULL +); + +-- Unique index backs the hot lookup by (deployment, type, value) and enforces idempotent writes. +CREATE UNIQUE INDEX idx_revocation_criteria_lookup + ON "REVOCATION_CRITERIA" (DEPLOYMENT_ID, CRITERION_TYPE, CRITERION_VALUE); + +-- Index for expiry time on REVOCATION_CRITERIA (supports cleanup and expiry checks). +CREATE INDEX idx_revocation_criteria_expiry_time ON "REVOCATION_CRITERIA" (EXPIRY_TIME); + -- Table to store SSO sessions, grouped by flow (FLOW_ID) and resolved by an opaque handle. -- Part of the database.runtime_persistent classification: persistent session state that must survive a -- runtime database flush. @@ -81,6 +103,7 @@ CREATE TABLE "SSO_SESSION_PARTICIPANT" ( SESSION_ID VARCHAR(36) NOT NULL, DEPLOYMENT_ID VARCHAR(255) NOT NULL, APP_ID VARCHAR(36) NOT NULL, + TFID VARCHAR(36), FIRST_JOINED_AT TIMESTAMP NOT NULL, LAST_ACTIVE_AT TIMESTAMP NOT NULL, PRIMARY KEY (SESSION_ID, DEPLOYMENT_ID, APP_ID) diff --git a/backend/dbscripts/runtime_persistent/sqlite.sql b/backend/dbscripts/runtime_persistent/sqlite.sql index 62db31c0c0..337fc031b8 100644 --- a/backend/dbscripts/runtime_persistent/sqlite.sql +++ b/backend/dbscripts/runtime_persistent/sqlite.sql @@ -33,6 +33,28 @@ CREATE UNIQUE INDEX idx_revoked_token_jti_deployment ON "REVOKED_TOKEN" (DEPLOYM -- Index for expiry time on REVOKED_TOKEN (supports cleanup and expiry checks). CREATE INDEX idx_revoked_token_expiry_time ON "REVOKED_TOKEN" (EXPIRY_TIME); +-- Table to store criteria-based (many-token) revocations: a generalized attribute deny list. +-- CRITERION_TYPE names the dimension ('token_family' today; subject/client/consent are future types) +-- and CRITERION_VALUE holds the revoked value (the tfid for 'token_family'). Part of the +-- database.runtime_persistent classification: authoritative enforcement state that must survive a +-- runtime database flush. +CREATE TABLE "REVOCATION_CRITERIA" ( + DEPLOYMENT_ID VARCHAR(255) NOT NULL, + ID VARCHAR(36) NOT NULL PRIMARY KEY, + CRITERION_TYPE VARCHAR(30) NOT NULL, + CRITERION_VALUE VARCHAR(255) NOT NULL, + REASON VARCHAR(30) NOT NULL, + REVOKED_AT DATETIME NOT NULL, + EXPIRY_TIME DATETIME NOT NULL +); + +-- Unique index backs the hot lookup by (deployment, type, value) and enforces idempotent writes. +CREATE UNIQUE INDEX idx_revocation_criteria_lookup + ON "REVOCATION_CRITERIA" (DEPLOYMENT_ID, CRITERION_TYPE, CRITERION_VALUE); + +-- Index for expiry time on REVOCATION_CRITERIA (supports cleanup and expiry checks). +CREATE INDEX idx_revocation_criteria_expiry_time ON "REVOCATION_CRITERIA" (EXPIRY_TIME); + -- Table to store SSO sessions, grouped by flow (FLOW_ID) and resolved by an opaque handle. -- Part of the database.runtime_persistent classification: persistent session state that must survive a -- runtime database flush. @@ -81,6 +103,7 @@ CREATE TABLE "SSO_SESSION_PARTICIPANT" ( SESSION_ID VARCHAR(36) NOT NULL, DEPLOYMENT_ID VARCHAR(255) NOT NULL, APP_ID VARCHAR(36) NOT NULL, + TFID VARCHAR(36), FIRST_JOINED_AT DATETIME NOT NULL, LAST_ACTIVE_AT DATETIME NOT NULL, PRIMARY KEY (SESSION_ID, DEPLOYMENT_ID, APP_ID) diff --git a/backend/internal/flow/common/constants.go b/backend/internal/flow/common/constants.go index feef87858b..1318e60861 100644 --- a/backend/internal/flow/common/constants.go +++ b/backend/internal/flow/common/constants.go @@ -238,6 +238,12 @@ const ( // cookie. Like RuntimeKeySSOSessionHandle it rides the engine-only EngineData channel, keeping SSO // concepts off the reusable engine contract. RuntimeKeySSOSessionCleared = "ssoSessionCleared" + // RuntimeKeyTokenFamilyID carries the token family id (tfid) minted once per login flow execution + // by the Session node. It is stamped onto the auth assertion, and from there onto the grant's + // access and refresh tokens, so revocation can target a whole family. It is deliberately excluded + // from the SSO checkpoint snapshot so each flow execution mints a fresh tfid rather than reusing a + // prior one on SSO reuse. + RuntimeKeyTokenFamilyID = "tokenFamilyId" ) // SSOCheckpointKey scopes a per-checkpoint SSO control key (RuntimeKeySSOSessionPresent, diff --git a/backend/internal/flow/executor/auth_assert_executor.go b/backend/internal/flow/executor/auth_assert_executor.go index 4f8703f20c..3025cd98d6 100644 --- a/backend/internal/flow/executor/auth_assert_executor.go +++ b/backend/internal/flow/executor/auth_assert_executor.go @@ -230,6 +230,12 @@ func (a *authAssertExecutor) generateAuthAssertion( jwtClaims[oauth2const.ClaimAuthorizationRequestID] = authReqID } + // Carry the token family id (minted by the Session node) so the authorization code, and in turn + // the grant's access and refresh tokens, are stamped with it for family-scoped revocation. + if tokenFamilyID, exists := ctx.RuntimeData[common.RuntimeKeyTokenFamilyID]; exists && tokenFamilyID != "" { + jwtClaims[oauth2const.ClaimTokenFamilyID] = tokenFamilyID + } + requiredAttributes := a.getRequiredUserAttributes(ctx) metadata := core.BuildGetAttributesMetadata(ctx) diff --git a/backend/internal/flow/executor/session_executor.go b/backend/internal/flow/executor/session_executor.go index 07134bbe20..299eb83bed 100644 --- a/backend/internal/flow/executor/session_executor.go +++ b/backend/internal/flow/executor/session_executor.go @@ -28,6 +28,7 @@ import ( "github.com/thunder-id/thunderid/internal/flow/core" "github.com/thunder-id/thunderid/internal/flow/session" "github.com/thunder-id/thunderid/internal/system/log" + sysutils "github.com/thunder-id/thunderid/internal/system/utils" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) @@ -123,6 +124,14 @@ func (e *sessionExecutor) saveCheckpoint(ctx *providers.NodeContext, execResp *p return nil } + // Mint (or reuse) this execution's token family id and publish it so the auth-assertion node + // stamps it onto the grant's tokens. Set it before the idempotency return so a re-executed join + // still carries it. + tokenFamilyID := e.resolveTokenFamilyID(ctx, logger) + if tokenFamilyID != "" { + execResp.RuntimeData[common.RuntimeKeyTokenFamilyID] = tokenFamilyID + } + // Idempotency: if this checkpoint was already saved in this flow execution, re-emit its handle // instead of saving again. savedKey := common.SSOCheckpointKey(common.RuntimeKeySSOSessionSaved, checkpoint) @@ -164,6 +173,7 @@ func (e *sessionExecutor) saveCheckpoint(ctx *providers.NodeContext, execResp *p RuntimeData: sanitizeSnapshotRuntimeData(ctx.RuntimeData), CompletedSteps: buildCompletedSteps(ctx.ExecutionHistory), AppID: ctx.Application.ID, + TokenFamilyID: tokenFamilyID, }) if err != nil { return err @@ -187,6 +197,22 @@ func (e *sessionExecutor) saveCheckpoint(ctx *providers.NodeContext, execResp *p return nil } +// resolveTokenFamilyID returns the token family id for this flow execution: the one an earlier +// Session node already minted (kept stable across a flow's checkpoints), or a freshly minted UUIDv7. +// A mint failure degrades gracefully — the grant's tokens simply carry no tfid. +func (e *sessionExecutor) resolveTokenFamilyID(ctx *providers.NodeContext, logger *log.Logger) string { + if existing := ctx.RuntimeData[common.RuntimeKeyTokenFamilyID]; existing != "" { + return existing + } + tokenFamilyID, err := sysutils.GenerateUUIDv7() + if err != nil { + logger.Warn(ctx.Context, "Failed to mint token family id; grant tokens will carry no tfid", + log.Error(err)) + return "" + } + return tokenFamilyID +} + // setHandleOut records a minted session handle on the response's EngineData channel — engine-only // output that the flow engine lifts onto the flow step for the transport layer to set the per-flow // cookie. EngineData is never returned to the client, so the handle does not leak into the response, @@ -205,7 +231,10 @@ func setHandleOut(execResp *providers.ExecutorResponse, handle string) { func (e *sessionExecutor) loadCheckpoint(ctx *providers.NodeContext, execResp *providers.ExecutorResponse, checkpoint string, logger *log.Logger) error { handle := ctx.RuntimeData[common.RuntimeKeySSOSessionHandle] - sess, sc, err := e.sso.LoadCheckpoint(ctx.Context, handle, checkpoint, ctx.Application.ID) + // An SSO reuse still issues a fresh grant, so mint a new token family id and record it against the + // joining participant. It is published onto RuntimeData after the snapshot replay below. + tokenFamilyID := e.resolveTokenFamilyID(ctx, logger) + sess, sc, err := e.sso.LoadCheckpoint(ctx.Context, handle, checkpoint, ctx.Application.ID, tokenFamilyID) if err != nil { return err } @@ -228,6 +257,11 @@ func (e *sessionExecutor) loadCheckpoint(ctx *providers.NodeContext, execResp *p if !sess.AuthenticatedAt.IsZero() { execResp.RuntimeData[common.RuntimeKeyAuthTime] = strconv.FormatInt(sess.AuthenticatedAt.Unix(), 10) } + // Publish the freshly minted token family id after the snapshot replay so it is never shadowed by + // a stale copy (the tfid is excluded from the snapshot, so this is the only source). + if tokenFamilyID != "" { + execResp.RuntimeData[common.RuntimeKeyTokenFamilyID] = tokenFamilyID + } logger.Debug(ctx.Context, "Loaded SSO checkpoint", log.String("flowId", session.SSOInputsFrom(ctx.Context).FlowID), @@ -249,6 +283,8 @@ var requestScopedSnapshotDenyList = map[string]struct{}{ common.RuntimeKeyRequiredLocales: {}, common.RuntimeKeyClientID: {}, common.RuntimeKeyAuthorizationRequestID: {}, + // The token family id is minted fresh per flow execution, so it must not ride a reused snapshot. + common.RuntimeKeyTokenFamilyID: {}, // applicationId has no shared constant (set as a raw literal in enrichRuntimeData). "applicationId": {}, } diff --git a/backend/internal/flow/executor/session_executor_test.go b/backend/internal/flow/executor/session_executor_test.go index cf8d4e4d82..6ae387160a 100644 --- a/backend/internal/flow/executor/session_executor_test.go +++ b/backend/internal/flow/executor/session_executor_test.go @@ -331,7 +331,7 @@ func (suite *SessionExecutorTestSuite) TestSSOLoad() { snapAuthUser := `{"default":{"entityReference":{"entityId":"user-2","ouId":"ou-9","type":"person"},` + `"attributes":{"attributes":{"email":{"value":"bob@example.com"}}}}}` sso := sessionmock.NewServiceMock(suite.T()) - sso.EXPECT().LoadCheckpoint(mock.Anything, "handle-abc", "session", "app-456").Return( + sso.EXPECT().LoadCheckpoint(mock.Anything, "handle-abc", "session", "app-456", mock.Anything).Return( &session.Session{ SessionID: "sess-1", SubjectID: "user-2", HandleID: "handle-abc", AuthenticatedAt: time.Unix(1700000000, 0).UTC(), @@ -372,7 +372,7 @@ func (suite *SessionExecutorTestSuite) TestSSOLoad() { // node fails the flow (the credential steps were already skipped). func (suite *SessionExecutorTestSuite) TestSSOLoad_ErrorFailsFlow() { sso := sessionmock.NewServiceMock(suite.T()) - sso.EXPECT().LoadCheckpoint(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + sso.EXPECT().LoadCheckpoint(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(nil, nil, errors.New("resolved session no longer exists")) exec := suite.newExecutor(sso, managermock.NewAuthnProviderManagerMock(suite.T())) @@ -386,7 +386,7 @@ func (suite *SessionExecutorTestSuite) TestSSOLoad_ErrorFailsFlow() { // reconstruct the subject, so the flow fails. func (suite *SessionExecutorTestSuite) TestSSOLoad_RehydrateErrorFailsFlow() { sso := sessionmock.NewServiceMock(suite.T()) - sso.EXPECT().LoadCheckpoint(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + sso.EXPECT().LoadCheckpoint(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( &session.Session{SessionID: "sess-1", HandleID: "handle-abc"}, &session.SessionContext{SessionID: "sess-1", AuthUser: json.RawMessage("not-json")}, nil) exec := suite.newExecutor(sso, managermock.NewAuthnProviderManagerMock(suite.T())) diff --git a/backend/internal/flow/session/CriteriaRevoker_mock_test.go b/backend/internal/flow/session/CriteriaRevoker_mock_test.go new file mode 100644 index 0000000000..043a450ca6 --- /dev/null +++ b/backend/internal/flow/session/CriteriaRevoker_mock_test.go @@ -0,0 +1,95 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package session + +import ( + "context" + + mock "github.com/stretchr/testify/mock" +) + +// NewCriteriaRevokerMock creates a new instance of CriteriaRevokerMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewCriteriaRevokerMock(t interface { + mock.TestingT + Cleanup(func()) +}) *CriteriaRevokerMock { + mock := &CriteriaRevokerMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// CriteriaRevokerMock is an autogenerated mock type for the CriteriaRevoker type +type CriteriaRevokerMock struct { + mock.Mock +} + +type CriteriaRevokerMock_Expecter struct { + mock *mock.Mock +} + +func (_m *CriteriaRevokerMock) EXPECT() *CriteriaRevokerMock_Expecter { + return &CriteriaRevokerMock_Expecter{mock: &_m.Mock} +} + +// RevokeTokenFamily provides a mock function for the type CriteriaRevokerMock +func (_mock *CriteriaRevokerMock) RevokeTokenFamily(ctx context.Context, tokenFamilyID string) error { + ret := _mock.Called(ctx, tokenFamilyID) + + if len(ret) == 0 { + panic("no return value specified for RevokeTokenFamily") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = returnFunc(ctx, tokenFamilyID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// CriteriaRevokerMock_RevokeTokenFamily_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RevokeTokenFamily' +type CriteriaRevokerMock_RevokeTokenFamily_Call struct { + *mock.Call +} + +// RevokeTokenFamily is a helper method to define mock.On call +// - ctx context.Context +// - tokenFamilyID string +func (_e *CriteriaRevokerMock_Expecter) RevokeTokenFamily(ctx interface{}, tokenFamilyID interface{}) *CriteriaRevokerMock_RevokeTokenFamily_Call { + return &CriteriaRevokerMock_RevokeTokenFamily_Call{Call: _e.mock.On("RevokeTokenFamily", ctx, tokenFamilyID)} +} + +func (_c *CriteriaRevokerMock_RevokeTokenFamily_Call) Run(run func(ctx context.Context, tokenFamilyID string)) *CriteriaRevokerMock_RevokeTokenFamily_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *CriteriaRevokerMock_RevokeTokenFamily_Call) Return(err error) *CriteriaRevokerMock_RevokeTokenFamily_Call { + _c.Call.Return(err) + return _c +} + +func (_c *CriteriaRevokerMock_RevokeTokenFamily_Call) RunAndReturn(run func(ctx context.Context, tokenFamilyID string) error) *CriteriaRevokerMock_RevokeTokenFamily_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/flow/session/Service_mock_test.go b/backend/internal/flow/session/Service_mock_test.go index b9d0cd8ff9..2cb24b17a2 100644 --- a/backend/internal/flow/session/Service_mock_test.go +++ b/backend/internal/flow/session/Service_mock_test.go @@ -111,8 +111,8 @@ func (_c *ServiceMock_HasCheckpoint_Call) RunAndReturn(run func(ctx context.Cont } // LoadCheckpoint provides a mock function for the type ServiceMock -func (_mock *ServiceMock) LoadCheckpoint(ctx context.Context, handle string, checkpoint string, appID string) (*Session, *SessionContext, error) { - ret := _mock.Called(ctx, handle, checkpoint, appID) +func (_mock *ServiceMock) LoadCheckpoint(ctx context.Context, handle string, checkpoint string, appID string, tokenFamilyID string) (*Session, *SessionContext, error) { + ret := _mock.Called(ctx, handle, checkpoint, appID, tokenFamilyID) if len(ret) == 0 { panic("no return value specified for LoadCheckpoint") @@ -121,25 +121,25 @@ func (_mock *ServiceMock) LoadCheckpoint(ctx context.Context, handle string, che var r0 *Session var r1 *SessionContext var r2 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*Session, *SessionContext, error)); ok { - return returnFunc(ctx, handle, checkpoint, appID) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) (*Session, *SessionContext, error)); ok { + return returnFunc(ctx, handle, checkpoint, appID, tokenFamilyID) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *Session); ok { - r0 = returnFunc(ctx, handle, checkpoint, appID) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) *Session); ok { + r0 = returnFunc(ctx, handle, checkpoint, appID, tokenFamilyID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*Session) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) *SessionContext); ok { - r1 = returnFunc(ctx, handle, checkpoint, appID) + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, string) *SessionContext); ok { + r1 = returnFunc(ctx, handle, checkpoint, appID, tokenFamilyID) } else { if ret.Get(1) != nil { r1 = ret.Get(1).(*SessionContext) } } - if returnFunc, ok := ret.Get(2).(func(context.Context, string, string, string) error); ok { - r2 = returnFunc(ctx, handle, checkpoint, appID) + if returnFunc, ok := ret.Get(2).(func(context.Context, string, string, string, string) error); ok { + r2 = returnFunc(ctx, handle, checkpoint, appID, tokenFamilyID) } else { r2 = ret.Error(2) } @@ -156,11 +156,12 @@ type ServiceMock_LoadCheckpoint_Call struct { // - handle string // - checkpoint string // - appID string -func (_e *ServiceMock_Expecter) LoadCheckpoint(ctx interface{}, handle interface{}, checkpoint interface{}, appID interface{}) *ServiceMock_LoadCheckpoint_Call { - return &ServiceMock_LoadCheckpoint_Call{Call: _e.mock.On("LoadCheckpoint", ctx, handle, checkpoint, appID)} +// - tokenFamilyID string +func (_e *ServiceMock_Expecter) LoadCheckpoint(ctx interface{}, handle interface{}, checkpoint interface{}, appID interface{}, tokenFamilyID interface{}) *ServiceMock_LoadCheckpoint_Call { + return &ServiceMock_LoadCheckpoint_Call{Call: _e.mock.On("LoadCheckpoint", ctx, handle, checkpoint, appID, tokenFamilyID)} } -func (_c *ServiceMock_LoadCheckpoint_Call) Run(run func(ctx context.Context, handle string, checkpoint string, appID string)) *ServiceMock_LoadCheckpoint_Call { +func (_c *ServiceMock_LoadCheckpoint_Call) Run(run func(ctx context.Context, handle string, checkpoint string, appID string, tokenFamilyID string)) *ServiceMock_LoadCheckpoint_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -178,11 +179,16 @@ func (_c *ServiceMock_LoadCheckpoint_Call) Run(run func(ctx context.Context, han if args[3] != nil { arg3 = args[3].(string) } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } run( arg0, arg1, arg2, arg3, + arg4, ) }) return _c @@ -193,7 +199,7 @@ func (_c *ServiceMock_LoadCheckpoint_Call) Return(session *Session, sessionConte return _c } -func (_c *ServiceMock_LoadCheckpoint_Call) RunAndReturn(run func(ctx context.Context, handle string, checkpoint string, appID string) (*Session, *SessionContext, error)) *ServiceMock_LoadCheckpoint_Call { +func (_c *ServiceMock_LoadCheckpoint_Call) RunAndReturn(run func(ctx context.Context, handle string, checkpoint string, appID string, tokenFamilyID string) (*Session, *SessionContext, error)) *ServiceMock_LoadCheckpoint_Call { _c.Call.Return(run) return _c } diff --git a/backend/internal/flow/session/init.go b/backend/internal/flow/session/init.go index 682b5bfc87..de4d6b5336 100644 --- a/backend/internal/flow/session/init.go +++ b/backend/internal/flow/session/init.go @@ -29,7 +29,7 @@ import ( // receive only the Service and never hold a store. Timeouts fall back per field to the built-in // defaults so an unset (zero) value never makes sessions expire immediately. func Initialize(dbProvider provider.DBProviderInterface, deploymentID string, - timeouts Timeouts) (Service, error) { + timeouts Timeouts, criteriaRevoker CriteriaRevoker) (Service, error) { transactioner, err := dbProvider.GetRuntimePersistentDBTransactioner() if err != nil { return nil, fmt.Errorf("failed to get runtime persistent DB transactioner for the SSO session service: %w", err) @@ -45,10 +45,11 @@ func Initialize(dbProvider provider.DBProviderInterface, deploymentID string, store := newStore(dbProvider, deploymentID) return &service{ - store: store, - resolver: newResolver(store), - transactioner: transactioner, - timeouts: timeouts, - logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "SSOSessionService")), + store: store, + resolver: newResolver(store), + transactioner: transactioner, + criteriaRevoker: criteriaRevoker, + timeouts: timeouts, + logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "SSOSessionService")), }, nil } diff --git a/backend/internal/flow/session/model.go b/backend/internal/flow/session/model.go index 38b0ce3aa0..902bae9045 100644 --- a/backend/internal/flow/session/model.go +++ b/backend/internal/flow/session/model.go @@ -86,6 +86,11 @@ type Participant struct { SessionID string // AppID is the participating application's id. AppID string + // TokenFamilyID is the token family id (tfid) minted for this application's most recent grant in + // the session. It links the session to the grant's tokens so logout can revoke the whole family. + // Refreshed on each re-authorization (latest grant wins); empty for participants recorded before + // tfid was introduced. + TokenFamilyID string // FirstJoinedAt is when the application first joined the session (write-once). FirstJoinedAt time.Time // LastActiveAt is refreshed each time the application reuses the session. diff --git a/backend/internal/flow/session/participant_store_test.go b/backend/internal/flow/session/participant_store_test.go index d46e83884a..813235d956 100644 --- a/backend/internal/flow/session/participant_store_test.go +++ b/backend/internal/flow/session/participant_store_test.go @@ -51,11 +51,13 @@ func (s *ParticipantStoreTestSuite) SetupTest() { func (s *ParticipantStoreTestSuite) TestRecord_Upserts() { now := time.Unix(1700000000, 0).UTC() - p := Participant{SessionID: "sess-1", AppID: "app-1", FirstJoinedAt: now, LastActiveAt: now} + p := Participant{ + SessionID: "sess-1", AppID: "app-1", TokenFamilyID: "tfid-1", FirstJoinedAt: now, LastActiveAt: now, + } s.mockDBProvider.On("GetRuntimePersistentDBClient").Return(s.mockDBClient, nil) s.mockDBClient.On("ExecuteContext", context.Background(), queryUpsertParticipant, - "sess-1", testDeploymentID, "app-1", now, now). + "sess-1", testDeploymentID, "app-1", now, now, "tfid-1"). Return(int64(1), nil) err := s.store.Record(context.Background(), p) @@ -67,11 +69,13 @@ func (s *ParticipantStoreTestSuite) TestRecord_Upserts() { func (s *ParticipantStoreTestSuite) TestRecord_DBError() { now := time.Unix(1700000000, 0).UTC() - p := Participant{SessionID: "sess-1", AppID: "app-1", FirstJoinedAt: now, LastActiveAt: now} + p := Participant{ + SessionID: "sess-1", AppID: "app-1", TokenFamilyID: "tfid-1", FirstJoinedAt: now, LastActiveAt: now, + } s.mockDBProvider.On("GetRuntimePersistentDBClient").Return(s.mockDBClient, nil) s.mockDBClient.On("ExecuteContext", context.Background(), queryUpsertParticipant, - "sess-1", testDeploymentID, "app-1", now, now). + "sess-1", testDeploymentID, "app-1", now, now, "tfid-1"). Return(int64(0), errors.New("db down")) err := s.store.Record(context.Background(), p) @@ -84,7 +88,8 @@ func (s *ParticipantStoreTestSuite) TestListBySessionID() { first := time.Unix(1700000000, 0).UTC() second := time.Unix(1700000100, 0).UTC() rows := []map[string]interface{}{ - {"session_id": "sess-1", "app_id": "app-1", "first_joined_at": first, "last_active_at": first}, + {"session_id": "sess-1", "app_id": "app-1", "tfid": "tfid-1", + "first_joined_at": first, "last_active_at": first}, {"session_id": "sess-1", "app_id": "app-2", "first_joined_at": second, "last_active_at": second}, } s.mockDBProvider.On("GetRuntimePersistentDBClient").Return(s.mockDBClient, nil) @@ -99,6 +104,8 @@ func (s *ParticipantStoreTestSuite) TestListBySessionID() { s.Equal("app-1", got[0].AppID) s.Equal("app-2", got[1].AppID) s.Equal(first, got[0].FirstJoinedAt) + s.Equal("tfid-1", got[0].TokenFamilyID) + s.Empty(got[1].TokenFamilyID) } func (s *ParticipantStoreTestSuite) TestListBySessionID_Empty() { diff --git a/backend/internal/flow/session/service.go b/backend/internal/flow/session/service.go index d8da1f8d2a..13dce4f238 100644 --- a/backend/internal/flow/session/service.go +++ b/backend/internal/flow/session/service.go @@ -56,9 +56,11 @@ type Service interface { SaveCheckpoint(ctx context.Context, in SaveCheckpointInput) (SaveCheckpointResult, error) // LoadCheckpoint fetches the session referenced by handle and its checkpoint context, refreshes - // the session's last-active timestamp and idle deadline, and records the joining participant - // (both best-effort). It errors when the session or its checkpoint context no longer exists. - LoadCheckpoint(ctx context.Context, handle, checkpoint, appID string) (*Session, *SessionContext, error) + // the session's last-active timestamp and idle deadline, and records the joining participant with + // the grant's token family id (all best-effort). It errors when the session or its checkpoint + // context no longer exists. + LoadCheckpoint(ctx context.Context, handle, checkpoint, appID, tokenFamilyID string) ( + *Session, *SessionContext, error) // Terminate ends the session referenced by handle: it marks the session ENDED (so it can no // longer back SSO) and removes its checkpoint contexts and participants, all in one transaction. @@ -82,6 +84,10 @@ type SaveCheckpointInput struct { RuntimeData map[string]string CompletedSteps map[string]StepFact AppID string + // TokenFamilyID is the token family id (tfid) minted by the caller for this grant. It is stored on + // the joining participant so logout can resolve the session to its families. Empty leaves the + // participant's tfid unset. + TokenFamilyID string } // SaveCheckpointResult reports the outcome of a save. Handle is the session's handle; Created is @@ -93,13 +99,21 @@ type SaveCheckpointResult struct { Skipped bool } +// CriteriaRevoker revokes a token family (one authorization grant) by its id. It is injected so session +// sign-out can drop the session's grants without the session package depending on the OAuth +// revocation implementation. A nil revoker disables sign-out revocation. +type CriteriaRevoker interface { + RevokeTokenFamily(ctx context.Context, tokenFamilyID string) error +} + // service is the store-backed implementation of Service. type service struct { - store sessionStore - resolver Resolver - transactioner transaction.Transactioner - timeouts Timeouts - logger *log.Logger + store sessionStore + resolver Resolver + transactioner transaction.Transactioner + criteriaRevoker CriteriaRevoker + timeouts Timeouts + logger *log.Logger } var _ Service = (*service)(nil) @@ -167,7 +181,7 @@ func (s *service) SaveCheckpoint(ctx context.Context, in SaveCheckpointInput) (S if err := s.store.CreateContext(txCtx, snapshot); err != nil { return err } - return s.recordParticipant(txCtx, target.SessionID, in.AppID, now) + return s.recordParticipant(txCtx, target.SessionID, in.AppID, in.TokenFamilyID, now) }); err != nil { return SaveCheckpointResult{}, err } @@ -177,7 +191,7 @@ func (s *service) SaveCheckpoint(ctx context.Context, in SaveCheckpointInput) (S } // LoadCheckpoint implements Service. -func (s *service) LoadCheckpoint(ctx context.Context, handle, checkpoint, appID string) ( +func (s *service) LoadCheckpoint(ctx context.Context, handle, checkpoint, appID, tokenFamilyID string) ( *Session, *SessionContext, error) { if handle == "" { return nil, nil, fmt.Errorf("no resolved session handle to load") @@ -209,9 +223,16 @@ func (s *service) LoadCheckpoint(ctx context.Context, handle, checkpoint, appID s.logger.Warn(ctx, "Failed to refresh session last-active timestamp", log.Error(updErr)) } - // Record the joining application as a participant. Best-effort: the session loaded fine even if - // this fails. - if partErr := s.recordParticipant(ctx, sess.SessionID, appID, now); partErr != nil { + // Record the joining application as a participant. When this reused session issues a token family, + // its SESSION_ID -> tfid mapping is security-critical: logout resolves the families to revoke from + // these rows, so a token stamped with a tfid that has no persisted mapping would be unrevocable. + // Fail closed in that case so the reuse does not issue an unrevocable family (the caller aborts the + // load before publishing the tfid, forcing full re-authentication). Without a tfid there is nothing + // to revoke, so the write stays best-effort. + if partErr := s.recordParticipant(ctx, sess.SessionID, appID, tokenFamilyID, now); partErr != nil { + if tokenFamilyID != "" { + return nil, nil, fmt.Errorf("failed to record SSO session participant for token family: %w", partErr) + } s.logger.Warn(ctx, "Failed to record SSO session participant", log.Error(partErr)) } @@ -240,8 +261,12 @@ func (s *service) Terminate(ctx context.Context, handle, flowID string) (*Sessio // row is removed rather than tombstoned. DeleteSession removes the session row (SSO_SESSION), Delete // its checkpoint contexts (SSO_SESSION_CONTEXT), and DeleteBySessionID its participants // (SSO_SESSION_PARTICIPANT). Repeated calls are idempotent: once the row is gone, GetByHandle - // returns nil above. + // returns nil above. Token families are revoked first, in the same transaction, so a crash can + // never orphan live tokens for a deleted session. if txErr := s.transactioner.Transact(ctx, func(txCtx context.Context) error { + if revErr := s.revokeSessionFamilies(txCtx, sess.SessionID); revErr != nil { + return revErr + } if delErr := s.store.DeleteSession(txCtx, sess.SessionID); delErr != nil { return delErr } @@ -257,6 +282,25 @@ func (s *service) Terminate(ctx context.Context, handle, flowID string) (*Sessio return sess, nil } +// revokeSessionFamilies revokes the token family of every application participating in the session, +// so signing out of a login drops all of that login's grants. It is a no-op when no family revoker is +// wired. A participant recorded before tfid was introduced (empty tfid) is skipped by the revoker. +func (s *service) revokeSessionFamilies(ctx context.Context, sessionID string) error { + if s.criteriaRevoker == nil { + return nil + } + participants, err := s.store.ListBySessionID(ctx, sessionID) + if err != nil { + return err + } + for _, p := range participants { + if err := s.criteriaRevoker.RevokeTokenFamily(ctx, p.TokenFamilyID); err != nil { + return err + } + } + return nil +} + // targetSession returns the session this execution's checkpoints attach to, establishing one when // none exists yet. The bool reports whether this call minted the session. It returns (nil, false, // nil) when an existing session belongs to a different subject than the one just authenticated, so @@ -339,14 +383,17 @@ func (s *service) establishSession(ctx context.Context, in SaveCheckpointInput) } // recordParticipant records the application as a participant of the session, refreshing its -// last-active time if it has joined before. It is a no-op when the application id is unknown. -func (s *service) recordParticipant(ctx context.Context, sessionID, appID string, now time.Time) error { +// last-active time and current-grant tfid if it has joined before. It is a no-op when the +// application id is unknown. +func (s *service) recordParticipant(ctx context.Context, sessionID, appID, tokenFamilyID string, + now time.Time) error { if appID == "" { return nil } return s.store.Record(ctx, Participant{ SessionID: sessionID, AppID: appID, + TokenFamilyID: tokenFamilyID, FirstJoinedAt: now, LastActiveAt: now, }) diff --git a/backend/internal/flow/session/service_test.go b/backend/internal/flow/session/service_test.go index 3d5deda229..44584c52b5 100644 --- a/backend/internal/flow/session/service_test.go +++ b/backend/internal/flow/session/service_test.go @@ -289,7 +289,7 @@ func (suite *ServiceTestSuite) TestLoadCheckpoint_Success() { m.store.EXPECT().Record(mock.Anything, mock.Anything).RunAndReturn( func(_ context.Context, p Participant) error { recorded = p; return nil }) - sess, sc, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456") + sess, sc, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456", "tfid-1") suite.Require().NoError(err) suite.Require().NotNil(sess) @@ -306,7 +306,7 @@ func (suite *ServiceTestSuite) TestLoadCheckpoint_Success() { func (suite *ServiceTestSuite) TestLoadCheckpoint_NoHandle() { svc, _ := suite.newService() - _, _, err := svc.LoadCheckpoint(context.Background(), "", "session", "app-456") + _, _, err := svc.LoadCheckpoint(context.Background(), "", "session", "app-456", "tfid-1") suite.Require().Error(err) suite.Contains(err.Error(), "no resolved session handle") @@ -316,7 +316,7 @@ func (suite *ServiceTestSuite) TestLoadCheckpoint_MissingSession() { svc, m := suite.newService() m.store.EXPECT().GetByHandle(mock.Anything, mock.Anything).Return(nil, nil) - _, _, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456") + _, _, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456", "tfid-1") suite.Require().Error(err) suite.Contains(err.Error(), "resolved session no longer exists") @@ -328,13 +328,13 @@ func (suite *ServiceTestSuite) TestLoadCheckpoint_MissingContext() { Return(&Session{SessionID: "sess-1", HandleID: "handle-abc", State: StateActive}, nil) m.store.EXPECT().GetByCheckpoint(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) - _, _, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456") + _, _, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456", "tfid-1") suite.Require().Error(err) suite.Contains(err.Error(), "session context for checkpoint") } -func (suite *ServiceTestSuite) TestLoadCheckpoint_ParticipantErrorIsNonFatal() { +func (suite *ServiceTestSuite) TestLoadCheckpoint_ParticipantErrorWithTokenFamilyIsFatal() { svc, m := suite.newService() m.store.EXPECT().GetByHandle(mock.Anything, mock.Anything). Return(&Session{SessionID: "sess-1", HandleID: "handle-abc", State: StateActive}, nil) @@ -343,9 +343,26 @@ func (suite *ServiceTestSuite) TestLoadCheckpoint_ParticipantErrorIsNonFatal() { m.store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil) m.store.EXPECT().Record(mock.Anything, mock.Anything).Return(errors.New("db down")) - sess, sc, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456") + sess, sc, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456", "tfid-1") - suite.Require().NoError(err, "a participant-record failure must not fail the load") + suite.Require().Error(err, "issuing a token family whose mapping cannot persist must fail the load") + suite.Contains(err.Error(), "token family") + suite.Nil(sess) + suite.Nil(sc) +} + +func (suite *ServiceTestSuite) TestLoadCheckpoint_ParticipantErrorWithoutTokenFamilyIsNonFatal() { + svc, m := suite.newService() + m.store.EXPECT().GetByHandle(mock.Anything, mock.Anything). + Return(&Session{SessionID: "sess-1", HandleID: "handle-abc", State: StateActive}, nil) + m.store.EXPECT().GetByCheckpoint(mock.Anything, mock.Anything, mock.Anything). + Return(&SessionContext{SessionID: "sess-1"}, nil) + m.store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil) + m.store.EXPECT().Record(mock.Anything, mock.Anything).Return(errors.New("db down")) + + sess, sc, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456", "") + + suite.Require().NoError(err, "with no token family there is nothing to revoke, so the load survives") suite.NotNil(sess) suite.NotNil(sc) } @@ -367,6 +384,41 @@ func (suite *ServiceTestSuite) TestTerminate_DeletesSessionAndPurges() { suite.Equal("sess-1", got.SessionID, "the terminated session is returned") } +func (suite *ServiceTestSuite) TestTerminate_RevokesParticipantFamilies() { + m := &serviceMocks{ + store: newSessionStoreMock(suite.T()), + tx: transactionmock.NewTransactionerMock(suite.T()), + } + revoker := NewCriteriaRevokerMock(suite.T()) + svc := &service{ + store: m.store, + resolver: newResolver(m.store), + transactioner: m.tx, + criteriaRevoker: revoker, + timeouts: DefaultTimeouts(), + logger: log.GetLogger(), + } + + m.store.EXPECT().GetByHandle(mock.Anything, "handle-abc").Return(liveStoreSession(), nil) + runTx(m) + // Families are revoked before the deletes, one per participant. + m.store.EXPECT().ListBySessionID(mock.Anything, "sess-1").Return([]Participant{ + {SessionID: "sess-1", AppID: "app-1", TokenFamilyID: "tfid-a"}, + {SessionID: "sess-1", AppID: "app-2", TokenFamilyID: "tfid-b"}, + }, nil) + revoker.EXPECT().RevokeTokenFamily(mock.Anything, "tfid-a").Return(nil) + revoker.EXPECT().RevokeTokenFamily(mock.Anything, "tfid-b").Return(nil) + m.store.EXPECT().DeleteSession(mock.Anything, "sess-1").Return(nil) + m.store.EXPECT().Delete(mock.Anything, "sess-1").Return(nil) + m.store.EXPECT().DeleteBySessionID(mock.Anything, "sess-1").Return(nil) + + got, err := svc.Terminate(context.Background(), "handle-abc", "flow-1") + + suite.Require().NoError(err) + suite.Require().NotNil(got) + revoker.AssertExpectations(suite.T()) +} + func (suite *ServiceTestSuite) TestTerminate_NoHandle() { svc, _ := suite.newService() diff --git a/backend/internal/flow/session/store.go b/backend/internal/flow/session/store.go index 45efd61912..93ed0a4a02 100644 --- a/backend/internal/flow/session/store.go +++ b/backend/internal/flow/session/store.go @@ -252,7 +252,7 @@ func (st *store) buildSessionContextFromRow(row map[string]interface{}) (*Sessio func (st *store) Record(ctx context.Context, p Participant) error { return withRuntimePersistentDBClient(st.dbProvider, func(dbClient provider.DBClientInterface) error { _, err := dbClient.ExecuteContext(ctx, queryUpsertParticipant, - p.SessionID, st.deploymentID, p.AppID, p.FirstJoinedAt, p.LastActiveAt) + p.SessionID, st.deploymentID, p.AppID, p.FirstJoinedAt, p.LastActiveAt, p.TokenFamilyID) if err != nil { return fmt.Errorf("failed to record session participant: %w", err) } @@ -316,6 +316,7 @@ func buildParticipantFromRow(row map[string]interface{}) (Participant, error) { return Participant{ SessionID: sessionID, AppID: appID, + TokenFamilyID: parseNullableString(row["tfid"]), FirstJoinedAt: firstJoinedAt, LastActiveAt: lastActiveAt, }, nil diff --git a/backend/internal/flow/session/store_constants.go b/backend/internal/flow/session/store_constants.go index bc11185bc8..7433e65fb1 100644 --- a/backend/internal/flow/session/store_constants.go +++ b/backend/internal/flow/session/store_constants.go @@ -99,21 +99,23 @@ var ( } // queryUpsertParticipant records an application as a participant of a session, refreshing - // LAST_ACTIVE_AT (but preserving FIRST_JOINED_AT) when the application has already joined. The - // ON CONFLICT ... DO UPDATE form is valid in both PostgreSQL and SQLite. + // LAST_ACTIVE_AT and the current-grant TFID (but preserving FIRST_JOINED_AT) when the application + // has already joined. TFID moves to the latest grant so logout revokes the most recent family. + // The ON CONFLICT ... DO UPDATE form is valid in both PostgreSQL and SQLite. queryUpsertParticipant = model.DBQuery{ ID: "SSO-SESS-09", Query: `INSERT INTO "SSO_SESSION_PARTICIPANT" ` + - `(SESSION_ID, DEPLOYMENT_ID, APP_ID, FIRST_JOINED_AT, LAST_ACTIVE_AT) ` + - `VALUES ($1, $2, $3, $4, $5) ` + - `ON CONFLICT (SESSION_ID, DEPLOYMENT_ID, APP_ID) DO UPDATE SET LAST_ACTIVE_AT = excluded.LAST_ACTIVE_AT`, + `(SESSION_ID, DEPLOYMENT_ID, APP_ID, FIRST_JOINED_AT, LAST_ACTIVE_AT, TFID) ` + + `VALUES ($1, $2, $3, $4, $5, $6) ` + + `ON CONFLICT (SESSION_ID, DEPLOYMENT_ID, APP_ID) DO UPDATE SET ` + + `LAST_ACTIVE_AT = excluded.LAST_ACTIVE_AT, TFID = excluded.TFID`, } // queryListParticipantsBySessionID returns the applications that have joined a session, oldest // first. queryListParticipantsBySessionID = model.DBQuery{ ID: "SSO-SESS-10", - Query: `SELECT SESSION_ID, APP_ID, FIRST_JOINED_AT, LAST_ACTIVE_AT FROM "SSO_SESSION_PARTICIPANT" ` + + Query: `SELECT SESSION_ID, APP_ID, FIRST_JOINED_AT, LAST_ACTIVE_AT, TFID FROM "SSO_SESSION_PARTICIPANT" ` + `WHERE SESSION_ID = $1 AND DEPLOYMENT_ID = $2 ORDER BY FIRST_JOINED_AT`, } diff --git a/backend/internal/oauth/init.go b/backend/internal/oauth/init.go index 94c0d087a3..648ceeecbc 100644 --- a/backend/internal/oauth/init.go +++ b/backend/internal/oauth/init.go @@ -22,6 +22,7 @@ package oauth import ( "net/http" "slices" + "time" "github.com/thunder-id/thunderid/internal/attributecache" "github.com/thunder-id/thunderid/internal/flow/flowexec" @@ -78,14 +79,15 @@ func Initialize( resolver := jwksresolver.Initialize(httpClient) scopeValidator := scope.Initialize() discoveryService := discovery.Initialize(mux, runtimeCrypto, cfg) - var enforcementService revocation.EnforcementServiceInterface - var refreshTokenRevoker revocation.RefreshTokenRevokerInterface + var revocationSvc revocation.RevocationServiceInterface if cfg.OAuth.TokenRevocation.Enabled { // The enforcement service (revocation read path) is built before the token service so it can be // injected into the validator, which enforces the deny list as the final step of every validation. - enforcementService, refreshTokenRevoker = revocation.Initialize( - mux, jwtService, actorProvider, authnProvider, discoveryService, observabilitySvc) + tokenFamilyRevocationTTL := time.Duration(cfg.OAuth.RefreshToken.ValidityPeriod) * time.Second + enforcementService, revocationSvc = revocation.Initialize( + mux, jwtService, actorProvider, authnProvider, discoveryService, observabilitySvc, + tokenFamilyRevocationTTL, cfg.OAuth.Revocation.TokenFamily.OnExplicitRevoke) } tokenBuilder, tokenValidator := tokenservice.Initialize( @@ -93,7 +95,7 @@ func Initialize( parService := par.Initialize(mux, actorProvider, authnProvider, jwtService, discoveryService, resourceService, dpopVerifier, cfg, runtimeStore) oauth2AuthzService, err := oauth2authz.Initialize(mux, actorProvider, resourceService, - jwtService, flowExecService, parService, cfg, runtimeStore, transactioner) + jwtService, flowExecService, parService, revocationSvc, cfg, runtimeStore, transactioner) if err != nil { return err } @@ -108,7 +110,7 @@ func Initialize( grantHandlerProvider := granthandlers.Initialize( jwtService, oauth2AuthzService, tokenBuilder, tokenValidator, attributeCacheSvc, ouService, authzService, actorProvider, resourceService, - cibaService, refreshTokenRevoker, cfg) + cibaService, revocationSvc, revocationSvc, cfg) token.Initialize(mux, jwtService, actorProvider, authnProvider, grantHandlerProvider, scopeValidator, observabilitySvc, discoveryService, dpopVerifier, cfg) diff --git a/backend/internal/oauth/oauth2/authz/AuthorizationCodeStoreInterface_mock_test.go b/backend/internal/oauth/oauth2/authz/AuthorizationCodeStoreInterface_mock_test.go index a36bb9b821..8a8333b62c 100644 --- a/backend/internal/oauth/oauth2/authz/AuthorizationCodeStoreInterface_mock_test.go +++ b/backend/internal/oauth/oauth2/authz/AuthorizationCodeStoreInterface_mock_test.go @@ -6,6 +6,7 @@ package authz import ( "context" + "time" mock "github.com/stretchr/testify/mock" ) @@ -103,6 +104,78 @@ func (_c *AuthorizationCodeStoreInterfaceMock_ConsumeAuthorizationCode_Call) Run return _c } +// ConsumedTokenFamily provides a mock function for the type AuthorizationCodeStoreInterfaceMock +func (_mock *AuthorizationCodeStoreInterfaceMock) ConsumedTokenFamily(ctx context.Context, authCode string) (string, bool, error) { + ret := _mock.Called(ctx, authCode) + + if len(ret) == 0 { + panic("no return value specified for ConsumedTokenFamily") + } + + var r0 string + var r1 bool + var r2 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, bool, error)); ok { + return returnFunc(ctx, authCode) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { + r0 = returnFunc(ctx, authCode) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok { + r1 = returnFunc(ctx, authCode) + } else { + r1 = ret.Get(1).(bool) + } + if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { + r2 = returnFunc(ctx, authCode) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// AuthorizationCodeStoreInterfaceMock_ConsumedTokenFamily_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumedTokenFamily' +type AuthorizationCodeStoreInterfaceMock_ConsumedTokenFamily_Call struct { + *mock.Call +} + +// ConsumedTokenFamily is a helper method to define mock.On call +// - ctx context.Context +// - authCode string +func (_e *AuthorizationCodeStoreInterfaceMock_Expecter) ConsumedTokenFamily(ctx interface{}, authCode interface{}) *AuthorizationCodeStoreInterfaceMock_ConsumedTokenFamily_Call { + return &AuthorizationCodeStoreInterfaceMock_ConsumedTokenFamily_Call{Call: _e.mock.On("ConsumedTokenFamily", ctx, authCode)} +} + +func (_c *AuthorizationCodeStoreInterfaceMock_ConsumedTokenFamily_Call) Run(run func(ctx context.Context, authCode string)) *AuthorizationCodeStoreInterfaceMock_ConsumedTokenFamily_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *AuthorizationCodeStoreInterfaceMock_ConsumedTokenFamily_Call) Return(s string, b bool, err error) *AuthorizationCodeStoreInterfaceMock_ConsumedTokenFamily_Call { + _c.Call.Return(s, b, err) + return _c +} + +func (_c *AuthorizationCodeStoreInterfaceMock_ConsumedTokenFamily_Call) RunAndReturn(run func(ctx context.Context, authCode string) (string, bool, error)) *AuthorizationCodeStoreInterfaceMock_ConsumedTokenFamily_Call { + _c.Call.Return(run) + return _c +} + // GetAuthorizationCode provides a mock function for the type AuthorizationCodeStoreInterfaceMock func (_mock *AuthorizationCodeStoreInterfaceMock) GetAuthorizationCode(ctx context.Context, authCode string) (*AuthorizationCode, error) { ret := _mock.Called(ctx, authCode) @@ -227,3 +300,72 @@ func (_c *AuthorizationCodeStoreInterfaceMock_InsertAuthorizationCode_Call) RunA _c.Call.Return(run) return _c } + +// MarkConsumedTokenFamily provides a mock function for the type AuthorizationCodeStoreInterfaceMock +func (_mock *AuthorizationCodeStoreInterfaceMock) MarkConsumedTokenFamily(ctx context.Context, authCode string, tokenFamilyID string, ttl time.Duration) error { + ret := _mock.Called(ctx, authCode, tokenFamilyID, ttl) + + if len(ret) == 0 { + panic("no return value specified for MarkConsumedTokenFamily") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, time.Duration) error); ok { + r0 = returnFunc(ctx, authCode, tokenFamilyID, ttl) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// AuthorizationCodeStoreInterfaceMock_MarkConsumedTokenFamily_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkConsumedTokenFamily' +type AuthorizationCodeStoreInterfaceMock_MarkConsumedTokenFamily_Call struct { + *mock.Call +} + +// MarkConsumedTokenFamily is a helper method to define mock.On call +// - ctx context.Context +// - authCode string +// - tokenFamilyID string +// - ttl time.Duration +func (_e *AuthorizationCodeStoreInterfaceMock_Expecter) MarkConsumedTokenFamily(ctx interface{}, authCode interface{}, tokenFamilyID interface{}, ttl interface{}) *AuthorizationCodeStoreInterfaceMock_MarkConsumedTokenFamily_Call { + return &AuthorizationCodeStoreInterfaceMock_MarkConsumedTokenFamily_Call{Call: _e.mock.On("MarkConsumedTokenFamily", ctx, authCode, tokenFamilyID, ttl)} +} + +func (_c *AuthorizationCodeStoreInterfaceMock_MarkConsumedTokenFamily_Call) Run(run func(ctx context.Context, authCode string, tokenFamilyID string, ttl time.Duration)) *AuthorizationCodeStoreInterfaceMock_MarkConsumedTokenFamily_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 time.Duration + if args[3] != nil { + arg3 = args[3].(time.Duration) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *AuthorizationCodeStoreInterfaceMock_MarkConsumedTokenFamily_Call) Return(err error) *AuthorizationCodeStoreInterfaceMock_MarkConsumedTokenFamily_Call { + _c.Call.Return(err) + return _c +} + +func (_c *AuthorizationCodeStoreInterfaceMock_MarkConsumedTokenFamily_Call) RunAndReturn(run func(ctx context.Context, authCode string, tokenFamilyID string, ttl time.Duration) error) *AuthorizationCodeStoreInterfaceMock_MarkConsumedTokenFamily_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/oauth/oauth2/authz/auth_code_store.go b/backend/internal/oauth/oauth2/authz/auth_code_store.go index 3aa35f3b42..66e86a51ed 100644 --- a/backend/internal/oauth/oauth2/authz/auth_code_store.go +++ b/backend/internal/oauth/oauth2/authz/auth_code_store.go @@ -27,11 +27,24 @@ import ( "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) +// consumedCodeReplayKeyPrefix namespaces the short-lived replay markers written when an authorization +// code is consumed. A marker records the code's token family id so a later replay of the (now removed) +// code can still revoke the grant. It shares NamespaceAuthzCode but cannot collide with a code key, +// which is a bare UUID. +const consumedCodeReplayKeyPrefix = "consumed:" + // AuthorizationCodeStoreInterface defines the interface for managing authorization codes. type AuthorizationCodeStoreInterface interface { InsertAuthorizationCode(ctx context.Context, authzCode AuthorizationCode) error ConsumeAuthorizationCode(ctx context.Context, authCode string) (bool, error) GetAuthorizationCode(ctx context.Context, authCode string) (*AuthorizationCode, error) + // MarkConsumedTokenFamily records tokenFamilyID under a replay-lookup key for a just-consumed code, + // bounded by ttl, so a later replay of the removed code can recover the tfid. An empty + // tokenFamilyID is a no-op. + MarkConsumedTokenFamily(ctx context.Context, authCode, tokenFamilyID string, ttl time.Duration) error + // ConsumedTokenFamily returns the token family id recorded for a consumed authorization code, and + // whether such a marker exists. + ConsumedTokenFamily(ctx context.Context, authCode string) (string, bool, error) } // authorizationCodeStore implements the AuthorizationCodeStoreInterface for managing authorization codes. @@ -78,6 +91,47 @@ func (acs *authorizationCodeStore) ConsumeAuthorizationCode(ctx context.Context, return data != nil, nil } +// MarkConsumedTokenFamily records the token family id of a just-consumed authorization code so a later +// replay of the (now removed) code can recover it and revoke the grant. An empty token family id is a +// no-op. ttl bounds the marker; a non-positive ttl is floored to one second. +func (acs *authorizationCodeStore) MarkConsumedTokenFamily( + ctx context.Context, authCode, tokenFamilyID string, ttl time.Duration) error { + if tokenFamilyID == "" { + return nil + } + if ttl < time.Second { + ttl = time.Second + } + data, err := json.Marshal(tokenFamilyID) + if err != nil { + return fmt.Errorf("failed to marshal token family id: %w", err) + } + err = acs.storeProvider.Put(ctx, providers.NamespaceAuthzCode, consumedCodeReplayKeyPrefix+authCode, + data, int64(ttl.Seconds())) + if err != nil { + return fmt.Errorf("error recording consumed authorization code marker: %w", err) + } + return nil +} + +// ConsumedTokenFamily returns the token family id recorded for a consumed authorization code, and +// whether such a marker exists. +func (acs *authorizationCodeStore) ConsumedTokenFamily( + ctx context.Context, authCode string) (string, bool, error) { + data, err := acs.storeProvider.Get(ctx, providers.NamespaceAuthzCode, consumedCodeReplayKeyPrefix+authCode) + if err != nil { + return "", false, fmt.Errorf("error reading consumed authorization code marker: %w", err) + } + if data == nil { + return "", false, nil + } + var tokenFamilyID string + if err := json.Unmarshal(data, &tokenFamilyID); err != nil { + return "", false, fmt.Errorf("failed to unmarshal consumed authorization code marker: %w", err) + } + return tokenFamilyID, true, nil +} + // GetAuthorizationCode retrieves an authorization code by code value. func (acs *authorizationCodeStore) GetAuthorizationCode( ctx context.Context, authCode string, diff --git a/backend/internal/oauth/oauth2/authz/auth_code_store_test.go b/backend/internal/oauth/oauth2/authz/auth_code_store_test.go index 57147e02d9..7bb98b9f18 100644 --- a/backend/internal/oauth/oauth2/authz/auth_code_store_test.go +++ b/backend/internal/oauth/oauth2/authz/auth_code_store_test.go @@ -69,6 +69,34 @@ func (suite *AuthorizationCodeStoreTestSuite) TestNewAuthorizationCodeStore() { assert.Implements(suite.T(), (*AuthorizationCodeStoreInterface)(nil), store) } +// Tests for the consumed-code replay markers + +func (suite *AuthorizationCodeStoreTestSuite) TestMarkAndReadConsumedTokenFamily() { + err := suite.store.MarkConsumedTokenFamily(context.Background(), "code-x", "tfid-x", time.Minute) + suite.NoError(err) + + tfid, found, err := suite.store.ConsumedTokenFamily(context.Background(), "code-x") + suite.NoError(err) + suite.True(found) + suite.Equal("tfid-x", tfid) +} + +func (suite *AuthorizationCodeStoreTestSuite) TestConsumedTokenFamily_Missing() { + tfid, found, err := suite.store.ConsumedTokenFamily(context.Background(), "no-such-code") + suite.NoError(err) + suite.False(found) + suite.Empty(tfid) +} + +func (suite *AuthorizationCodeStoreTestSuite) TestMarkConsumedTokenFamily_EmptyTokenFamilyIsNoOp() { + err := suite.store.MarkConsumedTokenFamily(context.Background(), "code-y", "", time.Minute) + suite.NoError(err) + + _, found, err := suite.store.ConsumedTokenFamily(context.Background(), "code-y") + suite.NoError(err) + suite.False(found) +} + // Tests for InsertAuthorizationCode func (suite *AuthorizationCodeStoreTestSuite) TestInsertAuthorizationCode_Success() { diff --git a/backend/internal/oauth/oauth2/authz/init.go b/backend/internal/oauth/oauth2/authz/init.go index 377bbd1d9b..0954b21079 100644 --- a/backend/internal/oauth/oauth2/authz/init.go +++ b/backend/internal/oauth/oauth2/authz/init.go @@ -24,6 +24,7 @@ import ( "github.com/thunder-id/thunderid/internal/flow/flowexec" oauthconfig "github.com/thunder-id/thunderid/internal/oauth/config" "github.com/thunder-id/thunderid/internal/oauth/oauth2/par" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/revocation" "github.com/thunder-id/thunderid/internal/system/constants" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/transaction" @@ -38,6 +39,7 @@ func Initialize( jwtService jwt.JWTServiceInterface, flowExecService flowexec.FlowExecServiceInterface, parService par.PARServiceInterface, + criteriaRevoker revocation.CriteriaRevokerInterface, cfg oauthconfig.Config, storeProvider providers.RuntimeStoreProvider, transactioner transaction.Transactioner, @@ -47,7 +49,7 @@ func Initialize( authzService := newAuthorizeService( actorProvider, resourceService, jwtService, flowExecService, - authzCodeStore, authzReqStore, parService, transactioner, cfg, + authzCodeStore, authzReqStore, parService, transactioner, criteriaRevoker, cfg, ) authzHandler := newAuthorizeHandler(authzService, cfg) registerRoutes(mux, authzHandler) diff --git a/backend/internal/oauth/oauth2/authz/init_test.go b/backend/internal/oauth/oauth2/authz/init_test.go index fcdde4a313..3c392191c1 100644 --- a/backend/internal/oauth/oauth2/authz/init_test.go +++ b/backend/internal/oauth/oauth2/authz/init_test.go @@ -95,7 +95,7 @@ func (suite *InitTestSuite) TestInitialize() { mux, actorprovider.Initialize(suite.mockInboundClient, suite.mockEntityProvider, noopAuthnMgr()), suite.mockResourceService, - suite.mockJWTService, suite.mockFlowExecService, nil, testhelpers.OAuthConfig(), + suite.mockJWTService, suite.mockFlowExecService, nil, nil, testhelpers.OAuthConfig(), inmemory.Initialize("test-deployment"), transaction.NewNoOpTransactioner(), ) @@ -111,7 +111,7 @@ func (suite *InitTestSuite) TestInitialize_RegistersRoutes() { mux, actorprovider.Initialize(suite.mockInboundClient, suite.mockEntityProvider, noopAuthnMgr()), suite.mockResourceService, - suite.mockJWTService, suite.mockFlowExecService, nil, testhelpers.OAuthConfig(), + suite.mockJWTService, suite.mockFlowExecService, nil, nil, testhelpers.OAuthConfig(), inmemory.Initialize("test-deployment"), transaction.NewNoOpTransactioner(), ) assert.NoError(suite.T(), err) @@ -129,7 +129,7 @@ func (suite *InitTestSuite) TestRegisterRoutes_CORSConfiguration() { mux, actorprovider.Initialize(suite.mockInboundClient, suite.mockEntityProvider, noopAuthnMgr()), suite.mockResourceService, - suite.mockJWTService, suite.mockFlowExecService, nil, testhelpers.OAuthConfig(), + suite.mockJWTService, suite.mockFlowExecService, nil, nil, testhelpers.OAuthConfig(), inmemory.Initialize("test-deployment"), transaction.NewNoOpTransactioner(), ) assert.NoError(suite.T(), err) diff --git a/backend/internal/oauth/oauth2/authz/model.go b/backend/internal/oauth/oauth2/authz/model.go index 1ed8f1f526..54a110e134 100644 --- a/backend/internal/oauth/oauth2/authz/model.go +++ b/backend/internal/oauth/oauth2/authz/model.go @@ -55,6 +55,10 @@ type AuthorizationCode struct { Nonce string CompletedACR string DPoPJkt string + // TokenFamilyID is the token family id (tfid) minted during the login flow and carried on the flow + // assertion. It is stamped onto the access and refresh tokens issued for this code so revocation + // can target the whole family. Empty when the login flow issued no tfid (e.g. pre-rollout tokens). + TokenFamilyID string } // AuthZPostRequest represents the request body for the authorization POST request. @@ -89,4 +93,5 @@ type assertionClaims struct { attributeCacheID string completedACR string authorizationRequestID string + tokenFamilyID string } diff --git a/backend/internal/oauth/oauth2/authz/service.go b/backend/internal/oauth/oauth2/authz/service.go index c819ed7226..1a95341d03 100644 --- a/backend/internal/oauth/oauth2/authz/service.go +++ b/backend/internal/oauth/oauth2/authz/service.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -37,6 +37,7 @@ import ( oauth2model "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" "github.com/thunder-id/thunderid/internal/oauth/oauth2/par" "github.com/thunder-id/thunderid/internal/oauth/oauth2/resourceindicators" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/revocation" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" oauth2utils "github.com/thunder-id/thunderid/internal/oauth/oauth2/utils" "github.com/thunder-id/thunderid/internal/system/jose/jwt" @@ -67,6 +68,7 @@ type authorizeService struct { jwtService jwt.JWTServiceInterface flowExecService flowexec.FlowExecServiceInterface transactioner transaction.Transactioner + criteriaRevoker revocation.CriteriaRevokerInterface logger *log.Logger } @@ -80,6 +82,7 @@ func newAuthorizeService( authReqStore authorizationRequestStoreInterface, parService par.PARServiceInterface, transactioner transaction.Transactioner, + criteriaRevoker revocation.CriteriaRevokerInterface, cfg oauthconfig.Config, ) AuthorizeServiceInterface { return &authorizeService{ @@ -93,6 +96,7 @@ func newAuthorizeService( jwtService: jwtService, flowExecService: flowExecService, transactioner: transactioner, + criteriaRevoker: criteriaRevoker, logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "AuthorizeService")), } } @@ -118,19 +122,56 @@ func (as *authorizeService) GetAuthorizationCodeDetails( return err } if !consumed { - // TODO: Revoke all access tokens already granted for this authorization code - // when the code has already been consumed (replay attack detected). + // The code was already consumed: this second redemption is a replay (RFC 9700). The token + // family is revoked on the error path below, via the replay marker. return errAuthorizationCodeAlreadyConsumed } + + // Record a short-lived replay marker carrying the grant's tfid. The code is removed on consume, + // so this marker is what lets a later replay of the same code revoke the whole family. Best + // effort: a failed marker write does not fail the legitimate redemption. + if record.TokenFamilyID != "" { + if mErr := as.authCodeStore.MarkConsumedTokenFamily(ctx, code, record.TokenFamilyID, + time.Until(record.ExpiryTime)); mErr != nil { + as.logger.Error(ctx, "Failed to record consumed authorization code replay marker", + log.Error(mErr)) + } + } return nil }) if err != nil { + // A failed redemption of an already-consumed code is a replay: if a marker exists for the code, + // revoke the family issued from the first redemption. It is a no-op when no marker exists (e.g. a + // genuinely unknown code or a client-id mismatch on a still-unconsumed code). + as.revokeTokenFamilyOnCodeReplay(ctx, code) as.logger.Error(ctx, "Failed to get authorization code details", log.Error(err)) return nil, err } return record, nil } +// revokeTokenFamilyOnCodeReplay revokes the token family of a replayed authorization code, when +// enabled. The code itself is removed on consume, so it looks up the replay marker written at first +// redemption to recover the tfid and drop the whole family. It is best-effort: a missing marker or a +// failed revoke is logged and does not change the replay rejection. +func (as *authorizeService) revokeTokenFamilyOnCodeReplay(ctx context.Context, code string) { + if as.criteriaRevoker == nil || !as.cfg.OAuth.Revocation.TokenFamily.OnCodeReplay { + return + } + tokenFamilyID, found, err := as.authCodeStore.ConsumedTokenFamily(ctx, code) + if err != nil { + as.logger.Error(ctx, "Failed to look up consumed authorization code replay marker", log.Error(err)) + return + } + if !found || tokenFamilyID == "" { + return + } + if err := as.criteriaRevoker.RevokeTokenFamily(ctx, tokenFamilyID, + revocation.RevocationReasonCodeReplay); err != nil { + as.logger.Error(ctx, "Failed to revoke token family on authorization code replay", log.Error(err)) + } +} + // HandleInitialAuthorizationRequest processes an initial authorization request from the client. // Returns the query params needed to redirect to the login page, or a structured authorization error. func (as *authorizeService) HandleInitialAuthorizationRequest(ctx context.Context, msg *OAuthMessage) ( @@ -685,6 +726,10 @@ func decodeAttributesFromAssertion(assertion string) (assertionClaims, time.Time claims.authorizedPermissions = v } + if v, ok := payload[oauth2const.ClaimTokenFamilyID].(string); ok { + claims.tokenFamilyID = v + } + if v, ok := payload[oauth2const.ClaimAuthorizationRequestID]; ok { strValue, ok := v.(string) if !ok { @@ -734,6 +779,18 @@ func createAuthorizationCode( return AuthorizationCode{}, errors.New("failed to generate UUID") } + // Fall back to minting the token family id here when the login flow did not (a flow without an SSO + // SessionExecutor node never mints one). Every authorization code then anchors a revocable family, + // so grant-scoped revocation works regardless of whether SSO was enabled. SSO flows already carry + // the tfid minted at the session node, so this preserves their session-participant linkage. + tokenFamilyID := claims.tokenFamilyID + if tokenFamilyID == "" { + tokenFamilyID, err = utils.GenerateUUIDv7() + if err != nil { + return AuthorizationCode{}, errors.New("failed to generate token family id") + } + } + code, err := oauth2utils.GenerateAuthorizationCode() if err != nil { return AuthorizationCode{}, errors.New("failed to generate authorization code") @@ -759,6 +816,7 @@ func createAuthorizationCode( Nonce: authRequestCtx.OAuthParameters.Nonce, CompletedACR: claims.completedACR, DPoPJkt: authRequestCtx.OAuthParameters.DPoPJkt, + TokenFamilyID: tokenFamilyID, }, nil } diff --git a/backend/internal/oauth/oauth2/authz/service_test.go b/backend/internal/oauth/oauth2/authz/service_test.go index 7f2b7157d5..c5ddbc8512 100644 --- a/backend/internal/oauth/oauth2/authz/service_test.go +++ b/backend/internal/oauth/oauth2/authz/service_test.go @@ -24,6 +24,7 @@ import ( "net/url" "strings" "testing" + "time" engineconfig "github.com/thunder-id/thunderid/pkg/thunderidengine/config" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" @@ -40,6 +41,7 @@ import ( oauthconfig "github.com/thunder-id/thunderid/internal/oauth/config" oauth2const "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" oauth2model "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/revocation" "github.com/thunder-id/thunderid/internal/system/config" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/log" @@ -48,6 +50,7 @@ import ( "github.com/thunder-id/thunderid/tests/mocks/flow/flowexecmock" "github.com/thunder-id/thunderid/tests/mocks/inboundclientmock" "github.com/thunder-id/thunderid/tests/mocks/jose/jwtmock" + "github.com/thunder-id/thunderid/tests/mocks/oauth/oauth2/revocationmock" "github.com/thunder-id/thunderid/tests/mocks/resourcemock" ) @@ -965,6 +968,62 @@ func (suite *AuthorizeServiceTestSuite) TestGetAuthorizationCodeDetails_AlreadyC assert.ErrorIs(suite.T(), err, errAuthorizationCodeAlreadyConsumed) } +func (suite *AuthorizeServiceTestSuite) TestGetAuthorizationCodeDetails_ReplayRevokesTokenFamily() { + // A replay of an already-consumed code: the code is gone from the store, but the replay marker + // written at first redemption still carries the grant's tfid, so the whole family is revoked. + suite.mockAuthzCodeStore.EXPECT().GetAuthorizationCode(mock.Anything, "code"). + Return(nil, errAuthorizationCodeNotFound) + suite.mockAuthzCodeStore.EXPECT().ConsumedTokenFamily(mock.Anything, "code"). + Return("tfid-replay", true, nil) + + revoker := revocationmock.NewCriteriaRevokerInterfaceMock(suite.T()) + revoker.EXPECT().RevokeTokenFamily(mock.Anything, "tfid-replay", revocation.RevocationReasonCodeReplay). + Return(nil) + + svc := suite.newService() + svc.criteriaRevoker = revoker + svc.cfg.OAuth.Revocation.TokenFamily.OnCodeReplay = true + + result, err := svc.GetAuthorizationCodeDetails(context.Background(), "client-id", "code") + + assert.Nil(suite.T(), result) + assert.ErrorIs(suite.T(), err, errAuthorizationCodeNotFound) + revoker.AssertExpectations(suite.T()) +} + +func (suite *AuthorizeServiceTestSuite) TestCreateAuthorizationCode_MintsFallbackTokenFamilyID() { + // A non-SSO flow issues no token family id, so the code must mint one to anchor a revocable family. + authCtx := &authRequestContext{ + OAuthParameters: oauth2model.OAuthParameters{ + ClientID: "test-client", + RedirectURI: "https://client.example.com/callback", + }, + } + claims := &assertionClaims{userID: "user-1"} + + code, err := createAuthorizationCode(authorizeServiceCfgFromRuntime(), authCtx, claims, time.Now()) + + assert.NoError(suite.T(), err) + assert.NotEmpty(suite.T(), code.TokenFamilyID) +} + +func (suite *AuthorizeServiceTestSuite) TestCreateAuthorizationCode_PreservesIncomingTokenFamilyID() { + // An SSO flow already minted the tfid at the session node; the code must carry that same value so + // its session-participant linkage stays consistent. + authCtx := &authRequestContext{ + OAuthParameters: oauth2model.OAuthParameters{ + ClientID: "test-client", + RedirectURI: "https://client.example.com/callback", + }, + } + claims := &assertionClaims{userID: "user-1", tokenFamilyID: "tfid-from-sso"} + + code, err := createAuthorizationCode(authorizeServiceCfgFromRuntime(), authCtx, claims, time.Now()) + + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "tfid-from-sso", code.TokenFamilyID) +} + func (suite *AuthorizeServiceTestSuite) TestGetAuthorizationCodeDetails_Success() { record := &AuthorizationCode{ CodeID: "code-id-123", diff --git a/backend/internal/oauth/oauth2/constants/constants.go b/backend/internal/oauth/oauth2/constants/constants.go index 2cc2e25743..6c660aac4a 100644 --- a/backend/internal/oauth/oauth2/constants/constants.go +++ b/backend/internal/oauth/oauth2/constants/constants.go @@ -283,6 +283,12 @@ const ( // jwt-bearer-grant (ID-JAG) access token, so downstream consumers can distinguish a federated // principal from a local one. ClaimIDP string = "idp" + // ClaimTokenFamilyID identifies the token family (one authorization grant) a token belongs to. + // A single tfid is minted per grant during the login flow and rides every access and refresh + // token of that grant, unchanged across refresh rotation, so revocation can target a whole + // family at once. Revocation-only and not a client-managed identifier: it rides the token JWTs + // but is not part of any client-facing API. + ClaimTokenFamilyID string = "tfid" ) // OIDC subject types. @@ -290,6 +296,14 @@ const ( SubjectTypePublic string = "public" ) +// Token-exchange token family modes (oauth.token_exchange.token_family). +const ( + // TokenExchangeTokenFamilyNone issues an exchanged token with no token family id (independent). + TokenExchangeTokenFamilyNone string = "none" + // TokenExchangeTokenFamilyInherit copies the subject token's token family id onto the exchanged token. + TokenExchangeTokenFamilyInherit string = "inherit" +) + // User attribute constants. const ( // UserAttributeGroups is the constant for user's groups attribute. diff --git a/backend/internal/oauth/oauth2/granthandlers/authorization_code.go b/backend/internal/oauth/oauth2/granthandlers/authorization_code.go index e7e0bd82df..969732fbd3 100644 --- a/backend/internal/oauth/oauth2/granthandlers/authorization_code.go +++ b/backend/internal/oauth/oauth2/granthandlers/authorization_code.go @@ -180,6 +180,7 @@ func (h *authorizationCodeGrantHandler) HandleGrant(ctx context.Context, tokenRe ClaimsLocales: authCode.ClaimsLocales, ValidityPeriod: userSubConfig.ValidityPeriodOrZero(), DPoPJkt: dpop.GetJkt(ctx), + TokenFamilyID: authCode.TokenFamilyID, } if oauthApp.ShouldAppendActorClaim() { accessTokenCtx.ActorClaims = &tokenservice.SubjectTokenClaims{Sub: oauthApp.ID} diff --git a/backend/internal/oauth/oauth2/granthandlers/grant_handler.go b/backend/internal/oauth/oauth2/granthandlers/grant_handler.go index eedce6974c..e3b06a86bd 100644 --- a/backend/internal/oauth/oauth2/granthandlers/grant_handler.go +++ b/backend/internal/oauth/oauth2/granthandlers/grant_handler.go @@ -49,5 +49,6 @@ type RefreshTokenGrantHandlerInterface interface { claimsRequest *model.ClaimsRequest, claimsLocales string, attributeCacheID string, + tokenFamilyID string, ) *model.ErrorResponse } diff --git a/backend/internal/oauth/oauth2/granthandlers/init.go b/backend/internal/oauth/oauth2/granthandlers/init.go index d29a669ee8..5a691ce747 100644 --- a/backend/internal/oauth/oauth2/granthandlers/init.go +++ b/backend/internal/oauth/oauth2/granthandlers/init.go @@ -44,6 +44,7 @@ func Initialize( resourceService providers.ResourceServerProvider, cibaService ciba.CIBAServiceInterface, refreshTokenRevoker revocation.RefreshTokenRevokerInterface, + criteriaRevoker revocation.CriteriaRevokerInterface, cfg oauthconfig.Config, ) GrantHandlerProviderInterface { return newGrantHandlerProvider( @@ -58,6 +59,7 @@ func Initialize( resourceService, cibaService, refreshTokenRevoker, + criteriaRevoker, cfg, ) } diff --git a/backend/internal/oauth/oauth2/granthandlers/provider.go b/backend/internal/oauth/oauth2/granthandlers/provider.go index 4592db3de6..3f39cbbb54 100644 --- a/backend/internal/oauth/oauth2/granthandlers/provider.go +++ b/backend/internal/oauth/oauth2/granthandlers/provider.go @@ -60,6 +60,7 @@ func newGrantHandlerProvider( resourceService providers.ResourceServerProvider, cibaService ciba.CIBAServiceInterface, refreshTokenRevoker revocation.RefreshTokenRevokerInterface, + criteriaRevoker revocation.CriteriaRevokerInterface, cfg oauthconfig.Config, ) GrantHandlerProviderInterface { allowedGrantTypes := cfg.OAuth.AllowedGrantTypes @@ -75,11 +76,11 @@ func newGrantHandlerProvider( if isGrantTypeAllowed(allowedGrantTypes, providers.GrantTypeRefreshToken) { grantProvider.refreshTokenGrantHandler = newRefreshTokenGrantHandler( jwtService, tokenBuilder, tokenValidator, attrCacheService, resourceService, - refreshTokenRevoker, cfg) + refreshTokenRevoker, criteriaRevoker, cfg) } if isGrantTypeAllowed(allowedGrantTypes, providers.GrantTypeTokenExchange) { grantProvider.tokenExchangeGrantHandler = newTokenExchangeGrantHandler( - tokenBuilder, tokenValidator, rbacAuthzService, actorProvider, resourceService) + tokenBuilder, tokenValidator, rbacAuthzService, actorProvider, resourceService, cfg) } if isGrantTypeAllowed(allowedGrantTypes, providers.GrantTypeCIBA) { grantProvider.cibaGrantHandler = newCIBAGrantHandler(cibaService, tokenBuilder, attrCacheService, diff --git a/backend/internal/oauth/oauth2/granthandlers/provider_test.go b/backend/internal/oauth/oauth2/granthandlers/provider_test.go index 4893b3360f..e7ae75711c 100644 --- a/backend/internal/oauth/oauth2/granthandlers/provider_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/provider_test.go @@ -81,6 +81,7 @@ func (suite *GrantHandlerProviderTestSuite) SetupTest() { suite.mockResourceService, suite.mockCIBAService, revocationmock.NewRefreshTokenRevokerInterfaceMock(suite.T()), + revocationmock.NewCriteriaRevokerInterfaceMock(suite.T()), testhelpers.OAuthConfig(), ) } @@ -98,6 +99,7 @@ func (suite *GrantHandlerProviderTestSuite) TestNewGrantHandlerProvider() { suite.mockResourceService, suite.mockCIBAService, revocationmock.NewRefreshTokenRevokerInterfaceMock(suite.T()), + revocationmock.NewCriteriaRevokerInterfaceMock(suite.T()), testhelpers.OAuthConfig(), ) assert.NotNil(suite.T(), provider) diff --git a/backend/internal/oauth/oauth2/granthandlers/refresh_token.go b/backend/internal/oauth/oauth2/granthandlers/refresh_token.go index 1fc45fe2bd..2ee20d3edf 100644 --- a/backend/internal/oauth/oauth2/granthandlers/refresh_token.go +++ b/backend/internal/oauth/oauth2/granthandlers/refresh_token.go @@ -50,6 +50,7 @@ type refreshTokenGrantHandler struct { attrCacheService attributecache.AttributeCacheServiceInterface resourceService providers.ResourceServerProvider refreshRevoker revocation.RefreshTokenRevokerInterface + criteriaRevoker revocation.CriteriaRevokerInterface } // newRefreshTokenGrantHandler creates a new instance of RefreshTokenGrantHandler. @@ -60,6 +61,7 @@ func newRefreshTokenGrantHandler( attrCacheService attributecache.AttributeCacheServiceInterface, resourceService providers.ResourceServerProvider, refreshRevoker revocation.RefreshTokenRevokerInterface, + criteriaRevoker revocation.CriteriaRevokerInterface, cfg oauthconfig.Config, ) RefreshTokenGrantHandlerInterface { return &refreshTokenGrantHandler{ @@ -70,6 +72,7 @@ func newRefreshTokenGrantHandler( attrCacheService: attrCacheService, resourceService: resourceService, refreshRevoker: refreshRevoker, + criteriaRevoker: criteriaRevoker, } } @@ -122,6 +125,11 @@ func (h *refreshTokenGrantHandler) HandleGrant(ctx context.Context, tokenRequest ErrorDescription: "Token revocation status could not be verified", } } + // A revoked (already-rotated) refresh token presented again is a replay signal: revoke + // the whole token family so the attacker's freshly rotated tokens die too (RFC 9700 §4.14.2). + if errors.Is(err, revocation.ErrTokenRevoked) { + h.revokeTokenFamilyOnReplay(ctx, tokenRequest.RefreshToken, logger) + } return nil, &model.ErrorResponse{ Error: constants.ErrorInvalidGrant, ErrorDescription: "Invalid refresh token", @@ -231,6 +239,7 @@ func (h *refreshTokenGrantHandler) HandleGrant(ctx context.Context, tokenRequest ClaimsLocales: refreshTokenClaims.ClaimsLocales, ValidityPeriod: userSubConfig.ValidityPeriodOrZero(), DPoPJkt: dpop.GetJkt(ctx), + TokenFamilyID: refreshTokenClaims.TokenFamilyID, } // Replay the on-behalf-of decision frozen at issuance, sourced from the stored marker // rather than the client's current setting. @@ -281,7 +290,7 @@ func (h *refreshTokenGrantHandler) HandleGrant(ctx context.Context, tokenRequest refreshTokenClaims.Sub, audiences, refreshTokenClaims.GrantType, newTokenScopes, refreshTokenClaims.ClaimsRequest, refreshTokenClaims.ClaimsLocales, - refreshTokenClaims.AttributeCacheID) + refreshTokenClaims.AttributeCacheID, refreshTokenClaims.TokenFamilyID) if errResp != nil && errResp.Error != "" { logger.Error(ctx, "Failed to issue refresh token", log.String("error", errResp.Error)) return nil, errResp @@ -319,6 +328,31 @@ func (h *refreshTokenGrantHandler) HandleGrant(ctx context.Context, tokenRequest return tokenResponse, nil } +// revokeTokenFamilyOnReplay revokes the token family of a replayed (already-revoked) refresh token, when +// enabled. It is best-effort: the refresh grant is rejected as invalid_grant regardless, and a failed +// family revoke is logged but does not change that outcome. The refresh token's signature was already +// verified upstream, so its tfid claim is trustworthy; the payload is decoded here only to read it. +func (h *refreshTokenGrantHandler) revokeTokenFamilyOnReplay(ctx context.Context, refreshToken string, + logger *log.Logger) { + if h.criteriaRevoker == nil || !h.cfg.OAuth.Revocation.TokenFamily.OnRefreshReplay { + return + } + claims, err := jwt.DecodeJWTPayload(refreshToken) + if err != nil { + logger.Debug(ctx, "Could not decode replayed refresh token to resolve its token family", + log.Error(err)) + return + } + tokenFamilyID, _ := claims[constants.ClaimTokenFamilyID].(string) + if tokenFamilyID == "" { + return + } + if err := h.criteriaRevoker.RevokeTokenFamily(ctx, tokenFamilyID, + revocation.RevocationReasonRefreshReplay); err != nil { + logger.Error(ctx, "Failed to revoke token family on refresh token replay", log.Error(err)) + } +} + // IssueRefreshToken generates a new refresh token for the given OAuth application and scopes. func (h *refreshTokenGrantHandler) IssueRefreshToken( ctx context.Context, @@ -329,6 +363,7 @@ func (h *refreshTokenGrantHandler) IssueRefreshToken( claimsRequest *model.ClaimsRequest, claimsLocales string, attributeCacheID string, + tokenFamilyID string, ) *model.ErrorResponse { tokenCtx := &tokenservice.RefreshTokenBuildContext{ ClientID: oauthApp.ClientID, @@ -341,6 +376,7 @@ func (h *refreshTokenGrantHandler) IssueRefreshToken( ClaimsRequest: claimsRequest, ClaimsLocales: claimsLocales, DPoPJkt: dpopJktForRefresh(ctx, oauthApp), + TokenFamilyID: tokenFamilyID, } if oauthApp.ShouldAppendActorClaim() { tokenCtx.ActorSub = oauthApp.ID diff --git a/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go b/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go index 31bdd8a5c6..2503ed09bc 100644 --- a/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go @@ -20,6 +20,7 @@ package granthandlers import ( "context" + "encoding/base64" "errors" "testing" "time" @@ -67,6 +68,7 @@ type RefreshTokenGrantHandlerTestSuite struct { mockAttrCacheService *attributecachemock.AttributeCacheServiceInterfaceMock mockResourceService *resourcemock.ResourceServiceInterfaceMock mockRefreshRevoker *revocationmock.RefreshTokenRevokerInterfaceMock + mockCriteriaRevoker *revocationmock.CriteriaRevokerInterfaceMock oauthApp *providers.OAuthClient validRefreshToken string validClaims map[string]interface{} @@ -102,6 +104,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) SetupTest() { suite.mockAttrCacheService = attributecachemock.NewAttributeCacheServiceInterfaceMock(suite.T()) suite.mockResourceService = resourcemock.NewResourceServiceInterfaceMock(suite.T()) suite.mockRefreshRevoker = revocationmock.NewRefreshTokenRevokerInterfaceMock(suite.T()) + suite.mockCriteriaRevoker = revocationmock.NewCriteriaRevokerInterfaceMock(suite.T()) suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, mock.Anything). Return(func(_ context.Context, identifier string) *providers.ResourceServer { @@ -155,6 +158,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) rebuildHandlerWithConfig() { suite.mockAttrCacheService, suite.mockResourceService, suite.mockRefreshRevoker, + suite.mockCriteriaRevoker, suite.testCfg, ).(*refreshTokenGrantHandler) } @@ -168,11 +172,38 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestNewRefreshTokenGrantHandler( suite.mockTokenBuilder, suite.mockTokenValidator, suite.mockAttrCacheService, - suite.mockResourceService, suite.mockRefreshRevoker, testhelpers.OAuthConfig()) + suite.mockResourceService, suite.mockRefreshRevoker, + suite.mockCriteriaRevoker, testhelpers.OAuthConfig()) assert.NotNil(suite.T(), handler) assert.Implements(suite.T(), (*RefreshTokenGrantHandlerInterface)(nil), handler) } +// A replayed (already-revoked) refresh token triggers a family revoke and is rejected as invalid_grant. +func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_ReplayRevokesTokenFamily() { + suite.testCfg.OAuth.Revocation.TokenFamily.OnRefreshReplay = true + suite.rebuildHandlerWithConfig() + + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"jti":"jti-old","tfid":"tfid-reuse"}`)) + reusedToken := "eyJhbGciOiJub25lIn0." + payload + ".sig" + req := &model.TokenRequest{ + GrantType: string(providers.GrantTypeRefreshToken), + ClientID: testClientID, + RefreshToken: reusedToken, + } + + suite.mockTokenValidator.On("ValidateRefreshToken", mock.Anything, reusedToken, testClientID). + Return(nil, revocation.ErrTokenRevoked) + suite.mockCriteriaRevoker.On("RevokeTokenFamily", mock.Anything, "tfid-reuse", + revocation.RevocationReasonRefreshReplay).Return(nil) + + resp, errResp := suite.handler.HandleGrant(context.Background(), req, suite.oauthApp) + + assert.Nil(suite.T(), resp) + suite.Require().NotNil(errResp) + assert.Equal(suite.T(), constants.ErrorInvalidGrant, errResp.Error) + suite.mockCriteriaRevoker.AssertExpectations(suite.T()) +} + func (suite *RefreshTokenGrantHandlerTestSuite) TestValidateGrant_Success() { err := suite.handler.ValidateGrant(context.Background(), suite.testTokenReq, suite.oauthApp) assert.Nil(suite.T(), err) @@ -264,6 +295,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestIssueRefreshToken_Success() return ctx.ClientID == testRefreshTokenClientID && ctx.GrantType == "authorization_code" && ctx.AccessTokenSubject == testRefreshTokenUserID && + ctx.TokenFamilyID == "tfid-issue-refresh" && len(ctx.AccessTokenAudiences) == 1 && ctx.AccessTokenAudiences[0] == testRefreshTokenAudience })).Return(&model.TokenDTO{ Token: "new.refresh.token", @@ -278,7 +310,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestIssueRefreshToken_Success() err := suite.handler.IssueRefreshToken(context.Background(), tokenResponse, suite.oauthApp, testRefreshTokenUserID, []string{testRefreshTokenAudience}, - "authorization_code", []string{"read", "write"}, nil, "", "") + "authorization_code", []string{"read", "write"}, nil, "", "", "tfid-issue-refresh") assert.Nil(suite.T(), err) assert.NotNil(suite.T(), tokenResponse.RefreshToken) @@ -298,7 +330,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestIssueRefreshToken_JWTGenerat tokenResponse := &model.TokenResponseDTO{} err := suite.handler.IssueRefreshToken(context.Background(), tokenResponse, suite.oauthApp, "", nil, - "authorization_code", []string{"read"}, nil, "", "") + "authorization_code", []string{"read"}, nil, "", "", "") assert.NotNil(suite.T(), err) assert.Equal(suite.T(), constants.ErrorServerError, err.Error) @@ -318,7 +350,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestIssueRefreshToken_WithEmptyT tokenResponse := &model.TokenResponseDTO{} err := suite.handler.IssueRefreshToken(context.Background(), tokenResponse, suite.oauthApp, "", nil, - "authorization_code", []string{"read"}, nil, "", "") + "authorization_code", []string{"read"}, nil, "", "", "") assert.Nil(suite.T(), err) } @@ -343,7 +375,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestIssueRefreshToken_WithClaims err := suite.handler.IssueRefreshToken(context.Background(), tokenResponse, suite.oauthApp, testRefreshTokenUserID, []string{testRefreshTokenAudience}, - "authorization_code", []string{"read"}, nil, "en-US fr-CA ja", "") + "authorization_code", []string{"read"}, nil, "en-US fr-CA ja", "", "") assert.Nil(suite.T(), err) assert.NotNil(suite.T(), tokenResponse.RefreshToken) @@ -373,7 +405,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestIssueRefreshToken_AgentClien tokenResponse := &model.TokenResponseDTO{} err := suite.handler.IssueRefreshToken(context.Background(), tokenResponse, agentApp, testRefreshTokenUserID, []string{testRefreshTokenAudience}, - "authorization_code", []string{"read"}, nil, "", "") + "authorization_code", []string{"read"}, nil, "", "", "") assert.Nil(suite.T(), err) assert.Equal(suite.T(), actAppID, capturedActorSub) @@ -402,7 +434,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestIssueRefreshToken_AppClientW tokenResponse := &model.TokenResponseDTO{} err := suite.handler.IssueRefreshToken(context.Background(), tokenResponse, appApp, testRefreshTokenUserID, []string{testRefreshTokenAudience}, - "authorization_code", []string{"read"}, nil, "", "") + "authorization_code", []string{"read"}, nil, "", "", "") assert.Nil(suite.T(), err) assert.Empty(suite.T(), capturedActorSub) @@ -607,6 +639,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_RevokePreviousOn suite.mockAttrCacheService, suite.mockResourceService, nil, + nil, suite.testCfg, ).(*refreshTokenGrantHandler) @@ -1940,7 +1973,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestIssueRefreshToken_PublicClie err := suite.handler.IssueRefreshToken(ctx, tokenResponse, suite.oauthApp, testRefreshTokenUserID, []string{testRefreshTokenAudience}, - "authorization_code", []string{"read"}, nil, "", "") + "authorization_code", []string{"read"}, nil, "", "", "") assert.Nil(suite.T(), err) suite.mockTokenBuilder.AssertExpectations(suite.T()) @@ -1962,7 +1995,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestIssueRefreshToken_Confidenti err := suite.handler.IssueRefreshToken(ctx, tokenResponse, suite.oauthApp, testRefreshTokenUserID, []string{testRefreshTokenAudience}, - "authorization_code", []string{"read"}, nil, "", "") + "authorization_code", []string{"read"}, nil, "", "", "") assert.Nil(suite.T(), err) suite.mockTokenBuilder.AssertExpectations(suite.T()) diff --git a/backend/internal/oauth/oauth2/granthandlers/token_exchange.go b/backend/internal/oauth/oauth2/granthandlers/token_exchange.go index 7889036d53..9d54efd6ba 100644 --- a/backend/internal/oauth/oauth2/granthandlers/token_exchange.go +++ b/backend/internal/oauth/oauth2/granthandlers/token_exchange.go @@ -23,6 +23,7 @@ import ( "errors" "slices" + oauthconfig "github.com/thunder-id/thunderid/internal/oauth/config" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" @@ -41,6 +42,7 @@ type tokenExchangeGrantHandler struct { authzService providers.AuthorizationProvider actorProvider providers.ActorProvider resourceService providers.ResourceServerProvider + cfg oauthconfig.Config } // newTokenExchangeGrantHandler creates a new instance of tokenExchangeGrantHandler. @@ -50,6 +52,7 @@ func newTokenExchangeGrantHandler( authzService providers.AuthorizationProvider, actorProvider providers.ActorProvider, resourceService providers.ResourceServerProvider, + cfg oauthconfig.Config, ) GrantHandlerInterface { return &tokenExchangeGrantHandler{ tokenBuilder: tokenBuilder, @@ -57,6 +60,7 @@ func newTokenExchangeGrantHandler( authzService: authzService, actorProvider: actorProvider, resourceService: resourceService, + cfg: cfg, } } @@ -251,6 +255,19 @@ func (h *tokenExchangeGrantHandler) HandleGrant(ctx context.Context, tokenReques finalAudiences = []string{targetRS.Identifier} } + // Resolve how the exchanged token relates to the subject token's revocation family: inherit joins + // the subject's family (both revoked together); none (the default, and the empty value) issues an + // independent token with no tfid. An unrecognized value is treated as none and surfaced. + var exchangedTokenFamilyID string + switch h.cfg.OAuth.TokenExchange.TokenFamily { + case constants.TokenExchangeTokenFamilyInherit: + exchangedTokenFamilyID = subjectClaims.TokenFamilyID + case constants.TokenExchangeTokenFamilyNone, "": + default: + logger.Warn(ctx, "Unrecognized oauth.token_exchange.token_family mode; issuing an independent token", + log.String("mode", h.cfg.OAuth.TokenExchange.TokenFamily)) + } + // Build access token using token builder userSubConfig := oauthApp.UserAccessTokenConfig() accessToken, err := h.tokenBuilder.BuildAccessToken(ctx, &tokenservice.AccessTokenBuildContext{ @@ -264,6 +281,7 @@ func (h *tokenExchangeGrantHandler) HandleGrant(ctx context.Context, tokenReques ActorClaims: actorClaims, ValidityPeriod: userSubConfig.ValidityPeriodOrZero(), DPoPJkt: dpop.GetJkt(ctx), + TokenFamilyID: exchangedTokenFamilyID, }) if err != nil { logger.Error(ctx, "Failed to generate token", log.Error(err)) diff --git a/backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go b/backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go index 017fd8e795..0389d14f40 100644 --- a/backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go @@ -36,6 +36,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" + oauthconfig "github.com/thunder-id/thunderid/internal/oauth/config" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" @@ -256,7 +257,8 @@ func (suite *TokenExchangeGrantHandlerTestSuite) setupSuccessfulJWTMockWithScope // TestNewTokenExchangeGrantHandler tests the constructor func (suite *TokenExchangeGrantHandlerTestSuite) TestNewTokenExchangeGrantHandler() { handler := newTokenExchangeGrantHandler(suite.mockTokenBuilder, suite.mockTokenValidator, - suite.mockAuthzService, suite.mockActorProvider, suite.mockResourceService) + suite.mockAuthzService, suite.mockActorProvider, suite.mockResourceService, + oauthconfig.Config{}) assert.NotNil(suite.T(), handler) assert.Implements(suite.T(), (*GrantHandlerInterface)(nil), handler) } diff --git a/backend/internal/oauth/oauth2/model/token.go b/backend/internal/oauth/oauth2/model/token.go index a9573bb0fc..9c1f009406 100644 --- a/backend/internal/oauth/oauth2/model/token.go +++ b/backend/internal/oauth/oauth2/model/token.go @@ -68,6 +68,9 @@ type TokenDTO struct { OriginalAudiences []string ClaimsRequest *ClaimsRequest ClaimsLocales string + // TokenFamilyID is the token family id (tfid) stamped on the token, carried here so the refresh + // token issued alongside an access token can be stamped with the same family id. + TokenFamilyID string } // TokenResponseDTO represents the data transfer object for token responses. diff --git a/backend/internal/oauth/oauth2/revocation/CriteriaRevokerInterface_mock_test.go b/backend/internal/oauth/oauth2/revocation/CriteriaRevokerInterface_mock_test.go new file mode 100644 index 0000000000..21070fe11d --- /dev/null +++ b/backend/internal/oauth/oauth2/revocation/CriteriaRevokerInterface_mock_test.go @@ -0,0 +1,101 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package revocation + +import ( + "context" + + mock "github.com/stretchr/testify/mock" +) + +// NewCriteriaRevokerInterfaceMock creates a new instance of CriteriaRevokerInterfaceMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewCriteriaRevokerInterfaceMock(t interface { + mock.TestingT + Cleanup(func()) +}) *CriteriaRevokerInterfaceMock { + mock := &CriteriaRevokerInterfaceMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// CriteriaRevokerInterfaceMock is an autogenerated mock type for the CriteriaRevokerInterface type +type CriteriaRevokerInterfaceMock struct { + mock.Mock +} + +type CriteriaRevokerInterfaceMock_Expecter struct { + mock *mock.Mock +} + +func (_m *CriteriaRevokerInterfaceMock) EXPECT() *CriteriaRevokerInterfaceMock_Expecter { + return &CriteriaRevokerInterfaceMock_Expecter{mock: &_m.Mock} +} + +// RevokeTokenFamily provides a mock function for the type CriteriaRevokerInterfaceMock +func (_mock *CriteriaRevokerInterfaceMock) RevokeTokenFamily(ctx context.Context, tokenFamilyID string, reason RevocationReason) error { + ret := _mock.Called(ctx, tokenFamilyID, reason) + + if len(ret) == 0 { + panic("no return value specified for RevokeTokenFamily") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, RevocationReason) error); ok { + r0 = returnFunc(ctx, tokenFamilyID, reason) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RevokeTokenFamily' +type CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call struct { + *mock.Call +} + +// RevokeTokenFamily is a helper method to define mock.On call +// - ctx context.Context +// - tokenFamilyID string +// - reason RevocationReason +func (_e *CriteriaRevokerInterfaceMock_Expecter) RevokeTokenFamily(ctx interface{}, tokenFamilyID interface{}, reason interface{}) *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call { + return &CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call{Call: _e.mock.On("RevokeTokenFamily", ctx, tokenFamilyID, reason)} +} + +func (_c *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call) Run(run func(ctx context.Context, tokenFamilyID string, reason RevocationReason)) *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 RevocationReason + if args[2] != nil { + arg2 = args[2].(RevocationReason) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call) Return(err error) *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call { + _c.Call.Return(err) + return _c +} + +func (_c *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call) RunAndReturn(run func(ctx context.Context, tokenFamilyID string, reason RevocationReason) error) *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/oauth/oauth2/revocation/EnforcementServiceInterface_mock_test.go b/backend/internal/oauth/oauth2/revocation/EnforcementServiceInterface_mock_test.go index 1e6450cf53..70e4168f49 100644 --- a/backend/internal/oauth/oauth2/revocation/EnforcementServiceInterface_mock_test.go +++ b/backend/internal/oauth/oauth2/revocation/EnforcementServiceInterface_mock_test.go @@ -38,16 +38,16 @@ func (_m *EnforcementServiceInterfaceMock) EXPECT() *EnforcementServiceInterface } // EnsureNotRevoked provides a mock function for the type EnforcementServiceInterfaceMock -func (_mock *EnforcementServiceInterfaceMock) EnsureNotRevoked(ctx context.Context, jti string) error { - ret := _mock.Called(ctx, jti) +func (_mock *EnforcementServiceInterfaceMock) EnsureNotRevoked(ctx context.Context, jti string, tokenFamilyID string) error { + ret := _mock.Called(ctx, jti, tokenFamilyID) if len(ret) == 0 { panic("no return value specified for EnsureNotRevoked") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = returnFunc(ctx, jti) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = returnFunc(ctx, jti, tokenFamilyID) } else { r0 = ret.Error(0) } @@ -62,11 +62,12 @@ type EnforcementServiceInterfaceMock_EnsureNotRevoked_Call struct { // EnsureNotRevoked is a helper method to define mock.On call // - ctx context.Context // - jti string -func (_e *EnforcementServiceInterfaceMock_Expecter) EnsureNotRevoked(ctx interface{}, jti interface{}) *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call { - return &EnforcementServiceInterfaceMock_EnsureNotRevoked_Call{Call: _e.mock.On("EnsureNotRevoked", ctx, jti)} +// - tokenFamilyID string +func (_e *EnforcementServiceInterfaceMock_Expecter) EnsureNotRevoked(ctx interface{}, jti interface{}, tokenFamilyID interface{}) *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call { + return &EnforcementServiceInterfaceMock_EnsureNotRevoked_Call{Call: _e.mock.On("EnsureNotRevoked", ctx, jti, tokenFamilyID)} } -func (_c *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call) Run(run func(ctx context.Context, jti string)) *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call { +func (_c *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call) Run(run func(ctx context.Context, jti string, tokenFamilyID string)) *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -76,9 +77,14 @@ func (_c *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call) Run(run func(ct if args[1] != nil { arg1 = args[1].(string) } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } run( arg0, arg1, + arg2, ) }) return _c @@ -89,7 +95,7 @@ func (_c *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call) Return(err erro return _c } -func (_c *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call) RunAndReturn(run func(ctx context.Context, jti string) error) *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call { +func (_c *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call) RunAndReturn(run func(ctx context.Context, jti string, tokenFamilyID string) error) *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call { _c.Call.Return(run) return _c } diff --git a/backend/internal/oauth/oauth2/revocation/RevocationServiceInterface_mock_test.go b/backend/internal/oauth/oauth2/revocation/RevocationServiceInterface_mock_test.go index 744bd54c3d..589b93ca42 100644 --- a/backend/internal/oauth/oauth2/revocation/RevocationServiceInterface_mock_test.go +++ b/backend/internal/oauth/oauth2/revocation/RevocationServiceInterface_mock_test.go @@ -178,3 +178,66 @@ func (_c *RevocationServiceInterfaceMock_RevokeToken_Call) RunAndReturn(run func _c.Call.Return(run) return _c } + +// RevokeTokenFamily provides a mock function for the type RevocationServiceInterfaceMock +func (_mock *RevocationServiceInterfaceMock) RevokeTokenFamily(ctx context.Context, tokenFamilyID string, reason RevocationReason) error { + ret := _mock.Called(ctx, tokenFamilyID, reason) + + if len(ret) == 0 { + panic("no return value specified for RevokeTokenFamily") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, RevocationReason) error); ok { + r0 = returnFunc(ctx, tokenFamilyID, reason) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// RevocationServiceInterfaceMock_RevokeTokenFamily_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RevokeTokenFamily' +type RevocationServiceInterfaceMock_RevokeTokenFamily_Call struct { + *mock.Call +} + +// RevokeTokenFamily is a helper method to define mock.On call +// - ctx context.Context +// - tokenFamilyID string +// - reason RevocationReason +func (_e *RevocationServiceInterfaceMock_Expecter) RevokeTokenFamily(ctx interface{}, tokenFamilyID interface{}, reason interface{}) *RevocationServiceInterfaceMock_RevokeTokenFamily_Call { + return &RevocationServiceInterfaceMock_RevokeTokenFamily_Call{Call: _e.mock.On("RevokeTokenFamily", ctx, tokenFamilyID, reason)} +} + +func (_c *RevocationServiceInterfaceMock_RevokeTokenFamily_Call) Run(run func(ctx context.Context, tokenFamilyID string, reason RevocationReason)) *RevocationServiceInterfaceMock_RevokeTokenFamily_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 RevocationReason + if args[2] != nil { + arg2 = args[2].(RevocationReason) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *RevocationServiceInterfaceMock_RevokeTokenFamily_Call) Return(err error) *RevocationServiceInterfaceMock_RevokeTokenFamily_Call { + _c.Call.Return(err) + return _c +} + +func (_c *RevocationServiceInterfaceMock_RevokeTokenFamily_Call) RunAndReturn(run func(ctx context.Context, tokenFamilyID string, reason RevocationReason) error) *RevocationServiceInterfaceMock_RevokeTokenFamily_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/oauth/oauth2/revocation/RevokedTokenStoreInterface_mock_test.go b/backend/internal/oauth/oauth2/revocation/RevokedTokenStoreInterface_mock_test.go deleted file mode 100644 index c90635149a..0000000000 --- a/backend/internal/oauth/oauth2/revocation/RevokedTokenStoreInterface_mock_test.go +++ /dev/null @@ -1,161 +0,0 @@ -// Code generated by mockery; DO NOT EDIT. -// github.com/vektra/mockery -// template: testify - -package revocation - -import ( - "context" - - mock "github.com/stretchr/testify/mock" -) - -// NewRevokedTokenStoreInterfaceMock creates a new instance of RevokedTokenStoreInterfaceMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewRevokedTokenStoreInterfaceMock(t interface { - mock.TestingT - Cleanup(func()) -}) *RevokedTokenStoreInterfaceMock { - mock := &RevokedTokenStoreInterfaceMock{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} - -// RevokedTokenStoreInterfaceMock is an autogenerated mock type for the RevokedTokenStoreInterface type -type RevokedTokenStoreInterfaceMock struct { - mock.Mock -} - -type RevokedTokenStoreInterfaceMock_Expecter struct { - mock *mock.Mock -} - -func (_m *RevokedTokenStoreInterfaceMock) EXPECT() *RevokedTokenStoreInterfaceMock_Expecter { - return &RevokedTokenStoreInterfaceMock_Expecter{mock: &_m.Mock} -} - -// InsertRevokedToken provides a mock function for the type RevokedTokenStoreInterfaceMock -func (_mock *RevokedTokenStoreInterfaceMock) InsertRevokedToken(ctx context.Context, token RevokedToken) error { - ret := _mock.Called(ctx, token) - - if len(ret) == 0 { - panic("no return value specified for InsertRevokedToken") - } - - var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, RevokedToken) error); ok { - r0 = returnFunc(ctx, token) - } else { - r0 = ret.Error(0) - } - return r0 -} - -// RevokedTokenStoreInterfaceMock_InsertRevokedToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InsertRevokedToken' -type RevokedTokenStoreInterfaceMock_InsertRevokedToken_Call struct { - *mock.Call -} - -// InsertRevokedToken is a helper method to define mock.On call -// - ctx context.Context -// - token RevokedToken -func (_e *RevokedTokenStoreInterfaceMock_Expecter) InsertRevokedToken(ctx interface{}, token interface{}) *RevokedTokenStoreInterfaceMock_InsertRevokedToken_Call { - return &RevokedTokenStoreInterfaceMock_InsertRevokedToken_Call{Call: _e.mock.On("InsertRevokedToken", ctx, token)} -} - -func (_c *RevokedTokenStoreInterfaceMock_InsertRevokedToken_Call) Run(run func(ctx context.Context, token RevokedToken)) *RevokedTokenStoreInterfaceMock_InsertRevokedToken_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 RevokedToken - if args[1] != nil { - arg1 = args[1].(RevokedToken) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *RevokedTokenStoreInterfaceMock_InsertRevokedToken_Call) Return(err error) *RevokedTokenStoreInterfaceMock_InsertRevokedToken_Call { - _c.Call.Return(err) - return _c -} - -func (_c *RevokedTokenStoreInterfaceMock_InsertRevokedToken_Call) RunAndReturn(run func(ctx context.Context, token RevokedToken) error) *RevokedTokenStoreInterfaceMock_InsertRevokedToken_Call { - _c.Call.Return(run) - return _c -} - -// IsTokenRevoked provides a mock function for the type RevokedTokenStoreInterfaceMock -func (_mock *RevokedTokenStoreInterfaceMock) IsTokenRevoked(ctx context.Context, jti string) (bool, error) { - ret := _mock.Called(ctx, jti) - - if len(ret) == 0 { - panic("no return value specified for IsTokenRevoked") - } - - var r0 bool - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok { - return returnFunc(ctx, jti) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { - r0 = returnFunc(ctx, jti) - } else { - r0 = ret.Get(0).(bool) - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, jti) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// RevokedTokenStoreInterfaceMock_IsTokenRevoked_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsTokenRevoked' -type RevokedTokenStoreInterfaceMock_IsTokenRevoked_Call struct { - *mock.Call -} - -// IsTokenRevoked is a helper method to define mock.On call -// - ctx context.Context -// - jti string -func (_e *RevokedTokenStoreInterfaceMock_Expecter) IsTokenRevoked(ctx interface{}, jti interface{}) *RevokedTokenStoreInterfaceMock_IsTokenRevoked_Call { - return &RevokedTokenStoreInterfaceMock_IsTokenRevoked_Call{Call: _e.mock.On("IsTokenRevoked", ctx, jti)} -} - -func (_c *RevokedTokenStoreInterfaceMock_IsTokenRevoked_Call) Run(run func(ctx context.Context, jti string)) *RevokedTokenStoreInterfaceMock_IsTokenRevoked_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *RevokedTokenStoreInterfaceMock_IsTokenRevoked_Call) Return(b bool, err error) *RevokedTokenStoreInterfaceMock_IsTokenRevoked_Call { - _c.Call.Return(b, err) - return _c -} - -func (_c *RevokedTokenStoreInterfaceMock_IsTokenRevoked_Call) RunAndReturn(run func(ctx context.Context, jti string) (bool, error)) *RevokedTokenStoreInterfaceMock_IsTokenRevoked_Call { - _c.Call.Return(run) - return _c -} diff --git a/backend/internal/oauth/oauth2/revocation/enforcement_service.go b/backend/internal/oauth/oauth2/revocation/enforcement_service.go index 5ae8d1dc23..401adaa5c7 100644 --- a/backend/internal/oauth/oauth2/revocation/enforcement_service.go +++ b/backend/internal/oauth/oauth2/revocation/enforcement_service.go @@ -27,42 +27,45 @@ import ( "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) -// EnforcementServiceInterface enforces the single-token deny list on the AS hot path (introspection, refresh -// grant, token exchange) under a fail-closed policy: when the deny list cannot be consulted, tokens -// are rejected rather than allowed. +// EnforcementServiceInterface enforces the revocation deny lists on the AS hot path (introspection, +// refresh grant, token exchange) under a fail-closed policy: when a deny list cannot be consulted, +// tokens are rejected rather than allowed. type EnforcementServiceInterface interface { - // EnsureNotRevoked returns nil when the token identified by jti may proceed. It returns - // ErrTokenRevoked when the jti is on the deny list, and ErrEnforcementUnavailable when the - // deny list cannot be consulted (fail-closed). An empty jti is a no-op (nothing to enforce). - EnsureNotRevoked(ctx context.Context, jti string) error + // EnsureNotRevoked returns nil when the token may proceed. It returns ErrTokenRevoked when the + // token's jti is on the single-token deny list or its token family id (tfid) is on the criteria + // deny list, and ErrEnforcementUnavailable when a deny list cannot be consulted (fail-closed). + // Empty jti and tokenFamilyID are each a no-op for their respective check. + EnsureNotRevoked(ctx context.Context, jti, tokenFamilyID string) error } // enforcement service is the default EnforcementServiceInterface. It consults the runtime persistent DB behind a // circuit breaker and alerts (via an observability event) when the breaker trips. type enforcementService struct { - store RevokedTokenStoreInterface + store revocationStoreInterface breaker *circuitBreaker observabilitySvc providers.ObservabilityProvider logger *log.Logger } // newEnforcementService creates a deny-list enforcement service backed by the runtime persistent DB, guarded -// by a circuit breaker and the fail-closed policy. It is unexported and constructed once via +// by a circuit breaker and the fail-closed policy. It consults both the single-token deny list +// (by jti) and the criteria deny list (by token family id). It is unexported and constructed once via // Initialize so the shared enforcement instance — and its circuit breaker — cannot be duplicated by // external callers. -func newEnforcementService(observabilitySvc providers.ObservabilityProvider) EnforcementServiceInterface { +func newEnforcementService(observabilitySvc providers.ObservabilityProvider, + store revocationStoreInterface) EnforcementServiceInterface { return &enforcementService{ - store: newRevokedTokenStore(), + store: store, breaker: newCircuitBreaker(enforcementFailureThreshold, enforcementOpenDuration), observabilitySvc: observabilitySvc, logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "EnforcementService")), } } -// EnsureNotRevoked checks the deny list for the given jti, applying the circuit breaker and the -// fail-closed policy. -func (c *enforcementService) EnsureNotRevoked(ctx context.Context, jti string) error { - if jti == "" { +// EnsureNotRevoked checks the single-token deny list (by jti) and the criteria deny list (by token +// family id), applying the circuit breaker and the fail-closed policy. +func (c *enforcementService) EnsureNotRevoked(ctx context.Context, jti, tokenFamilyID string) error { + if jti == "" && tokenFamilyID == "" { return nil } @@ -71,23 +74,43 @@ func (c *enforcementService) EnsureNotRevoked(ctx context.Context, jti string) e return ErrEnforcementUnavailable } - revoked, err := c.store.IsTokenRevoked(ctx, jti) - if err != nil { - c.logger.Error(ctx, "Failed to consult token revocation deny list; failing closed", - log.Error(err)) - if c.breaker.recordFailure() { - c.publishRuntimePersistentDBUnavailableEvent(ctx, err) + if jti != "" { + revoked, err := c.store.IsTokenRevoked(ctx, jti) + if err != nil { + return c.failClosed(ctx, err) + } + if revoked { + c.breaker.recordSuccess() + return ErrTokenRevoked } - return ErrEnforcementUnavailable } - c.breaker.recordSuccess() - if revoked { - return ErrTokenRevoked + if tokenFamilyID != "" { + revoked, err := c.store.isCriterionRevoked(ctx, criterionTypeTokenFamily, tokenFamilyID) + if err != nil { + return c.failClosed(ctx, err) + } + if revoked { + c.breaker.recordSuccess() + return ErrTokenRevoked + } } + + c.breaker.recordSuccess() return nil } +// failClosed records a deny-list lookup failure against the circuit breaker (alerting when it trips) +// and returns ErrEnforcementUnavailable so the caller rejects the token. +func (c *enforcementService) failClosed(ctx context.Context, cause error) error { + c.logger.Error(ctx, "Failed to consult token revocation deny list; failing closed", + log.Error(cause)) + if c.breaker.recordFailure() { + c.publishRuntimePersistentDBUnavailableEvent(ctx, cause) + } + return ErrEnforcementUnavailable +} + // publishRuntimePersistentDBUnavailableEvent emits an alert event when the runtime-persistent-DB circuit trips. func (c *enforcementService) publishRuntimePersistentDBUnavailableEvent(ctx context.Context, cause error) { if c.observabilitySvc == nil || !c.observabilitySvc.IsEnabled() { diff --git a/backend/internal/oauth/oauth2/revocation/enforcement_service_test.go b/backend/internal/oauth/oauth2/revocation/enforcement_service_test.go index d2d49c7280..b8845751f5 100644 --- a/backend/internal/oauth/oauth2/revocation/enforcement_service_test.go +++ b/backend/internal/oauth/oauth2/revocation/enforcement_service_test.go @@ -35,7 +35,7 @@ import ( type EnforcementServiceTestSuite struct { suite.Suite - mockStore *RevokedTokenStoreInterfaceMock + mockStore *revocationStoreInterfaceMock enforcementService *enforcementService } @@ -44,7 +44,7 @@ func TestEnforcementServiceTestSuite(t *testing.T) { } func (s *EnforcementServiceTestSuite) SetupTest() { - s.mockStore = NewRevokedTokenStoreInterfaceMock(s.T()) + s.mockStore = newRevocationStoreInterfaceMock(s.T()) s.enforcementService = &enforcementService{ store: s.mockStore, breaker: newCircuitBreaker(enforcementFailureThreshold, enforcementOpenDuration), @@ -53,9 +53,45 @@ func (s *EnforcementServiceTestSuite) SetupTest() { } } +// A token whose family is revoked is rejected, even when its own jti is not on the deny list. +func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_TokenFamilyRevoked() { + s.mockStore.On("IsTokenRevoked", mock.Anything, "jti-ok").Return(false, nil) + s.mockStore.On("isCriterionRevoked", mock.Anything, criterionTypeTokenFamily, "tfid-x"). + Return(true, nil) + err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-ok", "tfid-x") + s.Assert().ErrorIs(err, ErrTokenRevoked) +} + +// A token whose family is not revoked (and whose jti is clean) may proceed. +func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_TokenFamilyNotRevoked() { + s.mockStore.On("IsTokenRevoked", mock.Anything, "jti-ok").Return(false, nil) + s.mockStore.On("isCriterionRevoked", mock.Anything, criterionTypeTokenFamily, "tfid-x"). + Return(false, nil) + err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-ok", "tfid-x") + s.Assert().NoError(err) +} + +// A criteria-store error fails closed. +func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_TokenFamilyLookupErrorFailsClosed() { + s.mockStore.On("IsTokenRevoked", mock.Anything, "jti-ok").Return(false, nil) + s.mockStore.On("isCriterionRevoked", mock.Anything, criterionTypeTokenFamily, "tfid-x"). + Return(false, errors.New("db down")) + err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-ok", "tfid-x") + s.Assert().ErrorIs(err, ErrEnforcementUnavailable) +} + +// A family-only check (no jti) consults just the criteria store. +func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_TokenFamilyOnly() { + s.mockStore.On("isCriterionRevoked", mock.Anything, criterionTypeTokenFamily, "tfid-x"). + Return(true, nil) + err := s.enforcementService.EnsureNotRevoked(context.Background(), "", "tfid-x") + s.Assert().ErrorIs(err, ErrTokenRevoked) + s.mockStore.AssertNotCalled(s.T(), "IsTokenRevoked", mock.Anything, mock.Anything) +} + // An empty jti is a no-op — there is nothing to match against the deny list. func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_EmptyJTI() { - err := s.enforcementService.EnsureNotRevoked(context.Background(), "") + err := s.enforcementService.EnsureNotRevoked(context.Background(), "", "") s.Assert().NoError(err) s.mockStore.AssertNotCalled(s.T(), "IsTokenRevoked", mock.Anything, mock.Anything) } @@ -63,21 +99,21 @@ func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_EmptyJTI() { // A token absent from the deny list may proceed. func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_NotRevoked() { s.mockStore.On("IsTokenRevoked", mock.Anything, "jti-1").Return(false, nil) - err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-1") + err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-1", "") s.Assert().NoError(err) } // A token on the deny list is rejected with ErrTokenRevoked. func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_Revoked() { s.mockStore.On("IsTokenRevoked", mock.Anything, "jti-2").Return(true, nil) - err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-2") + err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-2", "") s.Assert().ErrorIs(err, ErrTokenRevoked) } // A deny-list read error fails closed with ErrEnforcementUnavailable. func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_DBErrorFailsClosed() { s.mockStore.On("IsTokenRevoked", mock.Anything, "jti-3").Return(false, errors.New("db down")) - err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-3") + err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-3", "") s.Assert().ErrorIs(err, ErrEnforcementUnavailable) } @@ -87,13 +123,13 @@ func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_OpenCircuitShortCircu // Drive consecutive failures up to the threshold to trip the circuit. for i := 0; i < enforcementFailureThreshold; i++ { - err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-loop") + err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-loop", "") s.Assert().ErrorIs(err, ErrEnforcementUnavailable) } callsAtTrip := len(s.mockStore.Calls) // Further calls while open must not hit the store. - err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-loop") + err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-loop", "") s.Assert().ErrorIs(err, ErrEnforcementUnavailable) s.Assert().Equal(callsAtTrip, len(s.mockStore.Calls), "open circuit should not call the store") } @@ -117,7 +153,7 @@ func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_AlertsOncePerTrip() { // Drive failures up to the threshold (the trip) plus extra calls while open. for i := 0; i < enforcementFailureThreshold+3; i++ { - err := c.EnsureNotRevoked(context.Background(), "jti-alert") + err := c.EnsureNotRevoked(context.Background(), "jti-alert", "") s.Assert().ErrorIs(err, ErrEnforcementUnavailable) } @@ -139,7 +175,7 @@ func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_DisabledObservability s.mockStore.On("IsTokenRevoked", mock.Anything, mock.Anything).Return(false, errors.New("db down")) for i := 0; i < enforcementFailureThreshold; i++ { - s.Assert().ErrorIs(c.EnsureNotRevoked(context.Background(), "jti-alert"), ErrEnforcementUnavailable) + s.Assert().ErrorIs(c.EnsureNotRevoked(context.Background(), "jti-alert", ""), ErrEnforcementUnavailable) } obsMock.AssertNotCalled(s.T(), "PublishEvent", mock.Anything) @@ -150,14 +186,14 @@ func (s *EnforcementServiceTestSuite) TestEnsureNotRevoked_RecoversAfterCooldown s.mockStore.On("IsTokenRevoked", mock.Anything, "jti-recover"). Return(false, errors.New("db down")).Times(enforcementFailureThreshold) for i := 0; i < enforcementFailureThreshold; i++ { - _ = s.enforcementService.EnsureNotRevoked(context.Background(), "jti-recover") + _ = s.enforcementService.EnsureNotRevoked(context.Background(), "jti-recover", "") } // Simulate the cooldown elapsing, then let the store recover. s.enforcementService.breaker.openedAt = time.Now().Add(-2 * enforcementOpenDuration) s.mockStore.On("IsTokenRevoked", mock.Anything, "jti-recover").Return(false, nil) - err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-recover") + err := s.enforcementService.EnsureNotRevoked(context.Background(), "jti-recover", "") s.Assert().NoError(err) s.Assert().True(s.enforcementService.breaker.allow(), "circuit should be closed after a successful trial call") } diff --git a/backend/internal/oauth/oauth2/revocation/init.go b/backend/internal/oauth/oauth2/revocation/init.go index 48c0159915..49d6d695d9 100644 --- a/backend/internal/oauth/oauth2/revocation/init.go +++ b/backend/internal/oauth/oauth2/revocation/init.go @@ -25,6 +25,7 @@ package revocation import ( "context" "net/http" + "time" "github.com/thunder-id/thunderid/internal/oauth/oauth2/clientauth" "github.com/thunder-id/thunderid/internal/oauth/oauth2/discovery" @@ -33,10 +34,12 @@ import ( "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) -// Initialize wires the revocation feature: it constructs the shared enforcement service (read path) -// and registers the RFC 7009 revocation endpoint (write path). It returns the enforcement service (to -// inject into the hot paths — refresh grant, token exchange, introspection) and the refresh-token -// revoker (to inject into the refresh grant for single-use rotation). +// Initialize wires the revocation feature and registers the RFC 7009 revocation endpoint. It returns +// the enforcement service (the read path, injected into the hot paths: refresh grant, token exchange, +// introspection) and the revocation service (the write path, covering single-token revocation via +// RevokeRefreshToken and token-family revocation via RevokeTokenFamily). Consumers depend on the narrow +// RefreshTokenRevokerInterface / CriteriaRevokerInterface subsets of the revocation service. +// tokenFamilyRevocationTTL bounds each token-family deny-list entry; pass the refresh-token lifetime. func Initialize( mux *http.ServeMux, jwtService jwt.JWTServiceInterface, @@ -44,14 +47,29 @@ func Initialize( authnProvider providers.AuthnProviderManager, discoveryService discovery.DiscoveryServiceInterface, observabilitySvc providers.ObservabilityProvider, -) (EnforcementServiceInterface, RefreshTokenRevokerInterface) { - enforcementService := newEnforcementService(observabilitySvc) - revocationService := newRevocationService(jwtService, newRevokedTokenStore(), observabilitySvc) + tokenFamilyRevocationTTL time.Duration, + revokeTokenFamilyOnExplicit bool, +) (EnforcementServiceInterface, RevocationServiceInterface) { + store := newRevocationStore() + enforcementService := newEnforcementService(observabilitySvc, store) + revocationService := newRevocationService(jwtService, store, tokenFamilyRevocationTTL, + revokeTokenFamilyOnExplicit, observabilitySvc) revocationHandler := newRevocationHandler(revocationService) registerRoutes(mux, revocationHandler, actorProvider, authnProvider, jwtService, discoveryService) return enforcementService, revocationService } +// InitializeCriteriaRevoker builds a standalone criteria revoker for consumers wired at the composition +// root that cannot receive the revocation service from Initialize (which is created inside the OAuth +// engine after those consumers are constructed) — notably the SSO session service, which revokes a +// session's families on sign-out. It returns a revocation service narrowed to CriteriaRevokerInterface; +// only RevokeTokenFamily is reachable, which needs just the store and TTL, so the unused jwt and +// observability dependencies are nil and the family write path shares no mutable state (it is a +// stateless, idempotent writer). tokenFamilyRevocationTTL bounds each entry. +func InitializeCriteriaRevoker(tokenFamilyRevocationTTL time.Duration) CriteriaRevokerInterface { + return newRevocationService(nil, newRevocationStore(), tokenFamilyRevocationTTL, false, nil) +} + // registerRoutes registers the routes for the token revocation endpoint. func registerRoutes( mux *http.ServeMux, diff --git a/backend/internal/oauth/oauth2/revocation/init_test.go b/backend/internal/oauth/oauth2/revocation/init_test.go index 0c1e5641f1..9ab740481e 100644 --- a/backend/internal/oauth/oauth2/revocation/init_test.go +++ b/backend/internal/oauth/oauth2/revocation/init_test.go @@ -22,6 +22,7 @@ import ( "net/http" "net/url" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -69,19 +70,21 @@ func (suite *InitTestSuite) TearDownTest() { func (suite *InitTestSuite) TestInitialize() { mux := http.NewServeMux() - enforcementService, refreshTokenRevoker := Initialize( - mux, suite.mockJWTService, nil, nil, suite.mockDiscoveryService, nil) + enforcementService, revocationService := Initialize( + mux, suite.mockJWTService, nil, nil, suite.mockDiscoveryService, nil, time.Hour, true) assert.NotNil(suite.T(), enforcementService) assert.Implements(suite.T(), (*EnforcementServiceInterface)(nil), enforcementService) - assert.NotNil(suite.T(), refreshTokenRevoker) - assert.Implements(suite.T(), (*RefreshTokenRevokerInterface)(nil), refreshTokenRevoker) + assert.NotNil(suite.T(), revocationService) + assert.Implements(suite.T(), (*RevocationServiceInterface)(nil), revocationService) + assert.Implements(suite.T(), (*RefreshTokenRevokerInterface)(nil), revocationService) + assert.Implements(suite.T(), (*CriteriaRevokerInterface)(nil), revocationService) } func (suite *InitTestSuite) TestInitialize_RegistersRoutes() { mux := http.NewServeMux() - Initialize(mux, suite.mockJWTService, nil, nil, suite.mockDiscoveryService, nil) + Initialize(mux, suite.mockJWTService, nil, nil, suite.mockDiscoveryService, nil, time.Hour, true) // The pattern includes the method because of CORS middleware wrapping. _, pattern := mux.Handler(&http.Request{Method: "POST", URL: &url.URL{Path: "/oauth2/revoke"}}) diff --git a/backend/internal/oauth/oauth2/revocation/model.go b/backend/internal/oauth/oauth2/revocation/model.go index 4cd085e8dc..e0ea9a2db2 100644 --- a/backend/internal/oauth/oauth2/revocation/model.go +++ b/backend/internal/oauth/oauth2/revocation/model.go @@ -39,6 +39,16 @@ const ( RevocationReasonExplicit RevocationReason = "explicit" // RevocationReasonRefreshRotation denotes revocation of a consumed refresh token on rotation. RevocationReasonRefreshRotation RevocationReason = "refresh_rotation" + // RevocationReasonRefreshReplay denotes family revocation triggered by refresh-token replay + // (a rotated, already-revoked refresh token presented again). + RevocationReasonRefreshReplay RevocationReason = "refresh_replay" + // RevocationReasonSessionLogout denotes family revocation triggered by an SSO session sign-out. + RevocationReasonSessionLogout RevocationReason = "session_logout" + // RevocationReasonCodeReplay denotes family revocation triggered by authorization-code replay. + RevocationReasonCodeReplay RevocationReason = "code_replay" + // RevocationReasonExplicitTokenFamily denotes family revocation triggered by an explicit (RFC 7009) + // revocation of a token that carries a token family id. + RevocationReasonExplicitTokenFamily RevocationReason = "explicit_token_family" ) // RevokedToken represents a single revoked token entry in the deny list. diff --git a/backend/internal/oauth/oauth2/revocation/revocationStoreInterface_mock_test.go b/backend/internal/oauth/oauth2/revocation/revocationStoreInterface_mock_test.go new file mode 100644 index 0000000000..d0f6e8fc73 --- /dev/null +++ b/backend/internal/oauth/oauth2/revocation/revocationStoreInterface_mock_test.go @@ -0,0 +1,290 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package revocation + +import ( + "context" + + mock "github.com/stretchr/testify/mock" +) + +// newRevocationStoreInterfaceMock creates a new instance of revocationStoreInterfaceMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newRevocationStoreInterfaceMock(t interface { + mock.TestingT + Cleanup(func()) +}) *revocationStoreInterfaceMock { + mock := &revocationStoreInterfaceMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// revocationStoreInterfaceMock is an autogenerated mock type for the revocationStoreInterface type +type revocationStoreInterfaceMock struct { + mock.Mock +} + +type revocationStoreInterfaceMock_Expecter struct { + mock *mock.Mock +} + +func (_m *revocationStoreInterfaceMock) EXPECT() *revocationStoreInterfaceMock_Expecter { + return &revocationStoreInterfaceMock_Expecter{mock: &_m.Mock} +} + +// InsertRevokedToken provides a mock function for the type revocationStoreInterfaceMock +func (_mock *revocationStoreInterfaceMock) InsertRevokedToken(ctx context.Context, token RevokedToken) error { + ret := _mock.Called(ctx, token) + + if len(ret) == 0 { + panic("no return value specified for InsertRevokedToken") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, RevokedToken) error); ok { + r0 = returnFunc(ctx, token) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// revocationStoreInterfaceMock_InsertRevokedToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InsertRevokedToken' +type revocationStoreInterfaceMock_InsertRevokedToken_Call struct { + *mock.Call +} + +// InsertRevokedToken is a helper method to define mock.On call +// - ctx context.Context +// - token RevokedToken +func (_e *revocationStoreInterfaceMock_Expecter) InsertRevokedToken(ctx interface{}, token interface{}) *revocationStoreInterfaceMock_InsertRevokedToken_Call { + return &revocationStoreInterfaceMock_InsertRevokedToken_Call{Call: _e.mock.On("InsertRevokedToken", ctx, token)} +} + +func (_c *revocationStoreInterfaceMock_InsertRevokedToken_Call) Run(run func(ctx context.Context, token RevokedToken)) *revocationStoreInterfaceMock_InsertRevokedToken_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 RevokedToken + if args[1] != nil { + arg1 = args[1].(RevokedToken) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *revocationStoreInterfaceMock_InsertRevokedToken_Call) Return(err error) *revocationStoreInterfaceMock_InsertRevokedToken_Call { + _c.Call.Return(err) + return _c +} + +func (_c *revocationStoreInterfaceMock_InsertRevokedToken_Call) RunAndReturn(run func(ctx context.Context, token RevokedToken) error) *revocationStoreInterfaceMock_InsertRevokedToken_Call { + _c.Call.Return(run) + return _c +} + +// IsTokenRevoked provides a mock function for the type revocationStoreInterfaceMock +func (_mock *revocationStoreInterfaceMock) IsTokenRevoked(ctx context.Context, jti string) (bool, error) { + ret := _mock.Called(ctx, jti) + + if len(ret) == 0 { + panic("no return value specified for IsTokenRevoked") + } + + var r0 bool + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok { + return returnFunc(ctx, jti) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { + r0 = returnFunc(ctx, jti) + } else { + r0 = ret.Get(0).(bool) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, jti) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// revocationStoreInterfaceMock_IsTokenRevoked_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsTokenRevoked' +type revocationStoreInterfaceMock_IsTokenRevoked_Call struct { + *mock.Call +} + +// IsTokenRevoked is a helper method to define mock.On call +// - ctx context.Context +// - jti string +func (_e *revocationStoreInterfaceMock_Expecter) IsTokenRevoked(ctx interface{}, jti interface{}) *revocationStoreInterfaceMock_IsTokenRevoked_Call { + return &revocationStoreInterfaceMock_IsTokenRevoked_Call{Call: _e.mock.On("IsTokenRevoked", ctx, jti)} +} + +func (_c *revocationStoreInterfaceMock_IsTokenRevoked_Call) Run(run func(ctx context.Context, jti string)) *revocationStoreInterfaceMock_IsTokenRevoked_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *revocationStoreInterfaceMock_IsTokenRevoked_Call) Return(b bool, err error) *revocationStoreInterfaceMock_IsTokenRevoked_Call { + _c.Call.Return(b, err) + return _c +} + +func (_c *revocationStoreInterfaceMock_IsTokenRevoked_Call) RunAndReturn(run func(ctx context.Context, jti string) (bool, error)) *revocationStoreInterfaceMock_IsTokenRevoked_Call { + _c.Call.Return(run) + return _c +} + +// insertCriterion provides a mock function for the type revocationStoreInterfaceMock +func (_mock *revocationStoreInterfaceMock) insertCriterion(ctx context.Context, criterion revocationCriterion) error { + ret := _mock.Called(ctx, criterion) + + if len(ret) == 0 { + panic("no return value specified for insertCriterion") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, revocationCriterion) error); ok { + r0 = returnFunc(ctx, criterion) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// revocationStoreInterfaceMock_insertCriterion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'insertCriterion' +type revocationStoreInterfaceMock_insertCriterion_Call struct { + *mock.Call +} + +// insertCriterion is a helper method to define mock.On call +// - ctx context.Context +// - criterion revocationCriterion +func (_e *revocationStoreInterfaceMock_Expecter) insertCriterion(ctx interface{}, criterion interface{}) *revocationStoreInterfaceMock_insertCriterion_Call { + return &revocationStoreInterfaceMock_insertCriterion_Call{Call: _e.mock.On("insertCriterion", ctx, criterion)} +} + +func (_c *revocationStoreInterfaceMock_insertCriterion_Call) Run(run func(ctx context.Context, criterion revocationCriterion)) *revocationStoreInterfaceMock_insertCriterion_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 revocationCriterion + if args[1] != nil { + arg1 = args[1].(revocationCriterion) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *revocationStoreInterfaceMock_insertCriterion_Call) Return(err error) *revocationStoreInterfaceMock_insertCriterion_Call { + _c.Call.Return(err) + return _c +} + +func (_c *revocationStoreInterfaceMock_insertCriterion_Call) RunAndReturn(run func(ctx context.Context, criterion revocationCriterion) error) *revocationStoreInterfaceMock_insertCriterion_Call { + _c.Call.Return(run) + return _c +} + +// isCriterionRevoked provides a mock function for the type revocationStoreInterfaceMock +func (_mock *revocationStoreInterfaceMock) isCriterionRevoked(ctx context.Context, criterion criterionType, value string) (bool, error) { + ret := _mock.Called(ctx, criterion, value) + + if len(ret) == 0 { + panic("no return value specified for isCriterionRevoked") + } + + var r0 bool + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, criterionType, string) (bool, error)); ok { + return returnFunc(ctx, criterion, value) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, criterionType, string) bool); ok { + r0 = returnFunc(ctx, criterion, value) + } else { + r0 = ret.Get(0).(bool) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, criterionType, string) error); ok { + r1 = returnFunc(ctx, criterion, value) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// revocationStoreInterfaceMock_isCriterionRevoked_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'isCriterionRevoked' +type revocationStoreInterfaceMock_isCriterionRevoked_Call struct { + *mock.Call +} + +// isCriterionRevoked is a helper method to define mock.On call +// - ctx context.Context +// - criterion criterionType +// - value string +func (_e *revocationStoreInterfaceMock_Expecter) isCriterionRevoked(ctx interface{}, criterion interface{}, value interface{}) *revocationStoreInterfaceMock_isCriterionRevoked_Call { + return &revocationStoreInterfaceMock_isCriterionRevoked_Call{Call: _e.mock.On("isCriterionRevoked", ctx, criterion, value)} +} + +func (_c *revocationStoreInterfaceMock_isCriterionRevoked_Call) Run(run func(ctx context.Context, criterion criterionType, value string)) *revocationStoreInterfaceMock_isCriterionRevoked_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 criterionType + if args[1] != nil { + arg1 = args[1].(criterionType) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *revocationStoreInterfaceMock_isCriterionRevoked_Call) Return(b bool, err error) *revocationStoreInterfaceMock_isCriterionRevoked_Call { + _c.Call.Return(b, err) + return _c +} + +func (_c *revocationStoreInterfaceMock_isCriterionRevoked_Call) RunAndReturn(run func(ctx context.Context, criterion criterionType, value string) (bool, error)) *revocationStoreInterfaceMock_isCriterionRevoked_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/oauth/oauth2/revocation/service.go b/backend/internal/oauth/oauth2/revocation/service.go index 094e60d235..56e83f4993 100644 --- a/backend/internal/oauth/oauth2/revocation/service.go +++ b/backend/internal/oauth/oauth2/revocation/service.go @@ -31,9 +31,13 @@ import ( "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) -// RevocationServiceInterface defines the OAuth2 token revocation service (RFC 7009). +// RevocationServiceInterface is the OAuth2 revocation write service. It covers single-token revocation +// (the RFC 7009 endpoint and single-use refresh rotation, recorded on the JTI deny list) and +// token-family revocation (recorded on the criteria deny list). The enforcement service is the +// matching read path. type RevocationServiceInterface interface { RefreshTokenRevokerInterface + CriteriaRevokerInterface // RevokeToken revokes the presented token on behalf of the authenticated client. // @@ -57,27 +61,58 @@ type RefreshTokenRevokerInterface interface { RevokeRefreshToken(ctx context.Context, jti string, expiryTime time.Time) error } +// CriteriaRevokerInterface is the narrow write seam for criteria-based (many-token) revocation: it +// records a revocation criterion so every token matching it is rejected. The only criterion today is +// token_family, which revokes a whole authorization grant by its token family id (tfid); the seam is +// shaped so future criteria (e.g. by subject or client) reuse the same writer and store. It is +// consumed by the refresh grant (reuse), the RFC 7009 endpoint (explicit), the authorization service +// (code replay), and session sign-out (logout). +type CriteriaRevokerInterface interface { + // RevokeTokenFamily records a terminal revocation of the token family identified by tokenFamilyID, so + // every access and refresh token carrying that tfid is rejected. An empty tokenFamilyID is a + // no-op. The write is idempotent. + RevokeTokenFamily(ctx context.Context, tokenFamilyID string, reason RevocationReason) error +} + +// defaultTokenFamilyRevocationTTL bounds a family revocation entry when no refresh-token lifetime is +// configured. It is a safe upper bound: the entry only needs to outlive the longest-lived token of +// the family, and expired tokens are rejected on their own exp regardless. +const defaultTokenFamilyRevocationTTL = 30 * 24 * time.Hour + // revocationService implements RevocationServiceInterface. type revocationService struct { - jwtService jwt.JWTServiceInterface - store RevokedTokenStoreInterface - observabilitySvc providers.ObservabilityProvider - logger *log.Logger + jwtService jwt.JWTServiceInterface + store revocationStoreInterface + tokenFamilyLifetime time.Duration + revokeTokenFamily bool + observabilitySvc providers.ObservabilityProvider + logger *log.Logger } // newRevocationService creates a new revocationService (internal use). It returns -// RevocationServiceInterface; the same instance is handed to the refresh grant narrowed to the -// embedded RefreshTokenRevokerInterface subset, so the grant cannot invoke the full revocation API. +// RevocationServiceInterface; consumers receive it narrowed to the embedded RefreshTokenRevokerInterface +// or CriteriaRevokerInterface subset, so they cannot invoke the full revocation API (e.g. RevokeToken). +// When revokeTokenFamily is true, an explicit revocation of a token carrying a token family id also revokes +// the whole family (so a login's access tokens drop with its refresh token). tokenFamilyLifetime bounds +// each token-family deny-list entry (revoked_at + tokenFamilyLifetime); a non-positive value falls back +// to defaultTokenFamilyRevocationTTL. func newRevocationService( jwtService jwt.JWTServiceInterface, - store RevokedTokenStoreInterface, + store revocationStoreInterface, + tokenFamilyLifetime time.Duration, + revokeTokenFamily bool, observabilitySvc providers.ObservabilityProvider, ) RevocationServiceInterface { + if tokenFamilyLifetime <= 0 { + tokenFamilyLifetime = defaultTokenFamilyRevocationTTL + } return &revocationService{ - jwtService: jwtService, - store: store, - observabilitySvc: observabilitySvc, - logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "RevocationService")), + jwtService: jwtService, + store: store, + tokenFamilyLifetime: tokenFamilyLifetime, + revokeTokenFamily: revokeTokenFamily, + observabilitySvc: observabilitySvc, + logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "RevocationService")), } } @@ -110,10 +145,16 @@ func (s *revocationService) RevokeToken( return RevokeOutcomeRevoked, nil } - // Ownership enforcement: a client may only revoke tokens issued to it. ThunderID tokens carry the - // owning client in the client_id claim (no azp), so ownership is checked against client_id; a - // mismatch is rejected with invalid_grant per RFC 7009 §2.1. + // Ownership enforcement: a client may only revoke tokens issued to it (RFC 7009 §2.1). ThunderID + // records the owning client in the client_id claim on access tokens; refresh tokens carry no + // client_id claim but are minted with the owning client as their subject, so fall back to sub when + // client_id is absent. Without this fallback the check would be skipped for refresh tokens, letting + // any authenticated client revoke another client's refresh token (and cascade its token family). A + // mismatch is rejected with invalid_grant. tokenClientID, _ := payload[constants.ClaimClientID].(string) + if tokenClientID == "" { + tokenClientID, _ = payload[constants.ClaimSub].(string) + } if tokenClientID != "" && authenticatedClientID != "" && tokenClientID != authenticatedClientID { s.logger.Debug(ctx, "Revocation request for a token belonging to a different client") return RevokeOutcomeNotOwned, nil @@ -129,6 +170,17 @@ func (s *revocationService) RevokeToken( return RevokeOutcomeRevoked, fmt.Errorf("failed to record token revocation: %w", err) } + // When the revoked token carries a token family id, drop the whole family so revoking a login's + // refresh token also invalidates the access tokens issued from that same login (RFC 7009 §2.1). + if s.revokeTokenFamily { + if tokenFamilyID, _ := payload[constants.ClaimTokenFamilyID].(string); tokenFamilyID != "" { + if err := s.RevokeTokenFamily(ctx, tokenFamilyID, + RevocationReasonExplicitTokenFamily); err != nil { + return RevokeOutcomeRevoked, fmt.Errorf("failed to revoke token family: %w", err) + } + } + } + s.publishTokenRevokedEvent(ctx, authenticatedClientID, jti) return RevokeOutcomeRevoked, nil } @@ -153,6 +205,33 @@ func (s *revocationService) RevokeRefreshToken(ctx context.Context, jti string, return nil } +// RevokeTokenFamily records a terminal revocation of the token family (one authorization grant) +// identified by tokenFamilyID, writing a token_family criterion to the deny list so every access and +// refresh token carrying that tfid is rejected. The refresh grant (reuse), the authorization service +// (code replay), and session sign-out (logout) revoke families through this same method that backs the +// RFC 7009 endpoint's explicit-revoke family cascade. An empty tokenFamilyID is a no-op; the write is +// idempotent. +func (s *revocationService) RevokeTokenFamily(ctx context.Context, tokenFamilyID string, + reason RevocationReason) error { + if tokenFamilyID == "" { + return nil + } + + now := time.Now().UTC() + if err := s.store.insertCriterion(ctx, revocationCriterion{ + Type: criterionTypeTokenFamily, + Value: tokenFamilyID, + Reason: reason, + RevokedAt: now, + ExpiryTime: now.Add(s.tokenFamilyLifetime), + }); err != nil { + return fmt.Errorf("failed to revoke token family: %w", err) + } + + s.logger.Debug(ctx, "Revoked token family", log.String("reason", string(reason))) + return nil +} + // extractExpiryTime returns the token's exp claim as a time, falling back to now when absent // (an absent/expired exp simply makes the deny-list row immediately cleanup-eligible). func extractExpiryTime(payload map[string]interface{}) time.Time { diff --git a/backend/internal/oauth/oauth2/revocation/service_test.go b/backend/internal/oauth/oauth2/revocation/service_test.go index 7398b9e300..12d8be5f22 100644 --- a/backend/internal/oauth/oauth2/revocation/service_test.go +++ b/backend/internal/oauth/oauth2/revocation/service_test.go @@ -40,7 +40,7 @@ const testClientID = "test-client-id" type RevocationServiceTestSuite struct { suite.Suite jwtServiceMock *jwtmock.JWTServiceInterfaceMock - storeMock *RevokedTokenStoreInterfaceMock + storeMock *revocationStoreInterfaceMock obsMock *observabilitymock.ObservabilityServiceInterfaceMock service RevocationServiceInterface } @@ -51,9 +51,9 @@ func TestRevocationServiceTestSuite(t *testing.T) { func (s *RevocationServiceTestSuite) SetupTest() { s.jwtServiceMock = jwtmock.NewJWTServiceInterfaceMock(s.T()) - s.storeMock = NewRevokedTokenStoreInterfaceMock(s.T()) + s.storeMock = newRevocationStoreInterfaceMock(s.T()) s.obsMock = observabilitymock.NewObservabilityServiceInterfaceMock(s.T()) - s.service = newRevocationService(s.jwtServiceMock, s.storeMock, s.obsMock) + s.service = newRevocationService(s.jwtServiceMock, s.storeMock, time.Hour, true, s.obsMock) } // buildToken constructs a JWT-shaped string with the given claims. DecodeJWT only base64-decodes the @@ -82,6 +82,26 @@ func (s *RevocationServiceTestSuite) TestRevokeToken_Success() { assert.Equal(s.T(), RevokeOutcomeRevoked, revokeOutcome) } +func (s *RevocationServiceTestSuite) TestRevokeToken_RevokesTokenFamily() { + token := buildToken(map[string]interface{}{ + "jti": "jti-fam", + "client_id": testClientID, + "tfid": "tfid-77", + "exp": float64(time.Now().Add(time.Hour).Unix()), + }) + s.jwtServiceMock.On("VerifyJWTSignature", mock.Anything, token).Return(nil) + s.storeMock.On("InsertRevokedToken", mock.Anything, mock.Anything).Return(nil) + s.storeMock.On("insertCriterion", mock.Anything, mock.MatchedBy(func(c revocationCriterion) bool { + return c.Type == criterionTypeTokenFamily && c.Value == "tfid-77" && + c.Reason == RevocationReasonExplicitTokenFamily + })).Return(nil) + s.obsMock.On("IsEnabled").Return(false) + + revokeOutcome, err := s.service.RevokeToken(context.Background(), token, "", testClientID) + assert.NoError(s.T(), err) + assert.Equal(s.T(), RevokeOutcomeRevoked, revokeOutcome) +} + func (s *RevocationServiceTestSuite) TestRevokeToken_PublishesAuditEvent() { token := buildToken(map[string]interface{}{"jti": "jti-evt", "client_id": testClientID}) s.jwtServiceMock.On("VerifyJWTSignature", mock.Anything, token).Return(nil) @@ -131,6 +151,30 @@ func (s *RevocationServiceTestSuite) TestRevokeToken_NotOwnedByClient() { s.storeMock.AssertNotCalled(s.T(), "InsertRevokedToken", mock.Anything, mock.Anything) } +// A refresh token carries no client_id claim (its owning client is the subject), so ownership must be +// enforced via sub: a refresh token presented by a different client is rejected per RFC 7009 §2.1. +func (s *RevocationServiceTestSuite) TestRevokeToken_RefreshTokenNotOwnedByClient() { + token := buildToken(map[string]interface{}{"jti": "rt-jti", "sub": "another-client"}) + s.jwtServiceMock.On("VerifyJWTSignature", mock.Anything, token).Return(nil) + + revokeOutcome, err := s.service.RevokeToken(context.Background(), token, "", testClientID) + assert.NoError(s.T(), err) + assert.Equal(s.T(), RevokeOutcomeNotOwned, revokeOutcome) + s.storeMock.AssertNotCalled(s.T(), "InsertRevokedToken", mock.Anything, mock.Anything) +} + +// A refresh token whose subject is the authenticated client is owned by it and revoked. +func (s *RevocationServiceTestSuite) TestRevokeToken_RefreshTokenOwnedBySubjectSucceeds() { + token := buildToken(map[string]interface{}{"jti": "rt-jti", "sub": testClientID}) + s.jwtServiceMock.On("VerifyJWTSignature", mock.Anything, token).Return(nil) + s.storeMock.On("InsertRevokedToken", mock.Anything, mock.Anything).Return(nil) + s.obsMock.On("IsEnabled").Return(false) + + revokeOutcome, err := s.service.RevokeToken(context.Background(), token, "", testClientID) + assert.NoError(s.T(), err) + assert.Equal(s.T(), RevokeOutcomeRevoked, revokeOutcome) +} + func (s *RevocationServiceTestSuite) TestRevokeToken_NoJtiIsNoOp() { token := buildToken(map[string]interface{}{"client_id": testClientID}) s.jwtServiceMock.On("VerifyJWTSignature", mock.Anything, token).Return(nil) @@ -181,3 +225,57 @@ func (s *RevocationServiceTestSuite) TestRevokeRefreshToken_StoreErrorPropagates err := revoker.RevokeRefreshToken(context.Background(), "jti-x", time.Now().UTC()) assert.Error(s.T(), err) } + +func TestRevokeTokenFamily_WritesTokenFamilyCriterion(t *testing.T) { + store := newRevocationStoreInterfaceMock(t) + var captured revocationCriterion + store.On("insertCriterion", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + captured = args.Get(1).(revocationCriterion) + }). + Return(nil) + + revoker := newRevocationService(nil, store, time.Hour, false, nil) + err := revoker.RevokeTokenFamily(context.Background(), "tfid-abc", RevocationReasonSessionLogout) + + assert.NoError(t, err) + assert.Equal(t, criterionTypeTokenFamily, captured.Type) + assert.Equal(t, "tfid-abc", captured.Value) + assert.Equal(t, RevocationReasonSessionLogout, captured.Reason) + assert.WithinDuration(t, captured.RevokedAt.Add(time.Hour), captured.ExpiryTime, time.Second) +} + +func TestRevokeTokenFamily_EmptyIDIsNoOp(t *testing.T) { + store := newRevocationStoreInterfaceMock(t) + // No insertCriterion expectation: an empty tfid must not write. + revoker := newRevocationService(nil, store, time.Hour, false, nil) + + err := revoker.RevokeTokenFamily(context.Background(), "", RevocationReasonSessionLogout) + assert.NoError(t, err) + store.AssertNotCalled(t, "insertCriterion", mock.Anything, mock.Anything) +} + +func TestRevokeTokenFamily_PropagatesStoreError(t *testing.T) { + store := newRevocationStoreInterfaceMock(t) + store.On("insertCriterion", mock.Anything, mock.Anything).Return(errors.New("db down")) + + revoker := newRevocationService(nil, store, time.Hour, false, nil) + err := revoker.RevokeTokenFamily(context.Background(), "tfid-abc", RevocationReasonRefreshReplay) + assert.Error(t, err) + assert.Contains(t, err.Error(), "db down") +} + +func TestRevokeTokenFamily_NonPositiveTTLFallsBack(t *testing.T) { + store := newRevocationStoreInterfaceMock(t) + var captured revocationCriterion + store.On("insertCriterion", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + captured = args.Get(1).(revocationCriterion) + }). + Return(nil) + + revoker := newRevocationService(nil, store, 0, false, nil) + err := revoker.RevokeTokenFamily(context.Background(), "tfid-abc", RevocationReasonCodeReplay) + assert.NoError(t, err) + assert.WithinDuration(t, captured.RevokedAt.Add(defaultTokenFamilyRevocationTTL), captured.ExpiryTime, time.Second) +} diff --git a/backend/internal/oauth/oauth2/revocation/store.go b/backend/internal/oauth/oauth2/revocation/store.go index 9e942f085c..5a9d4e076f 100644 --- a/backend/internal/oauth/oauth2/revocation/store.go +++ b/backend/internal/oauth/oauth2/revocation/store.go @@ -28,27 +28,59 @@ import ( "github.com/thunder-id/thunderid/internal/system/utils" ) -// RevokedTokenStoreInterface defines the deny-list persistence for single-token revocation: the -// write path (InsertRevokedToken) used by the RFC 7009 revocation service and the read path -// (IsTokenRevoked) used by the enforcement service on the AS hot path. -type RevokedTokenStoreInterface interface { +// criterionType names a revocation criterion dimension in the REVOCATION_CRITERIA deny list. +type criterionType string + +const ( + // criterionTypeTokenFamily revokes every token carrying a given token family id (tfid). + criterionTypeTokenFamily criterionType = "token_family" +) + +// revocationCriterion is a single criteria-based (many-token) revocation entry. +type revocationCriterion struct { + // ID is the surrogate UUID v7 primary key. Generated by the store when empty. + ID string + // Type is the criterion dimension (e.g. token_family). + Type criterionType + // Value is the revoked value for the dimension (the tfid for token_family). + Value string + // Reason records why the criterion was revoked. + Reason RevocationReason + // RevokedAt is the time the revocation was recorded. + RevokedAt time.Time + // ExpiryTime bounds the entry; the row is removable once every token it could match has expired. + ExpiryTime time.Time +} + +// revocationStoreInterface defines the deny-list persistence for the revocation feature: the +// single-token deny list (InsertRevokedToken / IsTokenRevoked, by jti) written by the RFC 7009 +// revocation service, and the criteria (many-token) deny list (insertCriterion / isCriterionRevoked, +// by criterion) written by the family revoker. Both read paths are consulted by the enforcement +// service on the AS hot path. The constructor is unexported so neither write path can be reached from +// outside the package, bypassing the revocation service's and revoker's validation and ownership. +type revocationStoreInterface interface { // InsertRevokedToken writes a JTI to the deny list. The write is idempotent. InsertRevokedToken(ctx context.Context, token RevokedToken) error // IsTokenRevoked reports whether a non-expired deny-list entry exists for the given JTI. IsTokenRevoked(ctx context.Context, jti string) (bool, error) + // insertCriterion records a criteria-based revocation. The write is idempotent per + // (deployment, type, value). + insertCriterion(ctx context.Context, criterion revocationCriterion) error + // isCriterionRevoked reports whether a non-expired criteria entry exists for the (type, value) pair. + isCriterionRevoked(ctx context.Context, criterion criterionType, value string) (bool, error) } -// revokedTokenStore implements RevokedTokenStoreInterface against the runtime persistent database. -type revokedTokenStore struct { +// revocationStore implements revocationStoreInterface against the runtime persistent database. +type revocationStore struct { dbProvider provider.DBProviderInterface deploymentID string } -// newRevokedTokenStore creates a new revokedTokenStore. It is intentionally unexported so the -// deny-list write path (InsertRevokedToken) cannot be reached from outside the package, bypassing -// the revocation service's validation and ownership checks. -func newRevokedTokenStore() RevokedTokenStoreInterface { - return &revokedTokenStore{ +// newRevocationStore creates a new revocationStore. It is intentionally unexported so the deny-list +// write paths cannot be reached from outside the package, bypassing the revocation service's and +// family revoker's validation and ownership checks. +func newRevocationStore() revocationStoreInterface { + return &revocationStore{ dbProvider: provider.GetDBProvider(), deploymentID: config.GetServerRuntime().Config.Server.Identifier, } @@ -56,7 +88,7 @@ func newRevokedTokenStore() RevokedTokenStoreInterface { // InsertRevokedToken writes a JTI to the deny list. A duplicate (deployment, jti) is a no-op. // A UUID v7 surrogate primary key is generated when the token has no ID. -func (s *revokedTokenStore) InsertRevokedToken(ctx context.Context, token RevokedToken) error { +func (s *revocationStore) InsertRevokedToken(ctx context.Context, token RevokedToken) error { dbClient, err := s.dbProvider.GetRuntimePersistentDBClient() if err != nil { return fmt.Errorf("failed to get runtime persistent database client: %w", err) @@ -80,7 +112,7 @@ func (s *revokedTokenStore) InsertRevokedToken(ctx context.Context, token Revoke } // IsTokenRevoked reports whether a non-expired deny-list entry exists for the given JTI. -func (s *revokedTokenStore) IsTokenRevoked(ctx context.Context, jti string) (bool, error) { +func (s *revocationStore) IsTokenRevoked(ctx context.Context, jti string) (bool, error) { dbClient, err := s.dbProvider.GetRuntimePersistentDBClient() if err != nil { return false, fmt.Errorf("failed to get runtime persistent database client: %w", err) @@ -93,3 +125,45 @@ func (s *revokedTokenStore) IsTokenRevoked(ctx context.Context, jti string) (boo return len(results) > 0, nil } + +// insertCriterion records a criteria-based revocation. A duplicate (deployment, type, value) refreshes +// the reason and time bounds. A UUID v7 surrogate primary key is generated when none is supplied. +func (s *revocationStore) insertCriterion(ctx context.Context, criterion revocationCriterion) error { + dbClient, err := s.dbProvider.GetRuntimePersistentDBClient() + if err != nil { + return fmt.Errorf("failed to get runtime persistent database client: %w", err) + } + + id := criterion.ID + if id == "" { + id, err = utils.GenerateUUIDv7() + if err != nil { + return fmt.Errorf("failed to generate revocation criterion id: %w", err) + } + } + + _, err = dbClient.ExecuteContext(ctx, queryInsertRevocationCriterion, id, string(criterion.Type), + criterion.Value, string(criterion.Reason), criterion.RevokedAt, criterion.ExpiryTime, s.deploymentID) + if err != nil { + return fmt.Errorf("error inserting revocation criterion: %w", err) + } + + return nil +} + +// isCriterionRevoked reports whether a non-expired criteria entry exists for the (type, value) pair. +func (s *revocationStore) isCriterionRevoked(ctx context.Context, criterion criterionType, value string) ( + bool, error) { + dbClient, err := s.dbProvider.GetRuntimePersistentDBClient() + if err != nil { + return false, fmt.Errorf("failed to get runtime persistent database client: %w", err) + } + + results, err := dbClient.QueryContext(ctx, queryIsCriterionRevoked, string(criterion), value, + time.Now().UTC(), s.deploymentID) + if err != nil { + return false, fmt.Errorf("error checking revocation criterion: %w", err) + } + + return len(results) > 0, nil +} diff --git a/backend/internal/oauth/oauth2/revocation/store_constants.go b/backend/internal/oauth/oauth2/revocation/store_constants.go index 4ab332a898..c565f7e33c 100644 --- a/backend/internal/oauth/oauth2/revocation/store_constants.go +++ b/backend/internal/oauth/oauth2/revocation/store_constants.go @@ -33,3 +33,22 @@ var queryIsTokenRevoked = dbmodel.DBQuery{ ID: "RVQ-RTS-02", Query: `SELECT 1 FROM "REVOKED_TOKEN" WHERE JTI = $1 AND EXPIRY_TIME > $2 AND DEPLOYMENT_ID = $3`, } + +// queryInsertRevocationCriterion records a criteria-based (many-token) revocation. The write is +// idempotent: re-revoking the same (deployment, type, value) refreshes the reason and time bounds +// rather than inserting a duplicate, enforced by the unique index backing the conflict target. +var queryInsertRevocationCriterion = dbmodel.DBQuery{ + ID: "RVQ-RCS-01", + Query: `INSERT INTO "REVOCATION_CRITERIA" (ID, CRITERION_TYPE, CRITERION_VALUE, REASON, ` + + `REVOKED_AT, EXPIRY_TIME, DEPLOYMENT_ID) VALUES ($1, $2, $3, $4, $5, $6, $7) ` + + `ON CONFLICT (DEPLOYMENT_ID, CRITERION_TYPE, CRITERION_VALUE) DO UPDATE SET ` + + `REASON = excluded.REASON, REVOKED_AT = excluded.REVOKED_AT, EXPIRY_TIME = excluded.EXPIRY_TIME`, +} + +// queryIsCriterionRevoked checks whether a non-expired criteria entry exists for the given +// (type, value) pair. +var queryIsCriterionRevoked = dbmodel.DBQuery{ + ID: "RVQ-RCS-02", + Query: `SELECT 1 FROM "REVOCATION_CRITERIA" WHERE CRITERION_TYPE = $1 AND CRITERION_VALUE = $2 ` + + `AND EXPIRY_TIME > $3 AND DEPLOYMENT_ID = $4`, +} diff --git a/backend/internal/oauth/oauth2/revocation/store_test.go b/backend/internal/oauth/oauth2/revocation/store_test.go index 99995b95a3..041594dfc3 100644 --- a/backend/internal/oauth/oauth2/revocation/store_test.go +++ b/backend/internal/oauth/oauth2/revocation/store_test.go @@ -35,19 +35,20 @@ import ( const testDeploymentID = "test-deployment-id" -type RevokedTokenStoreTestSuite struct { +type RevocationStoreTestSuite struct { suite.Suite mockdbProvider *providermock.DBProviderInterfaceMock mockDBClient *providermock.DBClientInterfaceMock - store *revokedTokenStore + store *revocationStore testToken RevokedToken + testCriterion revocationCriterion } -func TestRevokedTokenStoreTestSuite(t *testing.T) { - suite.Run(t, new(RevokedTokenStoreTestSuite)) +func TestRevocationStoreTestSuite(t *testing.T) { + suite.Run(t, new(RevocationStoreTestSuite)) } -func (suite *RevokedTokenStoreTestSuite) SetupTest() { +func (suite *RevocationStoreTestSuite) SetupTest() { testConfig := &config.Config{ Database: config.DatabaseConfig{ RuntimePersistent: config.DataSource{ @@ -61,7 +62,7 @@ func (suite *RevokedTokenStoreTestSuite) SetupTest() { suite.mockdbProvider = providermock.NewDBProviderInterfaceMock(suite.T()) suite.mockDBClient = providermock.NewDBClientInterfaceMock(suite.T()) - suite.store = &revokedTokenStore{ + suite.store = &revocationStore{ dbProvider: suite.mockdbProvider, deploymentID: testDeploymentID, } @@ -73,19 +74,28 @@ func (suite *RevokedTokenStoreTestSuite) SetupTest() { RevokedAt: time.Now().UTC(), ExpiryTime: time.Now().UTC().Add(time.Hour), } + + suite.testCriterion = revocationCriterion{ + ID: "test-criterion-id", + Type: criterionTypeTokenFamily, + Value: "tfid-123", + Reason: RevocationReasonRefreshReplay, + RevokedAt: time.Now().UTC(), + ExpiryTime: time.Now().UTC().Add(time.Hour), + } } -func (suite *RevokedTokenStoreTestSuite) TearDownTest() { +func (suite *RevocationStoreTestSuite) TearDownTest() { config.ResetServerRuntime() } -func (suite *RevokedTokenStoreTestSuite) TestNewRevokedTokenStore() { - store := newRevokedTokenStore() +func (suite *RevocationStoreTestSuite) TestNewRevocationStore() { + store := newRevocationStore() assert.NotNil(suite.T(), store) - assert.Implements(suite.T(), (*RevokedTokenStoreInterface)(nil), store) + assert.Implements(suite.T(), (*revocationStoreInterface)(nil), store) } -func (suite *RevokedTokenStoreTestSuite) TestInsertRevokedToken_Success() { +func (suite *RevocationStoreTestSuite) TestInsertRevokedToken_Success() { suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) suite.mockDBClient.On("ExecuteContext", mock.Anything, queryInsertRevokedToken, @@ -101,7 +111,7 @@ func (suite *RevokedTokenStoreTestSuite) TestInsertRevokedToken_Success() { suite.mockDBClient.AssertExpectations(suite.T()) } -func (suite *RevokedTokenStoreTestSuite) TestInsertRevokedToken_GeneratesIDWhenEmpty() { +func (suite *RevocationStoreTestSuite) TestInsertRevokedToken_GeneratesIDWhenEmpty() { suite.testToken.ID = "" suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) @@ -118,7 +128,7 @@ func (suite *RevokedTokenStoreTestSuite) TestInsertRevokedToken_GeneratesIDWhenE suite.mockDBClient.AssertExpectations(suite.T()) } -func (suite *RevokedTokenStoreTestSuite) TestInsertRevokedToken_DBClientError() { +func (suite *RevocationStoreTestSuite) TestInsertRevokedToken_DBClientError() { suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(nil, errors.New("db client error")) err := suite.store.InsertRevokedToken(context.Background(), suite.testToken) @@ -128,7 +138,7 @@ func (suite *RevokedTokenStoreTestSuite) TestInsertRevokedToken_DBClientError() suite.mockdbProvider.AssertExpectations(suite.T()) } -func (suite *RevokedTokenStoreTestSuite) TestInsertRevokedToken_ExecError() { +func (suite *RevocationStoreTestSuite) TestInsertRevokedToken_ExecError() { suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) suite.mockDBClient.On("ExecuteContext", mock.Anything, queryInsertRevokedToken, @@ -144,7 +154,7 @@ func (suite *RevokedTokenStoreTestSuite) TestInsertRevokedToken_ExecError() { suite.mockDBClient.AssertExpectations(suite.T()) } -func (suite *RevokedTokenStoreTestSuite) TestIsTokenRevoked_True() { +func (suite *RevocationStoreTestSuite) TestIsTokenRevoked_True() { suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) suite.mockDBClient.On("QueryContext", mock.Anything, queryIsTokenRevoked, @@ -158,7 +168,7 @@ func (suite *RevokedTokenStoreTestSuite) TestIsTokenRevoked_True() { suite.mockDBClient.AssertExpectations(suite.T()) } -func (suite *RevokedTokenStoreTestSuite) TestIsTokenRevoked_False() { +func (suite *RevocationStoreTestSuite) TestIsTokenRevoked_False() { suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) suite.mockDBClient.On("QueryContext", mock.Anything, queryIsTokenRevoked, @@ -172,7 +182,7 @@ func (suite *RevokedTokenStoreTestSuite) TestIsTokenRevoked_False() { suite.mockDBClient.AssertExpectations(suite.T()) } -func (suite *RevokedTokenStoreTestSuite) TestIsTokenRevoked_DBClientError() { +func (suite *RevocationStoreTestSuite) TestIsTokenRevoked_DBClientError() { suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(nil, errors.New("db client error")) revoked, err := suite.store.IsTokenRevoked(context.Background(), "test-jti") @@ -182,7 +192,7 @@ func (suite *RevokedTokenStoreTestSuite) TestIsTokenRevoked_DBClientError() { suite.mockdbProvider.AssertExpectations(suite.T()) } -func (suite *RevokedTokenStoreTestSuite) TestIsTokenRevoked_QueryError() { +func (suite *RevocationStoreTestSuite) TestIsTokenRevoked_QueryError() { suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) suite.mockDBClient.On("QueryContext", mock.Anything, queryIsTokenRevoked, @@ -196,3 +206,86 @@ func (suite *RevokedTokenStoreTestSuite) TestIsTokenRevoked_QueryError() { suite.mockDBClient.AssertExpectations(suite.T()) } + +func (suite *RevocationStoreTestSuite) TestInsertCriterion_Success() { + suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) + + suite.mockDBClient.On("ExecuteContext", mock.Anything, queryInsertRevocationCriterion, + suite.testCriterion.ID, string(suite.testCriterion.Type), suite.testCriterion.Value, + string(suite.testCriterion.Reason), suite.testCriterion.RevokedAt, suite.testCriterion.ExpiryTime, + testDeploymentID). + Return(int64(1), nil) + + err := suite.store.insertCriterion(context.Background(), suite.testCriterion) + assert.NoError(suite.T(), err) + suite.mockDBClient.AssertExpectations(suite.T()) +} + +func (suite *RevocationStoreTestSuite) TestInsertCriterion_GeneratesIDWhenEmpty() { + suite.testCriterion.ID = "" + suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) + + suite.mockDBClient.On("ExecuteContext", mock.Anything, queryInsertRevocationCriterion, + mock.Anything, string(suite.testCriterion.Type), suite.testCriterion.Value, + string(suite.testCriterion.Reason), suite.testCriterion.RevokedAt, suite.testCriterion.ExpiryTime, + testDeploymentID). + Return(int64(1), nil) + + err := suite.store.insertCriterion(context.Background(), suite.testCriterion) + assert.NoError(suite.T(), err) + suite.mockDBClient.AssertExpectations(suite.T()) +} + +func (suite *RevocationStoreTestSuite) TestInsertCriterion_DBClientError() { + suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(nil, errors.New("db client error")) + + err := suite.store.insertCriterion(context.Background(), suite.testCriterion) + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "db client error") +} + +func (suite *RevocationStoreTestSuite) TestInsertCriterion_ExecError() { + suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) + suite.mockDBClient.On("ExecuteContext", mock.Anything, queryInsertRevocationCriterion, + mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, + testDeploymentID). + Return(int64(0), errors.New("execute error")) + + err := suite.store.insertCriterion(context.Background(), suite.testCriterion) + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "error inserting revocation criterion") +} + +func (suite *RevocationStoreTestSuite) TestIsCriterionRevoked_True() { + suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) + suite.mockDBClient.On("QueryContext", mock.Anything, queryIsCriterionRevoked, + string(criterionTypeTokenFamily), "tfid-123", mock.Anything, testDeploymentID). + Return([]map[string]interface{}{{"1": 1}}, nil) + + revoked, err := suite.store.isCriterionRevoked(context.Background(), criterionTypeTokenFamily, "tfid-123") + assert.NoError(suite.T(), err) + assert.True(suite.T(), revoked) +} + +func (suite *RevocationStoreTestSuite) TestIsCriterionRevoked_False() { + suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) + suite.mockDBClient.On("QueryContext", mock.Anything, queryIsCriterionRevoked, + string(criterionTypeTokenFamily), "tfid-123", mock.Anything, testDeploymentID). + Return([]map[string]interface{}{}, nil) + + revoked, err := suite.store.isCriterionRevoked(context.Background(), criterionTypeTokenFamily, "tfid-123") + assert.NoError(suite.T(), err) + assert.False(suite.T(), revoked) +} + +func (suite *RevocationStoreTestSuite) TestIsCriterionRevoked_QueryError() { + suite.mockdbProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) + suite.mockDBClient.On("QueryContext", mock.Anything, queryIsCriterionRevoked, + string(criterionTypeTokenFamily), "tfid-123", mock.Anything, testDeploymentID). + Return([]map[string]interface{}(nil), errors.New("query error")) + + revoked, err := suite.store.isCriterionRevoked(context.Background(), criterionTypeTokenFamily, "tfid-123") + assert.Error(suite.T(), err) + assert.False(suite.T(), revoked) + assert.Contains(suite.T(), err.Error(), "error checking revocation criterion") +} diff --git a/backend/internal/oauth/oauth2/token/TokenServiceInterface_mock_test.go b/backend/internal/oauth/oauth2/token/TokenServiceInterface_mock_test.go index 5d61b09801..7d0e26d69c 100644 --- a/backend/internal/oauth/oauth2/token/TokenServiceInterface_mock_test.go +++ b/backend/internal/oauth/oauth2/token/TokenServiceInterface_mock_test.go @@ -3,9 +3,8 @@ package token import ( - "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" context "context" - + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" mock "github.com/stretchr/testify/mock" model "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" diff --git a/backend/internal/oauth/oauth2/token/service.go b/backend/internal/oauth/oauth2/token/service.go index df4575cdaa..aaadcedd49 100644 --- a/backend/internal/oauth/oauth2/token/service.go +++ b/backend/internal/oauth/oauth2/token/service.go @@ -231,6 +231,7 @@ func (ts *tokenService) ProcessTokenRequest( tokenRespDTO.AccessToken.Subject, refreshAudiences, grantTypeStr, tokenRespDTO.AccessToken.Scopes, tokenRespDTO.AccessToken.ClaimsRequest, tokenRespDTO.AccessToken.ClaimsLocales, tokenRespDTO.AccessToken.AttributeCacheID, + tokenRespDTO.AccessToken.TokenFamilyID, ) if refreshTokenError != nil && refreshTokenError.Error != "" { publishTokenIssuanceFailedEvent(ts.observabilitySvc, ctx, clientID, grantTypeStr, scopeStr, diff --git a/backend/internal/oauth/oauth2/token/service_test.go b/backend/internal/oauth/oauth2/token/service_test.go index a85cc64665..899c406590 100644 --- a/backend/internal/oauth/oauth2/token/service_test.go +++ b/backend/internal/oauth/oauth2/token/service_test.go @@ -514,21 +514,23 @@ func (suite *TokenServiceTestSuite) TestProcessTokenRequest_WithRefreshToken() { tokenRespDTO := &model.TokenResponseDTO{ AccessToken: model.TokenDTO{ - Token: "access-token-123", - TokenType: "Bearer", - ExpiresIn: 3600, - Scopes: []string{"openid"}, - Subject: "user123", - Audiences: []string{"test-audience"}, + Token: "access-token-123", + TokenType: "Bearer", + ExpiresIn: 3600, + Scopes: []string{"openid"}, + Subject: "user123", + Audiences: []string{"test-audience"}, + TokenFamilyID: "tfid-access-123", }, RefreshToken: model.TokenDTO{Token: ""}, IDToken: model.TokenDTO{Token: ""}, } suite.mockGrantHandler.On("HandleGrant", mock.Anything, mock.Anything, app).Return(tokenRespDTO, nil) + // The access token's tfid must be forwarded to refresh-token issuance so both tokens share the family. mockRefreshHandler. On("IssueRefreshToken", mock.Anything, tokenRespDTO, app, "user123", []string{"test-audience"}, - "authorization_code", []string{"openid"}, (*model.ClaimsRequest)(nil), "", ""). + "authorization_code", []string{"openid"}, (*model.ClaimsRequest)(nil), "", "", "tfid-access-123"). Return(nil) svc := suite.newService() @@ -583,7 +585,7 @@ func (suite *TokenServiceTestSuite) TestProcessTokenRequest_RefreshTokenIssuance mockRefreshHandler. On("IssueRefreshToken", mock.Anything, tokenRespDTO, app, "user123", []string{"test-audience"}, - "authorization_code", []string{"openid"}, (*model.ClaimsRequest)(nil), "", ""). + "authorization_code", []string{"openid"}, (*model.ClaimsRequest)(nil), "", "", ""). Return(&model.ErrorResponse{ Error: "server_error", ErrorDescription: "Failed to issue refresh token", @@ -805,7 +807,7 @@ func (suite *TokenServiceTestSuite) TestProcessTokenRequest_WithRefreshToken_Use mockRefreshHandler. On("IssueRefreshToken", mock.Anything, tokenRespDTO, app, "user123", []string{"original-audience-1", "original-audience-2"}, - "authorization_code", []string{"openid"}, (*model.ClaimsRequest)(nil), "", ""). + "authorization_code", []string{"openid"}, (*model.ClaimsRequest)(nil), "", "", ""). Return(nil) svc := suite.newService() @@ -863,7 +865,7 @@ func (suite *TokenServiceTestSuite) TestProcessTokenRequest_CIBA_RefreshTokenUse mockRefreshHandler. On("IssueRefreshToken", mock.Anything, tokenRespDTO, app, "user-1", []string{"https://api.example.com"}, - string(providers.GrantTypeCIBA), []string{"openid", "read"}, (*model.ClaimsRequest)(nil), "", ""). + string(providers.GrantTypeCIBA), []string{"openid", "read"}, (*model.ClaimsRequest)(nil), "", "", ""). Return(nil) svc := suite.newService() diff --git a/backend/internal/oauth/oauth2/tokenservice/builder.go b/backend/internal/oauth/oauth2/tokenservice/builder.go index 1ec0f9611e..37b1914323 100644 --- a/backend/internal/oauth/oauth2/tokenservice/builder.go +++ b/backend/internal/oauth/oauth2/tokenservice/builder.go @@ -96,6 +96,7 @@ func (tb *tokenBuilder) BuildAccessToken( Audiences: tokenCtx.Audiences, ClaimsRequest: tokenCtx.ClaimsRequest, ClaimsLocales: tokenCtx.ClaimsLocales, + TokenFamilyID: tokenCtx.TokenFamilyID, } token, iat, err := tb.jwtService.GenerateJWT( @@ -241,6 +242,10 @@ func (tb *tokenBuilder) buildAccessTokenClaims( dpop.SetCnfJkt(claims, ctx.DPoPJkt) + if ctx.TokenFamilyID != "" { + claims[constants.ClaimTokenFamilyID] = ctx.TokenFamilyID + } + return claims, nil } @@ -348,6 +353,10 @@ func (tb *tokenBuilder) buildRefreshTokenClaims(ctx *RefreshTokenBuildContext) ( claims[constants.ClaimDPoPJkt] = ctx.DPoPJkt } + if ctx.TokenFamilyID != "" { + claims[constants.ClaimTokenFamilyID] = ctx.TokenFamilyID + } + return claims, nil } diff --git a/backend/internal/oauth/oauth2/tokenservice/model.go b/backend/internal/oauth/oauth2/tokenservice/model.go index b326a46a26..5334544fff 100644 --- a/backend/internal/oauth/oauth2/tokenservice/model.go +++ b/backend/internal/oauth/oauth2/tokenservice/model.go @@ -71,6 +71,9 @@ type AccessTokenBuildContext struct { // subject (used by the jwt-bearer/ID-JAG grant). It is emitted as the `idp` claim so downstream // consumers can distinguish a federated principal from a local one. SourceIDP string + // TokenFamilyID, when set, is stamped as the `tfid` claim so the token can be revoked as part of + // its authorization grant's family. It is constant across refresh rotation. + TokenFamilyID string } // RefreshTokenBuildContext contains all the information needed to build a refresh token. @@ -86,6 +89,9 @@ type RefreshTokenBuildContext struct { ClaimsLocales string DPoPJkt string ActorSub string + // TokenFamilyID, when set, is stamped as the `tfid` claim on the refresh token. It is copied + // unchanged across rotation so every token of the grant shares one family id. + TokenFamilyID string } // IDJAGBuildContext contains all the information needed to build an ID-JAG (Identity Assertion @@ -131,6 +137,10 @@ type RefreshTokenClaims struct { // Exp is the refresh token's expiry (exp claim); used to bound the deny-list entry when the token // is revoked on rotation. Exp int64 + // TokenFamilyID is the token family id (tfid) carried on the refresh token. It is copied onto the + // tokens minted during rotation so the family stays intact, and used to revoke the whole family on + // reuse. Empty for pre-rollout tokens. + TokenFamilyID string } // SubjectTokenClaims represents the validated claims from a subject token (for token exchange). @@ -147,6 +157,9 @@ type SubjectTokenClaims struct { // JTI is the subject token's unique identifier, populated only for self-issued tokens and used // for deny-list (revocation) enforcement. Empty for externally-issued subject tokens. JTI string + // TokenFamilyID is the subject token's token family id (tfid), if any. Token exchange may inherit + // it onto the exchanged token so the two share a revocation family. + TokenFamilyID string } // IDJAGAssertionClaims represents the validated claims from an ID-JAG assertion presented on the diff --git a/backend/internal/oauth/oauth2/tokenservice/validator.go b/backend/internal/oauth/oauth2/tokenservice/validator.go index 493fb3a3c4..8365e30c5b 100644 --- a/backend/internal/oauth/oauth2/tokenservice/validator.go +++ b/backend/internal/oauth/oauth2/tokenservice/validator.go @@ -131,7 +131,8 @@ func (tv *tokenValidator) ValidateAccessToken(ctx context.Context, token string) scopes := extractScopesFromClaims(claims, false) jti, _ := extractStringClaim(claims, constants.ClaimJTI) - if err := tv.ensureNotRevoked(ctx, jti); err != nil { + tokenFamilyID, _ := extractStringClaim(claims, constants.ClaimTokenFamilyID) + if err := tv.ensureNotRevoked(ctx, jti, tokenFamilyID); err != nil { return nil, err } @@ -173,6 +174,7 @@ func (tv *tokenValidator) ValidateRefreshToken( attributeCacheID, _ := extractStringClaim(claims, "aci") actorSub, _ := extractStringClaim(claims, "act_sub") jti, _ := extractStringClaim(claims, "jti") + tokenFamilyID, _ := extractStringClaim(claims, constants.ClaimTokenFamilyID) // Extract claims request if present var claimsRequest *oauth2model.ClaimsRequest @@ -197,7 +199,7 @@ func (tv *tokenValidator) ValidateRefreshToken( dpopJkt = s } - if err := tv.ensureNotRevoked(ctx, jti); err != nil { + if err := tv.ensureNotRevoked(ctx, jti, tokenFamilyID); err != nil { return nil, err } @@ -215,6 +217,7 @@ func (tv *tokenValidator) ValidateRefreshToken( ActorSub: actorSub, JTI: jti, Exp: exp, + TokenFamilyID: tokenFamilyID, }, nil } @@ -253,7 +256,8 @@ func (tv *tokenValidator) ValidateSubjectToken( if err != nil { return nil, err } - if err := tv.ensureNotRevoked(ctx, selfClaims.JTI); err != nil { + selfTokenFamilyID, _ := extractStringClaim(claims, constants.ClaimTokenFamilyID) + if err := tv.ensureNotRevoked(ctx, selfClaims.JTI, selfTokenFamilyID); err != nil { return nil, err } return selfClaims, nil @@ -337,7 +341,8 @@ func (tv *tokenValidator) ValidateToken(ctx context.Context, token string) (map[ } jti, _ := extractStringClaim(claims, constants.ClaimJTI) - if err := tv.ensureNotRevoked(ctx, jti); err != nil { + tokenFamilyID, _ := extractStringClaim(claims, constants.ClaimTokenFamilyID) + if err := tv.ensureNotRevoked(ctx, jti, tokenFamilyID); err != nil { return nil, err } @@ -587,10 +592,11 @@ func (tv *tokenValidator) extractSubjectTokenClaims( } // Only self-issued tokens participate in deny-list (revocation) enforcement; an external - // issuer's jti has no meaning in this server's deny list. - var jti string + // issuer's jti and token family id have no meaning in this server's deny list. + var jti, tokenFamilyID string if tv.isSelfIssuer(iss) { jti, _ = extractStringClaim(claims, "jti") + tokenFamilyID, _ = extractStringClaim(claims, constants.ClaimTokenFamilyID) } return &SubjectTokenClaims{ @@ -602,6 +608,7 @@ func (tv *tokenValidator) extractSubjectTokenClaims( NestedAct: nestedAct, CnfJkt: cnfJkt, JTI: jti, + TokenFamilyID: tokenFamilyID, }, nil } @@ -689,9 +696,9 @@ func (tv *tokenValidator) isAuthAssertion( return false } -func (tv *tokenValidator) ensureNotRevoked(ctx context.Context, jti string) error { +func (tv *tokenValidator) ensureNotRevoked(ctx context.Context, jti, tokenFamilyID string) error { if tv.enforcementService != nil { - return tv.enforcementService.EnsureNotRevoked(ctx, jti) + return tv.enforcementService.EnsureNotRevoked(ctx, jti, tokenFamilyID) } return nil } diff --git a/backend/internal/oauth/oauth2/tokenservice/validator_test.go b/backend/internal/oauth/oauth2/tokenservice/validator_test.go index 1e27cb567e..1734a04b33 100644 --- a/backend/internal/oauth/oauth2/tokenservice/validator_test.go +++ b/backend/internal/oauth/oauth2/tokenservice/validator_test.go @@ -79,7 +79,7 @@ func (suite *TokenValidatorTestSuite) SetupTest() { suite.mockJWTService = jwtmock.NewJWTServiceInterfaceMock(suite.T()) suite.mockEnforcementService = revocationmock.NewEnforcementServiceInterfaceMock(suite.T()) // Default: tokens are not revoked. Individual tests override this to exercise revocation. - suite.mockEnforcementService.On("EnsureNotRevoked", mock.Anything, mock.Anything).Return(nil).Maybe() + suite.mockEnforcementService.On("EnsureNotRevoked", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() suite.validator = &tokenValidator{ cfg: oauthconfig.Config{ JWT: engineconfig.JWTConfig{ @@ -2013,7 +2013,7 @@ func (suite *TokenValidatorTestSuite) validatorWithEnforcement( jti string, returnedErr error, ) *tokenValidator { enforcement := revocationmock.NewEnforcementServiceInterfaceMock(suite.T()) - enforcement.On("EnsureNotRevoked", mock.Anything, jti).Return(returnedErr) + enforcement.On("EnsureNotRevoked", mock.Anything, jti, mock.Anything).Return(returnedErr) return &tokenValidator{ cfg: suite.validator.cfg, jwtService: suite.mockJWTService, @@ -2030,12 +2030,15 @@ func (suite *TokenValidatorTestSuite) TestValidateAccessToken_Revoked() { "aud": "test-app", "client_id": "test-client", "jti": "at-jti-revoked", + "tfid": "tfid-at-revoked", } token := suite.createTestAccessToken(claims) suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) + // The token's tfid claim must reach the enforcement service verbatim so family-scoped revocation works. enforcement := revocationmock.NewEnforcementServiceInterfaceMock(suite.T()) - enforcement.On("EnsureNotRevoked", mock.Anything, "at-jti-revoked").Return(revocation.ErrTokenRevoked) + enforcement.On("EnsureNotRevoked", mock.Anything, "at-jti-revoked", "tfid-at-revoked"). + Return(revocation.ErrTokenRevoked) validator := &tokenValidator{ cfg: suite.validator.cfg, jwtService: suite.mockJWTService, @@ -2062,7 +2065,7 @@ func (suite *TokenValidatorTestSuite) TestValidateAccessToken_EnforcementUnavail suite.mockJWTService.On("VerifyJWT", mock.Anything, token, "", "https://example.com").Return(nil) enforcement := revocationmock.NewEnforcementServiceInterfaceMock(suite.T()) - enforcement.On("EnsureNotRevoked", mock.Anything, "at-jti-unknown"). + enforcement.On("EnsureNotRevoked", mock.Anything, "at-jti-unknown", mock.Anything). Return(revocation.ErrEnforcementUnavailable) validator := &tokenValidator{ cfg: suite.validator.cfg, @@ -2415,7 +2418,7 @@ func (suite *ExternalIDPValidatorTestSuite) SetupTest() { suite.mockJWTService = jwtmock.NewJWTServiceInterfaceMock(suite.T()) suite.mockIDPService = idpmock.NewIDPServiceInterfaceMock(suite.T()) suite.mockEnforcementService = revocationmock.NewEnforcementServiceInterfaceMock(suite.T()) - suite.mockEnforcementService.On("EnsureNotRevoked", mock.Anything, mock.Anything).Return(nil).Maybe() + suite.mockEnforcementService.On("EnsureNotRevoked", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() suite.validator = &tokenValidator{ cfg: oauthconfig.Config{ JWT: engineconfig.JWTConfig{ @@ -2959,7 +2962,7 @@ func (suite *IDJAGValidatorTestSuite) SetupTest() { suite.mockJWTService = jwtmock.NewJWTServiceInterfaceMock(suite.T()) suite.mockIDPService = idpmock.NewIDPServiceInterfaceMock(suite.T()) suite.mockEnforcementService = revocationmock.NewEnforcementServiceInterfaceMock(suite.T()) - suite.mockEnforcementService.On("EnsureNotRevoked", mock.Anything, mock.Anything).Return(nil).Maybe() + suite.mockEnforcementService.On("EnsureNotRevoked", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() suite.validator = &tokenValidator{ cfg: oauthconfig.Config{ JWT: engineconfig.JWTConfig{ diff --git a/backend/internal/system/config/config.go b/backend/internal/system/config/config.go index 0cf6939a64..ee75af0362 100644 --- a/backend/internal/system/config/config.go +++ b/backend/internal/system/config/config.go @@ -699,6 +699,9 @@ func LoadConfig(configPath string, defaultPath string, serverHome string) (*Conf if err := cfg.OAuth.DPoP.Validate(); err != nil { return nil, err } + if err := cfg.OAuth.TokenExchange.Validate(); err != nil { + return nil, err + } if err := cfg.Notification.Validate(); err != nil { return nil, err } diff --git a/backend/internal/system/revocationcache/cache.go b/backend/internal/system/revocationcache/cache.go index 040b5098f1..49a12e184b 100644 --- a/backend/internal/system/revocationcache/cache.go +++ b/backend/internal/system/revocationcache/cache.go @@ -23,35 +23,56 @@ import ( "time" ) -// revokedCache is the concurrent in-memory deny-list snapshot. It maps a token's revocation -// identifier to the revoked token's original expiry, so a lookup can ignore entries whose token has -// already expired (and is rejected by time-claim validation anyway) even between syncs. +// revokedCache is the concurrent in-memory deny-list snapshot. It maps each revoked jti and each +// revoked token-family id (tfid) to its original expiry, so a lookup can ignore entries whose token +// has already expired (and is rejected by time-claim validation anyway) even between syncs. The two +// dimensions are kept separate so a jti is never matched against a tfid. type revokedCache struct { - mu sync.RWMutex - entries map[string]time.Time + mu sync.RWMutex + tokens map[string]time.Time + families map[string]time.Time } // newRevokedCache creates an empty cache. It holds nothing until the first snapshot is loaded. func newRevokedCache() *revokedCache { - return &revokedCache{entries: make(map[string]time.Time)} + return &revokedCache{ + tokens: make(map[string]time.Time), + families: make(map[string]time.Time), + } } // replace atomically swaps the snapshot for the given entries. It is called by the syncer after each // successful source read; a failed read leaves the previous snapshot in place (last-known-good). -func (c *revokedCache) replace(entries []revokedEntry) { - next := make(map[string]time.Time, len(entries)) - for _, e := range entries { - next[e.JTI] = e.ExpiryTime - } +func (c *revokedCache) replace(snapshot revokedSnapshot) { + tokens := indexByValue(snapshot.Tokens) + families := indexByValue(snapshot.Families) c.mu.Lock() - c.entries = next + c.tokens = tokens + c.families = families c.mu.Unlock() } -// isRevoked reports whether id is in the deny list and its token has not yet expired. -func (c *revokedCache) isRevoked(id string) bool { +// isTokenRevoked reports whether jti is on the single-token deny list and has not yet expired. +func (c *revokedCache) isTokenRevoked(jti string) bool { + c.mu.RLock() + expiry, ok := c.tokens[jti] + c.mu.RUnlock() + return ok && time.Now().Before(expiry) +} + +// isTokenFamilyRevoked reports whether tfid is on the family deny list and has not yet expired. +func (c *revokedCache) isTokenFamilyRevoked(tfid string) bool { c.mu.RLock() - expiry, ok := c.entries[id] + expiry, ok := c.families[tfid] c.mu.RUnlock() return ok && time.Now().Before(expiry) } + +// indexByValue builds a value -> expiry map from a slice of entries. +func indexByValue(entries []revokedEntry) map[string]time.Time { + m := make(map[string]time.Time, len(entries)) + for _, e := range entries { + m[e.Value] = e.ExpiryTime + } + return m +} diff --git a/backend/internal/system/revocationcache/cache_test.go b/backend/internal/system/revocationcache/cache_test.go index 74f61c83a3..6dcf493369 100644 --- a/backend/internal/system/revocationcache/cache_test.go +++ b/backend/internal/system/revocationcache/cache_test.go @@ -30,35 +30,41 @@ func TestRevokedCache_ReplaceAndIsRevoked(t *testing.T) { c := newRevokedCache() future := time.Now().Add(time.Hour) - assert.False(t, c.isRevoked("jti-1"), "empty cache reports nothing revoked") + assert.False(t, c.isTokenRevoked("jti-1"), "empty cache reports nothing revoked") - c.replace([]revokedEntry{ - {JTI: "jti-1", ExpiryTime: future}, - {JTI: "jti-2", ExpiryTime: future}, + c.replace(revokedSnapshot{ + Tokens: []revokedEntry{ + {Value: "jti-1", ExpiryTime: future}, + {Value: "jti-2", ExpiryTime: future}, + }, + Families: []revokedEntry{{Value: "tfid-1", ExpiryTime: future}}, }) - assert.True(t, c.isRevoked("jti-1")) - assert.True(t, c.isRevoked("jti-2")) - assert.False(t, c.isRevoked("jti-3")) + assert.True(t, c.isTokenRevoked("jti-1")) + assert.True(t, c.isTokenRevoked("jti-2")) + assert.False(t, c.isTokenRevoked("jti-3")) + assert.True(t, c.isTokenFamilyRevoked("tfid-1")) + assert.False(t, c.isTokenFamilyRevoked("jti-1"), "a jti must not match the family dimension") + assert.False(t, c.isTokenRevoked("tfid-1"), "a tfid must not match the token dimension") } func TestRevokedCache_ReplaceSwapsSnapshot(t *testing.T) { c := newRevokedCache() future := time.Now().Add(time.Hour) - c.replace([]revokedEntry{{JTI: "old", ExpiryTime: future}}) - assert.True(t, c.isRevoked("old")) + c.replace(revokedSnapshot{Tokens: []revokedEntry{{Value: "old", ExpiryTime: future}}}) + assert.True(t, c.isTokenRevoked("old")) - c.replace([]revokedEntry{{JTI: "new", ExpiryTime: future}}) - assert.False(t, c.isRevoked("old"), "prior entries are dropped on replace") - assert.True(t, c.isRevoked("new")) + c.replace(revokedSnapshot{Tokens: []revokedEntry{{Value: "new", ExpiryTime: future}}}) + assert.False(t, c.isTokenRevoked("old"), "prior entries are dropped on replace") + assert.True(t, c.isTokenRevoked("new")) } func TestRevokedCache_ExpiredEntryNotRevoked(t *testing.T) { c := newRevokedCache() - c.replace([]revokedEntry{{JTI: "expired", ExpiryTime: time.Now().Add(-time.Second)}}) + c.replace(revokedSnapshot{Tokens: []revokedEntry{{Value: "expired", ExpiryTime: time.Now().Add(-time.Second)}}}) - assert.False(t, c.isRevoked("expired"), "an entry past its expiry is treated as not revoked") + assert.False(t, c.isTokenRevoked("expired"), "an entry past its expiry is treated as not revoked") } func TestRevokedCache_ConcurrentAccess(t *testing.T) { @@ -70,14 +76,14 @@ func TestRevokedCache_ConcurrentAccess(t *testing.T) { wg.Add(2) go func() { defer wg.Done() - c.replace([]revokedEntry{{JTI: "jti", ExpiryTime: future}}) + c.replace(revokedSnapshot{Tokens: []revokedEntry{{Value: "jti", ExpiryTime: future}}}) }() go func() { defer wg.Done() - _ = c.isRevoked("jti") + _ = c.isTokenRevoked("jti") }() } wg.Wait() - assert.True(t, c.isRevoked("jti")) + assert.True(t, c.isTokenRevoked("jti")) } diff --git a/backend/internal/system/revocationcache/enforcer.go b/backend/internal/system/revocationcache/enforcer.go index 039d015842..c716cc7660 100644 --- a/backend/internal/system/revocationcache/enforcer.go +++ b/backend/internal/system/revocationcache/enforcer.go @@ -20,14 +20,14 @@ package revocationcache import "context" -// EnforcerInterface answers revocation checks for the Resource Server enforcement point. It is -// token-format agnostic: id is the token's revocation identifier (the jti for JWTs today, an opaque -// token handle in future). Reads are served entirely from the in-memory cache, so the request hot -// path never touches the source. +// EnforcerInterface answers revocation checks for the Resource Server enforcement point. jti is the +// token's own identifier and tokenFamilyID is its grant's token family id (tfid); a token is rejected +// when either is on the cached deny list. Reads are served entirely from the in-memory cache, so the +// request hot path never touches the source. type EnforcerInterface interface { - // EnsureNotRevoked returns nil when the token identified by id may proceed and errTokenRevoked - // when id is present in the cached deny list. An empty id is a no-op (nothing to enforce). - EnsureNotRevoked(ctx context.Context, id string) error + // EnsureNotRevoked returns nil when the token may proceed and errTokenRevoked when its jti or its + // token family id is present in the cached deny list. Empty jti and tokenFamilyID are each a no-op. + EnsureNotRevoked(ctx context.Context, jti, tokenFamilyID string) error } // enforcer serves revocation checks from the in-memory cache. It holds no write capability. @@ -40,13 +40,13 @@ func newEnforcer(cache *revokedCache) *enforcer { return &enforcer{cache: cache} } -// EnsureNotRevoked returns errTokenRevoked when id is present in the cached deny list, nil otherwise. -// An empty id is treated as nothing to enforce. -func (e *enforcer) EnsureNotRevoked(_ context.Context, id string) error { - if id == "" { - return nil +// EnsureNotRevoked returns errTokenRevoked when the token's jti or token family id is on the cached +// deny list, nil otherwise. Empty identifiers are treated as nothing to enforce. +func (e *enforcer) EnsureNotRevoked(_ context.Context, jti, tokenFamilyID string) error { + if jti != "" && e.cache.isTokenRevoked(jti) { + return errTokenRevoked } - if e.cache.isRevoked(id) { + if tokenFamilyID != "" && e.cache.isTokenFamilyRevoked(tokenFamilyID) { return errTokenRevoked } return nil @@ -56,4 +56,4 @@ func (e *enforcer) EnsureNotRevoked(_ context.Context, id string) error { type noopEnforcer struct{} // EnsureNotRevoked always returns nil. -func (noopEnforcer) EnsureNotRevoked(_ context.Context, _ string) error { return nil } +func (noopEnforcer) EnsureNotRevoked(_ context.Context, _, _ string) error { return nil } diff --git a/backend/internal/system/revocationcache/enforcer_test.go b/backend/internal/system/revocationcache/enforcer_test.go index 8c81bd6939..1fbee9a605 100644 --- a/backend/internal/system/revocationcache/enforcer_test.go +++ b/backend/internal/system/revocationcache/enforcer_test.go @@ -28,19 +28,24 @@ import ( func TestEnforcer_EnsureNotRevoked(t *testing.T) { cache := newRevokedCache() - cache.replace([]revokedEntry{{JTI: "revoked-jti", ExpiryTime: time.Now().Add(time.Hour)}}) + cache.replace(revokedSnapshot{ + Tokens: []revokedEntry{{Value: "revoked-jti", ExpiryTime: time.Now().Add(time.Hour)}}, + Families: []revokedEntry{{Value: "revoked-tfid", ExpiryTime: time.Now().Add(time.Hour)}}, + }) e := newEnforcer(cache) - assert.NoError(t, e.EnsureNotRevoked(context.Background(), ""), - "empty id is a no-op") - assert.NoError(t, e.EnsureNotRevoked(context.Background(), "active-jti"), - "a jti not on the deny list may proceed") - assert.ErrorIs(t, e.EnsureNotRevoked(context.Background(), "revoked-jti"), errTokenRevoked, + assert.NoError(t, e.EnsureNotRevoked(context.Background(), "", ""), + "empty ids are a no-op") + assert.NoError(t, e.EnsureNotRevoked(context.Background(), "active-jti", "active-tfid"), + "a token with a clean jti and family may proceed") + assert.ErrorIs(t, e.EnsureNotRevoked(context.Background(), "revoked-jti", ""), errTokenRevoked, "a jti on the deny list is rejected") + assert.ErrorIs(t, e.EnsureNotRevoked(context.Background(), "active-jti", "revoked-tfid"), errTokenRevoked, + "a token whose family is revoked is rejected even with a clean jti") } func TestNoopEnforcer_AlwaysAllows(t *testing.T) { var e EnforcerInterface = noopEnforcer{} - assert.NoError(t, e.EnsureNotRevoked(context.Background(), "anything")) - assert.NoError(t, e.EnsureNotRevoked(context.Background(), "")) + assert.NoError(t, e.EnsureNotRevoked(context.Background(), "anything", "")) + assert.NoError(t, e.EnsureNotRevoked(context.Background(), "", "")) } diff --git a/backend/internal/system/revocationcache/init_test.go b/backend/internal/system/revocationcache/init_test.go index 9c8cc6b1e7..70dbfd685e 100644 --- a/backend/internal/system/revocationcache/init_test.go +++ b/backend/internal/system/revocationcache/init_test.go @@ -36,7 +36,7 @@ func TestInitialize_DisabledReturnsNoops(t *testing.T) { assert.NoError(t, err) assert.IsType(t, noopEnforcer{}, enforcer) assert.IsType(t, noopSyncer{}, syncer) - assert.NoError(t, enforcer.EnsureNotRevoked(context.Background(), "anything")) + assert.NoError(t, enforcer.EnsureNotRevoked(context.Background(), "anything", "")) } func TestInitialize_UnsupportedSource(t *testing.T) { @@ -53,8 +53,8 @@ func TestInitializeWithSource_InitialLoadPopulatesCache(t *testing.T) { enforcer, syncer := initializeWithSource(Config{Enabled: true, SyncInterval: time.Minute}, source) assert.Equal(t, 1, source.callCount(), "the initial snapshot is loaded synchronously") - assert.ErrorIs(t, enforcer.EnsureNotRevoked(context.Background(), "jti-1"), errTokenRevoked) - assert.NoError(t, enforcer.EnsureNotRevoked(context.Background(), "other")) + assert.ErrorIs(t, enforcer.EnsureNotRevoked(context.Background(), "jti-1", ""), errTokenRevoked) + assert.NoError(t, enforcer.EnsureNotRevoked(context.Background(), "other", "")) assert.NotNil(t, syncer, "initializeWithSource returns a syncer whose loop the caller starts") } @@ -66,7 +66,7 @@ func TestInitializeWithSource_InitialLoadFailureStartsWithEmptyDenyList(t *testi require.NotNil(t, enforcer, "a failed initial load must not stop startup") require.NotNil(t, syncer) // With no snapshot loaded, the deny list is empty and nothing is treated as revoked. - assert.NoError(t, enforcer.EnsureNotRevoked(context.Background(), "jti-1")) + assert.NoError(t, enforcer.EnsureNotRevoked(context.Background(), "jti-1", "")) } func TestSelectSource(t *testing.T) { diff --git a/backend/internal/system/revocationcache/model.go b/backend/internal/system/revocationcache/model.go index 99ff30f47b..8cb667dac1 100644 --- a/backend/internal/system/revocationcache/model.go +++ b/backend/internal/system/revocationcache/model.go @@ -22,8 +22,18 @@ import "time" // revokedEntry is one non-expired deny-list record returned by a syncSource and held in the cache. type revokedEntry struct { - // JTI is the token identifier and the cache lookup key. - JTI string - // ExpiryTime is the revoked token's original expiry; the entry is prunable once it passes. + // Value is the cache lookup key: the jti for a single-token entry, the tfid for a family entry. + Value string + // ExpiryTime is the revoked token's (or family's) original expiry; the entry is prunable once it + // passes. ExpiryTime time.Time } + +// revokedSnapshot is one source read: the revoked single-token jtis and the revoked token-family ids +// for a deployment, held in separate cache dimensions so a jti is never matched against a tfid. +type revokedSnapshot struct { + // Tokens holds the revoked single-token entries (keyed by jti). + Tokens []revokedEntry + // Families holds the revoked token-family entries (keyed by tfid). + Families []revokedEntry +} diff --git a/backend/internal/system/revocationcache/query_constants.go b/backend/internal/system/revocationcache/query_constants.go index 5d360fb275..20ac9901f1 100644 --- a/backend/internal/system/revocationcache/query_constants.go +++ b/backend/internal/system/revocationcache/query_constants.go @@ -20,9 +20,17 @@ package revocationcache import dbmodel "github.com/thunder-id/thunderid/internal/system/database/model" -// querySnapshotRevokedTokens reads the full set of non-expired deny-list entries for this deployment. -// It is read-only: this package holds no insert/update/delete query against the deny list. +// querySnapshotRevokedTokens reads the full set of non-expired single-token deny-list entries for this +// deployment. It is read-only: this package holds no insert/update/delete query against the deny list. var querySnapshotRevokedTokens = dbmodel.DBQuery{ ID: "RVC-SRC-01", Query: `SELECT JTI, EXPIRY_TIME FROM "REVOKED_TOKEN" WHERE EXPIRY_TIME > $1 AND DEPLOYMENT_ID = $2`, } + +// querySnapshotRevokedTokenFamilies reads the full set of non-expired token-family revocation entries for +// this deployment from the criteria deny list. It is read-only. +var querySnapshotRevokedTokenFamilies = dbmodel.DBQuery{ + ID: "RVC-SRC-02", + Query: `SELECT CRITERION_VALUE, EXPIRY_TIME FROM "REVOCATION_CRITERIA" ` + + `WHERE CRITERION_TYPE = $1 AND EXPIRY_TIME > $2 AND DEPLOYMENT_ID = $3`, +} diff --git a/backend/internal/system/revocationcache/source.go b/backend/internal/system/revocationcache/source.go index 65a56bef28..0a63f87859 100644 --- a/backend/internal/system/revocationcache/source.go +++ b/backend/internal/system/revocationcache/source.go @@ -24,6 +24,7 @@ import "context" // the Resource Server sync from the runtime persistent DB today and from another DB, endpoint, or event stream // in future without changing the cache, enforcer, or syncer. type syncSource interface { - // Snapshot returns all currently-revoked, non-expired entries for this deployment. - Snapshot(ctx context.Context) ([]revokedEntry, error) + // Snapshot returns all currently-revoked, non-expired entries for this deployment: the revoked + // single-token jtis and the revoked token-family ids. + Snapshot(ctx context.Context) (revokedSnapshot, error) } diff --git a/backend/internal/system/revocationcache/source_db.go b/backend/internal/system/revocationcache/source_db.go index 550694bb52..a489487110 100644 --- a/backend/internal/system/revocationcache/source_db.go +++ b/backend/internal/system/revocationcache/source_db.go @@ -29,8 +29,12 @@ import ( ) const ( - columnNameJTI = "jti" - columnNameExpiryTime = "expiry_time" + columnNameJTI = "jti" + columnNameCriterionValue = "criterion_value" + columnNameExpiryTime = "expiry_time" + // criterionTypeTokenFamily mirrors the revocation package's token_family criterion type. It is + // duplicated here (not imported) so this read-only RS package stays decoupled from the write path. + criterionTypeTokenFamily = "token_family" ) // dbSource reads the deny-list snapshot from the runtime persistent database. It is the only source today; it @@ -48,30 +52,52 @@ func newDBSource() syncSource { } } -// Snapshot returns all non-expired deny-list entries for this deployment. -func (s *dbSource) Snapshot(ctx context.Context) ([]revokedEntry, error) { +// Snapshot returns all non-expired deny-list entries for this deployment: the revoked single-token +// jtis and the revoked token-family ids. +func (s *dbSource) Snapshot(ctx context.Context) (revokedSnapshot, error) { dbClient, err := s.dbProvider.GetRuntimePersistentDBClient() if err != nil { - return nil, fmt.Errorf("failed to get runtime persistent database client: %w", err) + return revokedSnapshot{}, fmt.Errorf("failed to get runtime persistent database client: %w", err) } - rows, err := dbClient.QueryContext(ctx, querySnapshotRevokedTokens, time.Now().UTC(), s.deploymentID) + now := time.Now().UTC() + + tokenRows, err := dbClient.QueryContext(ctx, querySnapshotRevokedTokens, now, s.deploymentID) + if err != nil { + return revokedSnapshot{}, fmt.Errorf("error reading revoked token snapshot: %w", err) + } + tokens, err := parseEntries(tokenRows, columnNameJTI) if err != nil { - return nil, fmt.Errorf("error reading revoked token snapshot: %w", err) + return revokedSnapshot{}, err } + tokenFamilyRows, err := dbClient.QueryContext(ctx, querySnapshotRevokedTokenFamilies, + criterionTypeTokenFamily, now, s.deploymentID) + if err != nil { + return revokedSnapshot{}, fmt.Errorf("error reading revoked token family snapshot: %w", err) + } + families, err := parseEntries(tokenFamilyRows, columnNameCriterionValue) + if err != nil { + return revokedSnapshot{}, err + } + + return revokedSnapshot{Tokens: tokens, Families: families}, nil +} + +// parseEntries maps deny-list rows into revoked entries, reading the lookup value from valueColumn and +// the expiry from the standard expiry-time column. +func parseEntries(rows []map[string]interface{}, valueColumn string) ([]revokedEntry, error) { entries := make([]revokedEntry, 0, len(rows)) for _, row := range rows { - jti, ok := row[columnNameJTI].(string) - if !ok || jti == "" { - return nil, fmt.Errorf("invalid or missing %s in revoked token snapshot", columnNameJTI) + value, ok := row[valueColumn].(string) + if !ok || value == "" { + return nil, fmt.Errorf("invalid or missing %s in revocation snapshot", valueColumn) } expiryTime, err := utils.ParseDBTimeField(row[columnNameExpiryTime], columnNameExpiryTime) if err != nil { - return nil, fmt.Errorf("error parsing revoked token snapshot: %w", err) + return nil, fmt.Errorf("error parsing revocation snapshot: %w", err) } - entries = append(entries, revokedEntry{JTI: jti, ExpiryTime: expiryTime}) + entries = append(entries, revokedEntry{Value: value, ExpiryTime: expiryTime}) } - return entries, nil } diff --git a/backend/internal/system/revocationcache/source_db_test.go b/backend/internal/system/revocationcache/source_db_test.go index 2add4dcd9c..4cd5566073 100644 --- a/backend/internal/system/revocationcache/source_db_test.go +++ b/backend/internal/system/revocationcache/source_db_test.go @@ -62,13 +62,20 @@ func (suite *DBSourceTestSuite) TestSnapshot_Success() { {"jti": "jti-1", "expiry_time": expiry}, {"jti": "jti-2", "expiry_time": expiry}, }, nil) + suite.mockDBClient.On("QueryContext", mock.Anything, querySnapshotRevokedTokenFamilies, + criterionTypeTokenFamily, mock.Anything, testDeploymentID). + Return([]map[string]interface{}{ + {"criterion_value": "tfid-1", "expiry_time": expiry}, + }, nil) - entries, err := suite.source.Snapshot(context.Background()) + snapshot, err := suite.source.Snapshot(context.Background()) suite.Require().NoError(err) - assert.Len(suite.T(), entries, 2) - assert.Equal(suite.T(), "jti-1", entries[0].JTI) - assert.Equal(suite.T(), expiry, entries[0].ExpiryTime) + assert.Len(suite.T(), snapshot.Tokens, 2) + assert.Equal(suite.T(), "jti-1", snapshot.Tokens[0].Value) + assert.Equal(suite.T(), expiry, snapshot.Tokens[0].ExpiryTime) + assert.Len(suite.T(), snapshot.Families, 1) + assert.Equal(suite.T(), "tfid-1", snapshot.Families[0].Value) } func (suite *DBSourceTestSuite) TestSnapshot_Empty() { @@ -76,20 +83,24 @@ func (suite *DBSourceTestSuite) TestSnapshot_Empty() { suite.mockDBClient.On("QueryContext", mock.Anything, querySnapshotRevokedTokens, mock.Anything, testDeploymentID). Return([]map[string]interface{}{}, nil) + suite.mockDBClient.On("QueryContext", mock.Anything, querySnapshotRevokedTokenFamilies, + criterionTypeTokenFamily, mock.Anything, testDeploymentID). + Return([]map[string]interface{}{}, nil) - entries, err := suite.source.Snapshot(context.Background()) + snapshot, err := suite.source.Snapshot(context.Background()) suite.Require().NoError(err) - assert.Empty(suite.T(), entries) + assert.Empty(suite.T(), snapshot.Tokens) + assert.Empty(suite.T(), snapshot.Families) } func (suite *DBSourceTestSuite) TestSnapshot_DBClientError() { suite.mockDBProvider.On("GetRuntimePersistentDBClient").Return(nil, errors.New("db client error")) - entries, err := suite.source.Snapshot(context.Background()) + snapshot, err := suite.source.Snapshot(context.Background()) assert.Error(suite.T(), err) - assert.Nil(suite.T(), entries) + assert.Empty(suite.T(), snapshot.Tokens) assert.Contains(suite.T(), err.Error(), "db client error") } @@ -99,13 +110,29 @@ func (suite *DBSourceTestSuite) TestSnapshot_QueryError() { mock.Anything, testDeploymentID). Return(nil, errors.New("query error")) - entries, err := suite.source.Snapshot(context.Background()) + snapshot, err := suite.source.Snapshot(context.Background()) assert.Error(suite.T(), err) - assert.Nil(suite.T(), entries) + assert.Empty(suite.T(), snapshot.Tokens) assert.Contains(suite.T(), err.Error(), "error reading revoked token snapshot") } +func (suite *DBSourceTestSuite) TestSnapshot_TokenFamilyQueryError() { + suite.mockDBProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) + suite.mockDBClient.On("QueryContext", mock.Anything, querySnapshotRevokedTokens, + mock.Anything, testDeploymentID). + Return([]map[string]interface{}{}, nil) + suite.mockDBClient.On("QueryContext", mock.Anything, querySnapshotRevokedTokenFamilies, + criterionTypeTokenFamily, mock.Anything, testDeploymentID). + Return(nil, errors.New("query error")) + + snapshot, err := suite.source.Snapshot(context.Background()) + + assert.Error(suite.T(), err) + assert.Empty(suite.T(), snapshot.Families) + assert.Contains(suite.T(), err.Error(), "error reading revoked token family snapshot") +} + func (suite *DBSourceTestSuite) TestSnapshot_InvalidJTI() { suite.mockDBProvider.On("GetRuntimePersistentDBClient").Return(suite.mockDBClient, nil) suite.mockDBClient.On("QueryContext", mock.Anything, querySnapshotRevokedTokens, @@ -114,10 +141,10 @@ func (suite *DBSourceTestSuite) TestSnapshot_InvalidJTI() { {"jti": "", "expiry_time": time.Now().Add(time.Hour)}, }, nil) - entries, err := suite.source.Snapshot(context.Background()) + snapshot, err := suite.source.Snapshot(context.Background()) assert.Error(suite.T(), err) - assert.Nil(suite.T(), entries) + assert.Empty(suite.T(), snapshot.Tokens) assert.Contains(suite.T(), err.Error(), "jti") } @@ -129,9 +156,9 @@ func (suite *DBSourceTestSuite) TestSnapshot_InvalidExpiryTime() { {"jti": "jti-1", "expiry_time": 12345}, }, nil) - entries, err := suite.source.Snapshot(context.Background()) + snapshot, err := suite.source.Snapshot(context.Background()) assert.Error(suite.T(), err) - assert.Nil(suite.T(), entries) - assert.Contains(suite.T(), err.Error(), "error parsing revoked token snapshot") + assert.Empty(suite.T(), snapshot.Tokens) + assert.Contains(suite.T(), err.Error(), "error parsing revocation snapshot") } diff --git a/backend/internal/system/revocationcache/syncer.go b/backend/internal/system/revocationcache/syncer.go index 41c9305ece..b8bd1bfb45 100644 --- a/backend/internal/system/revocationcache/syncer.go +++ b/backend/internal/system/revocationcache/syncer.go @@ -65,11 +65,11 @@ func newSyncer(source syncSource, cache *revokedCache, interval time.Duration) * // refresh reads a fresh snapshot from the source and atomically replaces the cache. On error the // cache is left untouched so the last-known-good snapshot continues to serve lookups. func (s *syncer) refresh(ctx context.Context) error { - entries, err := s.source.Snapshot(ctx) + snapshot, err := s.source.Snapshot(ctx) if err != nil { return err } - s.cache.replace(entries) + s.cache.replace(snapshot) return nil } diff --git a/backend/internal/system/revocationcache/syncer_test.go b/backend/internal/system/revocationcache/syncer_test.go index 9d2bedbec7..bbe1eafdf5 100644 --- a/backend/internal/system/revocationcache/syncer_test.go +++ b/backend/internal/system/revocationcache/syncer_test.go @@ -36,14 +36,14 @@ type fakeSource struct { calls int } -func (f *fakeSource) Snapshot(context.Context) ([]revokedEntry, error) { +func (f *fakeSource) Snapshot(context.Context) (revokedSnapshot, error) { f.mu.Lock() defer f.mu.Unlock() f.calls++ if f.err != nil { - return nil, f.err + return revokedSnapshot{}, f.err } - return f.entries, nil + return revokedSnapshot{Tokens: f.entries}, nil } func (f *fakeSource) set(entries []revokedEntry, err error) { @@ -60,7 +60,7 @@ func (f *fakeSource) callCount() int { } func futureEntry(jti string) revokedEntry { - return revokedEntry{JTI: jti, ExpiryTime: time.Now().Add(time.Hour)} + return revokedEntry{Value: jti, ExpiryTime: time.Now().Add(time.Hour)} } func TestSyncer_RefreshSuccessUpdatesCache(t *testing.T) { @@ -69,7 +69,7 @@ func TestSyncer_RefreshSuccessUpdatesCache(t *testing.T) { s := newSyncer(source, cache, time.Minute) assert.NoError(t, s.refresh(context.Background())) - assert.True(t, cache.isRevoked("jti-1")) + assert.True(t, cache.isTokenRevoked("jti-1")) } func TestSyncer_RefreshErrorKeepsLastKnownGood(t *testing.T) { @@ -78,11 +78,11 @@ func TestSyncer_RefreshErrorKeepsLastKnownGood(t *testing.T) { s := newSyncer(source, cache, time.Minute) assert.NoError(t, s.refresh(context.Background())) - assert.True(t, cache.isRevoked("jti-1")) + assert.True(t, cache.isTokenRevoked("jti-1")) source.set(nil, errors.New("source unavailable")) assert.Error(t, s.refresh(context.Background())) - assert.True(t, cache.isRevoked("jti-1"), "a failed refresh must not empty the deny list") + assert.True(t, cache.isTokenRevoked("jti-1"), "a failed refresh must not empty the deny list") } func TestSyncer_StartRefreshesPeriodicallyThenStops(t *testing.T) { @@ -91,11 +91,11 @@ func TestSyncer_StartRefreshesPeriodicallyThenStops(t *testing.T) { s := newSyncer(source, cache, 5*time.Millisecond) s.Start(context.Background()) - assert.Eventually(t, func() bool { return cache.isRevoked("jti-1") }, time.Second, 5*time.Millisecond, + assert.Eventually(t, func() bool { return cache.isTokenRevoked("jti-1") }, time.Second, 5*time.Millisecond, "periodic refresh should load the snapshot into the cache") source.set([]revokedEntry{futureEntry("jti-2")}, nil) - assert.Eventually(t, func() bool { return cache.isRevoked("jti-2") }, time.Second, 5*time.Millisecond, + assert.Eventually(t, func() bool { return cache.isTokenRevoked("jti-2") }, time.Second, 5*time.Millisecond, "periodic refresh should pick up source changes") s.Stop() @@ -138,10 +138,10 @@ type blockingSource struct { enterOnce sync.Once } -func (b *blockingSource) Snapshot(ctx context.Context) ([]revokedEntry, error) { +func (b *blockingSource) Snapshot(ctx context.Context) (revokedSnapshot, error) { b.enterOnce.Do(func() { close(b.entered) }) <-ctx.Done() - return nil, ctx.Err() + return revokedSnapshot{}, ctx.Err() } func TestSyncer_StopAbortsInFlightRefresh(t *testing.T) { diff --git a/backend/internal/system/security/RevocationEnforcerInterface_mock_test.go b/backend/internal/system/security/RevocationEnforcerInterface_mock_test.go index 43a96513b9..c3e65302b1 100644 --- a/backend/internal/system/security/RevocationEnforcerInterface_mock_test.go +++ b/backend/internal/system/security/RevocationEnforcerInterface_mock_test.go @@ -38,16 +38,16 @@ func (_m *RevocationEnforcerInterfaceMock) EXPECT() *RevocationEnforcerInterface } // EnsureNotRevoked provides a mock function for the type RevocationEnforcerInterfaceMock -func (_mock *RevocationEnforcerInterfaceMock) EnsureNotRevoked(ctx context.Context, id string) error { - ret := _mock.Called(ctx, id) +func (_mock *RevocationEnforcerInterfaceMock) EnsureNotRevoked(ctx context.Context, jti string, tokenFamilyID string) error { + ret := _mock.Called(ctx, jti, tokenFamilyID) if len(ret) == 0 { panic("no return value specified for EnsureNotRevoked") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = returnFunc(ctx, id) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = returnFunc(ctx, jti, tokenFamilyID) } else { r0 = ret.Error(0) } @@ -61,12 +61,13 @@ type RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call struct { // EnsureNotRevoked is a helper method to define mock.On call // - ctx context.Context -// - id string -func (_e *RevocationEnforcerInterfaceMock_Expecter) EnsureNotRevoked(ctx interface{}, id interface{}) *RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call { - return &RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call{Call: _e.mock.On("EnsureNotRevoked", ctx, id)} +// - jti string +// - tokenFamilyID string +func (_e *RevocationEnforcerInterfaceMock_Expecter) EnsureNotRevoked(ctx interface{}, jti interface{}, tokenFamilyID interface{}) *RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call { + return &RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call{Call: _e.mock.On("EnsureNotRevoked", ctx, jti, tokenFamilyID)} } -func (_c *RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call) Run(run func(ctx context.Context, id string)) *RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call { +func (_c *RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call) Run(run func(ctx context.Context, jti string, tokenFamilyID string)) *RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -76,9 +77,14 @@ func (_c *RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call) Run(run func(ct if args[1] != nil { arg1 = args[1].(string) } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } run( arg0, arg1, + arg2, ) }) return _c @@ -89,7 +95,7 @@ func (_c *RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call) Return(err erro return _c } -func (_c *RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call) RunAndReturn(run func(ctx context.Context, id string) error) *RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call { +func (_c *RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call) RunAndReturn(run func(ctx context.Context, jti string, tokenFamilyID string) error) *RevocationEnforcerInterfaceMock_EnsureNotRevoked_Call { _c.Call.Return(run) return _c } diff --git a/backend/internal/system/security/context.go b/backend/internal/system/security/context.go index 5367d5f15b..bfb18cda1f 100644 --- a/backend/internal/system/security/context.go +++ b/backend/internal/system/security/context.go @@ -34,12 +34,13 @@ const ( // SecurityContext holds immutable authenticated subject information. type SecurityContext struct { - subject string - ouID string - token string - revocationID string - permissions []string - attributes map[string]interface{} + subject string + ouID string + token string + revocationID string + tokenFamilyID string + permissions []string + attributes map[string]interface{} } // newSecurityContext creates a new immutable SecurityContext. diff --git a/backend/internal/system/security/jwt_authenticator.go b/backend/internal/system/security/jwt_authenticator.go index 06829da008..7fbf0c030e 100644 --- a/backend/internal/system/security/jwt_authenticator.go +++ b/backend/internal/system/security/jwt_authenticator.go @@ -33,6 +33,11 @@ import ( // identifier for the enforcement step. const claimJTI = "jti" +// claimTokenFamilyID is the token family id claim (tfid); its value lets the enforcement step reject a +// token whose whole authorization grant has been revoked. It is defined here (not imported from the +// OAuth package) to keep the security layer decoupled from OAuth internals. +const claimTokenFamilyID = "tfid" + // jwtAuthenticator handles authentication and authorization using JWT Bearer tokens. type jwtAuthenticator struct { jwtService jwt.JWTServiceInterface @@ -94,6 +99,7 @@ func (h *jwtAuthenticator) Authenticate(r *http.Request) (*SecurityContext, erro // the enforcement step; it may be empty. securityCtx := newSecurityContext(subject, ouID, token, scopes, attributes) securityCtx.revocationID = extractAttribute(attributes, claimJTI) + securityCtx.tokenFamilyID = extractAttribute(attributes, claimTokenFamilyID) return securityCtx, nil } diff --git a/backend/internal/system/security/service.go b/backend/internal/system/security/service.go index d8441a59f0..7d61e99ae9 100644 --- a/backend/internal/system/security/service.go +++ b/backend/internal/system/security/service.go @@ -34,13 +34,13 @@ type SecurityServiceInterface interface { Process(r *http.Request) (context.Context, error) } -// RevocationEnforcerInterface rejects tokens whose revocation identifier is on the deny list. It is +// RevocationEnforcerInterface rejects tokens whose jti or token family id is on the deny list. It is // the read-only seam the security layer uses to consult the Resource Server's revocation cache // without depending on its implementation. type RevocationEnforcerInterface interface { - // EnsureNotRevoked returns a non-nil error when the token identified by id has been revoked. - // An empty id is a no-op. - EnsureNotRevoked(ctx context.Context, id string) error + // EnsureNotRevoked returns a non-nil error when the token's jti or its token family id has been + // revoked. Empty identifiers are each a no-op. + EnsureNotRevoked(ctx context.Context, jti, tokenFamilyID string) error } // securityService orchestrates authentication and authorization for HTTP requests. @@ -122,10 +122,11 @@ func (s *securityService) Process(r *http.Request) (context.Context, error) { ctx = withSecurityContext(ctx, securityCtx) // Reject the request when the presented token has been revoked. This runs after successful - // authentication and is format-agnostic: it enforces on the token's revocation identifier. A - // revoked token is surfaced as an invalid token (RFC 6750 §3.1) so the response does not + // authentication and is format-agnostic: it enforces on the token's jti and its token family + // id. A revoked token is surfaced as an invalid token (RFC 6750 §3.1) so the response does not // disclose that the token was specifically revoked. - if err := s.revocationEnforcer.EnsureNotRevoked(ctx, securityCtx.revocationID); err != nil { + if err := s.revocationEnforcer.EnsureNotRevoked(ctx, securityCtx.revocationID, + securityCtx.tokenFamilyID); err != nil { return s.handleAuthError(ctx, isPublic, errInvalidToken) } } diff --git a/backend/internal/system/security/service_test.go b/backend/internal/system/security/service_test.go index 4fba9b1f01..774cb309ea 100644 --- a/backend/internal/system/security/service_test.go +++ b/backend/internal/system/security/service_test.go @@ -65,7 +65,7 @@ func (suite *SecurityServiceTestSuite) SetupTest() { suite.mockRevocation = &RevocationEnforcerInterfaceMock{} // Default to "not revoked" so existing authentication paths pass; Maybe() keeps it optional for // tests where authentication never yields a security context. - suite.mockRevocation.On("EnsureNotRevoked", mock.Anything, mock.Anything).Return(nil).Maybe() + suite.mockRevocation.On("EnsureNotRevoked", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() var err error suite.service, err = newSecurityService( @@ -233,7 +233,7 @@ func (suite *SecurityServiceTestSuite) TestProcess_RevokedToken() { mockRevocation := &RevocationEnforcerInterfaceMock{} revokedErr := errors.New("token has been revoked") - mockRevocation.On("EnsureNotRevoked", mock.Anything, "jti-123").Return(revokedErr) + mockRevocation.On("EnsureNotRevoked", mock.Anything, "jti-123", mock.Anything).Return(revokedErr) service, err := newSecurityService( []AuthenticatorInterface{suite.mockAuth1}, mockRevocation, testPublicPaths, apiPermissionEntries) @@ -247,6 +247,30 @@ func (suite *SecurityServiceTestSuite) TestProcess_RevokedToken() { mockRevocation.AssertExpectations(suite.T()) } +// Test Process rejects a request whose token family has been revoked, even when its own jti is clean. +func (suite *SecurityServiceTestSuite) TestProcess_RevokedTokenFamily() { + req := httptest.NewRequest(http.MethodGet, "/api/users", nil) + + suite.testCtx.revocationID = "jti-clean" + suite.testCtx.tokenFamilyID = "tfid-revoked" + suite.mockAuth1.On("CanHandle", req).Return(true) + suite.mockAuth1.On("Authenticate", req).Return(suite.testCtx, nil) + + mockRevocation := &RevocationEnforcerInterfaceMock{} + mockRevocation.On("EnsureNotRevoked", mock.Anything, "jti-clean", "tfid-revoked"). + Return(errors.New("token family has been revoked")) + + service, err := newSecurityService( + []AuthenticatorInterface{suite.mockAuth1}, mockRevocation, testPublicPaths, apiPermissionEntries) + suite.Require().NoError(err) + + ctx, err := service.Process(req) + + assert.Nil(suite.T(), ctx) + assert.Equal(suite.T(), errInvalidToken, err) + mockRevocation.AssertExpectations(suite.T()) +} + // Test Process consults the enforcer with the token's revocation identifier and proceeds when the // token is not revoked. func (suite *SecurityServiceTestSuite) TestProcess_NotRevokedToken() { @@ -257,7 +281,7 @@ func (suite *SecurityServiceTestSuite) TestProcess_NotRevokedToken() { suite.mockAuth1.On("Authenticate", req).Return(suite.testCtx, nil) mockRevocation := &RevocationEnforcerInterfaceMock{} - mockRevocation.On("EnsureNotRevoked", mock.Anything, "jti-456").Return(nil) + mockRevocation.On("EnsureNotRevoked", mock.Anything, "jti-456", mock.Anything).Return(nil) service, err := newSecurityService( []AuthenticatorInterface{suite.mockAuth1}, mockRevocation, testPublicPaths, apiPermissionEntries) diff --git a/backend/pkg/thunderidengine/config/config.go b/backend/pkg/thunderidengine/config/config.go index c8a9caff6b..1a2e4929a5 100644 --- a/backend/pkg/thunderidengine/config/config.go +++ b/backend/pkg/thunderidengine/config/config.go @@ -218,6 +218,8 @@ type OAuthConfig struct { DPoP DPoPConfig `yaml:"dpop" json:"dpop"` AuthClass AuthClassConfig `yaml:"auth_class" json:"auth_class"` CIBA CIBAConfig `yaml:"ciba" json:"ciba"` + Revocation RevocationConfig `yaml:"revocation" json:"revocation"` + TokenExchange TokenExchangeConfig `yaml:"token_exchange" json:"token_exchange"` // AllowWildcardRedirectURI enables wildcard pattern matching for redirect URIs. // When false (default), only exact redirect URI matching is performed. AllowWildcardRedirectURI bool `yaml:"allow_wildcard_redirect_uri" json:"allow_wildcard_redirect_uri"` @@ -244,6 +246,33 @@ type LogoutConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` } +// RevocationConfig holds grant-scoped (token family) revocation settings. +type RevocationConfig struct { + TokenFamily TokenFamilyRevocationConfig `yaml:"token_family" json:"token_family"` +} + +// TokenFamilyRevocationConfig toggles the triggers that revoke a whole token family (one authorization +// grant). Each defaults to on (set in default.json), matching the fail-closed security posture. +type TokenFamilyRevocationConfig struct { + // OnRefreshReplay revokes the family when a rotated (already-revoked) refresh token is replayed. + // It has no effect unless refresh-token rotation (renew_on_grant) is enabled, since a token is only + // revoked, and thus only replayable, once it has been rotated. + OnRefreshReplay bool `yaml:"on_refresh_replay" json:"on_refresh_replay"` + // OnExplicitRevoke revokes the family when a token carrying a tfid is revoked via RFC 7009, so a + // login's access tokens drop with its refresh token. + OnExplicitRevoke bool `yaml:"on_explicit_revoke" json:"on_explicit_revoke"` + // OnCodeReplay revokes the family when an authorization code is redeemed twice (replay). + OnCodeReplay bool `yaml:"on_code_replay" json:"on_code_replay"` +} + +// TokenExchangeConfig holds RFC 8693 token-exchange settings. +type TokenExchangeConfig struct { + // TokenFamily selects how an exchanged token relates to the subject token's token family: + // "none" (default) issues an independent token with no tfid; "inherit" copies the subject + // token's tfid so the exchanged token is revoked with that token family. + TokenFamily string `yaml:"token_family" json:"token_family"` +} + // FlowConfig holds the configuration details for the flow service. type FlowConfig struct { DefaultAuthFlowHandle string `yaml:"default_auth_flow_handle" json:"default_auth_flow_handle"` diff --git a/backend/pkg/thunderidengine/config/validate.go b/backend/pkg/thunderidengine/config/validate.go index 804c332044..ba20832541 100644 --- a/backend/pkg/thunderidengine/config/validate.go +++ b/backend/pkg/thunderidengine/config/validate.go @@ -175,6 +175,17 @@ func (c *AuthClassConfig) Validate() error { return nil } +// Validate ensures the token-exchange token-family mode is one of the accepted values. An empty value +// is accepted and treated as the default (no inherited token family). +func (c *TokenExchangeConfig) Validate() error { + switch c.TokenFamily { + case "", "none", "inherit": + return nil + default: + return fmt.Errorf("token_exchange: token_family must be empty, \"none\", or \"inherit\", got %q", c.TokenFamily) + } +} + // GetServerURL constructs the server URL from the server configuration. // It uses PublicURL if set, otherwise constructs from hostname, port, and scheme. func GetServerURL(server *ServerConfig) string { diff --git a/backend/tests/mocks/flow/sessionmock/Service_mock.go b/backend/tests/mocks/flow/sessionmock/Service_mock.go index cbd4231d04..62338f95ce 100644 --- a/backend/tests/mocks/flow/sessionmock/Service_mock.go +++ b/backend/tests/mocks/flow/sessionmock/Service_mock.go @@ -112,8 +112,8 @@ func (_c *ServiceMock_HasCheckpoint_Call) RunAndReturn(run func(ctx context.Cont } // LoadCheckpoint provides a mock function for the type ServiceMock -func (_mock *ServiceMock) LoadCheckpoint(ctx context.Context, handle string, checkpoint string, appID string) (*session.Session, *session.SessionContext, error) { - ret := _mock.Called(ctx, handle, checkpoint, appID) +func (_mock *ServiceMock) LoadCheckpoint(ctx context.Context, handle string, checkpoint string, appID string, tokenFamilyID string) (*session.Session, *session.SessionContext, error) { + ret := _mock.Called(ctx, handle, checkpoint, appID, tokenFamilyID) if len(ret) == 0 { panic("no return value specified for LoadCheckpoint") @@ -122,25 +122,25 @@ func (_mock *ServiceMock) LoadCheckpoint(ctx context.Context, handle string, che var r0 *session.Session var r1 *session.SessionContext var r2 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*session.Session, *session.SessionContext, error)); ok { - return returnFunc(ctx, handle, checkpoint, appID) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) (*session.Session, *session.SessionContext, error)); ok { + return returnFunc(ctx, handle, checkpoint, appID, tokenFamilyID) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *session.Session); ok { - r0 = returnFunc(ctx, handle, checkpoint, appID) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) *session.Session); ok { + r0 = returnFunc(ctx, handle, checkpoint, appID, tokenFamilyID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*session.Session) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) *session.SessionContext); ok { - r1 = returnFunc(ctx, handle, checkpoint, appID) + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, string) *session.SessionContext); ok { + r1 = returnFunc(ctx, handle, checkpoint, appID, tokenFamilyID) } else { if ret.Get(1) != nil { r1 = ret.Get(1).(*session.SessionContext) } } - if returnFunc, ok := ret.Get(2).(func(context.Context, string, string, string) error); ok { - r2 = returnFunc(ctx, handle, checkpoint, appID) + if returnFunc, ok := ret.Get(2).(func(context.Context, string, string, string, string) error); ok { + r2 = returnFunc(ctx, handle, checkpoint, appID, tokenFamilyID) } else { r2 = ret.Error(2) } @@ -157,11 +157,12 @@ type ServiceMock_LoadCheckpoint_Call struct { // - handle string // - checkpoint string // - appID string -func (_e *ServiceMock_Expecter) LoadCheckpoint(ctx interface{}, handle interface{}, checkpoint interface{}, appID interface{}) *ServiceMock_LoadCheckpoint_Call { - return &ServiceMock_LoadCheckpoint_Call{Call: _e.mock.On("LoadCheckpoint", ctx, handle, checkpoint, appID)} +// - tokenFamilyID string +func (_e *ServiceMock_Expecter) LoadCheckpoint(ctx interface{}, handle interface{}, checkpoint interface{}, appID interface{}, tokenFamilyID interface{}) *ServiceMock_LoadCheckpoint_Call { + return &ServiceMock_LoadCheckpoint_Call{Call: _e.mock.On("LoadCheckpoint", ctx, handle, checkpoint, appID, tokenFamilyID)} } -func (_c *ServiceMock_LoadCheckpoint_Call) Run(run func(ctx context.Context, handle string, checkpoint string, appID string)) *ServiceMock_LoadCheckpoint_Call { +func (_c *ServiceMock_LoadCheckpoint_Call) Run(run func(ctx context.Context, handle string, checkpoint string, appID string, tokenFamilyID string)) *ServiceMock_LoadCheckpoint_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -179,11 +180,16 @@ func (_c *ServiceMock_LoadCheckpoint_Call) Run(run func(ctx context.Context, han if args[3] != nil { arg3 = args[3].(string) } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } run( arg0, arg1, arg2, arg3, + arg4, ) }) return _c @@ -194,7 +200,7 @@ func (_c *ServiceMock_LoadCheckpoint_Call) Return(session1 *session.Session, ses return _c } -func (_c *ServiceMock_LoadCheckpoint_Call) RunAndReturn(run func(ctx context.Context, handle string, checkpoint string, appID string) (*session.Session, *session.SessionContext, error)) *ServiceMock_LoadCheckpoint_Call { +func (_c *ServiceMock_LoadCheckpoint_Call) RunAndReturn(run func(ctx context.Context, handle string, checkpoint string, appID string, tokenFamilyID string) (*session.Session, *session.SessionContext, error)) *ServiceMock_LoadCheckpoint_Call { _c.Call.Return(run) return _c } diff --git a/backend/tests/mocks/oauth/oauth2/granthandlersmock/RefreshTokenGrantHandlerInterface_mock.go b/backend/tests/mocks/oauth/oauth2/granthandlersmock/RefreshTokenGrantHandlerInterface_mock.go index 9bcd548388..25d4a94607 100644 --- a/backend/tests/mocks/oauth/oauth2/granthandlersmock/RefreshTokenGrantHandlerInterface_mock.go +++ b/backend/tests/mocks/oauth/oauth2/granthandlersmock/RefreshTokenGrantHandlerInterface_mock.go @@ -116,16 +116,16 @@ func (_c *RefreshTokenGrantHandlerInterfaceMock_HandleGrant_Call) RunAndReturn(r } // IssueRefreshToken provides a mock function for the type RefreshTokenGrantHandlerInterfaceMock -func (_mock *RefreshTokenGrantHandlerInterfaceMock) IssueRefreshToken(ctx context.Context, tokenResponse *model.TokenResponseDTO, oauthApp *providers.OAuthClient, subject string, audiences []string, grantType string, scopes []string, claimsRequest *model.ClaimsRequest, claimsLocales string, attributeCacheID string) *model.ErrorResponse { - ret := _mock.Called(ctx, tokenResponse, oauthApp, subject, audiences, grantType, scopes, claimsRequest, claimsLocales, attributeCacheID) +func (_mock *RefreshTokenGrantHandlerInterfaceMock) IssueRefreshToken(ctx context.Context, tokenResponse *model.TokenResponseDTO, oauthApp *providers.OAuthClient, subject string, audiences []string, grantType string, scopes []string, claimsRequest *model.ClaimsRequest, claimsLocales string, attributeCacheID string, tokenFamilyID string) *model.ErrorResponse { + ret := _mock.Called(ctx, tokenResponse, oauthApp, subject, audiences, grantType, scopes, claimsRequest, claimsLocales, attributeCacheID, tokenFamilyID) if len(ret) == 0 { panic("no return value specified for IssueRefreshToken") } var r0 *model.ErrorResponse - if returnFunc, ok := ret.Get(0).(func(context.Context, *model.TokenResponseDTO, *providers.OAuthClient, string, []string, string, []string, *model.ClaimsRequest, string, string) *model.ErrorResponse); ok { - r0 = returnFunc(ctx, tokenResponse, oauthApp, subject, audiences, grantType, scopes, claimsRequest, claimsLocales, attributeCacheID) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.TokenResponseDTO, *providers.OAuthClient, string, []string, string, []string, *model.ClaimsRequest, string, string, string) *model.ErrorResponse); ok { + r0 = returnFunc(ctx, tokenResponse, oauthApp, subject, audiences, grantType, scopes, claimsRequest, claimsLocales, attributeCacheID, tokenFamilyID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.ErrorResponse) @@ -150,11 +150,12 @@ type RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call struct { // - claimsRequest *model.ClaimsRequest // - claimsLocales string // - attributeCacheID string -func (_e *RefreshTokenGrantHandlerInterfaceMock_Expecter) IssueRefreshToken(ctx interface{}, tokenResponse interface{}, oauthApp interface{}, subject interface{}, audiences interface{}, grantType interface{}, scopes interface{}, claimsRequest interface{}, claimsLocales interface{}, attributeCacheID interface{}) *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call { - return &RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call{Call: _e.mock.On("IssueRefreshToken", ctx, tokenResponse, oauthApp, subject, audiences, grantType, scopes, claimsRequest, claimsLocales, attributeCacheID)} +// - tokenFamilyID string +func (_e *RefreshTokenGrantHandlerInterfaceMock_Expecter) IssueRefreshToken(ctx interface{}, tokenResponse interface{}, oauthApp interface{}, subject interface{}, audiences interface{}, grantType interface{}, scopes interface{}, claimsRequest interface{}, claimsLocales interface{}, attributeCacheID interface{}, tokenFamilyID interface{}) *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call { + return &RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call{Call: _e.mock.On("IssueRefreshToken", ctx, tokenResponse, oauthApp, subject, audiences, grantType, scopes, claimsRequest, claimsLocales, attributeCacheID, tokenFamilyID)} } -func (_c *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call) Run(run func(ctx context.Context, tokenResponse *model.TokenResponseDTO, oauthApp *providers.OAuthClient, subject string, audiences []string, grantType string, scopes []string, claimsRequest *model.ClaimsRequest, claimsLocales string, attributeCacheID string)) *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call { +func (_c *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call) Run(run func(ctx context.Context, tokenResponse *model.TokenResponseDTO, oauthApp *providers.OAuthClient, subject string, audiences []string, grantType string, scopes []string, claimsRequest *model.ClaimsRequest, claimsLocales string, attributeCacheID string, tokenFamilyID string)) *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -196,6 +197,10 @@ func (_c *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call) Run(run if args[9] != nil { arg9 = args[9].(string) } + var arg10 string + if args[10] != nil { + arg10 = args[10].(string) + } run( arg0, arg1, @@ -207,6 +212,7 @@ func (_c *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call) Run(run arg7, arg8, arg9, + arg10, ) }) return _c @@ -217,7 +223,7 @@ func (_c *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call) Return(e return _c } -func (_c *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call) RunAndReturn(run func(ctx context.Context, tokenResponse *model.TokenResponseDTO, oauthApp *providers.OAuthClient, subject string, audiences []string, grantType string, scopes []string, claimsRequest *model.ClaimsRequest, claimsLocales string, attributeCacheID string) *model.ErrorResponse) *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call { +func (_c *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call) RunAndReturn(run func(ctx context.Context, tokenResponse *model.TokenResponseDTO, oauthApp *providers.OAuthClient, subject string, audiences []string, grantType string, scopes []string, claimsRequest *model.ClaimsRequest, claimsLocales string, attributeCacheID string, tokenFamilyID string) *model.ErrorResponse) *RefreshTokenGrantHandlerInterfaceMock_IssueRefreshToken_Call { _c.Call.Return(run) return _c } diff --git a/backend/tests/mocks/oauth/oauth2/revocationmock/CriteriaRevokerInterface_mock.go b/backend/tests/mocks/oauth/oauth2/revocationmock/CriteriaRevokerInterface_mock.go new file mode 100644 index 0000000000..8a6fd40090 --- /dev/null +++ b/backend/tests/mocks/oauth/oauth2/revocationmock/CriteriaRevokerInterface_mock.go @@ -0,0 +1,102 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package revocationmock + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/revocation" +) + +// NewCriteriaRevokerInterfaceMock creates a new instance of CriteriaRevokerInterfaceMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewCriteriaRevokerInterfaceMock(t interface { + mock.TestingT + Cleanup(func()) +}) *CriteriaRevokerInterfaceMock { + mock := &CriteriaRevokerInterfaceMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// CriteriaRevokerInterfaceMock is an autogenerated mock type for the CriteriaRevokerInterface type +type CriteriaRevokerInterfaceMock struct { + mock.Mock +} + +type CriteriaRevokerInterfaceMock_Expecter struct { + mock *mock.Mock +} + +func (_m *CriteriaRevokerInterfaceMock) EXPECT() *CriteriaRevokerInterfaceMock_Expecter { + return &CriteriaRevokerInterfaceMock_Expecter{mock: &_m.Mock} +} + +// RevokeTokenFamily provides a mock function for the type CriteriaRevokerInterfaceMock +func (_mock *CriteriaRevokerInterfaceMock) RevokeTokenFamily(ctx context.Context, tokenFamilyID string, reason revocation.RevocationReason) error { + ret := _mock.Called(ctx, tokenFamilyID, reason) + + if len(ret) == 0 { + panic("no return value specified for RevokeTokenFamily") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, revocation.RevocationReason) error); ok { + r0 = returnFunc(ctx, tokenFamilyID, reason) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RevokeTokenFamily' +type CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call struct { + *mock.Call +} + +// RevokeTokenFamily is a helper method to define mock.On call +// - ctx context.Context +// - tokenFamilyID string +// - reason revocation.RevocationReason +func (_e *CriteriaRevokerInterfaceMock_Expecter) RevokeTokenFamily(ctx interface{}, tokenFamilyID interface{}, reason interface{}) *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call { + return &CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call{Call: _e.mock.On("RevokeTokenFamily", ctx, tokenFamilyID, reason)} +} + +func (_c *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call) Run(run func(ctx context.Context, tokenFamilyID string, reason revocation.RevocationReason)) *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 revocation.RevocationReason + if args[2] != nil { + arg2 = args[2].(revocation.RevocationReason) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call) Return(err error) *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call { + _c.Call.Return(err) + return _c +} + +func (_c *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call) RunAndReturn(run func(ctx context.Context, tokenFamilyID string, reason revocation.RevocationReason) error) *CriteriaRevokerInterfaceMock_RevokeTokenFamily_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/tests/mocks/oauth/oauth2/revocationmock/EnforcementServiceInterface_mock.go b/backend/tests/mocks/oauth/oauth2/revocationmock/EnforcementServiceInterface_mock.go index de9cef3ac4..0c6b5c28fc 100644 --- a/backend/tests/mocks/oauth/oauth2/revocationmock/EnforcementServiceInterface_mock.go +++ b/backend/tests/mocks/oauth/oauth2/revocationmock/EnforcementServiceInterface_mock.go @@ -38,16 +38,16 @@ func (_m *EnforcementServiceInterfaceMock) EXPECT() *EnforcementServiceInterface } // EnsureNotRevoked provides a mock function for the type EnforcementServiceInterfaceMock -func (_mock *EnforcementServiceInterfaceMock) EnsureNotRevoked(ctx context.Context, jti string) error { - ret := _mock.Called(ctx, jti) +func (_mock *EnforcementServiceInterfaceMock) EnsureNotRevoked(ctx context.Context, jti string, tokenFamilyID string) error { + ret := _mock.Called(ctx, jti, tokenFamilyID) if len(ret) == 0 { panic("no return value specified for EnsureNotRevoked") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = returnFunc(ctx, jti) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = returnFunc(ctx, jti, tokenFamilyID) } else { r0 = ret.Error(0) } @@ -62,11 +62,12 @@ type EnforcementServiceInterfaceMock_EnsureNotRevoked_Call struct { // EnsureNotRevoked is a helper method to define mock.On call // - ctx context.Context // - jti string -func (_e *EnforcementServiceInterfaceMock_Expecter) EnsureNotRevoked(ctx interface{}, jti interface{}) *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call { - return &EnforcementServiceInterfaceMock_EnsureNotRevoked_Call{Call: _e.mock.On("EnsureNotRevoked", ctx, jti)} +// - tokenFamilyID string +func (_e *EnforcementServiceInterfaceMock_Expecter) EnsureNotRevoked(ctx interface{}, jti interface{}, tokenFamilyID interface{}) *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call { + return &EnforcementServiceInterfaceMock_EnsureNotRevoked_Call{Call: _e.mock.On("EnsureNotRevoked", ctx, jti, tokenFamilyID)} } -func (_c *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call) Run(run func(ctx context.Context, jti string)) *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call { +func (_c *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call) Run(run func(ctx context.Context, jti string, tokenFamilyID string)) *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -76,9 +77,14 @@ func (_c *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call) Run(run func(ct if args[1] != nil { arg1 = args[1].(string) } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } run( arg0, arg1, + arg2, ) }) return _c @@ -89,7 +95,7 @@ func (_c *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call) Return(err erro return _c } -func (_c *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call) RunAndReturn(run func(ctx context.Context, jti string) error) *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call { +func (_c *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call) RunAndReturn(run func(ctx context.Context, jti string, tokenFamilyID string) error) *EnforcementServiceInterfaceMock_EnsureNotRevoked_Call { _c.Call.Return(run) return _c } diff --git a/tests/integration/oauth/sso/rp_logout_test.go b/tests/integration/oauth/sso/rp_logout_test.go index bd0d5daed4..60a15f8a8f 100644 --- a/tests/integration/oauth/sso/rp_logout_test.go +++ b/tests/integration/oauth/sso/rp_logout_test.go @@ -72,6 +72,29 @@ func (ts *SSOLogoutTestSuite) TestRPInitiatedLogoutEndsSession() { ts.Empty(reAuthStep.Assertion, "no assertion should be issued when credentials are still required") } +// TestRPInitiatedLogoutRevokesTokenFamily proves that signing out revokes the login's token family: +// after logout the access token issued for that session is rejected by introspection, even though it +// has not yet expired. +func (ts *SSOLogoutTestSuite) TestRPInitiatedLogoutRevokesTokenFamily() { + client := ts.newSessionClient() + + tokens := ts.loginTokens(client, logoutUsername, "logout_revoke_state_1") + ts.Require().NotEmpty(tokens.AccessToken, "login should issue an access token") + ts.Require().True(ts.introspectActive(client, tokens.AccessToken), + "the access token should be active right after login") + + executionID, logoutID := ts.initiateLogout(client, tokens.IDToken, postLogoutRedirectURI, "logout_revoke_state_2") + ts.Require().NotEmpty(executionID) + ts.Require().NotEmpty(logoutID) + + step := ts.flowExecute(client, map[string]interface{}{"executionId": executionID}) + ts.Require().Equal("COMPLETE", step.FlowStatus, "the sign-out flow should complete") + ts.completeLogout(client, logoutID) + + ts.False(ts.introspectActive(client, tokens.AccessToken), + "signing out must revoke the session's token family, so its access token is no longer active") +} + // initiateLogout posts to the end_session_endpoint and returns the sign-out flow executionId and the // logoutId carried on the gate sign-out redirect. func (ts *SSOLogoutTestSuite) initiateLogout( diff --git a/tests/integration/oauth/sso/suite_test.go b/tests/integration/oauth/sso/suite_test.go index 20c95b0813..5001ce678b 100644 --- a/tests/integration/oauth/sso/suite_test.go +++ b/tests/integration/oauth/sso/suite_test.go @@ -541,10 +541,15 @@ func (ts *SSOLogoutTestSuite) exchangeCode(client *http.Client, code string) *te return &token } -// login drives a first-time SSO login to completion (prompting for credentials, establishing the -// session), and returns the issued id_token. It asserts the initial step prompts for credentials, -// proving the session did not already exist. +// login drives a first-time SSO login to completion and returns the issued id_token. See loginTokens. func (ts *SSOLogoutTestSuite) login(client *http.Client, username, state string) string { + return ts.loginTokens(client, username, state).IDToken +} + +// loginTokens drives a first-time SSO login to completion (prompting for credentials, establishing +// the session), and returns the issued tokens. It asserts the initial step prompts for credentials, +// proving the session did not already exist. +func (ts *SSOLogoutTestSuite) loginTokens(client *http.Client, username, state string) *testutils.TokenResponse { authID, executionID := ts.authorize(client, "openid", state) initial := ts.flowExecute(client, map[string]interface{}{"executionId": executionID}) @@ -565,7 +570,29 @@ func (ts *SSOLogoutTestSuite) login(client *http.Client, username, state string) token := ts.exchangeCode(client, code) ts.Require().NotEmpty(token.IDToken, "id_token should be issued for openid scope") - return token.IDToken + return token +} + +// introspectActive reports whether the access token is active per the AS introspection endpoint. +func (ts *SSOLogoutTestSuite) introspectActive(client *http.Client, accessToken string) bool { + form := url.Values{} + form.Set("token", accessToken) + req, err := http.NewRequest("POST", testutils.TestServerURL+"/oauth2/introspect", + strings.NewReader(form.Encode())) + ts.Require().NoError(err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) + + resp, err := client.Do(req) + ts.Require().NoError(err, "introspection request failed") + defer resp.Body.Close() + ts.Require().Equal(http.StatusOK, resp.StatusCode, "introspection should return 200") + + var out struct { + Active bool `json:"active"` + } + ts.Require().NoError(json.NewDecoder(resp.Body).Decode(&out), "failed to decode introspection response") + return out.Active } // authorizeWithResource starts an authorization code flow bound to the given RFC 8707 resource and diff --git a/tests/integration/oauth/token/tfid_test.go b/tests/integration/oauth/token/tfid_test.go new file mode 100644 index 0000000000..e2ee99b126 --- /dev/null +++ b/tests/integration/oauth/token/tfid_test.go @@ -0,0 +1,464 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package token + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +const ( + tfidTestClientID = "tfid_test_client" + tfidTestClientSecret = "tfid_test_secret" + tfidTestAppName = "TfidTestApp" + tfidTestRedirectURI = "https://localhost:3000" + tfidTestUsername = "tfid_test_user" + tfidTestPassword = "testpass123" + tfidTestResource = "https://tfid.example.com" + // A second, independent client used to prove that one client cannot revoke another's refresh token. + tfidOtherClientID = "tfid_other_client" + tfidOtherClientSecret = "tfid_other_secret" + tfidOtherAppName = "TfidOtherApp" + tfidOtherRedirectURI = "https://localhost:3001" +) + +var ( + tfidTestOU = testutils.OrganizationUnit{ + Handle: "tfid-test-ou", + Name: "Tfid Test OU", + Description: "Organization unit for token family id integration testing", + Parent: nil, + } + + tfidTestUserType = testutils.UserType{ + Name: "tfid-test-person", + Schema: map[string]interface{}{ + "username": map[string]interface{}{"type": "string"}, + "password": map[string]interface{}{"type": "string", "credential": true}, + "email": map[string]interface{}{"type": "string"}, + }, + } + + // tfidTestAuthFlow is an SSO-enabled authorization-code flow. The SessionExecutor node + // (session_main) establishes the SSO session and is where the token family id (tfid) is minted, + // so the issued tokens carry a tfid. + tfidTestAuthFlow = testutils.Flow{ + Name: "Tfid Test Auth Flow", + FlowType: "AUTHENTICATION", + Handle: "auth_flow_tfid_test", + Nodes: []map[string]interface{}{ + {"id": "start", "type": "START", "onSuccess": "sso_check"}, + { + "id": "sso_check", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{"name": "SSOCheckExecutor"}, + "properties": map[string]interface{}{"checkpointRef": "session_main"}, + "onSuccess": "session_main", + "onFailure": "prompt_credentials", + }, + { + "id": "prompt_credentials", + "type": "PROMPT", + "prompts": []map[string]interface{}{ + { + "inputs": []map[string]interface{}{ + {"ref": "input_001", "identifier": "username", "type": "TEXT_INPUT", "required": true}, + {"ref": "input_002", "identifier": "password", "type": "PASSWORD_INPUT", "required": true}, + }, + "action": map[string]interface{}{"ref": "action_001", "nextNode": "credentials_auth"}, + }, + }, + }, + { + "id": "credentials_auth", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "CredentialsAuthExecutor", + "inputs": []map[string]interface{}{ + {"ref": "input_001", "identifier": "username", "type": "TEXT_INPUT", "required": true}, + {"ref": "input_002", "identifier": "password", "type": "PASSWORD_INPUT", "required": true}, + }, + }, + "onSuccess": "session_main", + "onIncomplete": "prompt_credentials", + }, + { + "id": "session_main", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{"name": "SessionExecutor"}, + "onSuccess": "authorization_check", + }, + { + "id": "authorization_check", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{"name": "AuthorizationExecutor"}, + "onSuccess": "auth_assert", + }, + { + "id": "auth_assert", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{"name": "AuthAssertExecutor"}, + "onSuccess": "end", + }, + {"id": "end", "type": "END"}, + }, + } +) + +// TfidTestSuite exercises token family id (tfid) propagation and grant-scoped revocation end-to-end +// through the real authorization-code login flow, signed tokens, introspection, and the runtime +// persistent database. Revocation is observed via AS introspection, which reads the deny lists +// directly (unlike RS enforcement, which is eventually consistent through the cache). +type TfidTestSuite struct { + suite.Suite + applicationID string + otherApplicationID string + entityTypeID string + authFlowID string + ouID string + userID string + resourceServerID string + client *http.Client +} + +func TestTfidTestSuite(t *testing.T) { + suite.Run(t, new(TfidTestSuite)) +} + +func (ts *TfidTestSuite) SetupSuite() { + ts.client = testutils.GetHTTPClient() + + ouID, err := testutils.CreateOrganizationUnit(tfidTestOU) + ts.Require().NoError(err, "Failed to create test organization unit") + ts.ouID = ouID + + tfidTestUserType.OUID = ouID + schemaID, err := testutils.CreateUserType(tfidTestUserType) + ts.Require().NoError(err, "Failed to create test user type") + ts.entityTypeID = schemaID + + flowID, err := testutils.CreateFlow(tfidTestAuthFlow) + ts.Require().NoError(err, "Failed to create test authentication flow") + ts.authFlowID = flowID + + resourceServerID, err := testutils.CreateResourceServerWithActions(testutils.ResourceServer{ + Name: "Tfid Resource Server", + Description: "Resource server for tfid integration tests", + Identifier: tfidTestResource, + OUID: ts.ouID, + }, []testutils.Action{}) + ts.Require().NoError(err, "Failed to create tfid resource server") + ts.resourceServerID = resourceServerID + + ts.applicationID = ts.createTestApplication( + tfidTestAppName, tfidTestClientID, tfidTestClientSecret, tfidTestRedirectURI) + ts.otherApplicationID = ts.createTestApplication( + tfidOtherAppName, tfidOtherClientID, tfidOtherClientSecret, tfidOtherRedirectURI) + + user := testutils.User{ + OUID: ouID, + Type: "tfid-test-person", + Attributes: json.RawMessage(fmt.Sprintf(`{ + "username": "%s", + "password": "%s", + "email": "tfid_test@example.com" + }`, tfidTestUsername, tfidTestPassword)), + } + userID, err := testutils.CreateUser(user) + ts.Require().NoError(err, "Failed to create test user") + ts.userID = userID +} + +func (ts *TfidTestSuite) createTestApplication(name, clientID, clientSecret, redirectURI string) string { + app := map[string]interface{}{ + "name": name, + "description": "Application for tfid integration tests", + "ouId": ts.ouID, + "authFlowId": ts.authFlowID, + "isRegistrationFlowEnabled": false, + "allowedUserTypes": []string{"tfid-test-person"}, + "inboundAuthConfig": []map[string]interface{}{ + { + "type": "oauth2", + "config": map[string]interface{}{ + "clientId": clientID, + "clientSecret": clientSecret, + "redirectUris": []string{redirectURI}, + "grantTypes": []string{"authorization_code", "refresh_token"}, + "responseTypes": []string{"code"}, + "tokenEndpointAuthMethod": "client_secret_basic", + }, + }, + }, + } + + jsonData, err := json.Marshal(app) + ts.Require().NoError(err, "Failed to marshal application data") + + req, err := http.NewRequest("POST", testutils.TestServerURL+"/applications", bytes.NewBuffer(jsonData)) + ts.Require().NoError(err, "Failed to create request") + req.Header.Set("Content-Type", "application/json") + + resp, err := ts.client.Do(req) + ts.Require().NoError(err, "Failed to create application") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + bodyBytes, _ := io.ReadAll(resp.Body) + ts.T().Fatalf("Failed to create application. Status: %d, Response: %s", resp.StatusCode, string(bodyBytes)) + } + + var respData map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&respData) + ts.Require().NoError(err, "Failed to parse response") + return respData["id"].(string) +} + +func (ts *TfidTestSuite) TearDownSuite() { + if ts.userID != "" { + _ = testutils.DeleteUser(ts.userID) + } + if ts.applicationID != "" { + _ = testutils.DeleteApplication(ts.applicationID) + } + if ts.otherApplicationID != "" { + _ = testutils.DeleteApplication(ts.otherApplicationID) + } + if ts.authFlowID != "" { + _ = testutils.DeleteFlow(ts.authFlowID) + } + if ts.resourceServerID != "" { + _ = testutils.DeleteResourceServer(ts.resourceServerID) + } + if ts.entityTypeID != "" { + _ = testutils.DeleteUserType(ts.entityTypeID) + } + if ts.ouID != "" { + _ = testutils.DeleteOrganizationUnit(ts.ouID) + } +} + +// obtainCodeAndTokens drives the full authorization-code login flow and returns the authorization +// code (for replay tests) together with the issued tokens. +func (ts *TfidTestSuite) obtainCodeAndTokens() (string, *testutils.TokenResponse) { + resp, err := testutils.InitiateAuthorizationFlow( + tfidTestClientID, tfidTestRedirectURI, "code", "openid", "test-state") + ts.Require().NoError(err, "Failed to initiate authorization flow") + defer resp.Body.Close() + ts.Require().Equal(http.StatusFound, resp.StatusCode, "Expected redirect from authorization endpoint") + + location := resp.Header.Get("Location") + ts.Require().NotEmpty(location, "Expected Location header") + + authID, executionID, err := testutils.ExtractAuthData(location) + ts.Require().NoError(err, "Failed to extract auth data") + + initialStep, err := testutils.ExecuteAuthenticationFlow(executionID, nil, "") + ts.Require().NoError(err, "Failed to initiate authentication flow") + + flowStep, err := testutils.ExecuteAuthenticationFlow(executionID, map[string]string{ + "username": tfidTestUsername, + "password": tfidTestPassword, + }, "action_001", initialStep.ChallengeToken) + ts.Require().NoError(err, "Failed to execute authentication flow") + ts.Require().Equal("COMPLETE", flowStep.FlowStatus, "Authentication flow should complete") + + authzResp, err := testutils.CompleteAuthorization(authID, flowStep.Assertion) + ts.Require().NoError(err, "Failed to complete authorization") + + code, err := testutils.ExtractAuthorizationCode(authzResp.RedirectURI) + ts.Require().NoError(err, "Failed to extract authorization code") + + tokenResult, err := testutils.RequestTokenWithResource( + tfidTestClientID, tfidTestClientSecret, code, tfidTestRedirectURI, "authorization_code", tfidTestResource) + ts.Require().NoError(err, "Failed to request token") + ts.Require().Equal(http.StatusOK, tokenResult.StatusCode, "Token request should succeed: %s", string(tokenResult.Body)) + ts.Require().NotNil(tokenResult.Token, "Token should not be nil") + ts.Require().NotEmpty(tokenResult.Token.AccessToken, "Access token should not be empty") + ts.Require().NotEmpty(tokenResult.Token.RefreshToken, "Refresh token should not be empty") + + return code, tokenResult.Token +} + +// obtainTokens is obtainCodeAndTokens without the code. +func (ts *TfidTestSuite) obtainTokens() *testutils.TokenResponse { + _, tokens := ts.obtainCodeAndTokens() + return tokens +} + +// tfidClaim returns the tfid claim of a signed token, or "" when absent. +func (ts *TfidTestSuite) tfidClaim(token string) string { + claims, err := testutils.DecodeJWTPayloadMap(token) + ts.Require().NoError(err, "Failed to decode token payload") + tfid, _ := claims["tfid"].(string) + return tfid +} + +// introspectActive reports whether the access token is active per the AS introspection endpoint. +func (ts *TfidTestSuite) introspectActive(token string) bool { + form := url.Values{} + form.Set("token", token) + req, err := http.NewRequest("POST", testutils.TestServerURL+"/oauth2/introspect", + strings.NewReader(form.Encode())) + ts.Require().NoError(err, "Failed to build introspection request") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(tfidTestClientID, tfidTestClientSecret) + + resp, err := ts.client.Do(req) + ts.Require().NoError(err, "Introspection request failed") + defer resp.Body.Close() + ts.Require().Equal(http.StatusOK, resp.StatusCode, "Introspection should return 200") + + var result struct { + Active bool `json:"active"` + } + ts.Require().NoError(json.NewDecoder(resp.Body).Decode(&result), "Failed to parse introspection response") + return result.Active +} + +// revokeRefreshToken revokes a refresh token via the RFC 7009 endpoint under the owning client. +func (ts *TfidTestSuite) revokeRefreshToken(refreshToken string) { + form := url.Values{} + form.Set("token", refreshToken) + form.Set("token_type_hint", "refresh_token") + req, err := http.NewRequest("POST", testutils.TestServerURL+"/oauth2/revoke", + strings.NewReader(form.Encode())) + ts.Require().NoError(err, "Failed to build revocation request") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(tfidTestClientID, tfidTestClientSecret) + + resp, err := ts.client.Do(req) + ts.Require().NoError(err, "Revocation request failed") + defer resp.Body.Close() + ts.Require().Equal(http.StatusOK, resp.StatusCode, "Revocation should return 200") +} + +// revokeRefreshTokenAs posts an RFC 7009 revocation for refreshToken authenticated as the given client, +// and returns the raw response so the caller can assert the status. +func (ts *TfidTestSuite) revokeRefreshTokenAs(refreshToken, clientID, clientSecret string) *http.Response { + form := url.Values{} + form.Set("token", refreshToken) + form.Set("token_type_hint", "refresh_token") + req, err := http.NewRequest("POST", testutils.TestServerURL+"/oauth2/revoke", + strings.NewReader(form.Encode())) + ts.Require().NoError(err, "Failed to build revocation request") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) + + resp, err := ts.client.Do(req) + ts.Require().NoError(err, "Revocation request failed") + return resp +} + +// The access and refresh tokens of one login share a single, non-empty tfid. +func (ts *TfidTestSuite) TestAccessAndRefreshTokensShareTfid() { + tokens := ts.obtainTokens() + + atTfid := ts.tfidClaim(tokens.AccessToken) + rtTfid := ts.tfidClaim(tokens.RefreshToken) + + ts.NotEmpty(atTfid, "Access token should carry a tfid") + ts.NotEmpty(rtTfid, "Refresh token should carry a tfid") + ts.Equal(atTfid, rtTfid, "Access and refresh tokens of one grant share a tfid") +} + +// The tfid is preserved across a refresh onto both returned tokens: the new access token and the +// refresh token the response carries (reused or rotated, depending on renew_on_grant). +func (ts *TfidTestSuite) TestTfidPreservedOnRefresh() { + tokens := ts.obtainTokens() + originalTfid := ts.tfidClaim(tokens.AccessToken) + ts.Require().NotEmpty(originalTfid) + + refreshed, err := testutils.RefreshAccessToken(tfidTestClientID, tfidTestClientSecret, tokens.RefreshToken) + ts.Require().NoError(err, "Refresh should succeed") + ts.Require().NotEmpty(refreshed.AccessToken, "Refreshed access token should not be empty") + ts.Require().NotEmpty(refreshed.RefreshToken, "The refresh response should return a refresh token") + + ts.Equal(originalTfid, ts.tfidClaim(refreshed.AccessToken), + "The refreshed access token keeps the grant's tfid") + ts.Equal(originalTfid, ts.tfidClaim(refreshed.RefreshToken), + "The returned refresh token keeps the grant's tfid") +} + +// Explicitly revoking a login's refresh token also drops its access token (grant-scoped revocation). +func (ts *TfidTestSuite) TestExplicitRefreshRevokeDropsAccessToken() { + tokens := ts.obtainTokens() + ts.Require().True(ts.introspectActive(tokens.AccessToken), "Access token should start active") + + ts.revokeRefreshToken(tokens.RefreshToken) + + ts.False(ts.introspectActive(tokens.AccessToken), + "Revoking the refresh token must drop the login's access token via its tfid") +} + +// A refresh token carries no client_id claim, only the owning client as its subject. A different +// client must not be able to revoke it (RFC 7009 §2.1); the request is rejected with invalid_grant and +// the owning client's tokens (and token family) stay intact. +func (ts *TfidTestSuite) TestRefreshTokenRevokeRejectsOtherClient() { + tokens := ts.obtainTokens() + ts.Require().True(ts.introspectActive(tokens.AccessToken), "Access token should start active") + + resp := ts.revokeRefreshTokenAs(tokens.RefreshToken, tfidOtherClientID, tfidOtherClientSecret) + defer resp.Body.Close() + ts.Require().Equal(http.StatusBadRequest, resp.StatusCode, + "A different client must not revoke another client's refresh token") + + ts.True(ts.introspectActive(tokens.AccessToken), + "A rejected cross-client revoke must not drop the owning client's tokens or token family") +} + +// Redeeming an authorization code twice (replay) revokes the whole grant issued from the first redemption. +func (ts *TfidTestSuite) TestAuthCodeReplayRevokesGrant() { + code, tokens := ts.obtainCodeAndTokens() + ts.Require().True(ts.introspectActive(tokens.AccessToken), "Access token should start active") + + // Replay the already-consumed code. + replay, err := testutils.RequestTokenWithResource( + tfidTestClientID, tfidTestClientSecret, code, tfidTestRedirectURI, "authorization_code", tfidTestResource) + ts.Require().NoError(err, "Replay request should complete") + ts.NotEqual(http.StatusOK, replay.StatusCode, "Replaying a consumed code must not issue tokens") + + ts.False(ts.introspectActive(tokens.AccessToken), + "An authorization-code replay must revoke the grant issued from the first redemption") +} + +// Independent logins get distinct tfids, and revoking one family leaves the other untouched. +func (ts *TfidTestSuite) TestIndependentGrantsAreIsolated() { + first := ts.obtainTokens() + second := ts.obtainTokens() + + ts.NotEqual(ts.tfidClaim(first.AccessToken), ts.tfidClaim(second.AccessToken), + "Two independent logins must mint different tfids") + + // Revoke the first login's family; the second must remain active. + ts.revokeRefreshToken(first.RefreshToken) + + ts.False(ts.introspectActive(first.AccessToken), "The revoked login's access token is inactive") + ts.True(ts.introspectActive(second.AccessToken), + "An independent login must be unaffected by another login's revocation") +}