diff --git a/README.md b/README.md index 9927ed6b4..a6d23c79d 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ Priority order: | Setting | Default | Required | Description | |---------------------------------------------------|:-----------------:|:--------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| toolsets.security.allowedRedirectUris | [] | No | List of allowed redirect URIs that clients can provide during OAuth sign-in. Validated against this list and the toolset's own `redirect_uri`. For dynamic client registration, all URIs from this list are registered with the authorization server. | | toolsets.security.authorizationServers | - | No | Path(s) to the authorization server URLs trusted to issue access tokens for MCP clients. | | toolsets.security.resourceSchema | https | No | Schema of the resource server. This URL schema is used to construct the resource identifier for token validation, as defined in RFC 9728. If not specified, the default value will be applied. | | toolsets.security.resourceHost | - | No | The public, fully-qualified hostname of this resource server (e.g., api.example.com). This is used to construct the resource identifier for token validation per RFC 9728. If not set, the host is derived from the incoming request. | diff --git a/credentials/src/main/java/com/epam/aidial/core/credentials/data/credentials/ResourceSignInRequest.java b/credentials/src/main/java/com/epam/aidial/core/credentials/data/credentials/ResourceSignInRequest.java index 7f7f1942b..3ed748ed8 100644 --- a/credentials/src/main/java/com/epam/aidial/core/credentials/data/credentials/ResourceSignInRequest.java +++ b/credentials/src/main/java/com/epam/aidial/core/credentials/data/credentials/ResourceSignInRequest.java @@ -29,4 +29,7 @@ public class ResourceSignInRequest { @JsonAlias({"apiKey", "api_key"}) private String apiKey; + + @JsonAlias({"redirectUri", "redirect_uri"}) + private String redirectUri; } diff --git a/credentials/src/main/java/com/epam/aidial/core/credentials/service/RedirectUriHelper.java b/credentials/src/main/java/com/epam/aidial/core/credentials/service/RedirectUriHelper.java new file mode 100644 index 000000000..05862ed2c --- /dev/null +++ b/credentials/src/main/java/com/epam/aidial/core/credentials/service/RedirectUriHelper.java @@ -0,0 +1,23 @@ +package com.epam.aidial.core.credentials.service; + +import com.epam.aidial.core.config.ResourceAuthSettings; +import lombok.experimental.UtilityClass; + +import java.util.ArrayList; +import java.util.List; + +@UtilityClass +public class RedirectUriHelper { + + /** + * Builds the effective list of allowed redirect URIs by combining the global allowed list + * with the toolset's own redirect URI (if present and not already included). + */ + public List collectAllowedRedirectUris(List globalAllowedUris, ResourceAuthSettings resourceAuthSettings) { + List uris = new ArrayList<>(globalAllowedUris); + if (resourceAuthSettings.getRedirectUri() != null && !uris.contains(resourceAuthSettings.getRedirectUri())) { + uris.add(resourceAuthSettings.getRedirectUri()); + } + return uris; + } +} diff --git a/credentials/src/main/java/com/epam/aidial/core/credentials/service/TokenService.java b/credentials/src/main/java/com/epam/aidial/core/credentials/service/TokenService.java index 6ce21652d..296041735 100644 --- a/credentials/src/main/java/com/epam/aidial/core/credentials/service/TokenService.java +++ b/credentials/src/main/java/com/epam/aidial/core/credentials/service/TokenService.java @@ -7,17 +7,22 @@ import com.epam.aidial.core.credentials.data.credentials.TokenResponse; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; @AllArgsConstructor @Slf4j public class TokenService { private final ResourceAuthorizationClient resourceAuthorizationClient; + private final List allowedRedirectUris; public TokenResponse getToken(String resourceId, ResourceAuthSettings resourceAuthSettings, ResourceSignInRequest resourceSignInRequest) { log.debug("Start Resource {} token retrieval", resourceId); + String redirectUri = resolveRedirectUri(resourceAuthSettings, resourceSignInRequest); TokenRequest tokenRequest = TokenRequest.builder() .clientId(resourceAuthSettings.getClientId()) .clientSecret(resourceAuthSettings.getClientSecret()) @@ -25,7 +30,7 @@ public TokenResponse getToken(String resourceId, // TODO: do we need to support different? .grantType("authorization_code") .codeVerifier(resourceAuthSettings.getCodeVerifier()) - .redirectUri(resourceAuthSettings.getRedirectUri()) + .redirectUri(redirectUri) .build(); TokenResponse tokenResponse = doTokenCall(resourceAuthSettings.getTokenEndpoint(), tokenRequest.buildFormData()); @@ -36,7 +41,7 @@ public TokenResponse getToken(String resourceId, public TokenResponse getToken(String resourceId, ResourceAuthSettings resourceAuthSettings, String refreshToken) { - log.debug("Start Resource {} reresh token retrieval", resourceId); + log.debug("Start Resource {} refresh token retrieval", resourceId); RefreshTokenRequest tokenRequest = RefreshTokenRequest.builder() .clientId(resourceAuthSettings.getClientId()) .clientSecret(resourceAuthSettings.getClientSecret()) @@ -49,6 +54,27 @@ public TokenResponse getToken(String resourceId, return tokenResponse; } + private String resolveRedirectUri(ResourceAuthSettings resourceAuthSettings, + ResourceSignInRequest resourceSignInRequest) { + String requestRedirectUri = resourceSignInRequest.getRedirectUri(); + + if (StringUtils.isNotBlank(requestRedirectUri)) { + List effectiveAllowedUris = getEffectiveAllowedUris(resourceAuthSettings); + if (!effectiveAllowedUris.contains(requestRedirectUri)) { + throw new IllegalArgumentException( + "Provided redirect_uri is not in the list of allowed redirect URIs"); + } + return requestRedirectUri; + } + + // Fallback to toolset's own redirect_uri (backward compatible) + return resourceAuthSettings.getRedirectUri(); + } + + private List getEffectiveAllowedUris(ResourceAuthSettings resourceAuthSettings) { + return RedirectUriHelper.collectAllowedRedirectUris(allowedRedirectUris, resourceAuthSettings); + } + private TokenResponse doTokenCall(String tokenEndpoint, String tokenRequest) { return resourceAuthorizationClient.executePost( tokenEndpoint, tokenRequest, diff --git a/credentials/src/main/java/com/epam/aidial/core/credentials/service/registration/DynamicResourceRegistrationStrategy.java b/credentials/src/main/java/com/epam/aidial/core/credentials/service/registration/DynamicResourceRegistrationStrategy.java index f70464a6c..27805c963 100644 --- a/credentials/src/main/java/com/epam/aidial/core/credentials/service/registration/DynamicResourceRegistrationStrategy.java +++ b/credentials/src/main/java/com/epam/aidial/core/credentials/service/registration/DynamicResourceRegistrationStrategy.java @@ -6,6 +6,7 @@ import com.epam.aidial.core.credentials.data.registration.ClientRegistration; import com.epam.aidial.core.credentials.data.registration.ClientRegistrationRequest; import com.epam.aidial.core.credentials.data.registration.ClientRegistrationResponse; +import com.epam.aidial.core.credentials.service.RedirectUriHelper; import com.epam.aidial.core.credentials.service.ResourceAuthorizationClient; import com.epam.aidial.core.credentials.service.metadata.AuthorizationServerMetadataService; import com.epam.aidial.core.credentials.service.metadata.ProtectedResourceMetadataService; @@ -40,6 +41,7 @@ public class DynamicResourceRegistrationStrategy implements ResourceRegistration private final AuthorizationServerMetadataService authorizationServerMetadataService; private final ResourceAuthorizationClient resourceAuthorizationClient; private final ProtectedResourceMetadataService protectedResourceMetadataService; + private final List allowedRedirectUris; /** * Registers a protected resource dynamically using the authorization server's dynamic client registration @@ -72,7 +74,7 @@ public ClientRegistration register(String resourceId, String resourceEndpoint, R ClientRegistrationRequest clientRegistrationRequest = ClientRegistrationRequest.builder() .clientName(resourceId) - .redirectUris(List.of(resourceAuthSettings.getRedirectUri())) + .redirectUris(collectRedirectUris(resourceAuthSettings)) .build(); ClientRegistrationResponse clientRegistrationResponse = resourceAuthorizationClient.executePost( @@ -97,4 +99,8 @@ public ClientRegistration register(String resourceId, String resourceEndpoint, R log.info("Finished dynamic registration for Resource: {}", resourceId); return clientRegistration; } + + private List collectRedirectUris(ResourceAuthSettings resourceAuthSettings) { + return RedirectUriHelper.collectAllowedRedirectUris(allowedRedirectUris, resourceAuthSettings); + } } diff --git a/credentials/src/main/java/com/epam/aidial/core/credentials/service/registration/ResourceRegistrationService.java b/credentials/src/main/java/com/epam/aidial/core/credentials/service/registration/ResourceRegistrationService.java index e6949b582..b69a0b4ff 100644 --- a/credentials/src/main/java/com/epam/aidial/core/credentials/service/registration/ResourceRegistrationService.java +++ b/credentials/src/main/java/com/epam/aidial/core/credentials/service/registration/ResourceRegistrationService.java @@ -8,6 +8,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import java.util.List; + @Slf4j @RequiredArgsConstructor public class ResourceRegistrationService { @@ -15,6 +17,7 @@ public class ResourceRegistrationService { private final AuthorizationServerMetadataService authorizationServerMetadataService; private final ResourceAuthorizationClient resourceAuthorizationClient; private final ProtectedResourceMetadataService protectedResourceMetadataService; + private final List allowedRedirectUris; public ClientRegistration register(String resourceId, String resourceEndpoint, @@ -22,7 +25,7 @@ public ClientRegistration register(String resourceId, boolean oauthDynamicClientRegistrationRequired) { ResourceRegistrationStrategy strategy = oauthDynamicClientRegistrationRequired ? new DynamicResourceRegistrationStrategy( - authorizationServerMetadataService, resourceAuthorizationClient, protectedResourceMetadataService) + authorizationServerMetadataService, resourceAuthorizationClient, protectedResourceMetadataService, allowedRedirectUris) : new StaticResourceRegistrationStrategy( authorizationServerMetadataService, protectedResourceMetadataService); diff --git a/credentials/src/main/java/com/epam/aidial/core/credentials/validation/OauthAuthSettingsValidator.java b/credentials/src/main/java/com/epam/aidial/core/credentials/validation/OauthAuthSettingsValidator.java index 9880dbb9a..2d74accdd 100644 --- a/credentials/src/main/java/com/epam/aidial/core/credentials/validation/OauthAuthSettingsValidator.java +++ b/credentials/src/main/java/com/epam/aidial/core/credentials/validation/OauthAuthSettingsValidator.java @@ -62,7 +62,6 @@ protected ResourceAuthSettingsValidationFields getValidationFields(ResourceAuthS private ResourceAuthSettingsValidationFields getOauthWithStaticRegistrationValidationFields() { return ResourceAuthSettingsValidationFields.builder() .requiredFields(Set.of( - ResourceAuthSettingsField.REDIRECT_URI, ResourceAuthSettingsField.CLIENT_ID, ResourceAuthSettingsField.CLIENT_SECRET, ResourceAuthSettingsField.AUTHORIZATION_ENDPOINT, @@ -90,7 +89,7 @@ private ResourceAuthSettingsValidationFields getOauthWithStaticRegistrationValid */ private ResourceAuthSettingsValidationFields getOauthWithDynamicRegistrationValidationFields() { return ResourceAuthSettingsValidationFields.builder() - .requiredFields(Set.of(ResourceAuthSettingsField.REDIRECT_URI)) + .requiredFields(Set.of()) .forbiddenFields(Set.of( ResourceAuthSettingsField.CLIENT_ID, ResourceAuthSettingsField.CLIENT_SECRET, @@ -120,7 +119,6 @@ private ResourceAuthSettingsValidationFields getOauthWithDynamicRegistrationVali private ResourceAuthSettingsValidationFields getOauthWithNoAuthTypeChangeValidationFields() { return ResourceAuthSettingsValidationFields.builder() .requiredFields(Set.of( - ResourceAuthSettingsField.REDIRECT_URI, ResourceAuthSettingsField.CLIENT_ID, ResourceAuthSettingsField.AUTHORIZATION_ENDPOINT, ResourceAuthSettingsField.TOKEN_ENDPOINT) diff --git a/credentials/src/test/java/com/epam/aidial/core/credentials/service/TokenServiceTest.java b/credentials/src/test/java/com/epam/aidial/core/credentials/service/TokenServiceTest.java new file mode 100644 index 000000000..708ef850f --- /dev/null +++ b/credentials/src/test/java/com/epam/aidial/core/credentials/service/TokenServiceTest.java @@ -0,0 +1,178 @@ +package com.epam.aidial.core.credentials.service; + +import com.epam.aidial.core.config.ResourceAuthSettings; +import com.epam.aidial.core.credentials.data.credentials.ResourceSignInRequest; +import com.epam.aidial.core.credentials.data.credentials.TokenResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TokenServiceTest { + + @Mock + private ResourceAuthorizationClient resourceAuthorizationClient; + + @Test + void testGetToken_usesRedirectUriFromGlobalAllowedList() { + TokenService tokenService = new TokenService(resourceAuthorizationClient, + List.of("http://admin/callback", "http://chat/callback")); + + ResourceAuthSettings authSettings = ResourceAuthSettings.builder() + .clientId("client-id") + .clientSecret("client-secret") + .tokenEndpoint("http://auth-server/token") + .redirectUri("http://admin/callback") + .build(); + + ResourceSignInRequest signInRequest = ResourceSignInRequest.builder() + .code("auth-code") + .redirectUri("http://chat/callback") + .build(); + + when(resourceAuthorizationClient.executePost(any(), any(), any(), any())) + .thenReturn(new TokenResponse("access", "refresh", 3600L)); + + tokenService.getToken("resource-1", authSettings, signInRequest); + + ArgumentCaptor formDataCaptor = ArgumentCaptor.forClass(String.class); + verify(resourceAuthorizationClient).executePost( + eq("http://auth-server/token"), formDataCaptor.capture(), + eq("application/x-www-form-urlencoded"), eq(TokenResponse.class)); + + String formData = formDataCaptor.getValue(); + assertTrue(formData.contains("redirect_uri=http%3A%2F%2Fchat%2Fcallback"), + "Expected redirect_uri from sign-in request, got: " + formData); + } + + @Test + void testGetToken_usesRedirectUriMatchingToolsetOwnUri() { + // Global list is empty, but toolset's own redirect_uri is allowed + TokenService tokenService = new TokenService(resourceAuthorizationClient, List.of()); + + ResourceAuthSettings authSettings = ResourceAuthSettings.builder() + .clientId("client-id") + .clientSecret("client-secret") + .tokenEndpoint("http://auth-server/token") + .redirectUri("http://admin/callback") + .build(); + + ResourceSignInRequest signInRequest = ResourceSignInRequest.builder() + .code("auth-code") + .redirectUri("http://admin/callback") + .build(); + + when(resourceAuthorizationClient.executePost(any(), any(), any(), any())) + .thenReturn(new TokenResponse("access", "refresh", 3600L)); + + tokenService.getToken("resource-1", authSettings, signInRequest); + + ArgumentCaptor formDataCaptor = ArgumentCaptor.forClass(String.class); + verify(resourceAuthorizationClient).executePost( + eq("http://auth-server/token"), formDataCaptor.capture(), + eq("application/x-www-form-urlencoded"), eq(TokenResponse.class)); + + String formData = formDataCaptor.getValue(); + assertTrue(formData.contains("redirect_uri=http%3A%2F%2Fadmin%2Fcallback"), + "Expected toolset's own redirect_uri to be accepted, got: " + formData); + } + + @Test + void testGetToken_fallsBackToToolsetRedirectUri() { + TokenService tokenService = new TokenService(resourceAuthorizationClient, + List.of("http://chat/callback")); + + ResourceAuthSettings authSettings = ResourceAuthSettings.builder() + .clientId("client-id") + .clientSecret("client-secret") + .tokenEndpoint("http://auth-server/token") + .redirectUri("http://admin/callback") + .build(); + + ResourceSignInRequest signInRequest = ResourceSignInRequest.builder() + .code("auth-code") + .build(); + + when(resourceAuthorizationClient.executePost(any(), any(), any(), any())) + .thenReturn(new TokenResponse("access", "refresh", 3600L)); + + tokenService.getToken("resource-1", authSettings, signInRequest); + + ArgumentCaptor formDataCaptor = ArgumentCaptor.forClass(String.class); + verify(resourceAuthorizationClient).executePost( + eq("http://auth-server/token"), formDataCaptor.capture(), + eq("application/x-www-form-urlencoded"), eq(TokenResponse.class)); + + String formData = formDataCaptor.getValue(); + assertTrue(formData.contains("redirect_uri=http%3A%2F%2Fadmin%2Fcallback"), + "Should fall back to toolset's redirect_uri, got: " + formData); + } + + @Test + void testGetToken_fallsBackWhenRedirectUriIsBlank() { + TokenService tokenService = new TokenService(resourceAuthorizationClient, List.of()); + + ResourceAuthSettings authSettings = ResourceAuthSettings.builder() + .clientId("client-id") + .clientSecret("client-secret") + .tokenEndpoint("http://auth-server/token") + .redirectUri("http://admin/callback") + .build(); + + ResourceSignInRequest signInRequest = ResourceSignInRequest.builder() + .code("auth-code") + .redirectUri(" ") + .build(); + + when(resourceAuthorizationClient.executePost(any(), any(), any(), any())) + .thenReturn(new TokenResponse("access", "refresh", 3600L)); + + tokenService.getToken("resource-1", authSettings, signInRequest); + + ArgumentCaptor formDataCaptor = ArgumentCaptor.forClass(String.class); + verify(resourceAuthorizationClient).executePost( + eq("http://auth-server/token"), formDataCaptor.capture(), + eq("application/x-www-form-urlencoded"), eq(TokenResponse.class)); + + String formData = formDataCaptor.getValue(); + assertTrue(formData.contains("redirect_uri=http%3A%2F%2Fadmin%2Fcallback"), + "Should fall back when redirect_uri is blank, got: " + formData); + } + + @Test + void testGetToken_rejectsRedirectUriNotInAllowedList() { + TokenService tokenService = new TokenService(resourceAuthorizationClient, + List.of("http://chat/callback")); + + ResourceAuthSettings authSettings = ResourceAuthSettings.builder() + .clientId("client-id") + .clientSecret("client-secret") + .tokenEndpoint("http://auth-server/token") + .redirectUri("http://admin/callback") + .build(); + + ResourceSignInRequest signInRequest = ResourceSignInRequest.builder() + .code("auth-code") + .redirectUri("http://evil/callback") + .build(); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> tokenService.getToken("resource-1", authSettings, signInRequest)); + + assertEquals("Provided redirect_uri is not in the list of allowed redirect URIs", exception.getMessage()); + verifyNoInteractions(resourceAuthorizationClient); + } +} diff --git a/credentials/src/test/java/com/epam/aidial/core/credentials/service/registration/DynamicResourceRegistrationStrategyTest.java b/credentials/src/test/java/com/epam/aidial/core/credentials/service/registration/DynamicResourceRegistrationStrategyTest.java index c68887aad..7316b2cbf 100644 --- a/credentials/src/test/java/com/epam/aidial/core/credentials/service/registration/DynamicResourceRegistrationStrategyTest.java +++ b/credentials/src/test/java/com/epam/aidial/core/credentials/service/registration/DynamicResourceRegistrationStrategyTest.java @@ -10,9 +10,9 @@ import com.epam.aidial.core.credentials.service.metadata.AuthorizationServerMetadataService; import com.epam.aidial.core.credentials.service.metadata.ProtectedResourceMetadataService; import org.apache.hc.core5.http.ContentType; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -41,9 +41,14 @@ class DynamicResourceRegistrationStrategyTest { @Mock private ProtectedResourceMetadataService protectedResourceMetadataService; - @InjectMocks private DynamicResourceRegistrationStrategy resourceRegistrationStrategy; + @BeforeEach + void setUp() { + resourceRegistrationStrategy = new DynamicResourceRegistrationStrategy( + authorizationServerMetadataService, resourceAuthorizationClient, protectedResourceMetadataService, List.of()); + } + @Test void testCreateDynamicResourceRegistration_Success() { // Given diff --git a/credentials/src/test/java/com/epam/aidial/core/credentials/validation/OauthAuthSettingsValidatorTest.java b/credentials/src/test/java/com/epam/aidial/core/credentials/validation/OauthAuthSettingsValidatorTest.java index 181efd57c..8ea2da90b 100644 --- a/credentials/src/test/java/com/epam/aidial/core/credentials/validation/OauthAuthSettingsValidatorTest.java +++ b/credentials/src/test/java/com/epam/aidial/core/credentials/validation/OauthAuthSettingsValidatorTest.java @@ -36,6 +36,22 @@ static Stream provideValidResourceAuthSettingsForCreate() { .authenticationType(AuthenticationType.OAUTH) .redirectUri("https://example.com/oauth") .build(), + ResourceAuthSettingsChangeMode.CREATE_DYNAMIC_CLIENT), + + // Case 3: OAUTH authentication with static registration without redirect_uri + Arguments.of(ResourceAuthSettings.builder() + .authenticationType(AuthenticationType.OAUTH) + .clientId("client123") + .clientSecret("secret123") + .authorizationEndpoint("authorizationEndpoint") + .tokenEndpoint("tokenEndpoint") + .build(), + ResourceAuthSettingsChangeMode.CREATE_STATIC_CLIENT), + + // Case 4: OAUTH authentication with dynamic registration without redirect_uri + Arguments.of(ResourceAuthSettings.builder() + .authenticationType(AuthenticationType.OAUTH) + .build(), ResourceAuthSettingsChangeMode.CREATE_DYNAMIC_CLIENT) ); } @@ -106,16 +122,6 @@ static Stream provideInvalidResourceAuthSettingsForCreate() { ResourceAuthSettingsChangeMode.CREATE_STATIC_CLIENT, "Field 'CLIENT_ID' is required for OAUTH authentication."), - Arguments.of(ResourceAuthSettings.builder() - .authenticationType(AuthenticationType.OAUTH) - .clientId("client123") - .clientSecret("secret123") - .authorizationEndpoint("authorizationEndpoint") - .tokenEndpoint("tokenEndpoint") - .build(), - ResourceAuthSettingsChangeMode.CREATE_STATIC_CLIENT, - "Field 'REDIRECT_URI' is required for OAUTH authentication."), - Arguments.of(ResourceAuthSettings.builder() .authenticationType(AuthenticationType.OAUTH) .clientId("client123") diff --git a/docs/dynamic-settings/toolset_credentials_api.md b/docs/dynamic-settings/toolset_credentials_api.md index 41b10c84c..c22e5a544 100644 --- a/docs/dynamic-settings/toolset_credentials_api.md +++ b/docs/dynamic-settings/toolset_credentials_api.md @@ -32,8 +32,7 @@ This API allows the creation of a new ToolSet with customizable properties. The "tool2" ], "auth_settings": { - "authentication_type": "OAUTH", - "redirect_uri": "{chat-host}/toolset/sign-in" + "authentication_type": "OAUTH" } } ``` @@ -56,6 +55,7 @@ This API allows the creation of a new ToolSet with customizable properties. The |----------------------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------| | `client_id` | `string` | The unique identifier of the client/application requesting access to the resource. | Yes | | `client_secret` | `string` | A confidential key used by the client to authenticate itself with the authentication server. | Yes (for confidential clients) / No (for public clients like SPAs or mobile apps implementing PKCE) | +| `redirect_uri` | `string` | The redirect URI for the OAuth callback. If omitted, the client must provide `redirect_uri` at sign-in time (validated against `toolsets.security.allowedRedirectUris`). | No | | `authorization_endpoint` | `string` | The URL where the client directs the user to authenticate and obtain authorization. Can be discovered via .well-known metadata if provided by the Authorization Server. | No (if discoverable) / Yes (if not discoverable) | | `token_endpoint` | `string` | The URL where the client exchanges the authorization code for an access token. Can be discovered via .well-known metadata if provided by the Authorization Server. | No (if discoverable) / Yes (if not discoverable) | | `code_challenge_method` | `string` | The method used for Proof Key for Code Exchange (PKCE), usually plain or S256. | No (Required only if using PKCE) | @@ -77,17 +77,18 @@ This API allows the creation of a new ToolSet with customizable properties. The } ``` +> **Note:** `redirect_uri` is optional. When omitted, the client must provide `redirect_uri` in the sign-in request. The provided URI is validated against the `toolsets.security.allowedRedirectUris` setting and the toolset's own `redirect_uri` (if configured). + --- ##### **2. OAUTH (Dynamic Client Registration)** -The client can be dynamically created without including a `client_id` or `client_secret`. +The client can be dynamically created without including a `client_id` or `client_secret`. The `redirect_uri` is optional — if omitted, all URIs from `toolsets.security.allowedRedirectUris` are registered with the authorization server. ```json { "auth_settings": { - "authentication_type": "OAUTH", - "redirect_uri": "{chat-host}/toolset/sign-in" + "authentication_type": "OAUTH" } } ``` @@ -215,7 +216,8 @@ Authenticates the user for a specified ToolSet using OAUTH or API_KEY. "url": "toolsets/{bucket}/{path}", "credentials_level": "GLOBAL", "authentication_type": "OAUTH", - "code": "auth-code" + "code": "auth-code", + "redirect_uri": "{chat-host}/toolset/sign-in" } ``` @@ -229,13 +231,14 @@ Authenticates the user for a specified ToolSet using OAUTH or API_KEY. } ``` -| **Field** | **Type** | **Required** | **Description** | -|-----------------------|------------|----------------------|-------------------------------------------------------------------------------| -| `url` | `string` | Yes | The ToolSet URL (e.g., `toolsets/{bucket}/{path}`). | -| `credentials_level` | `string` | Yes | The scope of credentials for the ToolSet (`GLOBAL`, `APP`, or `USER`). | -| `authentication_type` | `string` | Yes | The authentication method used (`OAUTH` or `API_KEY`). | -| `code` | `string` | Required for OAUTH | The authorization code used in OAUTH authentication flows. | -| `api_key` | `string` | Required for API_KEY | The API key value. | +| **Field** | **Type** | **Required** | **Description** | +|-----------------------|------------|----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `url` | `string` | Yes | The ToolSet URL (e.g., `toolsets/{bucket}/{path}`). | +| `credentials_level` | `string` | Yes | The scope of credentials for the ToolSet (`GLOBAL`, `APP`, or `USER`). | +| `authentication_type` | `string` | Yes | The authentication method used (`OAUTH` or `API_KEY`). | +| `code` | `string` | Required for OAUTH | The authorization code used in OAUTH authentication flows. | +| `redirect_uri` | `string` | No (OAUTH only) | The redirect URI used in the authorization request. Must match `toolsets.security.allowedRedirectUris` or the toolset's own `redirect_uri`. Falls back to toolset's URI if omitted. | +| `api_key` | `string` | Required for API_KEY | The API key value. | --- diff --git a/server/src/main/java/com/epam/aidial/core/server/AiDial.java b/server/src/main/java/com/epam/aidial/core/server/AiDial.java index 6f4b5106f..3577aabae 100644 --- a/server/src/main/java/com/epam/aidial/core/server/AiDial.java +++ b/server/src/main/java/com/epam/aidial/core/server/AiDial.java @@ -197,10 +197,11 @@ void start() throws Exception { TokenRefreshStrategyFactory tokenRefreshStrategyFactory = new TokenRefreshStrategyFactory(timeProvider); ResourceAuthorizationClient resourceAuthorizationClient = new ResourceAuthorizationClient(httpProxySelector); CredentialEncryptionService credentialEncryptionService = getCredentialEncryptionService(); + List allowedRedirectUris = getAllowedRedirectUris(); ResourceCredentialsService resourceCredentialsService = getResourceCredentialsService( - tokenRefreshStrategyFactory, resourceAuthorizationClient, credentialEncryptionService, timeProvider); + tokenRefreshStrategyFactory, resourceAuthorizationClient, credentialEncryptionService, timeProvider, allowedRedirectUris); ResourceAuthSettingsService resourceAuthSettingsService = getResourceAuthSettingsService( - resourceCredentialsService, tokenRefreshStrategyFactory, resourceAuthorizationClient); + resourceCredentialsService, tokenRefreshStrategyFactory, resourceAuthorizationClient, allowedRedirectUris); AuthorizationHeaderProvider authorizationHeaderProvider = new AuthorizationHeaderProvider(resourceCredentialsService); ResourceAuthSettingsEncryptionService resourceAuthSettingsEncryptionService = new ResourceAuthSettingsEncryptionService( credentialEncryptionService); @@ -285,7 +286,8 @@ private static HttpProxySelector createHttpProxySelector(HttpClientOptions optio return new HttpProxySelector(proxyOptions, options.getNonProxyHosts()); } - private static ResourceRegistrationService getResourceRegistrationService(ResourceAuthorizationClient resourceAuthorizationClient) { + private static ResourceRegistrationService getResourceRegistrationService(ResourceAuthorizationClient resourceAuthorizationClient, + List allowedRedirectUris) { ProtectedResourceMetadataValidator protectedResourceMetadataValidator = new ProtectedResourceMetadataValidator(); HttpHeadersHandler httpHeadersHandler = new HttpHeadersHandler(); ProtectedResourceMetadataService protectedResourceMetadataService = new ProtectedResourceMetadataService( @@ -294,7 +296,7 @@ private static ResourceRegistrationService getResourceRegistrationService(Resour AuthorizationServerMetadataValidator authorizationServerMetadataValidator = new AuthorizationServerMetadataValidator(); AuthorizationServerMetadataService authorizationServerMetadataService = new AuthorizationServerMetadataService( resourceAuthorizationClient, authorizationServerMetadataValidator); - return new ResourceRegistrationService(authorizationServerMetadataService, resourceAuthorizationClient, protectedResourceMetadataService); + return new ResourceRegistrationService(authorizationServerMetadataService, resourceAuthorizationClient, protectedResourceMetadataService, allowedRedirectUris); } private CredentialEncryptionService getCredentialEncryptionService() { @@ -327,8 +329,9 @@ private ContentEncryptionKeyService getContentEncryptionKeyService(ContentEncryp private ResourceCredentialsService getResourceCredentialsService(TokenRefreshStrategyFactory tokenRefreshStrategyFactory, ResourceAuthorizationClient resourceAuthorizationClient, CredentialEncryptionService credentialEncryptionService, - TimeProvider timeProvider) { - TokenService tokenService = new TokenService(resourceAuthorizationClient); + TimeProvider timeProvider, + List allowedRedirectUris) { + TokenService tokenService = new TokenService(resourceAuthorizationClient, allowedRedirectUris); ResourceCredentialsFactoryProvider resourceCredentialsFactoryProvider = new ResourceCredentialsFactoryProvider(tokenService); return new ResourceCredentialsService(resourceService, credentialEncryptionService, resourceCredentialsFactoryProvider, tokenService, tokenRefreshStrategyFactory, timeProvider); @@ -336,8 +339,9 @@ private ResourceCredentialsService getResourceCredentialsService(TokenRefreshStr private ResourceAuthSettingsService getResourceAuthSettingsService(ResourceCredentialsService resourceCredentialsService, TokenRefreshStrategyFactory tokenRefreshStrategyFactory, - ResourceAuthorizationClient resourceAuthorizationClient) { - ResourceRegistrationService resourceRegistrationService = getResourceRegistrationService(resourceAuthorizationClient); + ResourceAuthorizationClient resourceAuthorizationClient, + List allowedRedirectUris) { + ResourceRegistrationService resourceRegistrationService = getResourceRegistrationService(resourceAuthorizationClient, allowedRedirectUris); AuthSettingsValidatorFactory authSettingsValidatorFactory = new AuthSettingsValidatorFactory(); return new ResourceAuthSettingsService(resourceRegistrationService, resourceCredentialsService, tokenRefreshStrategyFactory, authSettingsValidatorFactory); @@ -374,6 +378,15 @@ private JsonObject settings(String key) { return settings.getJsonObject(key, new JsonObject()); } + private List getAllowedRedirectUris() { + return settings("toolsets") + .getJsonObject("security", new JsonObject()) + .getJsonArray("allowedRedirectUris", new JsonArray()) + .stream() + .map(Object::toString) + .toList(); + } + private static JsonObject defaultSettings() throws IOException { String file = "aidial.settings.json"; diff --git a/server/src/test/java/com/epam/aidial/core/server/ToolSetApiTest.java b/server/src/test/java/com/epam/aidial/core/server/ToolSetApiTest.java index 1c815fa77..e3dcfa0fc 100644 --- a/server/src/test/java/com/epam/aidial/core/server/ToolSetApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/ToolSetApiTest.java @@ -1158,6 +1158,157 @@ void testCreateToolSetWithOauthStaticEndpointsPreservedAfterDiscovery() throws J } } + @Test + void testOauthSignInWithRedirectUriFromRequest() { + String tokenResponse = """ + { + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "expires_in": 3600 + } + """; + + try (TestWebServer server = new TestWebServer(9876)) { + // MCP endpoint returns 401 to trigger metadata discovery + server.map(HttpMethod.POST, "/mcp", 401, ""); + // No metadata endpoints — discovery will fail gracefully, static settings will be used + + // Token endpoint that validates redirect_uri from the request + server.map(HttpMethod.POST, "/token", request -> { + + String body = request.getBody().readUtf8(); + if (body.contains("redirect_uri=http%3A%2F%2Fchat%2Fcallback")) { + return new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json"); + } + return new MockResponse().setResponseCode(400).setBody("redirect_uri mismatch"); + }); + + // Create toolset with OAuth settings (single redirect_uri for admin) + Response response = send(HttpMethod.PUT, "/v1/toolsets/4X25dj1mja51jykqxsXnCH/toolset-oauth@", null, """ + { + "endpoint": "http://localhost:9876/mcp", + "transport": "HTTP", + "allowedTools": [], + "auth_settings": { + "authentication_type": "OAUTH", + "client_id": "my-client-id", + "client_secret": "my-client-secret", + "redirect_uri": "http://admin/callback", + "authorization_endpoint": "http://localhost:9876/authorize", + "token_endpoint": "http://localhost:9876/token" + } + } + """, "authorization", "admin"); + assertEquals(200, response.status()); + + // Sign in with redirect_uri from the chat (allowed via global allowedRedirectUris in settings) + response = send(HttpMethod.POST, "/v1/ops/toolset/signin", null, """ + { + "url": "toolsets/4X25dj1mja51jykqxsXnCH/toolset-oauth@", + "credentialsLevel": "GLOBAL", + "authenticationType": "OAUTH", + "code": "auth-code", + "redirect_uri": "http://chat/callback" + } + """, "authorization", "admin"); + verify(response, 200, "true"); + } + } + + @Test + void testOauthSignInWithDisallowedRedirectUri() { + try (TestWebServer server = new TestWebServer(9876)) { + server.map(HttpMethod.POST, "/mcp", 401, ""); + + // Create toolset with single redirect_uri + Response response = send(HttpMethod.PUT, "/v1/toolsets/4X25dj1mja51jykqxsXnCH/toolset-oauth-reject@", null, """ + { + "endpoint": "http://localhost:9876/mcp", + "transport": "HTTP", + "allowedTools": [], + "auth_settings": { + "authentication_type": "OAUTH", + "client_id": "my-client-id", + "client_secret": "my-client-secret", + "redirect_uri": "http://admin/callback", + "authorization_endpoint": "http://localhost:9876/authorize", + "token_endpoint": "http://localhost:9876/token" + } + } + """, "authorization", "admin"); + assertEquals(200, response.status()); + + // Sign in with redirect_uri not in allowed list — should be rejected + response = send(HttpMethod.POST, "/v1/ops/toolset/signin", null, """ + { + "url": "toolsets/4X25dj1mja51jykqxsXnCH/toolset-oauth-reject@", + "credentialsLevel": "GLOBAL", + "authenticationType": "OAUTH", + "code": "auth-code", + "redirect_uri": "http://evil/callback" + } + """, "authorization", "admin"); + assertEquals(400, response.status()); + } + } + + @Test + void testOauthSignInWithoutRedirectUriFallsBackToSettings() { + String tokenResponse = """ + { + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "expires_in": 3600 + } + """; + + try (TestWebServer server = new TestWebServer(9876)) { + server.map(HttpMethod.POST, "/mcp", 401, ""); + + // Token endpoint that validates redirect_uri from auth settings + server.map(HttpMethod.POST, "/token", request -> { + String body = request.getBody().readUtf8(); + if (body.contains("redirect_uri=http%3A%2F%2Fadmin%2Fcallback")) { + return new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json"); + } + return new MockResponse().setResponseCode(400).setBody("redirect_uri mismatch"); + }); + + // Create toolset with OAuth settings + Response response = send(HttpMethod.PUT, "/v1/toolsets/4X25dj1mja51jykqxsXnCH/toolset-oauth-fallback@", null, """ + { + "endpoint": "http://localhost:9876/mcp", + "transport": "HTTP", + "allowedTools": [], + "auth_settings": { + "authentication_type": "OAUTH", + "client_id": "my-client-id", + "client_secret": "my-client-secret", + "redirect_uri": "http://admin/callback", + "authorization_endpoint": "http://localhost:9876/authorize", + "token_endpoint": "http://localhost:9876/token" + } + } + """, "authorization", "admin"); + assertEquals(200, response.status()); + + // Sign in without redirect_uri — should fall back to auth settings + response = send(HttpMethod.POST, "/v1/ops/toolset/signin", null, """ + { + "url": "toolsets/4X25dj1mja51jykqxsXnCH/toolset-oauth-fallback@", + "credentialsLevel": "GLOBAL", + "authenticationType": "OAUTH", + "code": "auth-code" + } + """, "authorization", "admin"); + verify(response, 200, "true"); + } + } + private String replaceValueInJsonArray( String originalJson, String arrayFieldName, diff --git a/server/src/test/resources/aidial.settings.json b/server/src/test/resources/aidial.settings.json index ac7a35839..80bd5359e 100644 --- a/server/src/test/resources/aidial.settings.json +++ b/server/src/test/resources/aidial.settings.json @@ -71,6 +71,7 @@ }, "toolsets": { "security": { + "allowedRedirectUris": ["http://admin/callback", "http://chat/callback"], "authorizationServers": "https://example.com", "resourceHost": "localhost", "kms": {