Skip to content

Commit 8d78740

Browse files
committed
Revoke application artifacts through administration flows
Deleting an application or rotating its client secret are security events: artifacts already issued to that application's OAuth client must stop being accepted. Carry both out through administration flows so the revocation, the session detachment and the action itself run as one orchestrated sequence. Resolve an artifact's lifetime through an injected collaborator rather than by reading the server runtime. Sizing a revocation needs the longest an artifact issued to a client can live, which is a fact about OAuth token configuration, so the computation belongs to tokenservice and the application declares only the resolver it calls. The composition root is the one place that sees both domains, so it supplies it: the application package carries no OAuth configuration dependency and reads no global state. One preparatory executor serves both flows, selected by the node's revocation mode. The mode states the breadth of the revocation, and each breadth belongs to exactly one action: a deletion retires the client id, so every artifact it ever carried is denied, while a secret regeneration denies only what was issued before the rotation. It declares no default mode, so flow creation requires a node to state one. Add two shipped ADMINISTRATION flows, default-application-deletion-flow and default-client-secret-regeneration-flow, built from four new executors. The preparatory node validates the target and publishes a trusted revocation plan; the criteria and session nodes act on that plan; the final node performs the delete or the rotation. Deletion revokes terminally, since a retired client id can carry no future artifact. Rotation revokes only up to a cutoff, so tokens minted with the new secret still pass. Name both flows in the flow server-config section, alongside userDeletionFlow, and drive them from the console: the delete action and the regenerate-secret action resolve their configured handle and execute the flow, falling back to the native endpoint only when no such flow is configured. The rotation flow returns the new secret, generated with the server's entropy rather than the browser's, in the only response that exposes it. The native application endpoints are left unchanged, so revocation has exactly one home. A refused step reports its executor error in the response's error envelope, not in a failureReason field that the API never sends. Read the real envelope and carry the code on the thrown error so the console shows why a flow refused instead of a generic failure, and apply the same fix to the user deletion path. Cover the detachment in the integration suite. Deleting an application through the flow was only ever exercised against an application nobody had logged into, so the session node returned on an empty participation list and the work it exists to do never ran. Two tests now establish a real session: one where a second application shares it, which must survive the deletion, and one where the deleted application was its only participant, which must not. Rotation refusals for an application with no OAuth component, for a public client and for an unknown application are covered alongside them. Keep the seam internal. The administration executors declare the application contract they consume in the executor package, so the flow's dependency on the application service adds nothing to pkg, where anything added is exported from the library and cannot be changed without a major release. Name the credential operations for the credential rather than for the one action they perform today, and report what validation found rather than what a revocation needs. Validation answers whether an action may proceed and describes the artifacts the application has issued; sizing a deny-list row from that description is one consumer's business, not validation's. Further credential actions, a rotation that keeps the old secret briefly valid and additional secrets, arrive as actions on the same pair of methods instead of methods of their own. Refuse the deletion while a dependent forbids it, the check userService already performs before a user is deleted. The cascade removes the dependents that cascade; this refuses the ones that must not go silently, and fails closed when a provider cannot report its usage, since deleting on an unknown answer is what the check exists to prevent. Cover the branches an integration test can reach: the declarative refusal on both administration flows, a target that is not an application, and the client-credentials and refresh-token validity paths that decide how long a deny-list row must outlive the artifacts it denies. What remains uncovered in the application and session packages is error handling the integration suite has no way to provoke: a failing dependency lookup, a corrupt attribute blob, a failing client-secret write, a failing participation query, a failing token-family revocation, a failing transaction. Reaching those needs fault injection, which lives in the unit suite, and the integration patch-coverage check deliberately does not load unit coverage. No threshold entry is recorded for them, so that check reports both packages below its bar. Size the deny-list row from both access-token subject configurations. Access token validity is configured per token subject, and client_credentials reads the client sub-config while the row was sized from the user one alone. A machine-to-machine application with a longer client-token validity therefore got a row that went inert while the tokens it denies were still valid, which is the failure this sizing exists to avoid. Carry the validator's own refusal out of the preparatory node. Every client-side refusal became the executor's single error, so an application owned by a declarative file, and one that no longer exists, were both reported as having no client secret to rotate. The console resolves these codes to messages, so the reason it showed was wrong rather than merely vague. Refuse rather than fall back when a configured administration flow cannot be resolved. An unset handle is the documented opt-out and still falls back to the native endpoint. A handle that names a missing flow is the opposite: the deployment asked for flow-based revocation, and deleting or rotating natively would strip the application or mint a new secret while leaving every issued artifact valid, and report it as a success. Name the secret executor for the artifact it acts on rather than the one action it performs today. An executor name is persisted into stored flow definitions and their version history, so a deployment that authors a flow against ClientSecretRegenerationExecutor pins that name, and renaming it once further secret actions exist would break those flows and need a migration. ClientSecretExecutor carries the same behaviour under a name that already fits the rotation and additional secrets that arrive later as executor modes. Signed-off-by: Indeewai Wijesiri <indeewari@wso2.com>
1 parent edaf54c commit 8d78740

81 files changed

Lines changed: 5657 additions & 2056 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/.mockery.public.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,13 +404,16 @@ packages:
404404
pkgname: flowmgtmock
405405
filename: "{{.InterfaceName}}_mock.go"
406406

407+
# Only the registry is mocked outside this package. The rest of the executor interfaces are internal
408+
# seams, and one of them names an unexported type, which a mock in another package cannot reference.
407409
github.com/thunder-id/thunderid/internal/flow/executor:
408410
config:
409-
all: true
410411
dir: tests/mocks/flow/executormock
411412
structname: '{{.InterfaceName}}Mock'
412413
pkgname: executormock
413414
filename: "{{.InterfaceName}}_mock.go"
415+
interfaces:
416+
ExecutorRegistryInterface:
414417

415418
github.com/thunder-id/thunderid/internal/flow/interceptor:
416419
config:

backend/cmd/server/bootstrap/01-default-resources.yaml

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1991,6 +1991,168 @@ nodes:
19911991
x: 1740
19921992
y: 200
19931993
---
1994+
resource_type: flow
1995+
id: 01900000-0000-7000-8000-000000000079
1996+
name: Default Application Deletion Flow
1997+
handle: default-application-deletion-flow
1998+
flowType: ADMINISTRATION
1999+
nodes:
2000+
- id: start
2001+
type: START
2002+
onSuccess: permission_validator
2003+
layout:
2004+
size:
2005+
width: 101
2006+
height: 34
2007+
position:
2008+
x: 100
2009+
y: 200
2010+
- id: permission_validator
2011+
type: TASK_EXECUTION
2012+
executor:
2013+
name: PermissionValidator
2014+
onSuccess: validate_application_deletion
2015+
layout:
2016+
size:
2017+
width: 206
2018+
height: 113
2019+
position:
2020+
x: 300
2021+
y: 175
2022+
- id: validate_application_deletion
2023+
type: TASK_EXECUTION
2024+
executor:
2025+
name: ApplicationActionValidator
2026+
mode: revoke_all
2027+
onSuccess: revoke_tokens
2028+
layout:
2029+
size:
2030+
width: 240
2031+
height: 113
2032+
position:
2033+
x: 580
2034+
y: 175
2035+
- id: revoke_tokens
2036+
type: TASK_EXECUTION
2037+
executor:
2038+
name: CriteriaRevocationExecutor
2039+
onSuccess: revoke_sessions
2040+
layout:
2041+
size:
2042+
width: 206
2043+
height: 113
2044+
position:
2045+
x: 900
2046+
y: 175
2047+
- id: revoke_sessions
2048+
type: TASK_EXECUTION
2049+
executor:
2050+
name: SessionRevocationExecutor
2051+
onSuccess: delete_application
2052+
layout:
2053+
size:
2054+
width: 206
2055+
height: 113
2056+
position:
2057+
x: 1180
2058+
y: 175
2059+
- id: delete_application
2060+
type: TASK_EXECUTION
2061+
executor:
2062+
name: ApplicationDeleteExecutor
2063+
onSuccess: end
2064+
layout:
2065+
size:
2066+
width: 220
2067+
height: 113
2068+
position:
2069+
x: 1460
2070+
y: 175
2071+
- id: end
2072+
type: END
2073+
layout:
2074+
size:
2075+
width: 85
2076+
height: 34
2077+
position:
2078+
x: 1760
2079+
y: 200
2080+
---
2081+
resource_type: flow
2082+
id: 01900000-0000-7000-8000-00000000007a
2083+
name: Default Secret Regeneration Flow
2084+
handle: default-secret-regeneration-flow
2085+
flowType: ADMINISTRATION
2086+
nodes:
2087+
- id: start
2088+
type: START
2089+
onSuccess: permission_validator
2090+
layout:
2091+
size:
2092+
width: 101
2093+
height: 34
2094+
position:
2095+
x: 100
2096+
y: 200
2097+
- id: permission_validator
2098+
type: TASK_EXECUTION
2099+
executor:
2100+
name: PermissionValidator
2101+
onSuccess: validate_secret_regeneration
2102+
layout:
2103+
size:
2104+
width: 206
2105+
height: 113
2106+
position:
2107+
x: 300
2108+
y: 175
2109+
- id: validate_secret_regeneration
2110+
type: TASK_EXECUTION
2111+
executor:
2112+
name: ApplicationActionValidator
2113+
mode: revoke_before_action
2114+
onSuccess: revoke_tokens
2115+
layout:
2116+
size:
2117+
width: 250
2118+
height: 113
2119+
position:
2120+
x: 580
2121+
y: 175
2122+
- id: revoke_tokens
2123+
type: TASK_EXECUTION
2124+
executor:
2125+
name: CriteriaRevocationExecutor
2126+
onSuccess: regenerate_secret
2127+
layout:
2128+
size:
2129+
width: 206
2130+
height: 113
2131+
position:
2132+
x: 920
2133+
y: 175
2134+
- id: regenerate_secret
2135+
type: TASK_EXECUTION
2136+
executor:
2137+
name: ClientSecretExecutor
2138+
onSuccess: end
2139+
layout:
2140+
size:
2141+
width: 250
2142+
height: 113
2143+
position:
2144+
x: 1200
2145+
y: 175
2146+
- id: end
2147+
type: END
2148+
layout:
2149+
size:
2150+
width: 85
2151+
height: 34
2152+
position:
2153+
x: 1520
2154+
y: 200
2155+
---
19942156
resource_type: theme
19952157
id: 01900000-0000-7000-8000-000000000071
19962158
handle: acrylic-orange

backend/cmd/server/bootstrap/02-server-configurations.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ value:
1616
expirySeconds: 86400
1717
userDeletionFlow:
1818
defaultHandle: default-user-deletion-flow
19+
applicationDeletionFlow:
20+
defaultHandle: default-application-deletion-flow
21+
secretRegenerationFlow:
22+
defaultHandle: default-secret-regeneration-flow

backend/cmd/server/servicemanager.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import (
6464
"github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop"
6565
"github.com/thunder-id/thunderid/internal/oauth/oauth2/jti"
6666
"github.com/thunder-id/thunderid/internal/oauth/oauth2/revocation"
67+
"github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice"
6768
"github.com/thunder-id/thunderid/internal/openid4vci"
6869
"github.com/thunder-id/thunderid/internal/ou"
6970
"github.com/thunder-id/thunderid/internal/resource"
@@ -417,8 +418,14 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa
417418

418419
applicationService, applicationExporter, err := application.Initialize(
419420
mux, mcpServer, entityService, inboundClientService, ouService, i18nService,
420-
runtimeCryptoSvc, serverConfigService)
421+
runtimeCryptoSvc, serverConfigService,
422+
func(client *providers.OAuthClient) time.Duration {
423+
return tokenservice.ArtifactLifetime(oauthCfg, client)
424+
})
421425
fatalOnError(ctx, logger, err, "Failed to initialize ApplicationService")
426+
// Two-phase initialization: inject the application service into the executors that act on it.
427+
fatalOnError(ctx, logger, executor.SetApplicationProvider(execRegistry, applicationService),
428+
"Failed to inject the application provider into the flow executors")
422429
exporters = append(exporters, applicationExporter)
423430

424431
agentService, agentExporter, err := agent.Initialize(mux, entityService, inboundClientService, ouService,

backend/dbscripts/runtime_persistent/postgres.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ CREATE TABLE "SSO_SESSION_PARTICIPANT" (
9595
PRIMARY KEY (SESSION_ID, DEPLOYMENT_ID, APP_ID)
9696
);
9797

98+
-- Index for participation lookups by application, used when an application is deleted and its
99+
-- participation must be detached from every session it joined.
100+
CREATE INDEX idx_sso_session_participant_app ON "SSO_SESSION_PARTICIPANT" (DEPLOYMENT_ID, APP_ID);
101+
98102
-- Table to store consent records.
99103
CREATE TABLE "CONSENT" (
100104
DEPLOYMENT_ID VARCHAR(255) NOT NULL,

backend/dbscripts/runtime_persistent/sqlite.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ CREATE TABLE "SSO_SESSION_PARTICIPANT" (
9595
PRIMARY KEY (SESSION_ID, DEPLOYMENT_ID, APP_ID)
9696
);
9797

98+
-- Index for participation lookups by application, used when an application is deleted and its
99+
-- participation must be detached from every session it joined.
100+
CREATE INDEX idx_sso_session_participant_app ON "SSO_SESSION_PARTICIPANT" (DEPLOYMENT_ID, APP_ID);
101+
98102
-- Table to store consent records.
99103
CREATE TABLE "CONSENT" (
100104
DEPLOYMENT_ID VARCHAR(255) NOT NULL,

0 commit comments

Comments
 (0)