Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/.mockery.public.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions backend/cmd/server/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
26 changes: 23 additions & 3 deletions backend/cmd/server/servicemanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions backend/dbscripts/runtime_persistent/postgres-cleanup.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
$$;
23 changes: 23 additions & 0 deletions backend/dbscripts/runtime_persistent/postgres.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions backend/dbscripts/runtime_persistent/sqlite.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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" (
Comment thread
indeewari marked this conversation as resolved.
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.
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions backend/internal/flow/common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions backend/internal/flow/executor/auth_assert_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 37 additions & 1 deletion backend/internal/flow/executor/session_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
}
Expand All @@ -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),
Expand All @@ -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": {},
}
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/flow/executor/session_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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()))

Expand All @@ -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()))
Expand Down
Loading
Loading