diff --git a/backend/cmd/server/servicemanager.go b/backend/cmd/server/servicemanager.go index 82b86b347d..843fc805cc 100644 --- a/backend/cmd/server/servicemanager.go +++ b/backend/cmd/server/servicemanager.go @@ -316,6 +316,12 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa // CORS origins come from the server-config cors section. cors.InitializeDynamicMatcher(serverConfigService) + // Decorate the resource provider so an empty identifier resolves the configured default resource + // server. Keeps the default-resource-server policy server-side: OAuth, CIBA, PAR, grant handlers, + // and the flow executor depend only on providers.ResourceServerProvider, not on serverConfigService. + // The base resourceService is still used for resource-management APIs and server-config validation. + resourceServerProvider := resource.NewDefaultAwareResourceServerProvider(resourceService, serverConfigService) + flowConfig := flowconfig.FromServerRuntime() sessionService, sessionCfg := initSessionService(ctx, serverConfigService, runtime.Config.Server.Identifier, logger) flowConfig.Session = sessionCfg @@ -345,6 +351,7 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa GoogleSvc: googleAuthnService, OpenID4VPVerifierSvc: openid4vpSvc, SessionService: sessionService, + ResourceService: resourceServerProvider, }, interceptor.InterceptorDependencies{}, flowConfig, @@ -450,7 +457,7 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa // Initialize OAuth services. err = oauth.Initialize(mux, actorProvider, authnProvider, jwtService, jweService, flowExecService, observabilitySvc, runtimeCryptoSvc, ouService, attributeCacheService, authZService, - resourceService, serverConfigService, i18nService, idpService, dpopVerifier, + resourceServerProvider, i18nService, idpService, dpopVerifier, runtimeStoreProvider, transactioner, oauthCfg) fatalOnError(ctx, logger, err, "Failed to initialize OAuth services") diff --git a/backend/internal/flow/common/constants.go b/backend/internal/flow/common/constants.go index 13bdb2ce65..feef87858b 100644 --- a/backend/internal/flow/common/constants.go +++ b/backend/internal/flow/common/constants.go @@ -138,6 +138,11 @@ const ( RuntimeKeyClientID = "clientId" // RuntimeKeyRequestedPermissions holds the space-separated permission scopes requested by the OAuth client. RuntimeKeyRequestedPermissions = "requested_permissions" + // RuntimeKeyResourceServerIdentifier holds the identifier of the single resource server the request is + // bound to. When set, the authorization executor resolves it and scopes its permission evaluation to + // that resource server. Using the identifier (not the internal ID) keeps the executor contract the + // same for OAuth requests and direct flow executions. + RuntimeKeyResourceServerIdentifier = "resource_server_identifier" // RuntimeKeyConsentedPermissions holds the space-separated permission scopes the user has consented to // release to the client, as produced by the ConsentExecutor. RuntimeKeyConsentedPermissions = "consented_permissions" diff --git a/backend/internal/flow/executor/authz_executor.go b/backend/internal/flow/executor/authz_executor.go index 70f703e8e9..daed7cdf61 100644 --- a/backend/internal/flow/executor/authz_executor.go +++ b/backend/internal/flow/executor/authz_executor.go @@ -23,6 +23,7 @@ import ( "errors" "github.com/thunder-id/thunderid/internal/entityprovider" + "github.com/thunder-id/thunderid/internal/flow/common" "github.com/thunder-id/thunderid/internal/flow/core" "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/internal/system/utils" @@ -39,10 +40,11 @@ const ( // during flow execution. It enriches the flow context with authorized permissions. type authorizationExecutor struct { providers.Executor - authzService providers.AuthorizationProvider - entityProvider entityprovider.EntityProviderInterface - authnProvider providers.AuthnProviderManager - logger *log.Logger + authzService providers.AuthorizationProvider + entityProvider entityprovider.EntityProviderInterface + authnProvider providers.AuthnProviderManager + resourceService providers.ResourceServerProvider + logger *log.Logger } var _ providers.Executor = (*authorizationExecutor)(nil) @@ -53,6 +55,7 @@ func newAuthorizationExecutor( authZService providers.AuthorizationProvider, entityProvider entityprovider.EntityProviderInterface, authnProvider providers.AuthnProviderManager, + resourceService providers.ResourceServerProvider, ) *authorizationExecutor { logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, authzLoggerComponentName), log.String(log.LoggerKeyExecutorName, ExecutorNameAuthorization)) @@ -61,11 +64,12 @@ func newAuthorizationExecutor( []providers.Input{}, []providers.Input{}, &providers.ExecutorMeta{}) return &authorizationExecutor{ - Executor: base, - authzService: authZService, - entityProvider: entityProvider, - authnProvider: authnProvider, - logger: logger, + Executor: base, + authzService: authZService, + entityProvider: entityProvider, + authnProvider: authnProvider, + resourceService: resourceService, + logger: logger, } } @@ -110,6 +114,22 @@ func (a *authorizationExecutor) Execute(ctx *providers.NodeContext) (*providers. return execResp, nil } + // Resolve the single resource server the permission scopes are evaluated against: the OAuth layer + // seeds it in runtime data; a direct /flow/execute request (which does not go through the + // authorization endpoint) may supply it as an input; otherwise fall back to the configured default + // resource server. Permission evaluation must be scoped to a resource server, so when none can be + // resolved the requested permission scopes are dropped rather than evaluated unscoped (which could + // authorize a permission the user only holds on a different resource server). + resourceServerID := a.resolveResourceServerID(ctx) + if resourceServerID == "" { + logger.Debug(ctx.Context, + "No resource server bound to the request; dropping requested permission scopes", + log.Int("permissionCount", len(requestedPerms))) + setAuthorizedPermissions(execResp, []string{}) + execResp.Status = providers.ExecComplete + return execResp, nil + } + logger.Debug(ctx.Context, "Determined required permissions", log.Int("count", len(requestedPerms))) // Extract user ID and group IDs @@ -125,7 +145,7 @@ func (a *authorizationExecutor) Execute(ctx *providers.NodeContext) (*providers. log.Int("permissionCount", len(requestedPerms))) authzResp, svcErr := a.authzService.EvaluateAccessBatch(ctx.Context, - a.buildAccessEvaluationsRequest(userID, groupIDs, requestedPerms)) + a.buildAccessEvaluationsRequest(userID, groupIDs, requestedPerms, resourceServerID)) if svcErr != nil { logger.Error(ctx.Context, "Authorization service call failed", log.String("error", svcErr.Error.DefaultValue)) @@ -143,6 +163,33 @@ func (a *authorizationExecutor) Execute(ctx *providers.NodeContext) (*providers. return execResp, nil } +// resolveResourceServerID determines the internal ID of the single resource server that permission +// scopes are evaluated against. The binding is communicated as a resource server identifier: the OAuth +// layer seeds it in runtime data, and a direct /flow/execute request (which does not go through the +// authorization endpoint) may supply it as an input. The identifier is resolved to its internal ID +// through the provider; an empty identifier asks a default-aware provider to resolve the deployment's +// configured default resource server. Returns "" when none can be resolved (unknown identifier, no +// default configured, or no resource provider available, for example the embedded engine). +func (a *authorizationExecutor) resolveResourceServerID(ctx *providers.NodeContext) string { + identifier := ctx.RuntimeData[common.RuntimeKeyResourceServerIdentifier] + if identifier == "" { + identifier = ctx.UserInputs[common.RuntimeKeyResourceServerIdentifier] + } + if a.resourceService == nil { + a.logger.Debug(ctx.Context, + "No resource server service available; dropping requested permission scopes") + return "" + } + rs, svcErr := a.resourceService.GetResourceServerByIdentifier(ctx.Context, identifier) + if svcErr != nil { + a.logger.Debug(ctx.Context, + "Resource server did not resolve; dropping requested permission scopes", + log.String("identifier", identifier)) + return "" + } + return rs.ID +} + // extractRequestedPermissions extracts requested permissions from the context. func extractRequestedPermissions(ctx *providers.NodeContext) []string { requestedPermissions := ctx.RuntimeData[requestedPermissionsKey] @@ -163,6 +210,7 @@ func (a *authorizationExecutor) buildAccessEvaluationsRequest( entityID string, groupIDs []string, requestedPermissions []string, + resourceServerID string, ) providers.AccessEvaluationsRequest { evaluations := make([]providers.AccessEvaluationRequest, 0, len(requestedPermissions)) for _, permission := range requestedPermissions { @@ -171,7 +219,8 @@ func (a *authorizationExecutor) buildAccessEvaluationsRequest( ID: entityID, GroupIDs: groupIDs, }, - Permission: providers.Permission{Name: permission}, + ResourceServer: providers.AccessEvaluationResourceServer{ID: resourceServerID}, + Permission: providers.Permission{Name: permission}, }) } return providers.AccessEvaluationsRequest{Evaluations: evaluations} diff --git a/backend/internal/flow/executor/authz_executor_test.go b/backend/internal/flow/executor/authz_executor_test.go index 884920656a..f8ccfb666f 100644 --- a/backend/internal/flow/executor/authz_executor_test.go +++ b/backend/internal/flow/executor/authz_executor_test.go @@ -29,19 +29,39 @@ import ( "github.com/stretchr/testify/mock" "github.com/thunder-id/thunderid/internal/entityprovider" + "github.com/thunder-id/thunderid/internal/flow/common" "github.com/thunder-id/thunderid/tests/mocks/authnprovider/managermock" "github.com/thunder-id/thunderid/tests/mocks/authzmock" "github.com/thunder-id/thunderid/tests/mocks/entityprovidermock" "github.com/thunder-id/thunderid/tests/mocks/flow/coremock" + "github.com/thunder-id/thunderid/tests/mocks/resourcemock" ) const testExistingUser123ID = "existing-user-123" -// createTestAuthzExecutor creates an authorization executor with mocks for testing +// createTestAuthzExecutor creates an authorization executor with a permissive resource provider that +// resolves any identifier to a resource server whose ID equals the identifier, so tests can pass a +// readable identifier and assert on the resolved ID directly. func createTestAuthzExecutor(t *testing.T, mockAuthzService *authzmock.AuthorizationProviderMock, mockEntityProvider *entityprovidermock.EntityProviderInterfaceMock, mockAuthnProvider *managermock.AuthnProviderManagerMock) *authorizationExecutor { + mockResource := resourcemock.NewResourceServiceInterfaceMock(t) + mockResource.On("GetResourceServerByIdentifier", mock.Anything, mock.Anything). + Return(func(_ context.Context, identifier string) *providers.ResourceServer { + return &providers.ResourceServer{ID: identifier, Identifier: identifier} + }, func(_ context.Context, _ string) *tidcommon.ServiceError { return nil }).Maybe() + return createTestAuthzExecutorWithResource(t, mockAuthzService, mockEntityProvider, mockAuthnProvider, mockResource) +} + +// createTestAuthzExecutorWithResource creates an authorization executor with a caller-supplied resource +// provider, used by tests that exercise default resource server resolution (an empty identifier +// resolved by a default-aware provider). +func createTestAuthzExecutorWithResource(t *testing.T, + mockAuthzService *authzmock.AuthorizationProviderMock, + mockEntityProvider *entityprovidermock.EntityProviderInterfaceMock, + mockAuthnProvider *managermock.AuthnProviderManagerMock, + resourceService providers.ResourceServerProvider) *authorizationExecutor { mockFlowFactory := coremock.NewFlowFactoryInterfaceMock(t) // Mock the CreateExecutor method to return a base executor @@ -49,7 +69,8 @@ func createTestAuthzExecutor(t *testing.T, []providers.Input{}, []providers.Input{}, mock.Anything). Return(createMockExecutor(t, "AuthorizationExecutor", providers.ExecutorTypeUtility)) - return newAuthorizationExecutor(mockFlowFactory, mockAuthzService, mockEntityProvider, mockAuthnProvider) + return newAuthorizationExecutor(mockFlowFactory, mockAuthzService, mockEntityProvider, mockAuthnProvider, + resourceService) } // newAuthzAuthenticatedAuthUser creates an AuthUser that returns true for IsAuthenticated(). @@ -94,8 +115,9 @@ func TestAuthorizationExecutor_Execute_Success(t *testing.T) { FlowType: providers.FlowTypeAuthentication, AuthUser: authUser, RuntimeData: map[string]string{ - requestedPermissionsKey: "read:documents write:documents delete:documents", - "groups": `["group1", "group2"]`, + requestedPermissionsKey: "read:documents write:documents delete:documents", + common.RuntimeKeyResourceServerIdentifier: "rs-1", + "groups": `["group1", "group2"]`, }, } @@ -110,7 +132,7 @@ func TestAuthorizationExecutor_Execute_Success(t *testing.T) { len(req.Evaluations[0].Subject.GroupIDs) == 2 && req.Evaluations[0].Subject.GroupIDs[0] == "group1" && req.Evaluations[0].Subject.GroupIDs[1] == "group2" && - req.Evaluations[0].ResourceServer.ID == "" && + req.Evaluations[0].ResourceServer.ID == "rs-1" && req.Evaluations[0].Permission.Name == "read:documents" && req.Evaluations[1].Permission.Name == "write:documents" && req.Evaluations[2].Permission.Name == "delete:documents" @@ -134,6 +156,199 @@ func TestAuthorizationExecutor_Execute_Success(t *testing.T) { mockAuthzService.AssertExpectations(t) } +func TestAuthorizationExecutor_Execute_ScopesEvaluationToResourceServer(t *testing.T) { + mockAuthzService := new(authzmock.AuthorizationProviderMock) + mockEntityProvider := new(entityprovidermock.EntityProviderInterfaceMock) + mockAuthnProvider := managermock.NewAuthnProviderManagerMock(t) + executor := createTestAuthzExecutor(t, mockAuthzService, mockEntityProvider, mockAuthnProvider) + + authUser := newAuthzAuthenticatedAuthUser() + ctx := &providers.NodeContext{ + ExecutionID: "test-flow", + FlowType: providers.FlowTypeAuthentication, + AuthUser: authUser, + RuntimeData: map[string]string{ + requestedPermissionsKey: "read", + common.RuntimeKeyResourceServerIdentifier: "rs-B", + }, + } + + mockAuthnProvider.On("GetEntityReference", mock.Anything, mock.Anything). + Return(authUser, &providers.EntityReference{EntityID: "user123"}, nil) + mockEntityProvider.On("GetTransitiveEntityGroups", "user123").Return( + []providers.EntityGroup{}, nil) + + // The evaluation must be scoped to the requested resource server. + mockAuthzService.On("EvaluateAccessBatch", + mock.Anything, + mock.MatchedBy(func(req providers.AccessEvaluationsRequest) bool { + return len(req.Evaluations) == 1 && + req.Evaluations[0].ResourceServer.ID == "rs-B" && + req.Evaluations[0].Permission.Name == "read" + })).Return(&providers.AccessEvaluationsResponse{ + Evaluations: []providers.AccessEvaluationResponse{{Decision: false}}, + }, nil) + + resp, err := executor.Execute(ctx) + + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, providers.ExecComplete, resp.Status) + // The user holds "read" on a different resource server, so it is dropped for rs-B. + assert.Empty(t, resp.RuntimeData[authorizedPermissionsKey]) + + mockAuthzService.AssertExpectations(t) +} + +func TestAuthorizationExecutor_Execute_DropsPermissionsWhenNoResourceServerBinding(t *testing.T) { + mockAuthzService := new(authzmock.AuthorizationProviderMock) + mockEntityProvider := new(entityprovidermock.EntityProviderInterfaceMock) + mockAuthnProvider := managermock.NewAuthnProviderManagerMock(t) + // No server config service, so no default resource server fallback is possible. + executor := createTestAuthzExecutor(t, mockAuthzService, mockEntityProvider, mockAuthnProvider) + + authUser := newAuthzAuthenticatedAuthUser() + ctx := &providers.NodeContext{ + ExecutionID: "test-flow", + FlowType: providers.FlowTypeAuthentication, + AuthUser: authUser, + RuntimeData: map[string]string{ + // Permission scopes present but no resource server binding (runtime data or input) and no + // configured default: the executor drops the permissions instead of evaluating unscoped. + requestedPermissionsKey: "read write", + }, + } + + mockAuthnProvider.On("GetEntityReference", mock.Anything, mock.Anything). + Return(authUser, &providers.EntityReference{EntityID: "user123"}, nil) + + resp, err := executor.Execute(ctx) + + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, providers.ExecComplete, resp.Status) + // No permission scopes are authorized when there is no resource server binding. + assert.Empty(t, resp.RuntimeData[authorizedPermissionsKey]) + // The authorization service must not be consulted with an empty resource server id. + mockAuthzService.AssertNotCalled(t, "EvaluateAccessBatch", mock.Anything, mock.Anything) +} + +func TestAuthorizationExecutor_Execute_DropsPermissionsWhenResourceServiceUnavailable(t *testing.T) { + // The embedded engine may construct the executor without a resource server service. A + // permission-bearing request that carries a resource server identifier must fail closed (drop the + // permissions) rather than panic on the nil service. + mockAuthzService := new(authzmock.AuthorizationProviderMock) + mockEntityProvider := new(entityprovidermock.EntityProviderInterfaceMock) + mockAuthnProvider := managermock.NewAuthnProviderManagerMock(t) + + mockFlowFactory := coremock.NewFlowFactoryInterfaceMock(t) + mockFlowFactory.On("CreateExecutor", ExecutorNameAuthorization, providers.ExecutorTypeUtility, + []providers.Input{}, []providers.Input{}, mock.Anything). + Return(createMockExecutor(t, "AuthorizationExecutor", providers.ExecutorTypeUtility)) + // nil resource service, mirroring an embedded engine setup with no resource provider configured. + executor := newAuthorizationExecutor(mockFlowFactory, mockAuthzService, mockEntityProvider, + mockAuthnProvider, nil) + + authUser := newAuthzAuthenticatedAuthUser() + ctx := &providers.NodeContext{ + ExecutionID: "test-flow", + FlowType: providers.FlowTypeAuthentication, + AuthUser: authUser, + RuntimeData: map[string]string{ + requestedPermissionsKey: "read write", + common.RuntimeKeyResourceServerIdentifier: "rs-1", + }, + } + + mockAuthnProvider.On("GetEntityReference", mock.Anything, mock.Anything). + Return(authUser, &providers.EntityReference{EntityID: "user123"}, nil) + + resp, err := executor.Execute(ctx) + + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, providers.ExecComplete, resp.Status) + // No permission scopes are authorized when the resource server cannot be resolved. + assert.Empty(t, resp.RuntimeData[authorizedPermissionsKey]) + // The authorization service must not be consulted with an empty resource server id. + mockAuthzService.AssertNotCalled(t, "EvaluateAccessBatch", mock.Anything, mock.Anything) +} + +func TestAuthorizationExecutor_Execute_ResourceServerFromUserInputFallback(t *testing.T) { + // A direct /flow/execute request supplies the resource server binding as an input (not runtime + // data). The executor must honor it, mirroring how requested_permissions falls back to inputs. + mockAuthzService := new(authzmock.AuthorizationProviderMock) + mockEntityProvider := new(entityprovidermock.EntityProviderInterfaceMock) + mockAuthnProvider := managermock.NewAuthnProviderManagerMock(t) + executor := createTestAuthzExecutor(t, mockAuthzService, mockEntityProvider, mockAuthnProvider) + + authUser := newAuthzAuthenticatedAuthUser() + ctx := &providers.NodeContext{ + ExecutionID: "test-flow", + FlowType: providers.FlowTypeAuthentication, + AuthUser: authUser, + RuntimeData: map[string]string{requestedPermissionsKey: "read"}, + UserInputs: map[string]string{common.RuntimeKeyResourceServerIdentifier: "rs-input"}, + } + + mockAuthnProvider.On("GetEntityReference", mock.Anything, mock.Anything). + Return(authUser, &providers.EntityReference{EntityID: "user123"}, nil) + mockEntityProvider.On("GetTransitiveEntityGroups", "user123").Return([]providers.EntityGroup{}, nil) + mockAuthzService.On("EvaluateAccessBatch", mock.Anything, + mock.MatchedBy(func(req providers.AccessEvaluationsRequest) bool { + return len(req.Evaluations) == 1 && req.Evaluations[0].ResourceServer.ID == "rs-input" + })).Return(&providers.AccessEvaluationsResponse{ + Evaluations: []providers.AccessEvaluationResponse{{Decision: true}}, + }, nil) + + resp, err := executor.Execute(ctx) + + assert.NoError(t, err) + assert.Equal(t, providers.ExecComplete, resp.Status) + assert.Equal(t, "read", resp.RuntimeData[authorizedPermissionsKey]) + mockAuthzService.AssertExpectations(t) +} + +func TestAuthorizationExecutor_Execute_DefaultResourceServerFallback(t *testing.T) { + // No explicit binding in runtime data or inputs: the executor falls back to the configured default + // resource server and scopes the evaluation to it. + mockAuthzService := new(authzmock.AuthorizationProviderMock) + mockEntityProvider := new(entityprovidermock.EntityProviderInterfaceMock) + mockAuthnProvider := managermock.NewAuthnProviderManagerMock(t) + // A default-aware provider resolves the empty identifier to the configured default resource server. + mockResource := resourcemock.NewResourceServiceInterfaceMock(t) + mockResource.On("GetResourceServerByIdentifier", mock.Anything, ""). + Return(&providers.ResourceServer{ID: "rs-default", Identifier: "rs-default"}, nil) + + executor := createTestAuthzExecutorWithResource( + t, mockAuthzService, mockEntityProvider, mockAuthnProvider, mockResource) + + authUser := newAuthzAuthenticatedAuthUser() + ctx := &providers.NodeContext{ + ExecutionID: "test-flow", + FlowType: providers.FlowTypeAuthentication, + AuthUser: authUser, + RuntimeData: map[string]string{requestedPermissionsKey: "read"}, + } + + mockAuthnProvider.On("GetEntityReference", mock.Anything, mock.Anything). + Return(authUser, &providers.EntityReference{EntityID: "user123"}, nil) + mockEntityProvider.On("GetTransitiveEntityGroups", "user123").Return([]providers.EntityGroup{}, nil) + mockAuthzService.On("EvaluateAccessBatch", mock.Anything, + mock.MatchedBy(func(req providers.AccessEvaluationsRequest) bool { + return len(req.Evaluations) == 1 && req.Evaluations[0].ResourceServer.ID == "rs-default" + })).Return(&providers.AccessEvaluationsResponse{ + Evaluations: []providers.AccessEvaluationResponse{{Decision: true}}, + }, nil) + + resp, err := executor.Execute(ctx) + + assert.NoError(t, err) + assert.Equal(t, providers.ExecComplete, resp.Status) + assert.Equal(t, "read", resp.RuntimeData[authorizedPermissionsKey]) + mockAuthzService.AssertExpectations(t) +} + func TestAuthorizationExecutor_Execute_PartialPermissions(t *testing.T) { // Setup - user requests multiple permissions but only gets some mockAuthzService := new(authzmock.AuthorizationProviderMock) @@ -147,7 +362,8 @@ func TestAuthorizationExecutor_Execute_PartialPermissions(t *testing.T) { FlowType: providers.FlowTypeAuthentication, AuthUser: authUser, RuntimeData: map[string]string{ - requestedPermissionsKey: "read:documents write:documents delete:documents", + requestedPermissionsKey: "read:documents write:documents delete:documents", + common.RuntimeKeyResourceServerIdentifier: "rs-1", }, } @@ -192,7 +408,8 @@ func TestAuthorizationExecutor_Execute_NoPermissions(t *testing.T) { FlowType: providers.FlowTypeAuthentication, AuthUser: authUser, RuntimeData: map[string]string{ - requestedPermissionsKey: "read:documents write:documents", + requestedPermissionsKey: "read:documents write:documents", + common.RuntimeKeyResourceServerIdentifier: "rs-1", }, } @@ -260,7 +477,8 @@ func TestAuthorizationExecutor_Execute_ServiceError(t *testing.T) { FlowType: providers.FlowTypeAuthentication, AuthUser: authUser, RuntimeData: map[string]string{ - requestedPermissionsKey: "read:documents write:documents", + requestedPermissionsKey: "read:documents write:documents", + common.RuntimeKeyResourceServerIdentifier: "rs-1", }, } @@ -299,7 +517,8 @@ func TestAuthorizationExecutor_Execute_GroupExtractionError(t *testing.T) { FlowType: providers.FlowTypeAuthentication, AuthUser: authUser, RuntimeData: map[string]string{ - requestedPermissionsKey: "read:documents write:documents", + requestedPermissionsKey: "read:documents write:documents", + common.RuntimeKeyResourceServerIdentifier: "rs-1", }, } @@ -486,8 +705,9 @@ func TestAuthorizationExecutor_Execute_WithMultipleGroups(t *testing.T) { FlowType: providers.FlowTypeAuthentication, AuthUser: authUser, RuntimeData: map[string]string{ - requestedPermissionsKey: "read:documents write:documents delete:documents", - "groups": `["admin", "editor", "viewer"]`, + requestedPermissionsKey: "read:documents write:documents delete:documents", + common.RuntimeKeyResourceServerIdentifier: "rs-1", + "groups": `["admin", "editor", "viewer"]`, }, } @@ -593,7 +813,8 @@ func TestAuthorizationExecutor_Execute_RegistrationFlow_UnauthenticatedWithPermi ExecutionID: "test-registration-flow", FlowType: providers.FlowTypeRegistration, RuntimeData: map[string]string{ - requestedPermissionsKey: "read:documents write:documents", + requestedPermissionsKey: "read:documents write:documents", + common.RuntimeKeyResourceServerIdentifier: "rs-1", }, } @@ -622,8 +843,9 @@ func TestAuthorizationExecutor_Execute_RegistrationFlow_AuthenticatedWithPermiss FlowType: providers.FlowTypeRegistration, AuthUser: authUser, RuntimeData: map[string]string{ - requestedPermissionsKey: "read:profile write:profile", - "groups": `["new-users"]`, + requestedPermissionsKey: "read:profile write:profile", + common.RuntimeKeyResourceServerIdentifier: "rs-1", + "groups": `["new-users"]`, }, } diff --git a/backend/internal/flow/executor/register.go b/backend/internal/flow/executor/register.go index 759500e95e..f99163d4d7 100644 --- a/backend/internal/flow/executor/register.go +++ b/backend/internal/flow/executor/register.go @@ -153,6 +153,7 @@ type ExecutorDependencies struct { GoogleSvc google.GoogleOIDCAuthnServiceInterface OpenID4VPVerifierSvc openid4vp.OpenID4VPServiceInterface SessionService session.Service + ResourceService providers.ResourceServerProvider } type builtInExecutorRegistrar func(ExecutorRegistryInterface, ExecutorDependencies) @@ -210,7 +211,8 @@ func newBuiltInExecutorRegistrars() map[string]builtInExecutorRegistrar { }, ExecutorNameAuthorization: func(reg ExecutorRegistryInterface, deps ExecutorDependencies) { reg.RegisterExecutor(ExecutorNameAuthorization, newAuthorizationExecutor( - deps.FlowFactory, deps.AuthZService, deps.EntityProvider, deps.AuthnProvider)) + deps.FlowFactory, deps.AuthZService, deps.EntityProvider, deps.AuthnProvider, + deps.ResourceService)) }, ExecutorNameHTTPRequest: func(reg ExecutorRegistryInterface, deps ExecutorDependencies) { reg.RegisterExecutor(ExecutorNameHTTPRequest, newHTTPRequestExecutor(deps.FlowFactory, deps.OUService, diff --git a/backend/internal/flow/executor/session_executor.go b/backend/internal/flow/executor/session_executor.go index 4ea5206790..07134bbe20 100644 --- a/backend/internal/flow/executor/session_executor.go +++ b/backend/internal/flow/executor/session_executor.go @@ -243,6 +243,7 @@ func (e *sessionExecutor) loadCheckpoint(ctx *providers.NodeContext, execResp *p // classification is implemented. var requestScopedSnapshotDenyList = map[string]struct{}{ common.RuntimeKeyRequestedPermissions: {}, + common.RuntimeKeyResourceServerIdentifier: {}, common.RuntimeKeyRequiredEssentialAttributes: {}, common.RuntimeKeyRequiredOptionalAttributes: {}, common.RuntimeKeyRequiredLocales: {}, diff --git a/backend/internal/flow/executor/session_executor_test.go b/backend/internal/flow/executor/session_executor_test.go index 63f2093eb0..cf8d4e4d82 100644 --- a/backend/internal/flow/executor/session_executor_test.go +++ b/backend/internal/flow/executor/session_executor_test.go @@ -284,6 +284,7 @@ func (suite *SessionExecutorTestSuite) TestFreshSave_SanitizesSnapshot() { ctx.RuntimeData[common.RuntimeKeyRequiredOptionalAttributes] = "phone" ctx.RuntimeData[common.RuntimeKeyRequiredLocales] = "en-US" ctx.RuntimeData[common.RuntimeKeyRequestedPermissions] = "openid profile" + ctx.RuntimeData[common.RuntimeKeyResourceServerIdentifier] = "rs-a" ctx.RuntimeData["applicationId"] = "app-a" ctx.RuntimeData[common.RuntimeKeyClientID] = "sso_app_a" ctx.RuntimeData[common.RuntimeKeyAuthorizationRequestID] = "authz-req-1" @@ -304,6 +305,7 @@ func (suite *SessionExecutorTestSuite) TestFreshSave_SanitizesSnapshot() { suite.NotContains(rd, common.RuntimeKeyRequiredOptionalAttributes) suite.NotContains(rd, common.RuntimeKeyRequiredLocales) suite.NotContains(rd, common.RuntimeKeyRequestedPermissions) + suite.NotContains(rd, common.RuntimeKeyResourceServerIdentifier) suite.NotContains(rd, "applicationId") suite.NotContains(rd, common.RuntimeKeyClientID) suite.NotContains(rd, common.RuntimeKeyAuthorizationRequestID) diff --git a/backend/internal/oauth/init.go b/backend/internal/oauth/init.go index eb72d7cfa1..94c0d087a3 100644 --- a/backend/internal/oauth/init.go +++ b/backend/internal/oauth/init.go @@ -42,7 +42,6 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" "github.com/thunder-id/thunderid/internal/oauth/oauth2/userinfo" "github.com/thunder-id/thunderid/internal/oauth/scope" - "github.com/thunder-id/thunderid/internal/serverconfig" syshttp "github.com/thunder-id/thunderid/internal/system/http" "github.com/thunder-id/thunderid/internal/system/jose/jwe" "github.com/thunder-id/thunderid/internal/system/jose/jwt" @@ -65,7 +64,6 @@ func Initialize( attributeCacheSvc attributecache.AttributeCacheServiceInterface, authzService providers.AuthorizationProvider, resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, i18nService providers.I18nProvider, idpService providers.IDPProvider, dpopVerifier dpop.VerifierInterface, @@ -104,12 +102,12 @@ func Initialize( if len(cfg.OAuth.AllowedGrantTypes) == 0 || slices.Contains(cfg.OAuth.AllowedGrantTypes, string(providers.GrantTypeCIBA)) { cibaService = ciba.Initialize(mux, jwtService, actorProvider, authnProvider, flowExecService, - discoveryService, resourceService, serverConfigService, cfg) + discoveryService, resourceService, cfg) } grantHandlerProvider := granthandlers.Initialize( jwtService, oauth2AuthzService, tokenBuilder, tokenValidator, - attributeCacheSvc, ouService, authzService, actorProvider, resourceService, serverConfigService, + attributeCacheSvc, ouService, authzService, actorProvider, resourceService, cibaService, refreshTokenRevoker, cfg) token.Initialize(mux, jwtService, actorProvider, authnProvider, grantHandlerProvider, diff --git a/backend/internal/oauth/oauth2/authz/service.go b/backend/internal/oauth/oauth2/authz/service.go index 4a21ea6e57..c819ed7226 100644 --- a/backend/internal/oauth/oauth2/authz/service.go +++ b/backend/internal/oauth/oauth2/authz/service.go @@ -278,20 +278,8 @@ func (as *authorizeService) handleStandardAuthorizationRequest( oidcScopes, nonOidcScopes := oauth2utils.SeparateOIDCAndNonOIDCScopes(scope, app.ScopeClaims) oidcScopes = oauth2utils.FilterOIDCScopesByAllowedScopes(oidcScopes, app.Scopes) - // Resolve resource identifiers to Resource Servers and downscope non-OIDC scopes against - // the union of permissions defined on those Resource Servers. Unknown identifiers cause - // invalid_target; scopes not defined on any RS are silently dropped. - _, nonOidcScopes, errResp := resourceindicators.ResolveAndDownscope( - ctx, as.resourceService, resources, nonOidcScopes) - if errResp != nil { - return nil, &AuthorizationError{ - Code: errResp.Error, - Message: errResp.ErrorDescription, - SendErrorToClient: true, - ClientRedirectURI: redirectURI, - State: state, - } - } + // The single target resource server, downscoping, and audience binding are resolved in + // initiateFlowAndStoreRequest, the path shared by both standard and PAR-based requests. // Construct authorization request context. oauthParams := &oauth2model.OAuthParameters{ @@ -337,6 +325,40 @@ func (as *authorizeService) initiateFlowAndStoreRequest( ctx context.Context, oauthParams *oauth2model.OAuthParameters, app *providers.OAuthClient, initiatorReq *providers.InitiatorRequest, ) (*AuthorizationInitResult, *AuthorizationError) { + // Bind the request to a single target resource server before the flow starts. OIDC-only or + // scopeless requests stay unbound and their audience is the client_id. A permission-bearing + // request resolves an explicit resource or the configured default, rejecting with invalid_target + // when neither is available. The resolved resource server id is threaded into the flow so the + // authorization executor scopes its permission evaluation to it. + targetRS, errResp := resourceindicators.ResolveAudienceBinding( + ctx, as.resourceService, oauthParams.Resources, oauthParams.PermissionScopes) + if errResp != nil { + return nil, &AuthorizationError{ + Code: errResp.Error, + Message: errResp.ErrorDescription, + SendErrorToClient: oauthParams.RedirectURI != "", + ClientRedirectURI: oauthParams.RedirectURI, + State: oauthParams.State, + } + } + resourceServerIdentifier := "" + if targetRS != nil { + downscoped, dErr := resourceindicators.DownscopeToResourceServer( + ctx, as.resourceService, targetRS.ID, oauthParams.PermissionScopes) + if dErr != nil { + return nil, &AuthorizationError{ + Code: dErr.Error, + Message: dErr.ErrorDescription, + SendErrorToClient: oauthParams.RedirectURI != "", + ClientRedirectURI: oauthParams.RedirectURI, + State: oauthParams.State, + } + } + oauthParams.PermissionScopes = downscoped + oauthParams.Resources = []string{targetRS.Identifier} + resourceServerIdentifier = targetRS.Identifier + } + effectiveAcrValues := requestvalidator.ResolveACRValues(oauthParams.AcrValues, app.AcrValues) essentialAttributes, optionalAttributes := getRequiredAttributes( oauthParams.StandardScopes, oauthParams.ClaimsRequest, oauthParams.ResponseType, app) @@ -362,6 +384,7 @@ func (as *authorizeService) initiateFlowAndStoreRequest( runtimeData := map[string]string{ flowcm.RuntimeKeyClientID: oauthParams.ClientID, flowcm.RuntimeKeyRequestedPermissions: utils.StringifyStringArray(oauthParams.PermissionScopes, " "), + flowcm.RuntimeKeyResourceServerIdentifier: resourceServerIdentifier, flowcm.RuntimeKeyRequiredEssentialAttributes: essentialAttributes, flowcm.RuntimeKeyRequiredOptionalAttributes: optionalAttributes, flowcm.RuntimeKeyRequiredLocales: oauthParams.ClaimsLocales, diff --git a/backend/internal/oauth/oauth2/authz/service_test.go b/backend/internal/oauth/oauth2/authz/service_test.go index 8acb1725de..7f2b7157d5 100644 --- a/backend/internal/oauth/oauth2/authz/service_test.go +++ b/backend/internal/oauth/oauth2/authz/service_test.go @@ -48,6 +48,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/resourcemock" ) func authorizeServiceCfgFromRuntime() oauthconfig.Config { @@ -103,6 +104,7 @@ type AuthorizeServiceTestSuite struct { mockAuthReqStore *authorizationRequestStoreInterfaceMock mockFlowExecService *flowexecmock.FlowExecServiceInterfaceMock mockValidator *AuthorizationValidatorInterfaceMock + mockResourceService *resourcemock.ResourceServiceInterfaceMock } func TestAuthorizeServiceTestSuite(t *testing.T) { @@ -141,13 +143,24 @@ func (suite *AuthorizeServiceTestSuite) SetupTest() { suite.mockAuthReqStore = newAuthorizationRequestStoreInterfaceMock(suite.T()) suite.mockFlowExecService = flowexecmock.NewFlowExecServiceInterfaceMock(suite.T()) suite.mockValidator = NewAuthorizationValidatorInterfaceMock(suite.T()) + suite.mockResourceService = resourcemock.NewResourceServiceInterfaceMock(suite.T()) + + // Default resolution path: permission-bearing requests without an explicit resource resolve the + // configured default resource server via the empty identifier. Declared optional so tests that + // never reach flow initiation (or that exercise resource-binding specifics) are unaffected. + suite.mockResourceService.EXPECT().GetResourceServerByIdentifier(mock.Anything, ""). + Return(&providers.ResourceServer{ID: "rs-default", Identifier: "https://rs-default.example.com"}, nil).Maybe() + suite.mockResourceService.EXPECT().ValidatePermissions(mock.Anything, "rs-default", mock.Anything). + Return([]string{}, nil).Maybe() } // newService builds an authorizeService with all mocked dependencies. func (suite *AuthorizeServiceTestSuite) newService() *authorizeService { + inboundClient := actorprovider.Initialize(suite.mockInboundClient, suite.mockEntityProvider, noopAuthnMgr()) return &authorizeService{ cfg: authorizeServiceCfgFromRuntime(), - inboundClient: actorprovider.Initialize(suite.mockInboundClient, suite.mockEntityProvider, noopAuthnMgr()), + inboundClient: inboundClient, + resourceService: suite.mockResourceService, authZValidator: suite.mockValidator, authCodeStore: suite.mockAuthzCodeStore, authReqStore: suite.mockAuthReqStore, @@ -339,6 +352,99 @@ func (suite *AuthorizeServiceTestSuite) TestHandleInitialAuthorizationRequest_Su assert.Equal(suite.T(), "test-flow-id", result.QueryParams[oauth2const.ExecutionID]) } +func (suite *AuthorizeServiceTestSuite) TestHandleInitialAuthorizationRequest_ExplicitResourceSetsRuntimeRSID() { + app := suite.testApp() + suite.mockInboundClient.EXPECT().GetOAuthClientByClientID(mock.Anything, "test-client-id").Return(app, nil) + suite.mockValidator.On("validateInitialAuthorizationRequest", mock.Anything, mock.Anything, app). + Return(false, "", "") + suite.mockResourceService.EXPECT().GetResourceServerByIdentifier(mock.Anything, "https://api.example.com"). + Return(&providers.ResourceServer{ID: "rs-api", Identifier: "https://api.example.com"}, nil) + suite.mockResourceService.EXPECT().ValidatePermissions(mock.Anything, "rs-api", mock.Anything). + Return([]string{}, nil) + + var captured *flowexec.FlowInitContext + suite.mockFlowExecService.EXPECT().InitiateFlow(mock.Anything, mock.Anything). + Run(func(_ context.Context, ic *flowexec.FlowInitContext) { captured = ic }). + Return("test-flow-id", nil) + suite.mockAuthReqStore.EXPECT().AddRequest(mock.Anything, mock.Anything).Return(testAuthID, nil) + + msg := suite.testMsg() + msg.Resources = []string{"https://api.example.com"} + + svc := suite.newService() + _, authErr := svc.HandleInitialAuthorizationRequest(context.Background(), msg) + + suite.Require().Nil(authErr) + suite.Require().NotNil(captured) + assert.Equal(suite.T(), "https://api.example.com", captured.RuntimeData[flowcm.RuntimeKeyResourceServerIdentifier]) +} + +func (suite *AuthorizeServiceTestSuite) TestHandleInitialAuthorizationRequest_DefaultResourceServerFallback() { + app := suite.testApp() + suite.mockInboundClient.EXPECT().GetOAuthClientByClientID(mock.Anything, "test-client-id").Return(app, nil) + suite.mockValidator.On("validateInitialAuthorizationRequest", mock.Anything, mock.Anything, app). + Return(false, "", "") + + var captured *flowexec.FlowInitContext + suite.mockFlowExecService.EXPECT().InitiateFlow(mock.Anything, mock.Anything). + Run(func(_ context.Context, ic *flowexec.FlowInitContext) { captured = ic }). + Return("test-flow-id", nil) + suite.mockAuthReqStore.EXPECT().AddRequest(mock.Anything, mock.Anything).Return(testAuthID, nil) + + // No resource supplied; resolves the default resource server stubbed in SetupTest. + svc := suite.newService() + _, authErr := svc.HandleInitialAuthorizationRequest(context.Background(), suite.testMsg()) + + suite.Require().Nil(authErr) + suite.Require().NotNil(captured) + assert.Equal(suite.T(), "https://rs-default.example.com", + captured.RuntimeData[flowcm.RuntimeKeyResourceServerIdentifier]) +} + +func (suite *AuthorizeServiceTestSuite) TestHandleInitialAuthorizationRequest_NoResourceNoDefaultRejects() { + app := suite.testApp() + suite.mockInboundClient.EXPECT().GetOAuthClientByClientID(mock.Anything, "test-client-id").Return(app, nil) + suite.mockValidator.On("validateInitialAuthorizationRequest", mock.Anything, mock.Anything, app). + Return(false, "", "") + + // No default resource server configured; resolving the empty identifier fails, so the + // permission-bearing request cannot bind and is rejected with invalid_target. + rs := resourcemock.NewResourceServiceInterfaceMock(suite.T()) + rs.EXPECT().GetResourceServerByIdentifier(mock.Anything, ""). + Return(nil, &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "RES-1003"}) + + svc := suite.newService() + svc.resourceService = rs + + _, authErr := svc.HandleInitialAuthorizationRequest(context.Background(), suite.testMsg()) + + suite.Require().NotNil(authErr) + assert.Equal(suite.T(), oauth2const.ErrorInvalidTarget, authErr.Code) +} + +func (suite *AuthorizeServiceTestSuite) TestHandleInitialAuthorizationRequest_OIDCOnlyLeavesRSIDEmpty() { + app := suite.testApp() + suite.mockInboundClient.EXPECT().GetOAuthClientByClientID(mock.Anything, "test-client-id").Return(app, nil) + suite.mockValidator.On("validateInitialAuthorizationRequest", mock.Anything, mock.Anything, app). + Return(false, "", "") + + var captured *flowexec.FlowInitContext + suite.mockFlowExecService.EXPECT().InitiateFlow(mock.Anything, mock.Anything). + Run(func(_ context.Context, ic *flowexec.FlowInitContext) { captured = ic }). + Return("test-flow-id", nil) + suite.mockAuthReqStore.EXPECT().AddRequest(mock.Anything, mock.Anything).Return(testAuthID, nil) + + msg := suite.testMsg() + msg.RequestQueryParams["scope"] = []string{"openid profile"} + + svc := suite.newService() + _, authErr := svc.HandleInitialAuthorizationRequest(context.Background(), msg) + + suite.Require().Nil(authErr) + suite.Require().NotNil(captured) + assert.Empty(suite.T(), captured.RuntimeData[flowcm.RuntimeKeyResourceServerIdentifier]) +} + func (suite *AuthorizeServiceTestSuite) TestHandleInitialAuthorizationRequest_FiltersOIDCScopesByAppScopes() { app := suite.testApp() app.Scopes = []string{"profile"} diff --git a/backend/internal/oauth/oauth2/ciba/init.go b/backend/internal/oauth/oauth2/ciba/init.go index 2d76efddcf..f53e2521d4 100644 --- a/backend/internal/oauth/oauth2/ciba/init.go +++ b/backend/internal/oauth/oauth2/ciba/init.go @@ -27,7 +27,6 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/clientauth" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" "github.com/thunder-id/thunderid/internal/oauth/oauth2/discovery" - "github.com/thunder-id/thunderid/internal/serverconfig" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/middleware" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" @@ -44,12 +43,10 @@ func Initialize( flowExecService flowexec.FlowExecServiceInterface, discoveryService discovery.DiscoveryServiceInterface, resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, cfg oauthconfig.Config, ) CIBAServiceInterface { store := newCIBAStore(cfg) - cibaSvc := newCIBAService(store, flowExecService, jwtService, actorProvider, resourceService, - serverConfigService, cfg) + cibaSvc := newCIBAService(store, flowExecService, jwtService, actorProvider, resourceService, cfg) cibaHandler := newCIBAHandler(cibaSvc) registerRoutes(mux, cibaHandler, actorProvider, authnProvider, jwtService, discoveryService) return cibaSvc diff --git a/backend/internal/oauth/oauth2/ciba/service.go b/backend/internal/oauth/oauth2/ciba/service.go index 750374b1cf..99ef3ae6f0 100644 --- a/backend/internal/oauth/oauth2/ciba/service.go +++ b/backend/internal/oauth/oauth2/ciba/service.go @@ -36,7 +36,6 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/resourceindicators" "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/serverconfig" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/internal/system/utils" @@ -66,14 +65,13 @@ type CIBAServiceInterface interface { // cibaService implements the CIBAServiceInterface. type cibaService struct { - cfg oauthconfig.Config - store CIBARequestStoreInterface - flowExecService flowexec.FlowExecServiceInterface - jwtService jwt.JWTServiceInterface - inboundClient providers.ActorProvider - resourceService providers.ResourceServerProvider - serverConfigService serverconfig.ServerConfigService - logger *log.Logger + cfg oauthconfig.Config + store CIBARequestStoreInterface + flowExecService flowexec.FlowExecServiceInterface + jwtService jwt.JWTServiceInterface + inboundClient providers.ActorProvider + resourceService providers.ResourceServerProvider + logger *log.Logger } // newCIBAService creates a new instance of cibaService with injected dependencies. @@ -83,18 +81,16 @@ func newCIBAService( jwtService jwt.JWTServiceInterface, actorProvider providers.ActorProvider, resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, cfg oauthconfig.Config, ) CIBAServiceInterface { return &cibaService{ - cfg: cfg, - store: store, - flowExecService: flowExecService, - jwtService: jwtService, - inboundClient: actorProvider, - resourceService: resourceService, - serverConfigService: serverConfigService, - logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "CIBAService")), + cfg: cfg, + store: store, + flowExecService: flowExecService, + jwtService: jwtService, + inboundClient: actorProvider, + resourceService: resourceService, + logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "CIBAService")), } } @@ -134,12 +130,13 @@ func (s *cibaService) InitiateBackchannelAuth( // OIDC-only (no resource, no permission scopes) stays unbound; a permission-bearing request resolves // an explicit resource or the configured default, rejecting with invalid_target when none applies. targetRS, rsErr := resourceindicators.ResolveAudienceBinding( - ctx, s.resourceService, s.serverConfigService, request.Resources, permissionScopes) + ctx, s.resourceService, request.Resources, permissionScopes) if rsErr != nil { return nil, &CIBAError{Code: rsErr.Error, Message: rsErr.ErrorDescription} } var effectiveResources []string + resourceServerIdentifier := "" if targetRS != nil { downscoped, dErr := resourceindicators.DownscopeToResourceServer( ctx, s.resourceService, targetRS.ID, permissionScopes) @@ -148,6 +145,7 @@ func (s *cibaService) InitiateBackchannelAuth( } permissionScopes = downscoped effectiveResources = []string{targetRS.Identifier} + resourceServerIdentifier = targetRS.Identifier } cacheTTL := strconv.FormatInt(s.resolveUserAttributesCacheTTL(oauthApp), 10) @@ -165,6 +163,7 @@ func (s *cibaService) InitiateBackchannelAuth( flowcm.RuntimeKeyAuthorizationRequestID: authReqID, flowcm.RuntimeKeyClientID: oauthApp.ClientID, flowcm.RuntimeKeyRequestedPermissions: utils.StringifyStringArray(permissionScopes, " "), + flowcm.RuntimeKeyResourceServerIdentifier: resourceServerIdentifier, flowcm.RuntimeKeyRequiredEssentialAttributes: "", flowcm.RuntimeKeyRequiredOptionalAttributes: getRequiredOptionalAttributes( append(oidcScopes, permissionScopes...), oauthApp), diff --git a/backend/internal/oauth/oauth2/ciba/service_test.go b/backend/internal/oauth/oauth2/ciba/service_test.go index 17672ae1ca..cb3ae9b7de 100644 --- a/backend/internal/oauth/oauth2/ciba/service_test.go +++ b/backend/internal/oauth/oauth2/ciba/service_test.go @@ -38,7 +38,6 @@ import ( flowcm "github.com/thunder-id/thunderid/internal/flow/common" "github.com/thunder-id/thunderid/internal/flow/flowexec" oauth2const "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" - "github.com/thunder-id/thunderid/internal/resource" "github.com/thunder-id/thunderid/internal/system/config" "github.com/thunder-id/thunderid/tests/mocks/authnprovider/managermock" "github.com/thunder-id/thunderid/tests/mocks/entityprovidermock" @@ -46,7 +45,6 @@ import ( "github.com/thunder-id/thunderid/tests/mocks/inboundclientmock" "github.com/thunder-id/thunderid/tests/mocks/jose/jwtmock" "github.com/thunder-id/thunderid/tests/mocks/resourcemock" - "github.com/thunder-id/thunderid/tests/mocks/serverconfigmock" "github.com/thunder-id/thunderid/tests/testhelpers" ) @@ -60,7 +58,6 @@ type CIBAServiceTestSuite struct { mockInboundClient *inboundclientmock.InboundClientServiceInterfaceMock mockEntityProvider *entityprovidermock.EntityProviderInterfaceMock mockResourceSvc *resourcemock.ResourceServiceInterfaceMock - mockServerConfig *serverconfigmock.ServerConfigServiceMock service CIBAServiceInterface oauthApp *providers.OAuthClient } @@ -79,10 +76,9 @@ func (suite *CIBAServiceTestSuite) SetupTest() { suite.mockInboundClient = inboundclientmock.NewInboundClientServiceInterfaceMock(suite.T()) suite.mockEntityProvider = entityprovidermock.NewEntityProviderInterfaceMock(suite.T()) suite.mockResourceSvc = resourcemock.NewResourceServiceInterfaceMock(suite.T()) - suite.mockServerConfig = serverconfigmock.NewServerConfigServiceMock(suite.T()) actorProv := actorprovider.Initialize(suite.mockInboundClient, suite.mockEntityProvider, noopAuthnMgr()) suite.service = newCIBAService(suite.mockStore, suite.mockFlowExec, - suite.mockJWTService, actorProv, suite.mockResourceSvc, suite.mockServerConfig, testhelpers.OAuthConfig()) + suite.mockJWTService, actorProv, suite.mockResourceSvc, testhelpers.OAuthConfig()) suite.oauthApp = &providers.OAuthClient{ ID: "app-1", ClientID: "client-1", @@ -111,9 +107,7 @@ func (suite *CIBAServiceTestSuite) expectStoreAddSuccess() { // expectDefaultResourceServer stubs the resolver path for a request without an explicit resource: // the configured default resolves to the given RS and its permissions validate as requested. func (suite *CIBAServiceTestSuite) expectDefaultResourceServer(rsID, identifier string) { - suite.mockServerConfig.EXPECT().GetMergedConfig(mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{ResourceServerID: rsID}, nil) - suite.mockResourceSvc.EXPECT().GetResourceServer(mock.Anything, rsID). + suite.mockResourceSvc.EXPECT().GetResourceServerByIdentifier(mock.Anything, ""). Return(&providers.ResourceServer{ID: rsID, Identifier: identifier}, nil) suite.mockResourceSvc.EXPECT().ValidatePermissions(mock.Anything, rsID, mock.Anything). Return([]string{}, nil) @@ -312,6 +306,43 @@ func (suite *CIBAServiceTestSuite) TestInitiate_ExplicitResourceBindsAndDownscop suite.Equal([]string{"https://api.example.com"}, stored.Resources) } +func (suite *CIBAServiceTestSuite) TestInitiate_SetsResourceServerIDInRuntimeData() { + suite.mockResourceSvc.EXPECT().GetResourceServerByIdentifier(mock.Anything, "https://api.example.com"). + Return(&providers.ResourceServer{ID: "rs-1", Identifier: "https://api.example.com"}, nil) + suite.mockResourceSvc.EXPECT().ValidatePermissions(mock.Anything, "rs-1", mock.Anything). + Return([]string{}, nil) + suite.mockFlowExec.EXPECT().InitiateAndExecute(mock.Anything, mock.MatchedBy( + func(initCtx *flowexec.FlowInitContext) bool { + return initCtx.RuntimeData[flowcm.RuntimeKeyResourceServerIdentifier] == "https://api.example.com" + })).Return(&flowexec.FlowStep{ExecutionID: "exec-1", Status: providers.FlowStatusIncomplete}, nil) + suite.expectStoreAddSuccess() + + resp, cibaErr := suite.service.InitiateBackchannelAuth(context.Background(), &BackchannelAuthRequest{ + LoginHint: "alice", + Scope: "openid read:things", + Resources: []string{"https://api.example.com"}, + }, suite.oauthApp) + + suite.Nil(cibaErr) + suite.NotNil(resp) +} + +func (suite *CIBAServiceTestSuite) TestInitiate_OIDCOnlyLeavesResourceServerIDEmpty() { + suite.mockFlowExec.EXPECT().InitiateAndExecute(mock.Anything, mock.MatchedBy( + func(initCtx *flowexec.FlowInitContext) bool { + return initCtx.RuntimeData[flowcm.RuntimeKeyResourceServerIdentifier] == "" + })).Return(&flowexec.FlowStep{ExecutionID: "exec-1", Status: providers.FlowStatusIncomplete}, nil) + suite.expectStoreAddSuccess() + + resp, cibaErr := suite.service.InitiateBackchannelAuth(context.Background(), &BackchannelAuthRequest{ + LoginHint: "alice", + Scope: "openid profile", + }, suite.oauthApp) + + suite.Nil(cibaErr) + suite.NotNil(resp) +} + func (suite *CIBAServiceTestSuite) TestInitiate_MissingResourceWithPermissionUsesDefault() { suite.expectDefaultResourceServer("rs-1", "https://default.example.com") suite.expectFlowInitiateSuccess() @@ -332,8 +363,8 @@ func (suite *CIBAServiceTestSuite) TestInitiate_MissingResourceWithPermissionUse } func (suite *CIBAServiceTestSuite) TestInitiate_MissingResourceNoDefaultRejects() { - suite.mockServerConfig.EXPECT().GetMergedConfig(mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{}, nil) + suite.mockResourceSvc.EXPECT().GetResourceServerByIdentifier(mock.Anything, ""). + Return(nil, &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "RES-1003"}) resp, cibaErr := suite.service.InitiateBackchannelAuth(context.Background(), &BackchannelAuthRequest{ LoginHint: "alice", @@ -932,7 +963,7 @@ func (suite *CIBAServiceTestSuite) withIssuer() { cfg.JWT.Issuer = testIssuer actorProv := actorprovider.Initialize(suite.mockInboundClient, suite.mockEntityProvider, noopAuthnMgr()) suite.service = newCIBAService(suite.mockStore, suite.mockFlowExec, - suite.mockJWTService, actorProv, suite.mockResourceSvc, suite.mockServerConfig, cfg) + suite.mockJWTService, actorProv, suite.mockResourceSvc, cfg) } func (suite *CIBAServiceTestSuite) validIDTokenHint() string { diff --git a/backend/internal/oauth/oauth2/granthandlers/authorization_code.go b/backend/internal/oauth/oauth2/granthandlers/authorization_code.go index cee4be483c..e7e0bd82df 100644 --- a/backend/internal/oauth/oauth2/granthandlers/authorization_code.go +++ b/backend/internal/oauth/oauth2/granthandlers/authorization_code.go @@ -33,18 +33,16 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/resourceindicators" "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/serverconfig" "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) // authorizationCodeGrantHandler handles the authorization code grant type. type authorizationCodeGrantHandler struct { - authzService authz.AuthorizeServiceInterface - tokenBuilder tokenservice.TokenBuilderInterface - attributeCache attributecache.AttributeCacheServiceInterface - resourceService providers.ResourceServerProvider - serverConfigService serverconfig.ServerConfigService + authzService authz.AuthorizeServiceInterface + tokenBuilder tokenservice.TokenBuilderInterface + attributeCache attributecache.AttributeCacheServiceInterface + resourceService providers.ResourceServerProvider } // newAuthorizationCodeGrantHandler creates a new instance of AuthorizationCodeGrantHandler. @@ -53,14 +51,12 @@ func newAuthorizationCodeGrantHandler( tokenBuilder tokenservice.TokenBuilderInterface, attributeCache attributecache.AttributeCacheServiceInterface, resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, ) GrantHandlerInterface { return &authorizationCodeGrantHandler{ - authzService: authzService, - tokenBuilder: tokenBuilder, - attributeCache: attributeCache, - resourceService: resourceService, - serverConfigService: serverConfigService, + authzService: authzService, + tokenBuilder: tokenBuilder, + attributeCache: attributeCache, + resourceService: resourceService, } } @@ -145,7 +141,7 @@ func (h *authorizationCodeGrantHandler) HandleGrant(ctx context.Context, tokenRe oidcScopes, nonOidcScopes := oauth2utils.SeparateOIDCAndNonOIDCScopes( strings.Join(authorizedScopes, " "), oauthApp.ScopeClaims) targetRS, errResp := resourceindicators.ResolveAudienceBinding( - ctx, h.resourceService, h.serverConfigService, effectiveResources, nonOidcScopes) + ctx, h.resourceService, effectiveResources, nonOidcScopes) if errResp != nil { return nil, errResp } diff --git a/backend/internal/oauth/oauth2/granthandlers/authorization_code_test.go b/backend/internal/oauth/oauth2/granthandlers/authorization_code_test.go index eda0e77c26..27ae7e9725 100644 --- a/backend/internal/oauth/oauth2/granthandlers/authorization_code_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/authorization_code_test.go @@ -39,14 +39,12 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" - "github.com/thunder-id/thunderid/internal/resource" "github.com/thunder-id/thunderid/internal/system/config" "github.com/thunder-id/thunderid/tests/mocks/attributecachemock" "github.com/thunder-id/thunderid/tests/mocks/jose/jwtmock" "github.com/thunder-id/thunderid/tests/mocks/oauth/oauth2/authzmock" "github.com/thunder-id/thunderid/tests/mocks/oauth/oauth2/tokenservicemock" "github.com/thunder-id/thunderid/tests/mocks/resourcemock" - "github.com/thunder-id/thunderid/tests/mocks/serverconfigmock" ) const ( @@ -83,16 +81,15 @@ func convertToStringSlice(groups interface{}) []string { type AuthorizationCodeGrantHandlerTestSuite struct { suite.Suite - handler *authorizationCodeGrantHandler - mockJWTService *jwtmock.JWTServiceInterfaceMock - mockTokenBuilder *tokenservicemock.TokenBuilderInterfaceMock - mockAuthzService *authzmock.AuthorizeServiceInterfaceMock - mockAttrCacheService *attributecachemock.AttributeCacheServiceInterfaceMock - mockResourceService *resourcemock.ResourceServiceInterfaceMock - mockServerConfigService *serverconfigmock.ServerConfigServiceMock - oauthApp *providers.OAuthClient - testAuthzCode authz.AuthorizationCode - testTokenReq *model.TokenRequest + handler *authorizationCodeGrantHandler + mockJWTService *jwtmock.JWTServiceInterfaceMock + mockTokenBuilder *tokenservicemock.TokenBuilderInterfaceMock + mockAuthzService *authzmock.AuthorizeServiceInterfaceMock + mockAttrCacheService *attributecachemock.AttributeCacheServiceInterfaceMock + mockResourceService *resourcemock.ResourceServiceInterfaceMock + oauthApp *providers.OAuthClient + testAuthzCode authz.AuthorizationCode + testTokenReq *model.TokenRequest } func TestAuthorizationCodeGrantHandlerSuite(t *testing.T) { @@ -113,30 +110,26 @@ func (suite *AuthorizationCodeGrantHandlerTestSuite) SetupTest() { suite.mockAuthzService = authzmock.NewAuthorizeServiceInterfaceMock(suite.T()) suite.mockAttrCacheService = attributecachemock.NewAttributeCacheServiceInterfaceMock(suite.T()) suite.mockResourceService = resourcemock.NewResourceServiceInterfaceMock(suite.T()) - suite.mockServerConfigService = serverconfigmock.NewServerConfigServiceMock(suite.T()) - // Resolve any explicit resource identifier to an echo RS (ID == Identifier). + // Resolve any explicit resource identifier to an echo RS (ID == Identifier); an empty identifier + // resolves to the configured default resource server, as the default-aware provider does. suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, mock.Anything). Return(func(_ context.Context, identifier string) *providers.ResourceServer { + if identifier == "" { + return &providers.ResourceServer{ID: testDefaultRSID, Identifier: testDefaultRSIdentifier} + } return &providers.ResourceServer{ID: identifier, Identifier: identifier} }, func(_ context.Context, _ string) *tidcommon.ServiceError { return nil }).Maybe() - // Resolve the configured default RS ID (used when no resource is supplied). - suite.mockResourceService.On("GetResourceServer", mock.Anything, testDefaultRSID). - Return(&providers.ResourceServer{ID: testDefaultRSID, Identifier: testDefaultRSIdentifier}, nil).Maybe() suite.mockResourceService.On("ValidatePermissions", mock.Anything, mock.Anything, mock.Anything). Return([]string{}, nil).Maybe() - // Default deployment resolves to the configured default resource server. - suite.mockServerConfigService.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{ResourceServerID: testDefaultRSID}, nil).Maybe() suite.handler = &authorizationCodeGrantHandler{ - tokenBuilder: suite.mockTokenBuilder, - authzService: suite.mockAuthzService, - attributeCache: suite.mockAttrCacheService, - resourceService: suite.mockResourceService, - serverConfigService: suite.mockServerConfigService, + tokenBuilder: suite.mockTokenBuilder, + authzService: suite.mockAuthzService, + attributeCache: suite.mockAttrCacheService, + resourceService: suite.mockResourceService, } suite.oauthApp = &providers.OAuthClient{ @@ -177,21 +170,25 @@ func (suite *AuthorizationCodeGrantHandlerTestSuite) SetupTest() { } } -// stubDefaultResourceServer wires the resource and server-config mocks so that a request carrying no -// resource resolves to the configured default resource server. +// stubDefaultResourceServer wires the resource mock so that a request carrying no resource (empty +// identifier) resolves to the configured default resource server. func (suite *AuthorizationCodeGrantHandlerTestSuite) stubDefaultResourceServer() { - suite.mockResourceService.On("GetResourceServer", mock.Anything, testDefaultRSID). - Return(&providers.ResourceServer{ID: testDefaultRSID, Identifier: testDefaultRSIdentifier}, nil).Maybe() + suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, mock.Anything). + Return(func(_ context.Context, identifier string) *providers.ResourceServer { + if identifier == "" { + return &providers.ResourceServer{ID: testDefaultRSID, Identifier: testDefaultRSIdentifier} + } + return &providers.ResourceServer{ID: identifier, Identifier: identifier} + }, func(_ context.Context, _ string) *tidcommon.ServiceError { + return nil + }).Maybe() suite.mockResourceService.On("ValidatePermissions", mock.Anything, mock.Anything, mock.Anything). Return([]string{}, nil).Maybe() - suite.mockServerConfigService.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{ResourceServerID: testDefaultRSID}, nil).Maybe() } func (suite *AuthorizationCodeGrantHandlerTestSuite) TestNewAuthorizationCodeGrantHandler() { handler := newAuthorizationCodeGrantHandler( - suite.mockAuthzService, suite.mockTokenBuilder, suite.mockAttrCacheService, suite.mockResourceService, - suite.mockServerConfigService) + suite.mockAuthzService, suite.mockTokenBuilder, suite.mockAttrCacheService, suite.mockResourceService) assert.NotNil(suite.T(), handler) assert.Implements(suite.T(), (*GrantHandlerInterface)(nil), handler) } @@ -369,14 +366,12 @@ func (suite *AuthorizationCodeGrantHandlerTestSuite) TestHandleGrant_ActorClaim( suite.mockTokenBuilder = tokenservicemock.NewTokenBuilderInterfaceMock(suite.T()) suite.mockAttrCacheService = attributecachemock.NewAttributeCacheServiceInterfaceMock(suite.T()) suite.mockResourceService = resourcemock.NewResourceServiceInterfaceMock(suite.T()) - suite.mockServerConfigService = serverconfigmock.NewServerConfigServiceMock(suite.T()) suite.stubDefaultResourceServer() suite.handler = &authorizationCodeGrantHandler{ - tokenBuilder: suite.mockTokenBuilder, - authzService: suite.mockAuthzService, - attributeCache: suite.mockAttrCacheService, - resourceService: suite.mockResourceService, - serverConfigService: suite.mockServerConfigService, + tokenBuilder: suite.mockTokenBuilder, + authzService: suite.mockAuthzService, + attributeCache: suite.mockAttrCacheService, + resourceService: suite.mockResourceService, } oauthApp := &providers.OAuthClient{ @@ -651,14 +646,12 @@ func (suite *AuthorizationCodeGrantHandlerTestSuite) TestHandleGrant_WithGroups( suite.mockTokenBuilder = tokenservicemock.NewTokenBuilderInterfaceMock(suite.T()) suite.mockAttrCacheService = attributecachemock.NewAttributeCacheServiceInterfaceMock(suite.T()) suite.mockResourceService = resourcemock.NewResourceServiceInterfaceMock(suite.T()) - suite.mockServerConfigService = serverconfigmock.NewServerConfigServiceMock(suite.T()) suite.stubDefaultResourceServer() suite.handler = &authorizationCodeGrantHandler{ - tokenBuilder: suite.mockTokenBuilder, - authzService: suite.mockAuthzService, - attributeCache: suite.mockAttrCacheService, - resourceService: suite.mockResourceService, - serverConfigService: suite.mockServerConfigService, + tokenBuilder: suite.mockTokenBuilder, + authzService: suite.mockAuthzService, + attributeCache: suite.mockAttrCacheService, + resourceService: suite.mockResourceService, } accessTokenAttrs := []string{"email", "username"} @@ -853,14 +846,12 @@ func (suite *AuthorizationCodeGrantHandlerTestSuite) TestHandleGrant_WithEmptyGr suite.mockTokenBuilder = tokenservicemock.NewTokenBuilderInterfaceMock(suite.T()) suite.mockAttrCacheService = attributecachemock.NewAttributeCacheServiceInterfaceMock(suite.T()) suite.mockResourceService = resourcemock.NewResourceServiceInterfaceMock(suite.T()) - suite.mockServerConfigService = serverconfigmock.NewServerConfigServiceMock(suite.T()) suite.stubDefaultResourceServer() suite.handler = &authorizationCodeGrantHandler{ - tokenBuilder: suite.mockTokenBuilder, - authzService: suite.mockAuthzService, - attributeCache: suite.mockAttrCacheService, - resourceService: suite.mockResourceService, - serverConfigService: suite.mockServerConfigService, + tokenBuilder: suite.mockTokenBuilder, + authzService: suite.mockAuthzService, + attributeCache: suite.mockAttrCacheService, + resourceService: suite.mockResourceService, } accessTokenAttrs := []string{"email", "username"} diff --git a/backend/internal/oauth/oauth2/granthandlers/client_credentials.go b/backend/internal/oauth/oauth2/granthandlers/client_credentials.go index be21c51927..0cf7167953 100644 --- a/backend/internal/oauth/oauth2/granthandlers/client_credentials.go +++ b/backend/internal/oauth/oauth2/granthandlers/client_credentials.go @@ -27,19 +27,17 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" "github.com/thunder-id/thunderid/internal/oauth/oauth2/resourceindicators" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" - "github.com/thunder-id/thunderid/internal/serverconfig" "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) // clientCredentialsGrantHandler handles the client credentials grant type. type clientCredentialsGrantHandler struct { - tokenBuilder tokenservice.TokenBuilderInterface - ouService providers.OrganizationUnitProvider - authzService providers.AuthorizationProvider - actorProvider providers.ActorProvider - resourceService providers.ResourceServerProvider - serverConfigService serverconfig.ServerConfigService + tokenBuilder tokenservice.TokenBuilderInterface + ouService providers.OrganizationUnitProvider + authzService providers.AuthorizationProvider + actorProvider providers.ActorProvider + resourceService providers.ResourceServerProvider } // newClientCredentialsGrantHandler creates a new instance of ClientCredentialsGrantHandler. @@ -49,15 +47,13 @@ func newClientCredentialsGrantHandler( authzService providers.AuthorizationProvider, actorProvider providers.ActorProvider, resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, ) GrantHandlerInterface { return &clientCredentialsGrantHandler{ - tokenBuilder: tokenBuilder, - ouService: ouService, - authzService: authzService, - actorProvider: actorProvider, - resourceService: resourceService, - serverConfigService: serverConfigService, + tokenBuilder: tokenBuilder, + ouService: ouService, + authzService: authzService, + actorProvider: actorProvider, + resourceService: resourceService, } } @@ -92,7 +88,7 @@ func (h *clientCredentialsGrantHandler) HandleGrant(ctx context.Context, tokenRe // audience is the app's configured default audiences (falling back to the client_id) and it // carries no scopes. targetRS, errResp := resourceindicators.ResolveAudienceBinding( - ctx, h.resourceService, h.serverConfigService, tokenRequest.Resources, scopes) + ctx, h.resourceService, tokenRequest.Resources, scopes) if errResp != nil { return nil, errResp } diff --git a/backend/internal/oauth/oauth2/granthandlers/client_credentials_test.go b/backend/internal/oauth/oauth2/granthandlers/client_credentials_test.go index b18ae26189..dfdbde689c 100644 --- a/backend/internal/oauth/oauth2/granthandlers/client_credentials_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/client_credentials_test.go @@ -37,7 +37,6 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" - "github.com/thunder-id/thunderid/internal/resource" "github.com/thunder-id/thunderid/internal/system/config" "github.com/thunder-id/thunderid/tests/mocks/actorprovidermock" "github.com/thunder-id/thunderid/tests/mocks/authzmock" @@ -45,7 +44,6 @@ import ( "github.com/thunder-id/thunderid/tests/mocks/oauth/oauth2/tokenservicemock" "github.com/thunder-id/thunderid/tests/mocks/oumock" "github.com/thunder-id/thunderid/tests/mocks/resourcemock" - "github.com/thunder-id/thunderid/tests/mocks/serverconfigmock" ) // nolint:gosec // Test token, not a real credential @@ -65,7 +63,6 @@ type ClientCredentialsGrantHandlerTestSuite struct { mockAuthzService *authzmock.AuthorizationProviderMock mockEntityProvider *actorprovidermock.ActorProviderMock mockResourceService *resourcemock.ResourceServiceInterfaceMock - mockServerConfig *serverconfigmock.ServerConfigServiceMock handler *clientCredentialsGrantHandler oauthApp *providers.OAuthClient } @@ -91,30 +88,27 @@ func (suite *ClientCredentialsGrantHandlerTestSuite) SetupTest() { suite.mockAuthzService = authzmock.NewAuthorizationProviderMock(suite.T()) suite.mockEntityProvider = actorprovidermock.NewActorProviderMock(suite.T()) suite.mockResourceService = resourcemock.NewResourceServiceInterfaceMock(suite.T()) - suite.mockServerConfig = serverconfigmock.NewServerConfigServiceMock(suite.T()) - // Explicit resource: resolve the identifier to an RS whose ID and Identifier are the identifier. + // Explicit resource resolves to an RS whose ID and Identifier are the identifier; an empty + // identifier resolves to the configured default resource server, as the default-aware provider does. suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, mock.Anything). Return(func(_ context.Context, identifier string) *providers.ResourceServer { + if identifier == "" { + return &providers.ResourceServer{ID: defaultRSID, Identifier: defaultRSIdentifier} + } return &providers.ResourceServer{ID: identifier, Identifier: identifier} }, func(_ context.Context, _ string) *tidcommon.ServiceError { return nil }).Maybe() - // No resource: fall back to the configured default resource server. - suite.mockServerConfig.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{ResourceServerID: defaultRSID}, nil).Maybe() - suite.mockResourceService.On("GetResourceServer", mock.Anything, defaultRSID). - Return(&providers.ResourceServer{ID: defaultRSID, Identifier: defaultRSIdentifier}, nil).Maybe() suite.mockResourceService.On("ValidatePermissions", mock.Anything, mock.Anything, mock.Anything). Return([]string{}, nil).Maybe() suite.handler = &clientCredentialsGrantHandler{ - tokenBuilder: suite.mockTokenBuilder, - ouService: suite.mockOUService, - authzService: suite.mockAuthzService, - actorProvider: suite.mockEntityProvider, - resourceService: suite.mockResourceService, - serverConfigService: suite.mockServerConfig, + tokenBuilder: suite.mockTokenBuilder, + ouService: suite.mockOUService, + authzService: suite.mockAuthzService, + actorProvider: suite.mockEntityProvider, + resourceService: suite.mockResourceService, } suite.mockEntityProvider.On("GetActorGroups", mock.Anything). Return([]providers.EntityGroup{}, nil).Maybe() @@ -172,7 +166,7 @@ func mockEvaluateAccessBatch( func (suite *ClientCredentialsGrantHandlerTestSuite) TestNewClientCredentialsGrantHandler() { handler := newClientCredentialsGrantHandler( suite.mockTokenBuilder, suite.mockOUService, suite.mockAuthzService, - suite.mockEntityProvider, suite.mockResourceService, suite.mockServerConfig) + suite.mockEntityProvider, suite.mockResourceService) assert.NotNil(suite.T(), handler) assert.Implements(suite.T(), (*GrantHandlerInterface)(nil), handler) } @@ -810,20 +804,19 @@ func (suite *ClientCredentialsGrantHandlerTestSuite) TestHandleGrant_NoResourceN mockAuthzService := authzmock.NewAuthorizationProviderMock(suite.T()) mockResourceService := resourcemock.NewResourceServiceInterfaceMock(suite.T()) mockEntityProvider := actorprovidermock.NewActorProviderMock(suite.T()) - mockServerConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) handler := &clientCredentialsGrantHandler{ - tokenBuilder: mockTokenBuilder, - ouService: suite.mockOUService, - authzService: mockAuthzService, - actorProvider: mockEntityProvider, - resourceService: mockResourceService, - serverConfigService: mockServerConfig, + tokenBuilder: mockTokenBuilder, + ouService: suite.mockOUService, + authzService: mockAuthzService, + actorProvider: mockEntityProvider, + resourceService: mockResourceService, } - // No default resource server configured. - mockServerConfig.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{}, nil) + // No default resource server configured: the provider resolves the empty identifier to a client + // error, which HandleGrant maps to invalid_target. + mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, ""). + Return(nil, &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "RES-1003"}) tokenRequest := &model.TokenRequest{ GrantType: "client_credentials", diff --git a/backend/internal/oauth/oauth2/granthandlers/init.go b/backend/internal/oauth/oauth2/granthandlers/init.go index ca1f547687..d29a669ee8 100644 --- a/backend/internal/oauth/oauth2/granthandlers/init.go +++ b/backend/internal/oauth/oauth2/granthandlers/init.go @@ -25,7 +25,6 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/ciba" "github.com/thunder-id/thunderid/internal/oauth/oauth2/revocation" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" - "github.com/thunder-id/thunderid/internal/serverconfig" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) @@ -43,7 +42,6 @@ func Initialize( authzService providers.AuthorizationProvider, actorProvider providers.ActorProvider, resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, cibaService ciba.CIBAServiceInterface, refreshTokenRevoker revocation.RefreshTokenRevokerInterface, cfg oauthconfig.Config, @@ -58,7 +56,6 @@ func Initialize( authzService, actorProvider, resourceService, - serverConfigService, cibaService, refreshTokenRevoker, cfg, diff --git a/backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go b/backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go index b2552c8ba4..a7c62163aa 100644 --- a/backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go +++ b/backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go @@ -28,7 +28,6 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/resourceindicators" "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/serverconfig" "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) @@ -36,10 +35,9 @@ import ( // jwtBearerGrantHandler handles the jwt-bearer grant type used to present an ID-JAG assertion // (draft-ietf-oauth-identity-assertion-authz-grant) issued by a trusted external IdP. type jwtBearerGrantHandler struct { - tokenBuilder tokenservice.TokenBuilderInterface - tokenValidator tokenservice.TokenValidatorInterface - resourceService providers.ResourceServerProvider - serverConfigService serverconfig.ServerConfigService + tokenBuilder tokenservice.TokenBuilderInterface + tokenValidator tokenservice.TokenValidatorInterface + resourceService providers.ResourceServerProvider } // newJWTBearerGrantHandler creates a new instance of jwtBearerGrantHandler. @@ -47,13 +45,11 @@ func newJWTBearerGrantHandler( tokenBuilder tokenservice.TokenBuilderInterface, tokenValidator tokenservice.TokenValidatorInterface, resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, ) GrantHandlerInterface { return &jwtBearerGrantHandler{ - tokenBuilder: tokenBuilder, - tokenValidator: tokenValidator, - resourceService: resourceService, - serverConfigService: serverConfigService, + tokenBuilder: tokenBuilder, + tokenValidator: tokenValidator, + resourceService: resourceService, } } @@ -142,7 +138,7 @@ func (h *jwtBearerGrantHandler) HandleGrant(ctx context.Context, tokenRequest *m oidcScopes, permissionScopes := oauth2utils.SeparateOIDCAndNonOIDCScopes( tokenservice.JoinScopes(grantedScopes), oauthApp.ScopeClaims) targetRS, errResp := resourceindicators.ResolveAudienceBinding( - ctx, h.resourceService, h.serverConfigService, resources, permissionScopes) + ctx, h.resourceService, resources, permissionScopes) if errResp != nil { return nil, errResp } diff --git a/backend/internal/oauth/oauth2/granthandlers/jwt_bearer_test.go b/backend/internal/oauth/oauth2/granthandlers/jwt_bearer_test.go index 34b2b11235..611279998e 100644 --- a/backend/internal/oauth/oauth2/granthandlers/jwt_bearer_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/jwt_bearer_test.go @@ -32,11 +32,10 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" - "github.com/thunder-id/thunderid/internal/resource" + tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" "github.com/thunder-id/thunderid/tests/mocks/oauth/oauth2/tokenservicemock" "github.com/thunder-id/thunderid/tests/mocks/resourcemock" - "github.com/thunder-id/thunderid/tests/mocks/serverconfigmock" ) const testAssertion = "test-id-jag-assertion" //nolint:gosec // Test assertion, not a real credential @@ -53,7 +52,6 @@ type JWTBearerGrantHandlerTestSuite struct { mockTokenBuilder *tokenservicemock.TokenBuilderInterfaceMock mockTokenValidator *tokenservicemock.TokenValidatorInterfaceMock mockResourceService *resourcemock.ResourceServiceInterfaceMock - mockServerConfigSvc *serverconfigmock.ServerConfigServiceMock handler *jwtBearerGrantHandler oauthApp *providers.OAuthClient } @@ -66,20 +64,17 @@ func (suite *JWTBearerGrantHandlerTestSuite) SetupTest() { suite.mockTokenBuilder = tokenservicemock.NewTokenBuilderInterfaceMock(suite.T()) suite.mockTokenValidator = tokenservicemock.NewTokenValidatorInterfaceMock(suite.T()) suite.mockResourceService = resourcemock.NewResourceServiceInterfaceMock(suite.T()) - suite.mockServerConfigSvc = serverconfigmock.NewServerConfigServiceMock(suite.T()) - // A request that resolves no explicit resource falls back to the deployment default RS. - suite.mockServerConfigSvc.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{ResourceServerID: testJWTBearerDefaultRSID}, nil).Maybe() - suite.mockResourceService.On("GetResourceServer", mock.Anything, testJWTBearerDefaultRSID). + // A request that resolves no explicit resource (empty identifier) falls back to the deployment + // default RS, as the default-aware provider does. + suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, ""). Return(&providers.ResourceServer{ ID: testJWTBearerDefaultRSID, Identifier: testJWTBearerDefaultRSAudience, }, nil).Maybe() suite.handler = &jwtBearerGrantHandler{ - tokenBuilder: suite.mockTokenBuilder, - tokenValidator: suite.mockTokenValidator, - resourceService: suite.mockResourceService, - serverConfigService: suite.mockServerConfigSvc, + tokenBuilder: suite.mockTokenBuilder, + tokenValidator: suite.mockTokenValidator, + resourceService: suite.mockResourceService, } suite.oauthApp = &providers.OAuthClient{ @@ -99,7 +94,7 @@ func (suite *JWTBearerGrantHandlerTestSuite) SetupTest() { func (suite *JWTBearerGrantHandlerTestSuite) TestNewJWTBearerGrantHandler() { handler := newJWTBearerGrantHandler(suite.mockTokenBuilder, suite.mockTokenValidator, - suite.mockResourceService, suite.mockServerConfigSvc) + suite.mockResourceService) assert.NotNil(suite.T(), handler) assert.Implements(suite.T(), (*GrantHandlerInterface)(nil), handler) } @@ -385,14 +380,13 @@ func (suite *JWTBearerGrantHandlerTestSuite) TestHandleGrant_AssertionResource_A // no defaultResourceServer is configured, there is no target to bind to and the request is rejected // with invalid_target. func (suite *JWTBearerGrantHandlerTestSuite) TestHandleGrant_AssertionNoResource_NoDefault_InvalidTarget() { - scfg := serverconfigmock.NewServerConfigServiceMock(suite.T()) - scfg.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{ResourceServerID: ""}, nil) + rsvc := resourcemock.NewResourceServiceInterfaceMock(suite.T()) + rsvc.On("GetResourceServerByIdentifier", mock.Anything, ""). + Return(nil, &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "RES-1003"}) handler := &jwtBearerGrantHandler{ - tokenBuilder: suite.mockTokenBuilder, - tokenValidator: suite.mockTokenValidator, - resourceService: suite.mockResourceService, - serverConfigService: scfg, + tokenBuilder: suite.mockTokenBuilder, + tokenValidator: suite.mockTokenValidator, + resourceService: rsvc, } tokenRequest := &model.TokenRequest{ diff --git a/backend/internal/oauth/oauth2/granthandlers/provider.go b/backend/internal/oauth/oauth2/granthandlers/provider.go index 39165cce15..4592db3de6 100644 --- a/backend/internal/oauth/oauth2/granthandlers/provider.go +++ b/backend/internal/oauth/oauth2/granthandlers/provider.go @@ -28,7 +28,6 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" "github.com/thunder-id/thunderid/internal/oauth/oauth2/revocation" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" - "github.com/thunder-id/thunderid/internal/serverconfig" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) @@ -59,7 +58,6 @@ func newGrantHandlerProvider( rbacAuthzService providers.AuthorizationProvider, actorProvider providers.ActorProvider, resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, cibaService ciba.CIBAServiceInterface, refreshTokenRevoker revocation.RefreshTokenRevokerInterface, cfg oauthconfig.Config, @@ -68,20 +66,20 @@ func newGrantHandlerProvider( grantProvider := &GrantHandlerProvider{} if isGrantTypeAllowed(allowedGrantTypes, providers.GrantTypeClientCredentials) { grantProvider.clientCredentialsGrantHandler = newClientCredentialsGrantHandler( - tokenBuilder, ouService, rbacAuthzService, actorProvider, resourceService, serverConfigService) + tokenBuilder, ouService, rbacAuthzService, actorProvider, resourceService) } if isGrantTypeAllowed(allowedGrantTypes, providers.GrantTypeAuthorizationCode) { grantProvider.authorizationCodeGrantHandler = newAuthorizationCodeGrantHandler( - authzService, tokenBuilder, attrCacheService, resourceService, serverConfigService) + authzService, tokenBuilder, attrCacheService, resourceService) } if isGrantTypeAllowed(allowedGrantTypes, providers.GrantTypeRefreshToken) { grantProvider.refreshTokenGrantHandler = newRefreshTokenGrantHandler( jwtService, tokenBuilder, tokenValidator, attrCacheService, resourceService, - serverConfigService, refreshTokenRevoker, cfg) + refreshTokenRevoker, cfg) } if isGrantTypeAllowed(allowedGrantTypes, providers.GrantTypeTokenExchange) { grantProvider.tokenExchangeGrantHandler = newTokenExchangeGrantHandler( - tokenBuilder, tokenValidator, rbacAuthzService, actorProvider, resourceService, serverConfigService) + tokenBuilder, tokenValidator, rbacAuthzService, actorProvider, resourceService) } if isGrantTypeAllowed(allowedGrantTypes, providers.GrantTypeCIBA) { grantProvider.cibaGrantHandler = newCIBAGrantHandler(cibaService, tokenBuilder, attrCacheService, @@ -89,7 +87,7 @@ func newGrantHandlerProvider( } if isGrantTypeAllowed(allowedGrantTypes, providers.GrantTypeJWTBearer) { grantProvider.jwtBearerGrantHandler = newJWTBearerGrantHandler( - tokenBuilder, tokenValidator, resourceService, serverConfigService) + tokenBuilder, tokenValidator, resourceService) } return grantProvider } diff --git a/backend/internal/oauth/oauth2/granthandlers/provider_test.go b/backend/internal/oauth/oauth2/granthandlers/provider_test.go index f384645357..4893b3360f 100644 --- a/backend/internal/oauth/oauth2/granthandlers/provider_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/provider_test.go @@ -36,7 +36,6 @@ import ( "github.com/thunder-id/thunderid/tests/mocks/oauth/oauth2/tokenservicemock" "github.com/thunder-id/thunderid/tests/mocks/oumock" "github.com/thunder-id/thunderid/tests/mocks/resourcemock" - "github.com/thunder-id/thunderid/tests/mocks/serverconfigmock" "github.com/thunder-id/thunderid/tests/testhelpers" ) @@ -52,7 +51,6 @@ type GrantHandlerProviderTestSuite struct { mockRBACAuthzService *rbacauthzmock.AuthorizationProviderMock mockEntityProvider *actorprovidermock.ActorProviderMock mockResourceService *resourcemock.ResourceServiceInterfaceMock - mockServerConfig *serverconfigmock.ServerConfigServiceMock mockCIBAService *cibamock.CIBAServiceInterfaceMock } @@ -70,7 +68,6 @@ func (suite *GrantHandlerProviderTestSuite) SetupTest() { suite.mockRBACAuthzService = rbacauthzmock.NewAuthorizationProviderMock(suite.T()) suite.mockEntityProvider = actorprovidermock.NewActorProviderMock(suite.T()) suite.mockResourceService = resourcemock.NewResourceServiceInterfaceMock(suite.T()) - suite.mockServerConfig = serverconfigmock.NewServerConfigServiceMock(suite.T()) suite.mockCIBAService = cibamock.NewCIBAServiceInterfaceMock(suite.T()) suite.provider = newGrantHandlerProvider( suite.mockJWTService, @@ -82,7 +79,6 @@ func (suite *GrantHandlerProviderTestSuite) SetupTest() { suite.mockRBACAuthzService, suite.mockEntityProvider, suite.mockResourceService, - suite.mockServerConfig, suite.mockCIBAService, revocationmock.NewRefreshTokenRevokerInterfaceMock(suite.T()), testhelpers.OAuthConfig(), @@ -100,7 +96,6 @@ func (suite *GrantHandlerProviderTestSuite) TestNewGrantHandlerProvider() { suite.mockRBACAuthzService, suite.mockEntityProvider, suite.mockResourceService, - suite.mockServerConfig, suite.mockCIBAService, revocationmock.NewRefreshTokenRevokerInterfaceMock(suite.T()), testhelpers.OAuthConfig(), diff --git a/backend/internal/oauth/oauth2/granthandlers/refresh_token.go b/backend/internal/oauth/oauth2/granthandlers/refresh_token.go index 4adaeb9768..1fc45fe2bd 100644 --- a/backend/internal/oauth/oauth2/granthandlers/refresh_token.go +++ b/backend/internal/oauth/oauth2/granthandlers/refresh_token.go @@ -37,21 +37,19 @@ import ( "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/serverconfig" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/log" ) // refreshTokenGrantHandler handles the refresh token grant type. type refreshTokenGrantHandler struct { - cfg oauthconfig.Config - jwtService jwt.JWTServiceInterface - tokenBuilder tokenservice.TokenBuilderInterface - tokenValidator tokenservice.TokenValidatorInterface - attrCacheService attributecache.AttributeCacheServiceInterface - resourceService providers.ResourceServerProvider - serverConfigService serverconfig.ServerConfigService - refreshRevoker revocation.RefreshTokenRevokerInterface + cfg oauthconfig.Config + jwtService jwt.JWTServiceInterface + tokenBuilder tokenservice.TokenBuilderInterface + tokenValidator tokenservice.TokenValidatorInterface + attrCacheService attributecache.AttributeCacheServiceInterface + resourceService providers.ResourceServerProvider + refreshRevoker revocation.RefreshTokenRevokerInterface } // newRefreshTokenGrantHandler creates a new instance of RefreshTokenGrantHandler. @@ -61,19 +59,17 @@ func newRefreshTokenGrantHandler( tokenValidator tokenservice.TokenValidatorInterface, attrCacheService attributecache.AttributeCacheServiceInterface, resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, refreshRevoker revocation.RefreshTokenRevokerInterface, cfg oauthconfig.Config, ) RefreshTokenGrantHandlerInterface { return &refreshTokenGrantHandler{ - cfg: cfg, - jwtService: jwtService, - tokenBuilder: tokenBuilder, - tokenValidator: tokenValidator, - attrCacheService: attrCacheService, - resourceService: resourceService, - serverConfigService: serverConfigService, - refreshRevoker: refreshRevoker, + cfg: cfg, + jwtService: jwtService, + tokenBuilder: tokenBuilder, + tokenValidator: tokenValidator, + attrCacheService: attrCacheService, + resourceService: resourceService, + refreshRevoker: refreshRevoker, } } diff --git a/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go b/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go index 19c6e9af19..31bdd8a5c6 100644 --- a/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go @@ -47,7 +47,6 @@ import ( "github.com/thunder-id/thunderid/tests/mocks/oauth/oauth2/revocationmock" "github.com/thunder-id/thunderid/tests/mocks/oauth/oauth2/tokenservicemock" "github.com/thunder-id/thunderid/tests/mocks/resourcemock" - "github.com/thunder-id/thunderid/tests/mocks/serverconfigmock" "github.com/thunder-id/thunderid/tests/testhelpers" ) @@ -67,7 +66,6 @@ type RefreshTokenGrantHandlerTestSuite struct { mockTokenValidator *tokenservicemock.TokenValidatorInterfaceMock mockAttrCacheService *attributecachemock.AttributeCacheServiceInterfaceMock mockResourceService *resourcemock.ResourceServiceInterfaceMock - mockServerConfigSvc *serverconfigmock.ServerConfigServiceMock mockRefreshRevoker *revocationmock.RefreshTokenRevokerInterfaceMock oauthApp *providers.OAuthClient validRefreshToken string @@ -103,7 +101,6 @@ func (suite *RefreshTokenGrantHandlerTestSuite) SetupTest() { suite.mockTokenValidator = tokenservicemock.NewTokenValidatorInterfaceMock(suite.T()) suite.mockAttrCacheService = attributecachemock.NewAttributeCacheServiceInterfaceMock(suite.T()) suite.mockResourceService = resourcemock.NewResourceServiceInterfaceMock(suite.T()) - suite.mockServerConfigSvc = serverconfigmock.NewServerConfigServiceMock(suite.T()) suite.mockRefreshRevoker = revocationmock.NewRefreshTokenRevokerInterfaceMock(suite.T()) suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, mock.Anything). @@ -157,7 +154,6 @@ func (suite *RefreshTokenGrantHandlerTestSuite) rebuildHandlerWithConfig() { suite.mockTokenValidator, suite.mockAttrCacheService, suite.mockResourceService, - suite.mockServerConfigSvc, suite.mockRefreshRevoker, suite.testCfg, ).(*refreshTokenGrantHandler) @@ -172,7 +168,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestNewRefreshTokenGrantHandler( suite.mockTokenBuilder, suite.mockTokenValidator, suite.mockAttrCacheService, - suite.mockResourceService, suite.mockServerConfigSvc, suite.mockRefreshRevoker, testhelpers.OAuthConfig()) + suite.mockResourceService, suite.mockRefreshRevoker, testhelpers.OAuthConfig()) assert.NotNil(suite.T(), handler) assert.Implements(suite.T(), (*RefreshTokenGrantHandlerInterface)(nil), handler) } @@ -610,7 +606,6 @@ func (suite *RefreshTokenGrantHandlerTestSuite) TestHandleGrant_RevokePreviousOn suite.mockTokenValidator, suite.mockAttrCacheService, suite.mockResourceService, - suite.mockServerConfigSvc, nil, suite.testCfg, ).(*refreshTokenGrantHandler) diff --git a/backend/internal/oauth/oauth2/granthandlers/token_exchange.go b/backend/internal/oauth/oauth2/granthandlers/token_exchange.go index 21dd0ade62..7889036d53 100644 --- a/backend/internal/oauth/oauth2/granthandlers/token_exchange.go +++ b/backend/internal/oauth/oauth2/granthandlers/token_exchange.go @@ -30,19 +30,17 @@ import ( "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/serverconfig" "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) // tokenExchangeGrantHandler handles the token exchange grant type. type tokenExchangeGrantHandler struct { - tokenBuilder tokenservice.TokenBuilderInterface - tokenValidator tokenservice.TokenValidatorInterface - authzService providers.AuthorizationProvider - actorProvider providers.ActorProvider - resourceService providers.ResourceServerProvider - serverConfigService serverconfig.ServerConfigService + tokenBuilder tokenservice.TokenBuilderInterface + tokenValidator tokenservice.TokenValidatorInterface + authzService providers.AuthorizationProvider + actorProvider providers.ActorProvider + resourceService providers.ResourceServerProvider } // newTokenExchangeGrantHandler creates a new instance of tokenExchangeGrantHandler. @@ -52,15 +50,13 @@ func newTokenExchangeGrantHandler( authzService providers.AuthorizationProvider, actorProvider providers.ActorProvider, resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, ) GrantHandlerInterface { return &tokenExchangeGrantHandler{ - tokenBuilder: tokenBuilder, - tokenValidator: tokenValidator, - authzService: authzService, - actorProvider: actorProvider, - resourceService: resourceService, - serverConfigService: serverConfigService, + tokenBuilder: tokenBuilder, + tokenValidator: tokenValidator, + authzService: authzService, + actorProvider: actorProvider, + resourceService: resourceService, } } @@ -228,7 +224,7 @@ func (h *tokenExchangeGrantHandler) HandleGrant(ctx context.Context, tokenReques // and carries no resource is not bound to a resource server: its audience is the app's configured // default audiences, falling back to the client_id. targetRS, resErr := resourceindicators.ResolveAudienceBinding( - ctx, h.resourceService, h.serverConfigService, tokenRequest.Resources, permissionScopes) + ctx, h.resourceService, tokenRequest.Resources, permissionScopes) if resErr != nil { return nil, resErr } diff --git a/backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go b/backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go index 582f24c6cf..017fd8e795 100644 --- a/backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/token_exchange_test.go @@ -41,14 +41,12 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" "github.com/thunder-id/thunderid/internal/oauth/oauth2/revocation" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" - "github.com/thunder-id/thunderid/internal/resource" "github.com/thunder-id/thunderid/internal/system/config" "github.com/thunder-id/thunderid/tests/mocks/actorprovidermock" "github.com/thunder-id/thunderid/tests/mocks/authzmock" "github.com/thunder-id/thunderid/tests/mocks/jose/jwtmock" "github.com/thunder-id/thunderid/tests/mocks/oauth/oauth2/tokenservicemock" "github.com/thunder-id/thunderid/tests/mocks/resourcemock" - "github.com/thunder-id/thunderid/tests/mocks/serverconfigmock" ) const ( @@ -74,7 +72,6 @@ type TokenExchangeGrantHandlerTestSuite struct { mockAuthzService *authzmock.AuthorizationProviderMock mockActorProvider *actorprovidermock.ActorProviderMock mockResourceService *resourcemock.ResourceServiceInterfaceMock - mockServerConfigSvc *serverconfigmock.ServerConfigServiceMock handler *tokenExchangeGrantHandler oauthApp *providers.OAuthClient } @@ -100,7 +97,6 @@ func (suite *TokenExchangeGrantHandlerTestSuite) SetupTest() { suite.mockAuthzService = authzmock.NewAuthorizationProviderMock(suite.T()) suite.mockActorProvider = actorprovidermock.NewActorProviderMock(suite.T()) suite.mockResourceService = resourcemock.NewResourceServiceInterfaceMock(suite.T()) - suite.mockServerConfigSvc = serverconfigmock.NewServerConfigServiceMock(suite.T()) suite.mockActorProvider.On("GetActorGroups", mock.Anything). Return([]providers.EntityGroup{}, nil).Maybe() suite.mockAuthzService.On("EvaluateAccessBatch", mock.Anything, mock.Anything). @@ -115,30 +111,29 @@ func (suite *TokenExchangeGrantHandlerTestSuite) SetupTest() { } return &providers.AccessEvaluationsResponse{Evaluations: evaluations} }, nil).Maybe() - // Explicit resource parameter resolves to an RS whose identifier equals the resource URI. + // Explicit resource parameter resolves to an RS whose identifier equals the resource URI; an + // empty identifier resolves to the configured default resource server, as the default-aware + // provider does. suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, mock.Anything). Return(func(_ context.Context, identifier string) *providers.ResourceServer { + if identifier == "" { + return &providers.ResourceServer{ + ID: testTokenExchangeDefaultRSID, + Identifier: testTokenExchangeDefaultRSAudience, + } + } return &providers.ResourceServer{ID: identifier, Identifier: identifier} }, func(_ context.Context, _ string) *tidcommon.ServiceError { return nil }).Maybe() suite.mockResourceService.On("ValidatePermissions", mock.Anything, mock.Anything, mock.Anything). Return([]string{}, nil).Maybe() - // No explicit resource -> deployment default RS. - suite.mockServerConfigSvc.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{ResourceServerID: testTokenExchangeDefaultRSID}, nil).Maybe() - suite.mockResourceService.On("GetResourceServer", mock.Anything, testTokenExchangeDefaultRSID). - Return(&providers.ResourceServer{ - ID: testTokenExchangeDefaultRSID, - Identifier: testTokenExchangeDefaultRSAudience, - }, nil).Maybe() suite.handler = &tokenExchangeGrantHandler{ - tokenBuilder: suite.mockTokenBuilder, - tokenValidator: suite.mockTokenValidator, - authzService: suite.mockAuthzService, - actorProvider: suite.mockActorProvider, - resourceService: suite.mockResourceService, - serverConfigService: suite.mockServerConfigSvc, + tokenBuilder: suite.mockTokenBuilder, + tokenValidator: suite.mockTokenValidator, + authzService: suite.mockAuthzService, + actorProvider: suite.mockActorProvider, + resourceService: suite.mockResourceService, } suite.oauthApp = &providers.OAuthClient{ @@ -261,7 +256,7 @@ 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.mockServerConfigSvc) + suite.mockAuthzService, suite.mockActorProvider, suite.mockResourceService) assert.NotNil(suite.T(), handler) assert.Implements(suite.T(), (*GrantHandlerInterface)(nil), handler) } @@ -2197,19 +2192,17 @@ func (suite *TokenExchangeGrantHandlerTestSuite) TestHandleGrant_NoResource_NoDe tokenRequest := suite.createBasicTokenRequest(subjectToken) - // Fresh mocks so the default GetMergedConfig / GetResourceServer stubs from SetupTest are not - // used; the default RS ID is empty here. + // Fresh resource mock so the default-resolution stub from SetupTest is not used; resolving the + // empty identifier yields a client error (no default RS configured), mapped to invalid_target. rsvc := resourcemock.NewResourceServiceInterfaceMock(suite.T()) - scfg := serverconfigmock.NewServerConfigServiceMock(suite.T()) - scfg.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{ResourceServerID: ""}, nil) + rsvc.On("GetResourceServerByIdentifier", mock.Anything, ""). + Return(nil, &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "RES-1003"}) h := &tokenExchangeGrantHandler{ - tokenBuilder: suite.mockTokenBuilder, - tokenValidator: suite.mockTokenValidator, - authzService: suite.mockAuthzService, - actorProvider: suite.mockActorProvider, - resourceService: rsvc, - serverConfigService: scfg, + tokenBuilder: suite.mockTokenBuilder, + tokenValidator: suite.mockTokenValidator, + authzService: suite.mockAuthzService, + actorProvider: suite.mockActorProvider, + resourceService: rsvc, } suite.mockTokenValidator.On("ValidateSubjectToken", mock.Anything, subjectToken, suite.oauthApp). @@ -2771,12 +2764,11 @@ func (suite *TokenExchangeGrantHandlerTestSuite) TestHandleGrant_DownscopeValida rsvc.On("ValidatePermissions", mock.Anything, mock.Anything, mock.Anything). Return([]string(nil), &tidcommon.ServiceError{Type: tidcommon.ServerErrorType, Code: "RES-5001"}) handler := &tokenExchangeGrantHandler{ - tokenBuilder: suite.mockTokenBuilder, - tokenValidator: suite.mockTokenValidator, - authzService: suite.mockAuthzService, - actorProvider: suite.mockActorProvider, - resourceService: rsvc, - serverConfigService: suite.mockServerConfigSvc, + tokenBuilder: suite.mockTokenBuilder, + tokenValidator: suite.mockTokenValidator, + authzService: suite.mockAuthzService, + actorProvider: suite.mockActorProvider, + resourceService: rsvc, } suite.mockTokenValidator.On("ValidateSubjectToken", mock.Anything, subjectToken, suite.oauthApp). Return(&tokenservice.SubjectTokenClaims{ @@ -2816,11 +2808,10 @@ func (suite *TokenExchangeGrantHandlerTestSuite) TestHandleGrant_AppAuthorizatio }) // actorProvider nil so app group resolution is skipped and evaluation runs directly. handler := &tokenExchangeGrantHandler{ - tokenBuilder: suite.mockTokenBuilder, - tokenValidator: suite.mockTokenValidator, - authzService: authzService, - resourceService: rsvc, - serverConfigService: suite.mockServerConfigSvc, + tokenBuilder: suite.mockTokenBuilder, + tokenValidator: suite.mockTokenValidator, + authzService: authzService, + resourceService: rsvc, } suite.mockTokenValidator.On("ValidateSubjectToken", mock.Anything, subjectToken, suite.oauthApp). Return(&tokenservice.SubjectTokenClaims{ diff --git a/backend/internal/oauth/oauth2/par/service.go b/backend/internal/oauth/oauth2/par/service.go index 886a15ce97..e04877d9ed 100644 --- a/backend/internal/oauth/oauth2/par/service.go +++ b/backend/internal/oauth/oauth2/par/service.go @@ -58,7 +58,8 @@ type parService struct { // newPARService creates a new PAR service instance. func newPARService( - store parStoreInterface, resourceService providers.ResourceServerProvider, cfg oauthconfig.Config, + store parStoreInterface, resourceService providers.ResourceServerProvider, + cfg oauthconfig.Config, ) PARServiceInterface { return &parService{ store: store, @@ -119,12 +120,14 @@ func (s *parService) HandlePushedAuthorizationRequest( oidcScopes, nonOidcScopes := oauth2utils.SeparateOIDCAndNonOIDCScopes(scope, oauthApp.ScopeClaims) oidcScopes = oauth2utils.FilterOIDCScopesByAllowedScopes(oidcScopes, oauthApp.Scopes) - // Resolve resource identifiers to Resource Servers and downscope non-OIDC scopes against - // the union of permissions defined on those Resource Servers. Unknown identifiers cause - // invalid_target; scopes not defined on any RS are silently dropped. - _, nonOidcScopes, errResp := resourceindicators.ResolveAndDownscope( - ctx, s.resourceService, resources, nonOidcScopes) - if errResp != nil { + // Validate up front that the request can bind to a resource server: an explicit resource must + // resolve, or (with no resource) either the request is OIDC-only or a default resource server is + // configured; otherwise reject with invalid_target. This mirrors the redirect-URI validation done + // here at push time. The authoritative binding and per-resource-server downscoping still happen + // when the pushed request is redeemed at the authorization endpoint, so both standard and + // PAR-based requests bind identically. + if _, errResp := resourceindicators.ResolveAudienceBinding( + ctx, s.resourceService, resources, nonOidcScopes); errResp != nil { return nil, errResp.Error, errResp.ErrorDescription } diff --git a/backend/internal/oauth/oauth2/par/service_test.go b/backend/internal/oauth/oauth2/par/service_test.go index 78ef8d9862..752380bae6 100644 --- a/backend/internal/oauth/oauth2/par/service_test.go +++ b/backend/internal/oauth/oauth2/par/service_test.go @@ -336,7 +336,7 @@ func (s *ServiceTestSuite) TestHandlePAR_ResourceResolutionServerError() { assert.Equal(s.T(), oauth2const.ErrorServerError, errCode) } -func (s *ServiceTestSuite) TestHandlePAR_ScopesDownscopedAgainstResourceServers() { +func (s *ServiceTestSuite) TestHandlePAR_ValidatesResourceAndStoresRawScopes() { store := newParStoreInterfaceMock(s.T()) var captured pushedAuthorizationRequest store.EXPECT().Store(mock.Anything, mock.Anything, mock.Anything). @@ -348,12 +348,6 @@ func (s *ServiceTestSuite) TestHandlePAR_ScopesDownscopedAgainstResourceServers( rsMock.On("GetResourceServerByIdentifier", mock.Anything, "https://api.example.com"). Return(&providers.ResourceServer{ID: "rs-1", Identifier: "https://api.example.com"}, (*tidcommon.ServiceError)(nil)) - // "write" is not a permission on rs-1, so the helper should drop it. - rsMock.On("ValidatePermissions", mock.Anything, "rs-1", - mock.MatchedBy(func(scopes []string) bool { - return len(scopes) == 2 && scopes[0] == "read" && scopes[1] == "write" - })). - Return([]string{"write"}, (*tidcommon.ServiceError)(nil)) svc := newPARService(store, rsMock, s.testCfg) app := s.newTestApp() @@ -365,7 +359,59 @@ func (s *ServiceTestSuite) TestHandlePAR_ScopesDownscopedAgainstResourceServers( assert.Empty(s.T(), errCode) assert.NotNil(s.T(), resp) + // The single target resource server, downscoping, and audience binding are resolved when the + // pushed request is redeemed at the authorization endpoint, so raw non-OIDC scopes are stored. + assert.Equal(s.T(), []string{"read", "write"}, captured.OAuthParameters.PermissionScopes) +} + +func (s *ServiceTestSuite) TestHandlePAR_NoResourceNoDefaultRejectsAtPush() { + store := newParStoreInterfaceMock(s.T()) + // No default resource server configured: resolving the empty identifier reports not found. + rsMock := resourcemock.NewResourceServiceInterfaceMock(s.T()) + rsMock.On("GetResourceServerByIdentifier", mock.Anything, ""). + Return((*providers.ResourceServer)(nil), &tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "RES-1003", + }) + + svc := newPARService(store, rsMock, s.testCfg) + app := s.newTestApp() + params := s.newValidParams() + // Permission scope with no explicit resource and no default configured: reject up front. + params[oauth2const.RequestParamScope] = "read" + + resp, errCode, _ := svc.HandlePushedAuthorizationRequest(s.ctx, params, nil, app, "") + + assert.Nil(s.T(), resp) + assert.Equal(s.T(), oauth2const.ErrorInvalidTarget, errCode) +} + +func (s *ServiceTestSuite) TestHandlePAR_NoResourceWithDefaultSucceedsAtPush() { + store := newParStoreInterfaceMock(s.T()) + var captured pushedAuthorizationRequest + store.EXPECT().Store(mock.Anything, mock.Anything, mock.Anything). + Run(func(_ context.Context, req pushedAuthorizationRequest, _ int64) { captured = req }). + Return("test-uri", nil) + + // Default resource server configured: resolving the empty identifier returns it. + rsMock := resourcemock.NewResourceServiceInterfaceMock(s.T()) + rsMock.On("GetResourceServerByIdentifier", mock.Anything, ""). + Return(&providers.ResourceServer{ID: "rs-default", Identifier: "https://default.example.com"}, + (*tidcommon.ServiceError)(nil)) + + svc := newPARService(store, rsMock, s.testCfg) + app := s.newTestApp() + params := s.newValidParams() + params[oauth2const.RequestParamScope] = "openid read" + + resp, errCode, _ := svc.HandlePushedAuthorizationRequest(s.ctx, params, nil, app, "") + + assert.Empty(s.T(), errCode) + assert.NotNil(s.T(), resp) + // Binding and downscoping are deferred to redeem, so the raw non-OIDC scope is stored and the + // resource is not materialized at push. assert.Equal(s.T(), []string{"read"}, captured.OAuthParameters.PermissionScopes) + assert.Empty(s.T(), captured.OAuthParameters.Resources) } func (s *ServiceTestSuite) TestHandlePAR_FiltersOIDCScopesByAppScopes() { diff --git a/backend/internal/oauth/oauth2/resourceindicators/resourceindicators.go b/backend/internal/oauth/oauth2/resourceindicators/resourceindicators.go index 9db6198444..f58025ae8d 100644 --- a/backend/internal/oauth/oauth2/resourceindicators/resourceindicators.go +++ b/backend/internal/oauth/oauth2/resourceindicators/resourceindicators.go @@ -29,8 +29,6 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" - "github.com/thunder-id/thunderid/internal/resource" - "github.com/thunder-id/thunderid/internal/serverconfig" ) // ValidateResourceURIs returns an error response when any resource URI is not absolute @@ -56,13 +54,13 @@ func ValidateResourceURIs(resources []string) *model.ErrorResponse { // ResolveTargetResourceServer resolves the single target Resource Server for a token request. // At most one resource is allowed; more than one is invalid_target. When exactly one resource is -// supplied it is resolved by identifier. When none is supplied the deployment's configured -// defaultResourceServer is used; if no default is configured the request is rejected -// (invalid_target) — token issuance is bound to exactly one resource server. +// supplied it is resolved by identifier. When none is supplied an empty identifier is passed to the +// provider, which (when default-aware) resolves the deployment's configured default resource server; +// if no default is configured the request is rejected (invalid_target) — token issuance is bound to +// exactly one resource server. func ResolveTargetResourceServer( ctx context.Context, resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, resources []string, ) (*providers.ResourceServer, *model.ErrorResponse) { if errResp := ValidateResourceURIs(resources); errResp != nil { @@ -74,17 +72,24 @@ func ResolveTargetResourceServer( ErrorDescription: "Only a single resource parameter is supported", } } - - if len(resources) == 1 { - rs, svcErr := resourceService.GetResourceServerByIdentifier(ctx, resources[0]) - if svcErr != nil { - return nil, resolveTargetError(svcErr, - "The resource parameter does not match any registered resource server") + if resourceService == nil { + return nil, &model.ErrorResponse{ + Error: constants.ErrorInvalidTarget, + ErrorDescription: "No resource parameter supplied and no default resource server is configured", } - return rs, nil } - return resolveDefaultResourceServer(ctx, resourceService, serverConfigService) + identifier := "" + invalidTargetDescription := "No resource parameter supplied and no default resource server is configured" + if len(resources) == 1 { + identifier = resources[0] + invalidTargetDescription = "The resource parameter does not match any registered resource server" + } + rs, svcErr := resourceService.GetResourceServerByIdentifier(ctx, identifier) + if svcErr != nil { + return nil, resolveTargetError(svcErr, invalidTargetDescription) + } + return rs, nil } // ResolveAudienceBinding decides the single resource server an access token binds to. It returns @@ -95,49 +100,13 @@ func ResolveTargetResourceServer( func ResolveAudienceBinding( ctx context.Context, resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, resources []string, permissionScopes []string, ) (*providers.ResourceServer, *model.ErrorResponse) { if len(resources) == 0 && len(permissionScopes) == 0 { return nil, nil } - return ResolveTargetResourceServer(ctx, resourceService, serverConfigService, resources) -} - -// resolveDefaultResourceServer resolves the deployment's configured default resource server. -func resolveDefaultResourceServer( - ctx context.Context, - resourceService providers.ResourceServerProvider, - serverConfigService serverconfig.ServerConfigService, -) (*providers.ResourceServer, *model.ErrorResponse) { - // No server-config service (e.g. the embedded engine) means no default can be configured, so an - // implicit (no-resource) request cannot be bound to a resource server. - if serverConfigService == nil { - return nil, &model.ErrorResponse{ - Error: constants.ErrorInvalidTarget, - ErrorDescription: "No resource parameter supplied and no default resource server is configured", - } - } - merged, svcErr := serverConfigService.GetMergedConfig(ctx, string(serverconfig.ConfigNameDefaultResourceServer)) - if svcErr != nil { - return nil, &model.ErrorResponse{ - Error: constants.ErrorServerError, - ErrorDescription: "Failed to resolve default resource server", - } - } - cfg, _ := merged.(resource.DefaultResourceServerConfig) - if cfg.ResourceServerID == "" { - return nil, &model.ErrorResponse{ - Error: constants.ErrorInvalidTarget, - ErrorDescription: "No resource parameter supplied and no default resource server is configured", - } - } - rs, svcErr := resourceService.GetResourceServer(ctx, cfg.ResourceServerID) - if svcErr != nil { - return nil, resolveTargetError(svcErr, "The configured default resource server does not exist") - } - return rs, nil + return ResolveTargetResourceServer(ctx, resourceService, resources) } // resolveTargetError maps a resource-service error to invalid_target (client) or server_error. diff --git a/backend/internal/oauth/oauth2/resourceindicators/resourceindicators_test.go b/backend/internal/oauth/oauth2/resourceindicators/resourceindicators_test.go index f655d4fe0d..c76c447df2 100644 --- a/backend/internal/oauth/oauth2/resourceindicators/resourceindicators_test.go +++ b/backend/internal/oauth/oauth2/resourceindicators/resourceindicators_test.go @@ -30,9 +30,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" - "github.com/thunder-id/thunderid/internal/resource" "github.com/thunder-id/thunderid/tests/mocks/resourcemock" - "github.com/thunder-id/thunderid/tests/mocks/serverconfigmock" ) type ResourceIndicatorsTestSuite struct { @@ -132,20 +130,17 @@ func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_Single rs := providers.ResourceServer{ID: "rs01", Identifier: "https://api.example.com"} suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, "https://api.example.com"). Return(&rs, nil) - serverConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) resolved, err := ResolveTargetResourceServer(context.Background(), suite.mockResourceService, - serverConfig, []string{"https://api.example.com"}) + []string{"https://api.example.com"}) assert.Nil(suite.T(), err) assert.Equal(suite.T(), &rs, resolved) } func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_MultipleResources_ReturnsInvalidTarget() { - serverConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) - resolved, err := ResolveTargetResourceServer(context.Background(), suite.mockResourceService, - serverConfig, []string{"https://a.example.com", "https://b.example.com"}) + []string{"https://a.example.com", "https://b.example.com"}) assert.Nil(suite.T(), resolved) assert.NotNil(suite.T(), err) @@ -156,10 +151,9 @@ func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_Unknow svcErr := &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "RSE-4041"} suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, "https://unknown.example.com"). Return(nil, svcErr) - serverConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) resolved, err := ResolveTargetResourceServer(context.Background(), suite.mockResourceService, - serverConfig, []string{"https://unknown.example.com"}) + []string{"https://unknown.example.com"}) assert.Nil(suite.T(), resolved) assert.NotNil(suite.T(), err) @@ -170,81 +164,61 @@ func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_Lookup svcErr := &tidcommon.ServiceError{Type: tidcommon.ServerErrorType, Code: "SSE-5000"} suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, "https://api.example.com"). Return(nil, svcErr) - serverConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) resolved, err := ResolveTargetResourceServer(context.Background(), suite.mockResourceService, - serverConfig, []string{"https://api.example.com"}) + []string{"https://api.example.com"}) assert.Nil(suite.T(), resolved) assert.NotNil(suite.T(), err) assert.Equal(suite.T(), constants.ErrorServerError, err.Error) } -func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_NoResource_DefaultConfigured_Resolves() { +// When no resource is supplied, the resolver asks the provider to resolve the empty identifier; a +// default-aware provider turns this into the deployment's configured default resource server. +func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_NoResource_ProviderResolvesDefault() { rs := providers.ResourceServer{ID: "rs-1", Identifier: "https://api.example.com"} - suite.mockResourceService.On("GetResourceServer", mock.Anything, "rs-1"). + suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, ""). Return(&rs, nil) - serverConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) - serverConfig.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{ResourceServerID: "rs-1"}, nil) resolved, err := ResolveTargetResourceServer(context.Background(), suite.mockResourceService, - serverConfig, []string{}) + []string{}) assert.Nil(suite.T(), err) assert.Equal(suite.T(), &rs, resolved) } -func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_EmptyDefaultID_ReturnsInvalidTarget() { - serverConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) - serverConfig.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{ResourceServerID: ""}, nil) - - resolved, err := ResolveTargetResourceServer(context.Background(), suite.mockResourceService, - serverConfig, []string{}) - - assert.Nil(suite.T(), resolved) - assert.NotNil(suite.T(), err) - assert.Equal(suite.T(), constants.ErrorInvalidTarget, err.Error) -} +func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_NoResource_ProviderClientError() { + svcErr := &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "RSE-4041"} + suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, ""). + Return(nil, svcErr) -func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_NilServerConfig_ReturnsInvalidTarget() { resolved, err := ResolveTargetResourceServer(context.Background(), suite.mockResourceService, - nil, []string{}) + []string{}) assert.Nil(suite.T(), resolved) assert.NotNil(suite.T(), err) assert.Equal(suite.T(), constants.ErrorInvalidTarget, err.Error) } -func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_DefaultRSNotFound_ReturnsInvalidTarget() { - svcErr := &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "RSE-4041"} - suite.mockResourceService.On("GetResourceServer", mock.Anything, "rs-1"). +func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_NoResource_ProviderServerError() { + svcErr := &tidcommon.ServiceError{Type: tidcommon.ServerErrorType, Code: "SCE-5000"} + suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, ""). Return(nil, svcErr) - serverConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) - serverConfig.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{ResourceServerID: "rs-1"}, nil) resolved, err := ResolveTargetResourceServer(context.Background(), suite.mockResourceService, - serverConfig, []string{}) + []string{}) assert.Nil(suite.T(), resolved) assert.NotNil(suite.T(), err) - assert.Equal(suite.T(), constants.ErrorInvalidTarget, err.Error) + assert.Equal(suite.T(), constants.ErrorServerError, err.Error) } -func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_MergedConfigError_ReturnsServerError() { - svcErr := &tidcommon.ServiceError{Type: tidcommon.ServerErrorType, Code: "SCE-5000"} - serverConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) - serverConfig.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(nil, svcErr) - - resolved, err := ResolveTargetResourceServer(context.Background(), suite.mockResourceService, - serverConfig, []string{}) +func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_NilResourceService_ReturnsInvalidTarget() { + resolved, err := ResolveTargetResourceServer(context.Background(), nil, []string{}) assert.Nil(suite.T(), resolved) assert.NotNil(suite.T(), err) - assert.Equal(suite.T(), constants.ErrorServerError, err.Error) + assert.Equal(suite.T(), constants.ErrorInvalidTarget, err.Error) } // DownscopeToResourceServer tests @@ -368,10 +342,8 @@ func (suite *ResourceIndicatorsTestSuite) TestResolveAndDownscope_UnknownIdentif } func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_InvalidResourceURI_ReturnsInvalidTarget() { - serverConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) - resolved, err := ResolveTargetResourceServer(context.Background(), suite.mockResourceService, - serverConfig, []string{"api.example.com/resource"}) + []string{"api.example.com/resource"}) assert.Nil(suite.T(), resolved) assert.NotNil(suite.T(), err) @@ -381,22 +353,17 @@ func (suite *ResourceIndicatorsTestSuite) TestResolveTargetResourceServer_Invali // ResolveAudienceBinding tests func (suite *ResourceIndicatorsTestSuite) TestResolveAudienceBinding_NoResourceNoPermissionScopes_ReturnsNil() { - serverConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) - - rs, err := ResolveAudienceBinding(context.Background(), suite.mockResourceService, serverConfig, nil, nil) + rs, err := ResolveAudienceBinding(context.Background(), suite.mockResourceService, nil, nil) assert.Nil(suite.T(), rs) assert.Nil(suite.T(), err) } func (suite *ResourceIndicatorsTestSuite) TestResolveAudienceBinding_PermissionScopes_ResolvesDefault() { - suite.mockResourceService.On("GetResourceServer", mock.Anything, "rs-1"). + suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, ""). Return(&providers.ResourceServer{ID: "rs-1", Identifier: "https://api.example.com"}, nil) - serverConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) - serverConfig.On("GetMergedConfig", mock.Anything, "defaultResourceServer"). - Return(resource.DefaultResourceServerConfig{ResourceServerID: "rs-1"}, nil) - rs, err := ResolveAudienceBinding(context.Background(), suite.mockResourceService, serverConfig, + rs, err := ResolveAudienceBinding(context.Background(), suite.mockResourceService, nil, []string{"read"}) assert.Nil(suite.T(), err) @@ -408,9 +375,8 @@ func (suite *ResourceIndicatorsTestSuite) TestResolveAudienceBinding_ExplicitRes rs := providers.ResourceServer{ID: "rs01", Identifier: "https://api.example.com"} suite.mockResourceService.On("GetResourceServerByIdentifier", mock.Anything, "https://api.example.com"). Return(&rs, nil) - serverConfig := serverconfigmock.NewServerConfigServiceMock(suite.T()) - resolved, err := ResolveAudienceBinding(context.Background(), suite.mockResourceService, serverConfig, + resolved, err := ResolveAudienceBinding(context.Background(), suite.mockResourceService, []string{"https://api.example.com"}, nil) assert.Nil(suite.T(), err) diff --git a/backend/internal/resource/default_aware_provider.go b/backend/internal/resource/default_aware_provider.go new file mode 100644 index 0000000000..9f9e14f7fe --- /dev/null +++ b/backend/internal/resource/default_aware_provider.go @@ -0,0 +1,79 @@ +/* + * 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 resource + +import ( + "context" + + "github.com/thunder-id/thunderid/internal/serverconfig" + tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// defaultAwareResourceServerProvider decorates a providers.ResourceServerProvider so that an empty +// identifier resolves the deployment's configured default resource server. The default resource server +// is a server-side policy: the authentication engine and OAuth layers depend only on +// providers.ResourceServerProvider and never see the server-config store. +type defaultAwareResourceServerProvider struct { + providers.ResourceServerProvider + serverConfigService serverconfig.ServerConfigService +} + +var _ providers.ResourceServerProvider = (*defaultAwareResourceServerProvider)(nil) + +// NewDefaultAwareResourceServerProvider wraps base so that GetResourceServerByIdentifier resolves the +// configured default resource server when the identifier is empty. base and serverConfigService must +// both be non-nil. +func NewDefaultAwareResourceServerProvider( + base providers.ResourceServerProvider, + serverConfigService serverconfig.ServerConfigService, +) providers.ResourceServerProvider { + if base == nil { + panic("default-aware resource server provider requires a non-nil base provider") + } + if serverConfigService == nil { + panic("default-aware resource server provider requires a non-nil server config service") + } + return &defaultAwareResourceServerProvider{ + ResourceServerProvider: base, + serverConfigService: serverConfigService, + } +} + +// GetResourceServerByIdentifier resolves an explicit identifier through the wrapped provider. When the +// identifier is empty it resolves the deployment's configured default resource server: a client error +// when no default is configured (or the merged config is malformed), and a server error when the +// configuration cannot be read. +func (p *defaultAwareResourceServerProvider) GetResourceServerByIdentifier( + ctx context.Context, identifier string, +) (*providers.ResourceServer, *tidcommon.ServiceError) { + if identifier != "" { + return p.ResourceServerProvider.GetResourceServerByIdentifier(ctx, identifier) + } + merged, svcErr := p.serverConfigService.GetMergedConfig( + ctx, string(serverconfig.ConfigNameDefaultResourceServer)) + if svcErr != nil { + return nil, svcErr + } + cfg, _ := merged.(DefaultResourceServerConfig) + if cfg.ResourceServerID == "" { + return nil, &ErrorResourceServerNotFound + } + return p.ResourceServerProvider.GetResourceServer(ctx, cfg.ResourceServerID) +} diff --git a/backend/internal/resource/default_aware_provider_test.go b/backend/internal/resource/default_aware_provider_test.go new file mode 100644 index 0000000000..9883bb8eae --- /dev/null +++ b/backend/internal/resource/default_aware_provider_test.go @@ -0,0 +1,174 @@ +/* + * 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 resource_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/internal/resource" + tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + "github.com/thunder-id/thunderid/tests/mocks/resourcemock" + "github.com/thunder-id/thunderid/tests/mocks/serverconfigmock" +) + +const defaultResourceServerConfigName = "defaultResourceServer" + +type DefaultAwareProviderTestSuite struct { + suite.Suite + base *resourcemock.ResourceServiceInterfaceMock + config *serverconfigmock.ServerConfigServiceMock + subject providers.ResourceServerProvider +} + +func TestDefaultAwareProviderTestSuite(t *testing.T) { + suite.Run(t, new(DefaultAwareProviderTestSuite)) +} + +func (suite *DefaultAwareProviderTestSuite) SetupTest() { + suite.base = resourcemock.NewResourceServiceInterfaceMock(suite.T()) + suite.config = serverconfigmock.NewServerConfigServiceMock(suite.T()) + suite.subject = resource.NewDefaultAwareResourceServerProvider(suite.base, suite.config) +} + +// An explicit identifier is delegated to the wrapped provider verbatim; the server-config store is +// never consulted. +func (suite *DefaultAwareProviderTestSuite) TestExplicitIdentifier_DelegatesToBase() { + rs := providers.ResourceServer{ID: "rs01", Identifier: "https://api.example.com"} + suite.base.On("GetResourceServerByIdentifier", mock.Anything, "https://api.example.com"). + Return(&rs, nil) + + resolved, err := suite.subject.GetResourceServerByIdentifier(context.Background(), "https://api.example.com") + + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), &rs, resolved) +} + +func (suite *DefaultAwareProviderTestSuite) TestExplicitIdentifier_PropagatesBaseError() { + svcErr := &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "RES-1003"} + suite.base.On("GetResourceServerByIdentifier", mock.Anything, "https://unknown.example.com"). + Return(nil, svcErr) + + resolved, err := suite.subject.GetResourceServerByIdentifier(context.Background(), "https://unknown.example.com") + + assert.Nil(suite.T(), resolved) + assert.Same(suite.T(), svcErr, err) +} + +// An empty identifier resolves the configured default resource server through the wrapped provider. +func (suite *DefaultAwareProviderTestSuite) TestEmptyIdentifier_DefaultConfigured_Resolves() { + rs := providers.ResourceServer{ID: "rs-1", Identifier: "https://api.example.com"} + suite.config.On("GetMergedConfig", mock.Anything, defaultResourceServerConfigName). + Return(resource.DefaultResourceServerConfig{ResourceServerID: "rs-1"}, nil) + suite.base.On("GetResourceServer", mock.Anything, "rs-1").Return(&rs, nil) + + resolved, err := suite.subject.GetResourceServerByIdentifier(context.Background(), "") + + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), &rs, resolved) +} + +// No default configured surfaces as a client error so the caller maps it to invalid_target. +func (suite *DefaultAwareProviderTestSuite) TestEmptyIdentifier_NoDefaultConfigured_ReturnsClientError() { + suite.config.On("GetMergedConfig", mock.Anything, defaultResourceServerConfigName). + Return(resource.DefaultResourceServerConfig{ResourceServerID: ""}, nil) + + resolved, err := suite.subject.GetResourceServerByIdentifier(context.Background(), "") + + assert.Nil(suite.T(), resolved) + require.NotNil(suite.T(), err) + assert.Equal(suite.T(), tidcommon.ClientErrorType, err.Type) +} + +// A merged value of an unexpected type is treated as "no default configured" (client error), matching +// the pre-refactor behavior. +func (suite *DefaultAwareProviderTestSuite) TestEmptyIdentifier_ConfigTypeMismatch_ReturnsClientError() { + suite.config.On("GetMergedConfig", mock.Anything, defaultResourceServerConfigName). + Return("unexpected-type", nil) + + resolved, err := suite.subject.GetResourceServerByIdentifier(context.Background(), "") + + assert.Nil(suite.T(), resolved) + require.NotNil(suite.T(), err) + assert.Equal(suite.T(), tidcommon.ClientErrorType, err.Type) +} + +// A failure reading the merged config is propagated as a server error. +func (suite *DefaultAwareProviderTestSuite) TestEmptyIdentifier_ConfigReadFailure_ReturnsServerError() { + svcErr := &tidcommon.ServiceError{Type: tidcommon.ServerErrorType, Code: "SCE-5000"} + suite.config.On("GetMergedConfig", mock.Anything, defaultResourceServerConfigName). + Return(nil, svcErr) + + resolved, err := suite.subject.GetResourceServerByIdentifier(context.Background(), "") + + assert.Nil(suite.T(), resolved) + assert.Same(suite.T(), svcErr, err) +} + +// A configured default that no longer exists fails closed with the wrapped provider's error. +func (suite *DefaultAwareProviderTestSuite) TestEmptyIdentifier_DefaultDeleted_PropagatesBaseError() { + svcErr := &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "RES-1003"} + suite.config.On("GetMergedConfig", mock.Anything, defaultResourceServerConfigName). + Return(resource.DefaultResourceServerConfig{ResourceServerID: "rs-gone"}, nil) + suite.base.On("GetResourceServer", mock.Anything, "rs-gone").Return(nil, svcErr) + + resolved, err := suite.subject.GetResourceServerByIdentifier(context.Background(), "") + + assert.Nil(suite.T(), resolved) + assert.Same(suite.T(), svcErr, err) +} + +// GetResourceServer and ValidatePermissions are promoted from the embedded provider unchanged. +func (suite *DefaultAwareProviderTestSuite) TestGetResourceServer_Delegates() { + rs := providers.ResourceServer{ID: "rs01"} + suite.base.On("GetResourceServer", mock.Anything, "rs01").Return(&rs, nil) + + resolved, err := suite.subject.GetResourceServer(context.Background(), "rs01") + + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), &rs, resolved) +} + +func (suite *DefaultAwareProviderTestSuite) TestValidatePermissions_Delegates() { + suite.base.On("ValidatePermissions", mock.Anything, "rs01", []string{"read"}). + Return([]string{}, nil) + + invalid, err := suite.subject.ValidatePermissions(context.Background(), "rs01", []string{"read"}) + + assert.Nil(suite.T(), err) + assert.Empty(suite.T(), invalid) +} + +func (suite *DefaultAwareProviderTestSuite) TestConstructor_PanicsOnNilBase() { + assert.Panics(suite.T(), func() { + resource.NewDefaultAwareResourceServerProvider(nil, suite.config) + }) +} + +func (suite *DefaultAwareProviderTestSuite) TestConstructor_PanicsOnNilServerConfig() { + assert.Panics(suite.T(), func() { + resource.NewDefaultAwareResourceServerProvider(suite.base, nil) + }) +} diff --git a/backend/pkg/thunderidengine/engine.go b/backend/pkg/thunderidengine/engine.go index e8644b2c16..e9e624406f 100644 --- a/backend/pkg/thunderidengine/engine.go +++ b/backend/pkg/thunderidengine/engine.go @@ -140,6 +140,7 @@ func New(mux *http.ServeMux, opts ...Option) *Engine { AuthnProvider: engineCtx.authnProvider, JWTService: engineCtx.jwtService, AuthAssertGen: engineCtx.authAssertGen, + ResourceService: engineCtx.resourceProvider, } interceptorDeps := interceptor.InterceptorDependencies{ FlowFactory: engineCtx.flowFactory, @@ -185,13 +186,14 @@ func New(mux *http.ServeMux, opts ...Option) *Engine { engineCtx.dpopVerifier = dpop.Initialize(oauthConfig, jti.Initialize(engineCtx.runtimeStoreProvider)) - // The embedded engine has no server-config store, so no default resource server is available. - // Implicit no-resource requests that carry permission scopes are rejected; OIDC-only or + // The embedded engine has no server-config store, so no default resource server is available: the + // resource provider is passed undecorated. Implicit no-resource requests that carry permission + // scopes are rejected (the provider resolves no server for an empty identifier); OIDC-only or // scopeless requests do not need resource-server binding. err = oauth.Initialize(mux, engineCtx.actorProvider, engineCtx.authnProvider, engineCtx.jwtService, engineCtx.jweService, engineCtx.flowExecService, engineCtx.observabilitySvc, engineCtx.runtimeCryptoSvc, engineCtx.ouProvider, engineCtx.attributeCacheService, engineCtx.authzProvider, engineCtx.resourceProvider, - nil, engineCtx.i18nProvider, engineCtx.idpProvider, engineCtx.dpopVerifier, engineCtx.runtimeStoreProvider, + engineCtx.i18nProvider, engineCtx.idpProvider, engineCtx.dpopVerifier, engineCtx.runtimeStoreProvider, engineCtx.transactioner, oauthConfig) if err != nil { logger.Fatal(ctx, "Failed to initialize OAuth services", log.Error(err)) diff --git a/docs/content/guides/protocols/oauth-oidc/resource-indicators.mdx b/docs/content/guides/protocols/oauth-oidc/resource-indicators.mdx index f0d5f44d88..de7944c430 100644 --- a/docs/content/guides/protocols/oauth-oidc/resource-indicators.mdx +++ b/docs/content/guides/protocols/oauth-oidc/resource-indicators.mdx @@ -15,6 +15,8 @@ The effect: a token issued for the payments API cannot be replayed against the b The `resource` parameter is accepted on authorization and PAR requests, on authorization code, client credentials, JWT bearer, refresh token, and token exchange requests, and on CIBA backchannel authentication requests (`POST /oauth2/bc-authorize`). A CIBA request binds to its resource server at initiation, before the user authorizes it. A `resource` supplied when polling the token endpoint must match that binding. +For permission-bearing authorization requests, resource binding is resolved per request and is honored across single sign-on: when a request is satisfied by an existing SSO session, its permission scopes are evaluated against that request's own `resource` (or the configured default resource server), so a session established for one resource server never leaks permission scopes to a different one on SSO reuse. OIDC-only and scopeless requests without `resource` remain unbound to a resource server and use the `client_id` as the audience. + ```http GET /oauth2/authorize ?response_type=code diff --git a/tests/e2e/run-e2e.sh b/tests/e2e/run-e2e.sh index 74e880ac06..8f99d510db 100755 --- a/tests/e2e/run-e2e.sh +++ b/tests/e2e/run-e2e.sh @@ -131,6 +131,7 @@ curl -sk -o /dev/null -D /tmp/authz_headers.txt \ --data-urlencode "client_id=CONSOLE" \ --data-urlencode "redirect_uri=$CONSOLE_REDIRECT_URI" \ --data-urlencode "scope=system" \ + --data-urlencode "resource=$SERVER_URL/mcp" \ --data-urlencode "response_type=code" \ --data-urlencode "code_challenge=$CODE_CHALLENGE" \ --data-urlencode "code_challenge_method=S256" @@ -179,6 +180,7 @@ TOKEN_RESP=$(curl -sk -X POST "$SERVER_URL/oauth2/token" \ --data-urlencode "code=$AUTH_CODE" \ --data-urlencode "redirect_uri=$CONSOLE_REDIRECT_URI" \ --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "resource=$SERVER_URL/mcp" \ --data-urlencode "code_verifier=$CODE_VERIFIER") ADMIN_TOKEN=$(echo "$TOKEN_RESP" | python3 -c "import sys, json; print(json.load(sys.stdin).get('access_token', ''))" 2>/dev/null || echo "") diff --git a/tests/e2e/tests/sample-app-authentication/README-MFA.md b/tests/e2e/tests/sample-app-authentication/README-MFA.md index 81d2ce0e04..633810eb95 100644 --- a/tests/e2e/tests/sample-app-authentication/README-MFA.md +++ b/tests/e2e/tests/sample-app-authentication/README-MFA.md @@ -128,7 +128,7 @@ Run the following command with the extracted `executionId`. ```bash ADMIN_TOKEN_RESPONSE=$(curl -k -s -X POST 'https://localhost:8090/flow/execute' \ - -d '{"executionId":"'$EXECUTION_ID'","inputs":{"username":"admin","password":"admin","requested_permissions":"system"},"action":"action_001"}') + -d '{"executionId":"'$EXECUTION_ID'","inputs":{"username":"admin","password":"admin","requested_permissions":"system","resource_server_identifier":"https://localhost:8090/mcp"},"action":"action_001"}') ADMIN_TOKEN=$(echo $ADMIN_TOKEN_RESPONSE | jq -r '.assertion') ``` diff --git a/tests/e2e/utils/authentication/admin-api-auth.ts b/tests/e2e/utils/authentication/admin-api-auth.ts index ac847133dc..c0a7b6e838 100644 --- a/tests/e2e/utils/authentication/admin-api-auth.ts +++ b/tests/e2e/utils/authentication/admin-api-auth.ts @@ -47,7 +47,15 @@ export async function getAdminToken(request: import("@playwright/test").APIReque data: { executionId: flowData.executionId, ...(flowData.challengeToken && { challengeToken: flowData.challengeToken }), - inputs: { username: adminUsername, password: adminPassword, requested_permissions: "system" }, + // resource_server_identifier scopes the permission evaluation to the System resource server + // (identifier from backend/cmd/server/bootstrap/01-default-resources.yaml). Direct /flow/execute + // calls do not pass through the OAuth layer, so the target resource server must be declared here. + inputs: { + username: adminUsername, + password: adminPassword, + requested_permissions: "system", + resource_server_identifier: "https://localhost:8090/mcp", + }, action: "action_001", }, ignoreHTTPSErrors: true, diff --git a/tests/e2e/utils/server-setup/mfa-setup.ts b/tests/e2e/utils/server-setup/mfa-setup.ts index f47dd17637..94da9b4a44 100644 --- a/tests/e2e/utils/server-setup/mfa-setup.ts +++ b/tests/e2e/utils/server-setup/mfa-setup.ts @@ -212,6 +212,10 @@ export class MFASetup { username: this.config.adminUsername, password: this.config.adminPassword, requested_permissions: "system", + // Scope the permission evaluation to the System resource server (identifier from + // backend/cmd/server/bootstrap/01-default-resources.yaml). Direct /flow/execute calls + // do not pass through the OAuth layer, so the target resource server must be declared here. + resource_server_identifier: "https://localhost:8090/mcp", }, action: "action_001", }, diff --git a/tests/integration/flow/authentication/authz_test.go b/tests/integration/flow/authentication/authz_test.go index ae936295a2..04df628c9f 100644 --- a/tests/integration/flow/authentication/authz_test.go +++ b/tests/integration/flow/authentication/authz_test.go @@ -23,9 +23,9 @@ import ( "strings" "testing" + "github.com/stretchr/testify/suite" "github.com/thunder-id/thunderid/tests/integration/flow/common" "github.com/thunder-id/thunderid/tests/integration/testutils" - "github.com/stretchr/testify/suite" ) var ( @@ -345,8 +345,9 @@ func (ts *FlowAuthzTestSuite) TearDownSuite() { func (ts *FlowAuthzTestSuite) TestAuthorizationFlow_UserWithDirectRoleAssignment() { // Initiate authentication flow with requested permissions inputs := map[string]string{ - "applicationId": authzTestAppID, - "requested_permissions": "read write", + "applicationId": authzTestAppID, + "requested_permissions": "read write", + "resource_server_identifier": "document-mgmt", } flowStep, err := common.InitiateAuthenticationFlow(authzTestAppID, false, inputs, "") @@ -390,8 +391,9 @@ func (ts *FlowAuthzTestSuite) TestAuthorizationFlow_UserWithDirectRoleAssignment func (ts *FlowAuthzTestSuite) TestAuthorizationFlow_UserWithNoRole() { // Initiate authentication flow with requested permissions inputs := map[string]string{ - "applicationId": authzTestAppID, - "requested_permissions": "read write", + "applicationId": authzTestAppID, + "requested_permissions": "read write", + "resource_server_identifier": "document-mgmt", } flowStep, err := common.InitiateAuthenticationFlow(authzTestAppID, false, inputs, "") @@ -427,8 +429,9 @@ func (ts *FlowAuthzTestSuite) TestAuthorizationFlow_UserWithNoRole() { func (ts *FlowAuthzTestSuite) TestAuthorizationFlow_UserWithPartialPermissions() { // Initiate authentication flow requesting 3 permissions (user only has 2) inputs := map[string]string{ - "applicationId": authzTestAppID, - "requested_permissions": "read write delete", + "applicationId": authzTestAppID, + "requested_permissions": "read write delete", + "resource_server_identifier": "document-mgmt", } flowStep, err := common.InitiateAuthenticationFlow(authzTestAppID, false, inputs, "") diff --git a/tests/integration/oauth/authz/authz_scope_test.go b/tests/integration/oauth/authz/authz_scope_test.go index 7c955a9c17..2a8f9d16a2 100644 --- a/tests/integration/oauth/authz/authz_scope_test.go +++ b/tests/integration/oauth/authz/authz_scope_test.go @@ -35,18 +35,26 @@ const ( scopeTestClientSecret = "scope_authz_test_secret_456" scopeTestAppName = "ScopeAuthzTestApp" scopeTestRedirectURI = "https://localhost:3000/callback" + + scopeTestResourceServerIdentifier = "https://oauth-document-mgmt.example.com" + scopeTestResourceServerBIdentifier = "https://oauth-document-mgmt-b.example.com" ) var ( - scopeTestOUID string - scopeTestRoleID string - scopeUserWithRole string - scopeUserNoRole string - scopeUserWithGroup string - scopeGroupID string - scopeEntityTypeID string - scopeTestResourceServer string - scopeTestEntityType = testutils.UserType{ + scopeTestOUID string + scopeTestRoleID string + scopeUserWithRole string + scopeUserNoRole string + scopeUserWithGroup string + scopeGroupID string + scopeEntityTypeID string + scopeTestResourceServer string + scopeTestResourceServerB string + scopeUserMultiRS string + scopeUserSplitRS string + scopeMultiRSRoleID string + scopeSplitRSRoleID string + scopeTestEntityType = testutils.UserType{ Name: "authz-test-person", Schema: map[string]interface{}{ "username": map[string]interface{}{ @@ -183,6 +191,41 @@ func (ts *OAuthAuthzScopeTestSuite) SetupSuite() { ts.T().Fatalf("Failed to create user with group: %v", err) } + // Create a user granted the same permissions on BOTH resource servers. + userMultiRS := testutils.User{ + OUID: scopeTestOUID, + Type: "authz-test-person", + Attributes: json.RawMessage(`{ + "username": "oauth_multi_rs_user", + "password": "SecurePass123!", + "email": "oauth_multi_rs@test.com", + "given_name": "OAuth", + "family_name": "MultiRS" + }`), + } + scopeUserMultiRS, err = testutils.CreateUser(userMultiRS) + if err != nil { + ts.T().Fatalf("Failed to create multi-resource-server user: %v", err) + } + + // Create a user granted read on resource server A and write on resource server B, so the same + // permission strings map to different grants depending on the target resource server. + userSplitRS := testutils.User{ + OUID: scopeTestOUID, + Type: "authz-test-person", + Attributes: json.RawMessage(`{ + "username": "oauth_split_rs_user", + "password": "SecurePass123!", + "email": "oauth_split_rs@test.com", + "given_name": "OAuth", + "family_name": "SplitRS" + }`), + } + scopeUserSplitRS, err = testutils.CreateUser(userSplitRS) + if err != nil { + ts.T().Fatalf("Failed to create split-resource-server user: %v", err) + } + // Create group and assign user to group group := testutils.Group{ Name: "OAuth_DocumentEditors", @@ -204,7 +247,7 @@ func (ts *OAuthAuthzScopeTestSuite) SetupSuite() { resourceServer := testutils.ResourceServer{ Name: "OAuth Document Management System", Description: "System for managing documents via OAuth", - Identifier: "https://oauth-document-mgmt.example.com", + Identifier: scopeTestResourceServerIdentifier, OUID: scopeTestOUID, } actions := []testutils.Action{ @@ -224,6 +267,20 @@ func (ts *OAuthAuthzScopeTestSuite) SetupSuite() { ts.T().Fatalf("Failed to create resource server with actions: %v", err) } + // Create a second resource server that defines the SAME read/write permission strings. The test + // role below grants these on the first resource server only, so a request targeting this second + // server must not receive them because permissions must be scoped to the requested resource server. + resourceServerB := testutils.ResourceServer{ + Name: "OAuth Document Management System B", + Description: "A different system that happens to define the same permission strings", + Identifier: scopeTestResourceServerBIdentifier, + OUID: scopeTestOUID, + } + scopeTestResourceServerB, err = testutils.CreateResourceServerWithActions(resourceServerB, actions) + if err != nil { + ts.T().Fatalf("Failed to create second resource server with actions: %v", err) + } + // Create role with permissions and assign to first user role := testutils.Role{ Name: "OAuth_DocumentEditor", @@ -244,6 +301,42 @@ func (ts *OAuthAuthzScopeTestSuite) SetupSuite() { if err != nil { ts.T().Fatalf("Failed to create test role: %v", err) } + + // Role granting read/write on BOTH resource servers, assigned to the multi-RS user. + multiRSRole := testutils.Role{ + Name: "OAuth_MultiRSEditor", + Description: "Can read and write documents on both resource servers (OAuth test)", + OUID: scopeTestOUID, + Permissions: []testutils.ResourcePermissions{ + {ResourceServerID: scopeTestResourceServer, Permissions: []string{"read", "write"}}, + {ResourceServerID: scopeTestResourceServerB, Permissions: []string{"read", "write"}}, + }, + Assignments: []testutils.Assignment{ + {ID: scopeUserMultiRS, Type: "user"}, + }, + } + scopeMultiRSRoleID, err = testutils.CreateRole(multiRSRole) + if err != nil { + ts.T().Fatalf("Failed to create multi-resource-server role: %v", err) + } + + // Role granting read on resource server A and write on resource server B, assigned to the split user. + splitRSRole := testutils.Role{ + Name: "OAuth_SplitRSEditor", + Description: "Can read on A and write on B (OAuth test)", + OUID: scopeTestOUID, + Permissions: []testutils.ResourcePermissions{ + {ResourceServerID: scopeTestResourceServer, Permissions: []string{"read"}}, + {ResourceServerID: scopeTestResourceServerB, Permissions: []string{"write"}}, + }, + Assignments: []testutils.Assignment{ + {ID: scopeUserSplitRS, Type: "user"}, + }, + } + scopeSplitRSRoleID, err = testutils.CreateRole(splitRSRole) + if err != nil { + ts.T().Fatalf("Failed to create split-resource-server role: %v", err) + } } func (ts *OAuthAuthzScopeTestSuite) TearDownSuite() { @@ -254,12 +347,30 @@ func (ts *OAuthAuthzScopeTestSuite) TearDownSuite() { } } + if scopeMultiRSRoleID != "" { + if err := testutils.DeleteRole(scopeMultiRSRoleID); err != nil { + ts.T().Logf("Failed to delete multi-resource-server role: %v", err) + } + } + + if scopeSplitRSRoleID != "" { + if err := testutils.DeleteRole(scopeSplitRSRoleID); err != nil { + ts.T().Logf("Failed to delete split-resource-server role: %v", err) + } + } + if scopeTestResourceServer != "" { if err := testutils.DeleteResourceServer(scopeTestResourceServer); err != nil { ts.T().Logf("Failed to delete test resource server: %v", err) } } + if scopeTestResourceServerB != "" { + if err := testutils.DeleteResourceServer(scopeTestResourceServerB); err != nil { + ts.T().Logf("Failed to delete second test resource server: %v", err) + } + } + if scopeUserNoRole != "" { if err := testutils.DeleteUser(scopeUserNoRole); err != nil { ts.T().Logf("Failed to delete user without role: %v", err) @@ -284,6 +395,18 @@ func (ts *OAuthAuthzScopeTestSuite) TearDownSuite() { } } + if scopeUserMultiRS != "" { + if err := testutils.DeleteUser(scopeUserMultiRS); err != nil { + ts.T().Logf("Failed to delete multi-resource-server user: %v", err) + } + } + + if scopeUserSplitRS != "" { + if err := testutils.DeleteUser(scopeUserSplitRS); err != nil { + ts.T().Logf("Failed to delete split-resource-server user: %v", err) + } + } + if ts.applicationID != "" { if err := testutils.DeleteApplication(ts.applicationID); err != nil { ts.T().Logf("Failed to delete application: %v", err) @@ -447,6 +570,104 @@ func (ts *OAuthAuthzScopeTestSuite) TestOAuthAuthzFlow_WithNoAuthorizedScopes() } } +// TestOAuthAuthzFlow_CrossResourceServerPermissionIsolation verifies that a user granted +// read/write on resource server A must NOT receive those permissions when the token is bound to +// resource server B, even though B defines the same permission strings. +func (ts *OAuthAuthzScopeTestSuite) TestOAuthAuthzFlow_CrossResourceServerPermissionIsolation() { + // The authorized user holds read/write on resource server A only. Bind the token to server B, + // which defines the same permission strings but which the user has no grant on. + tokenResp, err := ts.obtainTokenWithResource( + scopeTestClientID, + scopeTestClientSecret, + "openid read write", + "oauth_authorized_user", + scopeTestResourceServerBIdentifier, + ) + ts.Require().NoError(err, "Failed to obtain access token") + ts.Require().NotNil(tokenResp, "Token response should not be nil") + ts.Require().NotEmpty(tokenResp.AccessToken, "Access token should not be empty") + + claims, err := testutils.DecodeJWT(tokenResp.AccessToken) + ts.Require().NoError(err, "Failed to decode access token") + ts.Require().NotNil(claims, "Claims should not be nil") + + scopeRaw, ok := claims.Additional["scope"] + ts.Require().True(ok, "scope claim should be present in access token") + scopeStr, ok := scopeRaw.(string) + ts.Require().True(ok, "scope claim should be a string") + scopes := strings.Split(scopeStr, " ") + + // read/write are dropped because the user has no grant on resource server B, even though B + // defines them. Only the OIDC scope survives. + ts.Require().Contains(scopes, "openid", "Token should retain the openid scope") + ts.Require().NotContains(scopes, "read", "read must not leak to resource server B") + ts.Require().NotContains(scopes, "write", "write must not leak to resource server B") + + // The access token is bound to resource server B. + ts.Require().Equal(scopeTestResourceServerBIdentifier, claims.Aud, + "Access token audience should be resource server B") +} + +// obtainScopesAndAudience runs the flow for the given user requesting "openid read write" bound to +// the given resource server, and returns the issued token's scope list and audience. +func (ts *OAuthAuthzScopeTestSuite) obtainScopesAndAudience(username, resource string) ([]string, string) { + tokenResp, err := ts.obtainTokenWithResource( + scopeTestClientID, scopeTestClientSecret, "openid read write", username, resource) + ts.Require().NoError(err, "Failed to obtain access token") + ts.Require().NotNil(tokenResp, "Token response should not be nil") + ts.Require().NotEmpty(tokenResp.AccessToken, "Access token should not be empty") + + claims, err := testutils.DecodeJWT(tokenResp.AccessToken) + ts.Require().NoError(err, "Failed to decode access token") + ts.Require().NotNil(claims, "Claims should not be nil") + + scopeRaw, ok := claims.Additional["scope"] + ts.Require().True(ok, "scope claim should be present in access token") + scopeStr, ok := scopeRaw.(string) + ts.Require().True(ok, "scope claim should be a string") + return strings.Split(scopeStr, " "), claims.Aud +} + +// TestOAuthAuthzFlow_GrantedOnBothResourceServers_ResourceA verifies a user granted read/write on +// both resource servers receives them when targeting resource server A. +func (ts *OAuthAuthzScopeTestSuite) TestOAuthAuthzFlow_GrantedOnBothResourceServers_ResourceA() { + scopes, aud := ts.obtainScopesAndAudience("oauth_multi_rs_user", scopeTestResourceServerIdentifier) + ts.Require().Equal(scopeTestResourceServerIdentifier, aud, "Audience should be resource server A") + ts.Require().Contains(scopes, "openid") + ts.Require().Contains(scopes, "read") + ts.Require().Contains(scopes, "write") +} + +// TestOAuthAuthzFlow_GrantedOnBothResourceServers_ResourceB verifies the same user receives read/write +// when targeting resource server B, even though B defines the identical permission strings. +func (ts *OAuthAuthzScopeTestSuite) TestOAuthAuthzFlow_GrantedOnBothResourceServers_ResourceB() { + scopes, aud := ts.obtainScopesAndAudience("oauth_multi_rs_user", scopeTestResourceServerBIdentifier) + ts.Require().Equal(scopeTestResourceServerBIdentifier, aud, "Audience should be resource server B") + ts.Require().Contains(scopes, "openid") + ts.Require().Contains(scopes, "read") + ts.Require().Contains(scopes, "write") +} + +// TestOAuthAuthzFlow_SharedPermissionStringScopedPerResourceServer_A verifies that the colliding +// permission strings resolve to the user's grant on resource server A only (read granted, write not). +func (ts *OAuthAuthzScopeTestSuite) TestOAuthAuthzFlow_SharedPermissionStringScopedPerResourceServer_A() { + scopes, aud := ts.obtainScopesAndAudience("oauth_split_rs_user", scopeTestResourceServerIdentifier) + ts.Require().Equal(scopeTestResourceServerIdentifier, aud, "Audience should be resource server A") + ts.Require().Contains(scopes, "openid") + ts.Require().Contains(scopes, "read", "read is granted on resource server A") + ts.Require().NotContains(scopes, "write", "write is not granted on resource server A") +} + +// TestOAuthAuthzFlow_SharedPermissionStringScopedPerResourceServer_B verifies the mirror case: the same +// user's grant on resource server B is write only, so read is dropped when targeting B. +func (ts *OAuthAuthzScopeTestSuite) TestOAuthAuthzFlow_SharedPermissionStringScopedPerResourceServer_B() { + scopes, aud := ts.obtainScopesAndAudience("oauth_split_rs_user", scopeTestResourceServerBIdentifier) + ts.Require().Equal(scopeTestResourceServerBIdentifier, aud, "Audience should be resource server B") + ts.Require().Contains(scopes, "openid") + ts.Require().Contains(scopes, "write", "write is granted on resource server B") + ts.Require().NotContains(scopes, "read", "read is not granted on resource server B") +} + // TestOAuthAuthzFlow_FiltersOIDCScopesByApplicationScopes verifies that requested OIDC scopes are // filtered by the application's active scopes before token issuance. func (ts *OAuthAuthzScopeTestSuite) TestOAuthAuthzFlow_FiltersOIDCScopesByApplicationScopes() { diff --git a/tests/integration/oauth/sso/sso_reuse_test.go b/tests/integration/oauth/sso/sso_reuse_test.go index 62f13ef99e..60a4b3b763 100644 --- a/tests/integration/oauth/sso/sso_reuse_test.go +++ b/tests/integration/oauth/sso/sso_reuse_test.go @@ -18,6 +18,8 @@ package sso +import "github.com/thunder-id/thunderid/tests/integration/testutils" + // TestSSOSessionReuseSkipsAuthentication verifies the core SSO promise: once a per-flow session is // established, a subsequent authorize on the same flow (carrying the SSO cookie) is satisfied without // re-prompting for credentials. The initial /flow/execute step completes immediately, whereas a @@ -37,3 +39,35 @@ func (ts *SSOLogoutTestSuite) TestSSOSessionReuseSkipsAuthentication() { ts.Equal("COMPLETE", step.FlowStatus, "second authorize should skip authentication via SSO") ts.NotEmpty(step.Assertion, "SSO-skipped flow should still yield an assertion") } + +// TestSSOReuse_ScopesPermissionsToRequestedResourceServer verifies that an +// SSO checkpoint established for resource server A must not carry A's resource-server binding into a +// later SSO-satisfied request targeting resource server B. The scope user is granted "read" on A only, +// so a second request bound to B (which defines the same "read") must not receive it. +func (ts *SSOLogoutTestSuite) TestSSOReuse_ScopesPermissionsToRequestedResourceServer() { + client := ts.newSessionClient() + + // First login binds to resource server A, where the scope user holds "read". + tokenA := ts.loginWithResource(client, ssoScopeUsername, "scope_state_1", "openid read", rsAIdentifier) + ts.Require().Equal(rsAIdentifier, ts.tokenAudience(tokenA.AccessToken), "first token audience should be rs-A") + ts.Require().Contains(ts.tokenScopes(tokenA.AccessToken), "read", "read is granted on rs-A") + ts.Require().NotEmpty(ts.ssoCookieNames(client), "an SSO cookie should be set after first login") + + // Second authorize with the SSO cookie present, bound to resource server B. SSO_CHECK satisfies the + // flow without a credential prompt; exchange the resulting code for a token bound to B. + authID, executionID := ts.authorizeWithResource(client, "openid read", "scope_state_2", rsBIdentifier) + step := ts.flowExecute(client, map[string]interface{}{"executionId": executionID}) + ts.Require().Equal("COMPLETE", step.FlowStatus, "second authorize should skip authentication via SSO") + ts.Require().NotEmpty(step.Assertion, "SSO-skipped flow should still yield an assertion") + + clientRedirect := ts.completeAuthorization(client, authID, step.Assertion) + code, err := testutils.ExtractAuthorizationCode(clientRedirect) + ts.Require().NoError(err, "failed to extract authorization code") + tokenB := ts.exchangeCodeWithResource(client, code, rsBIdentifier) + + // rs-B defines the same "read" but the user has no grant there; the checkpoint from rs-A must not + // leak it across the SSO-satisfied request. + ts.Require().Equal(rsBIdentifier, ts.tokenAudience(tokenB.AccessToken), "second token audience should be rs-B") + ts.Require().NotContains(ts.tokenScopes(tokenB.AccessToken), "read", + "read must not leak from resource server A to B across SSO reuse") +} diff --git a/tests/integration/oauth/sso/suite_test.go b/tests/integration/oauth/sso/suite_test.go index cfa235406f..20c95b0813 100644 --- a/tests/integration/oauth/sso/suite_test.go +++ b/tests/integration/oauth/sso/suite_test.go @@ -55,6 +55,12 @@ const ( testPassword = "testpass123" ssoReuseUsername = "sso_reuse_user" logoutUsername = "sso_logout_user" + ssoScopeUsername = "sso_scope_user" + + // Two resource servers defining the same permission string, used by the cross-resource-server + // SSO regression: the scope user is granted "read" on rs-A only. + rsAIdentifier = "https://sso-scope-a.example.com" + rsBIdentifier = "https://sso-scope-b.example.com" // ssoCookiePrefix is the per-flow SSO handle cookie prefix minted by the session transport. ssoCookiePrefix = "tid_sso_" @@ -200,6 +206,10 @@ type SSOLogoutTestSuite struct { authFlowID string signOutFlowID string resourceServerID string + rsAID string + rsBID string + scopeRoleID string + scopeUserID string userIDs []string } @@ -239,6 +249,43 @@ func (ts *SSOLogoutTestSuite) SetupSuite() { for _, username := range []string{ssoReuseUsername, logoutUsername} { ts.createUser(username) } + + // Cross-resource-server SSO regression fixtures: two resource servers defining the + // same "read" permission, and a user granted it on rs-A only. + readAction := []testutils.Action{{Name: "Read", Handle: "read", Description: "Read permission"}} + rsAID, err := testutils.CreateResourceServerWithActions(testutils.ResourceServer{ + Name: "SSO Scope Resource Server A", + Description: "Resource server A for the cross-RS SSO regression", + Identifier: rsAIdentifier, + OUID: testOUID, + }, readAction) + ts.Require().NoError(err, "Failed to create resource server A") + ts.rsAID = rsAID + + rsBID, err := testutils.CreateResourceServerWithActions(testutils.ResourceServer{ + Name: "SSO Scope Resource Server B", + Description: "Resource server B (defines the same permission strings as A)", + Identifier: rsBIdentifier, + OUID: testOUID, + }, readAction) + ts.Require().NoError(err, "Failed to create resource server B") + ts.rsBID = rsBID + + ts.scopeUserID = ts.createUser(ssoScopeUsername) + + scopeRoleID, err := testutils.CreateRole(testutils.Role{ + Name: "SSO_ScopeReader", + Description: "Grants read on resource server A only (OAuth SSO test)", + OUID: testOUID, + Permissions: []testutils.ResourcePermissions{ + {ResourceServerID: ts.rsAID, Permissions: []string{"read"}}, + }, + Assignments: []testutils.Assignment{ + {ID: ts.scopeUserID, Type: "user"}, + }, + }) + ts.Require().NoError(err, "Failed to create scope role") + ts.scopeRoleID = scopeRoleID } func (ts *SSOLogoutTestSuite) TearDownSuite() { @@ -260,6 +307,20 @@ func (ts *SSOLogoutTestSuite) TearDownSuite() { ts.T().Errorf("Failed to delete resource server: %v", err) } } + if ts.scopeRoleID != "" { + if err := testutils.DeleteRole(ts.scopeRoleID); err != nil { + ts.T().Errorf("Failed to delete scope role: %v", err) + } + } + // rs-A/rs-B carry actions; deletion may report a dependency error (RES-1006) which is harmless on + // the temporary test database, so log rather than fail. + for _, rsID := range []string{ts.rsAID, ts.rsBID} { + if rsID != "" { + if err := testutils.DeleteResourceServer(rsID); err != nil { + ts.T().Logf("Failed to delete SSO scope resource server %s: %v", rsID, err) + } + } + } // Delete the OU's children (users, then the user type) before the OU itself, otherwise the OU // delete fails with "organization unit has children" and leaks the fixed-handle OU across runs. for _, userID := range ts.userIDs { @@ -346,7 +407,7 @@ func (ts *SSOLogoutTestSuite) deleteAppByID(id string) { } } -func (ts *SSOLogoutTestSuite) createUser(username string) { +func (ts *SSOLogoutTestSuite) createUser(username string) string { user := testutils.User{ OUID: testOUID, Type: testUserType.Name, @@ -359,6 +420,7 @@ func (ts *SSOLogoutTestSuite) createUser(username string) { userID, err := testutils.CreateUser(user) ts.Require().NoError(err, "Failed to create test user %s", username) ts.userIDs = append(ts.userIDs, userID) + return userID } // newSessionClient returns a browser-like HTTP client: a cookie jar carries the per-flow SSO @@ -505,3 +567,97 @@ func (ts *SSOLogoutTestSuite) login(client *http.Client, username, state string) ts.Require().NotEmpty(token.IDToken, "id_token should be issued for openid scope") return token.IDToken } + +// authorizeWithResource starts an authorization code flow bound to the given RFC 8707 resource and +// returns the authId and executionId issued at the gate redirect. +func (ts *SSOLogoutTestSuite) authorizeWithResource(client *http.Client, scope, state, resource string) ( + string, string) { + params := url.Values{} + params.Set("client_id", clientID) + params.Set("redirect_uri", redirectURI) + params.Set("response_type", "code") + params.Set("scope", scope) + params.Set("state", state) + params.Set("resource", resource) + + req, err := http.NewRequest("GET", testutils.TestServerURL+"/oauth2/authorize?"+params.Encode(), nil) + ts.Require().NoError(err) + + resp, err := client.Do(req) + ts.Require().NoError(err, "authorize request failed") + defer resp.Body.Close() + + ts.Require().Equal(http.StatusFound, resp.StatusCode, "authorize should redirect to the gate") + authID, executionID, err := testutils.ExtractAuthData(resp.Header.Get("Location")) + ts.Require().NoError(err, "failed to extract auth data from authorize redirect") + return authID, executionID +} + +// exchangeCodeWithResource swaps an authorization code for tokens, binding the token to the given +// RFC 8707 resource. +func (ts *SSOLogoutTestSuite) exchangeCodeWithResource( + client *http.Client, code, resource string, +) *testutils.TokenResponse { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("redirect_uri", redirectURI) + form.Set("resource", resource) + + req, err := http.NewRequest("POST", testutils.TestServerURL+"/oauth2/token", 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, "token request failed") + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + ts.Require().Equal(http.StatusOK, resp.StatusCode, "token request failed: %s", string(respBody)) + + var token testutils.TokenResponse + ts.Require().NoError(json.Unmarshal(respBody, &token), "failed to decode token response") + return &token +} + +// loginWithResource drives a first-time login to completion for the given scope/resource and returns +// the issued token response. +func (ts *SSOLogoutTestSuite) loginWithResource( + client *http.Client, username, state, scope, resource string, +) *testutils.TokenResponse { + authID, executionID := ts.authorizeWithResource(client, scope, state, resource) + + initial := ts.flowExecute(client, map[string]interface{}{"executionId": executionID}) + ts.Require().NotEqual("COMPLETE", initial.FlowStatus, "first login must prompt for credentials") + + step := ts.flowExecute(client, map[string]interface{}{ + "executionId": executionID, + "inputs": map[string]string{"username": username, "password": testPassword}, + "action": "action_001", + "challengeToken": initial.ChallengeToken, + }) + ts.Require().Equal("COMPLETE", step.FlowStatus, "credential login should complete the flow") + ts.Require().NotEmpty(step.Assertion, "login should yield an assertion") + + clientRedirect := ts.completeAuthorization(client, authID, step.Assertion) + code, err := testutils.ExtractAuthorizationCode(clientRedirect) + ts.Require().NoError(err, "failed to extract authorization code") + + return ts.exchangeCodeWithResource(client, code, resource) +} + +// tokenScopes returns the space-separated `scope` claim of a decoded access token as a slice. +func (ts *SSOLogoutTestSuite) tokenScopes(accessToken string) []string { + claims, err := testutils.DecodeJWT(accessToken) + ts.Require().NoError(err, "failed to decode access token") + scopeStr, _ := claims.Additional["scope"].(string) + return strings.Fields(scopeStr) +} + +// tokenAudience returns the `aud` claim of a decoded access token. +func (ts *SSOLogoutTestSuite) tokenAudience(accessToken string) string { + claims, err := testutils.DecodeJWT(accessToken) + ts.Require().NoError(err, "failed to decode access token") + return claims.Aud +} diff --git a/tests/integration/oauth/token/refresh_token_test.go b/tests/integration/oauth/token/refresh_token_test.go index be907c498d..b65c52ca1c 100644 --- a/tests/integration/oauth/token/refresh_token_test.go +++ b/tests/integration/oauth/token/refresh_token_test.go @@ -310,9 +310,9 @@ func (ts *RefreshTokenTestSuite) obtainTokensViaAuthCodeFlow( scope string) *testutils.TokenResponse { // Step 1: Initiate authorization flow. - resp, err := testutils.InitiateAuthorizationFlow( + resp, err := testutils.InitiateAuthorizationFlowWithResource( refreshTokenTestClientID, refreshTokenTestRedirectURI, - "code", scope, "test-state") + "code", scope, "test-state", refreshTokenTestResource) ts.Require().NoError(err, "Failed to initiate authorization flow") defer resp.Body.Close() ts.Require().Equal(http.StatusFound, resp.StatusCode,