diff --git a/README.md b/README.md index 4d73f573e..a0803ec90 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ without putting the raw address in the log. | identityProviders.*.audience | - | No | If the setting is set it will be validated against the claim `aud` in JWT | | identityProviders.*.userDisplayName | - | No | Path to the claim in JWT token or user info response where user display name can be taken. | | identityProviders.*.userIdPath | sub | No | Path to the claim in JWT token or user info response where user ID can be taken. Can differ based on each IDP. E.g. Microsoft Entra ID uses `oid`. | +| identityProviders.*.offlineClient | - | No | OAuth client core uses to obtain a user's platform-wide offline credentials for `DIAL_NATIVE` external services (`/v1/user/offline-credentials`). Object with `clientId` (required), `tokenEndpoint` (required), `authorizationEndpoint` (required), `clientSecret`, `redirectUri` and `scopes` (default `["openid", "offline_access"]`). **Note**: `issuerPattern` must also match the issuer of **ID tokens** minted by this client — on Microsoft Entra ID, v1 access tokens (`sts.windows.net/...`) and v2 ID tokens (`login.microsoftonline.com/.../v2.0`) carry different issuers, and a pattern covering only one of them breaks the later credential refresh. | diff --git a/config/src/main/java/com/epam/aidial/core/config/AuthenticationType.java b/config/src/main/java/com/epam/aidial/core/config/AuthenticationType.java index 7f34e925a..6d9576c7b 100644 --- a/config/src/main/java/com/epam/aidial/core/config/AuthenticationType.java +++ b/config/src/main/java/com/epam/aidial/core/config/AuthenticationType.java @@ -4,6 +4,11 @@ public enum AuthenticationType { OAUTH, API_KEY, - NONE + NONE, + /** + * The target is DIAL itself. There is no credential on the declaration: the caller acts as the user via + * that user's offline credentials, and an administrator approves the application separately. + */ + DIAL_NATIVE } diff --git a/credentials/src/main/java/com/epam/aidial/core/credentials/service/token/TokenRefreshStrategyFactory.java b/credentials/src/main/java/com/epam/aidial/core/credentials/service/token/TokenRefreshStrategyFactory.java index d4fb10e8e..73d9361bc 100644 --- a/credentials/src/main/java/com/epam/aidial/core/credentials/service/token/TokenRefreshStrategyFactory.java +++ b/credentials/src/main/java/com/epam/aidial/core/credentials/service/token/TokenRefreshStrategyFactory.java @@ -14,7 +14,8 @@ public TokenRefreshStrategy getTokenValidatorStrategy(AuthenticationType type) { return new OauthTokenRefreshStrategy(timeProvider); } else if (type == AuthenticationType.API_KEY) { return new ApiKeyRefreshStrategy(); - } else if (type == AuthenticationType.NONE) { + } else if (type == AuthenticationType.NONE || type == AuthenticationType.DIAL_NATIVE) { + // DIAL_NATIVE records hold no token — an admin's approval neither expires nor refreshes. return new NoneAuthTokenRefreshStrategy(); } throw new IllegalArgumentException("Unsupported authentication type: " + type); diff --git a/credentials/src/main/java/com/epam/aidial/core/credentials/validation/AuthSettingsValidatorFactory.java b/credentials/src/main/java/com/epam/aidial/core/credentials/validation/AuthSettingsValidatorFactory.java index 5d71b7433..aea226fa1 100644 --- a/credentials/src/main/java/com/epam/aidial/core/credentials/validation/AuthSettingsValidatorFactory.java +++ b/credentials/src/main/java/com/epam/aidial/core/credentials/validation/AuthSettingsValidatorFactory.java @@ -13,6 +13,7 @@ public AuthSettingsValidatorFactory() { validators.put(AuthenticationType.OAUTH, new OauthAuthSettingsValidator()); validators.put(AuthenticationType.API_KEY, new ApiKeyAuthSettingsValidator()); validators.put(AuthenticationType.NONE, new NoneAuthSettingsValidator()); + validators.put(AuthenticationType.DIAL_NATIVE, new DialNativeAuthSettingsValidator()); } public AuthSettingsValidator getValidator(AuthenticationType authenticationType) { diff --git a/credentials/src/main/java/com/epam/aidial/core/credentials/validation/DialNativeAuthSettingsValidator.java b/credentials/src/main/java/com/epam/aidial/core/credentials/validation/DialNativeAuthSettingsValidator.java new file mode 100644 index 000000000..86e74bb79 --- /dev/null +++ b/credentials/src/main/java/com/epam/aidial/core/credentials/validation/DialNativeAuthSettingsValidator.java @@ -0,0 +1,31 @@ +package com.epam.aidial.core.credentials.validation; + +import com.epam.aidial.core.credentials.service.ResourceAuthSettingsChangeMode; + +import java.util.Set; + +/** + * The declaration carries no credential material. Every OAuth and API-key field is rejected so a misconfiguration + * fails at write time instead of looking like a connection that never works. + */ +public class DialNativeAuthSettingsValidator extends BaseAuthSettingsValidator { + + @Override + protected ResourceAuthSettingsValidationFields getValidationFields(ResourceAuthSettingsChangeMode changeMode) { + return new ResourceAuthSettingsValidationFields( + Set.of(), + Set.of( + ResourceAuthSettingsField.CLIENT_ID, + ResourceAuthSettingsField.CLIENT_SECRET, + ResourceAuthSettingsField.REDIRECT_URI, + ResourceAuthSettingsField.AUTHORIZATION_ENDPOINT, + ResourceAuthSettingsField.TOKEN_ENDPOINT, + ResourceAuthSettingsField.CODE_CHALLENGE, + ResourceAuthSettingsField.CODE_VERIFIER, + ResourceAuthSettingsField.CODE_CHALLENGE_METHOD, + ResourceAuthSettingsField.SCOPES_SUPPORTED, + ResourceAuthSettingsField.API_KEY_HEADER + ) + ); + } +} diff --git a/docs/open_api_core.yaml b/docs/open_api_core.yaml index 21564a8fa..b9e2cb861 100644 --- a/docs/open_api_core.yaml +++ b/docs/open_api_core.yaml @@ -12891,6 +12891,7 @@ components: - OAUTH - API_KEY - NONE + - DIAL_NATIVE AzureEmbeddingsRequest: required: - input diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceCredentialsController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceCredentialsController.java index cd458e9f1..083cc3bc4 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceCredentialsController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceCredentialsController.java @@ -108,6 +108,7 @@ public Future signIn() { ResolvedExternalService resolved = resolveExternalService(request.getUrl()); ResourceAuthSettings authSettings = resolved.externalService.getAuthSettings(); validateAuthType(authSettings.getAuthenticationType(), request.getAuthenticationType()); + rejectDialNative("Sign-in", authSettings.getAuthenticationType()); verifyAccess(resolved, request.getCredentialsLevel()); @@ -168,6 +169,7 @@ public Future signOut() { ResolvedExternalService resolved = resolveExternalService(request.getUrl()); ResourceAuthSettings authSettings = resolved.externalService.getAuthSettings(); validateAuthType(authSettings.getAuthenticationType(), request.getAuthenticationType()); + rejectDialNative("Sign-out", authSettings.getAuthenticationType()); verifyAccess(resolved, request.getCredentialsLevel()); CredentialsLocator locator = CredentialsLocatorFactory.fromExternalServiceScope(request.getUrl(), context); @@ -452,6 +454,19 @@ private void validateAuthType(AuthenticationType configured, AuthenticationType } } + /** + * A DIAL-native service has no credential to sign in with and nothing to sign out of: no USER-level record + * ever exists, and the APPLICATION-level record is the administrator's consent — deletable only through the + * audited, admin-only consent endpoint, never through the app-owner-accessible generic sign-out. + */ + private void rejectDialNative(String operation, AuthenticationType configured) { + if (AuthenticationType.DIAL_NATIVE.equals(configured)) { + throw new IllegalArgumentException(("%s is not applicable to %s services: users manage offline access " + + "via the offline-credentials endpoints, and an administrator manages consent separately") + .formatted(operation, AuthenticationType.DIAL_NATIVE)); + } + } + private void respondError(String message, Throwable error) { HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; String body = null; diff --git a/server/src/main/java/com/epam/aidial/core/server/security/IdentityProvider.java b/server/src/main/java/com/epam/aidial/core/server/security/IdentityProvider.java index 7e917f21c..6ba04969a 100644 --- a/server/src/main/java/com/epam/aidial/core/server/security/IdentityProvider.java +++ b/server/src/main/java/com/epam/aidial/core/server/security/IdentityProvider.java @@ -8,6 +8,8 @@ import com.auth0.jwt.interfaces.Claim; import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.jwt.interfaces.Verification; +import com.epam.aidial.core.config.AuthenticationType; +import com.epam.aidial.core.config.ResourceAuthSettings; import com.epam.aidial.core.server.util.ProxyUtil; import com.epam.aidial.core.server.vertx.AsyncTaskExecutor; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -20,6 +22,7 @@ import io.vertx.core.http.RequestOptions; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.http.HttpHeaders; @@ -102,6 +105,10 @@ public class IdentityProvider { private final String audience; + /** OAuth client used to obtain offline credentials on a user's behalf; null unless configured. */ + @Getter + private final ResourceAuthSettings offlineClient; + /** * The path to the claim to extract user display name */ @@ -202,6 +209,8 @@ public class IdentityProvider { audience = settings.getString("audience", null); + offlineClient = parseOfflineClient(settings.getJsonObject("offlineClient")); + userDisplayName = getClaimPath(settings, "userDisplayName", null); userIdPath = getClaimPath(settings, "userIdPath", new String[]{USER_SUB}); @@ -221,6 +230,26 @@ private static String[] getClaimPath(JsonObject settings, String claimName, Stri return settings.containsKey(claimName) ? parseClaimPath(settings.getString(claimName)) : defaultPath; } + /** Modelled as {@link ResourceAuthSettings} so the token service handles it without a parallel code path. */ + private static ResourceAuthSettings parseOfflineClient(JsonObject offlineClient) { + if (offlineClient == null) { + return null; + } + String clientId = Objects.requireNonNull(offlineClient.getString("clientId"), "offlineClient.clientId is missed"); + String tokenEndpoint = Objects.requireNonNull(offlineClient.getString("tokenEndpoint"), "offlineClient.tokenEndpoint is missed"); + String authorizationEndpoint = Objects.requireNonNull( + offlineClient.getString("authorizationEndpoint"), "offlineClient.authorizationEndpoint is missed"); + return ResourceAuthSettings.builder() + .authenticationType(AuthenticationType.OAUTH) + .clientId(clientId) + .clientSecret(offlineClient.getString("clientSecret")) + .authorizationEndpoint(authorizationEndpoint) + .tokenEndpoint(tokenEndpoint) + .redirectUri(offlineClient.getString("redirectUri")) + .scopesSupported(getAsStringList(offlineClient, "scopes", List.of("openid", "offline_access"))) + .build(); + } + private static List getAsStringList(JsonObject settings, String key, List defaultValue) { if (!settings.containsKey(key)) { return defaultValue; diff --git a/server/src/test/java/com/epam/aidial/core/server/ExternalServiceCredentialsApiTest.java b/server/src/test/java/com/epam/aidial/core/server/ExternalServiceCredentialsApiTest.java index aed324592..fb2bf45aa 100644 --- a/server/src/test/java/com/epam/aidial/core/server/ExternalServiceCredentialsApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/ExternalServiceCredentialsApiTest.java @@ -18,6 +18,7 @@ public class ExternalServiceCredentialsApiTest extends ResourceBaseTest { private static final String SALESFORCE_SCOPE = "applications/app-with-services/external_services/salesforce"; private static final String BILLING_SCOPE = "applications/app-with-services/external_services/billing-api"; + private static final String DIAL_NATIVE_SCOPE = "applications/app-with-services/external_services/dial"; private static final String OAUTH_TOKEN_RESPONSE = """ { @@ -1013,7 +1014,7 @@ void testListExternalServicesStaticAppAsAdmin() throws Exception { assertEquals(200, resp.status(), () -> resp.body()); JsonNode arr = ProxyUtil.MAPPER.readTree(resp.body()); assertTrue(arr.isArray()); - assertEquals(2, arr.size()); + assertEquals(3, arr.size()); assertFalse(resp.body().contains("test-client-secret"), "client_secret must not be leaked in list"); for (JsonNode node : arr) { assertNotNull(node.get("id")); @@ -1521,6 +1522,46 @@ void testExternalServiceIdWithSpecialCharsRejected() throws Exception { assertEquals(400, badCred.status(), () -> badCred.body()); } + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testSignInRejectedForDialNativeService() { + Response signIn = send(HttpMethod.POST, "/v1/ops/external-service/signin", null, """ + { + "url": "%s", + "credentials_level": "USER", + "authentication_type": "DIAL_NATIVE" + } + """.formatted(DIAL_NATIVE_SCOPE), "authorization", "user"); + assertEquals(400, signIn.status()); + assertTrue(signIn.body().contains("not applicable"), signIn.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testSignInRejectedForDialNativeServiceAtApplicationLevel() { + Response signIn = send(HttpMethod.POST, "/v1/ops/external-service/signin", null, """ + { + "url": "%s", + "credentials_level": "APPLICATION", + "authentication_type": "DIAL_NATIVE" + } + """.formatted(DIAL_NATIVE_SCOPE), "authorization", "user"); + assertEquals(400, signIn.status()); + } + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testSignOutRejectedForDialNativeServiceAtUserLevel() { + Response signOut = send(HttpMethod.POST, "/v1/ops/external-service/signout", null, """ + { + "url": "%s", + "credentials_level": "USER", + "authentication_type": "DIAL_NATIVE" + } + """.formatted(DIAL_NATIVE_SCOPE), "authorization", "user"); + assertEquals(400, signOut.status(), signOut.body()); + } + private ApiKeyData newAppKey(String sourceDeployment, String role) { ApiKeyData perRequestKey = new ApiKeyData(); perRequestKey.setExtractedClaims(createClaims(role)); diff --git a/server/src/test/java/com/epam/aidial/core/server/security/IdentityProviderTest.java b/server/src/test/java/com/epam/aidial/core/server/security/IdentityProviderTest.java index 0ba97781a..d92fc9298 100644 --- a/server/src/test/java/com/epam/aidial/core/server/security/IdentityProviderTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/security/IdentityProviderTest.java @@ -10,6 +10,8 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.interfaces.DecodedJWT; +import com.epam.aidial.core.config.AuthenticationType; +import com.epam.aidial.core.config.ResourceAuthSettings; import com.epam.aidial.core.server.vertx.AsyncTaskExecutor; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -20,6 +22,7 @@ import io.vertx.core.http.HttpClientRequest; import io.vertx.core.http.HttpClientResponse; import io.vertx.core.http.RequestOptions; +import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import lombok.RequiredArgsConstructor; import org.junit.jupiter.api.BeforeAll; @@ -45,6 +48,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +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.anyMap; @@ -1182,4 +1186,38 @@ public static LogContext create() { return new LogContext(logger, previousLevel, appender); } } + + @Test + public void testOfflineClientAbsentByDefault() { + IdentityProvider provider = new IdentityProvider(settings, vertx, taskExecutor, client, url -> jwkProvider, factory, "DEBUG"); + assertNull(provider.getOfflineClient()); + } + + @Test + public void testOfflineClientParsedFromSettings() { + settings.put("offlineClient", new JsonObject() + .put("clientId", "dial-credentials-manager") + .put("clientSecret", "s3cret") + .put("authorizationEndpoint", "http://idp/auth") + .put("tokenEndpoint", "http://idp/token") + .put("scopes", new JsonArray().add("openid").add("offline_access").add("dial"))); + + IdentityProvider provider = new IdentityProvider(settings, vertx, taskExecutor, client, url -> jwkProvider, factory, "DEBUG"); + ResourceAuthSettings offline = provider.getOfflineClient(); + + assertNotNull(offline); + assertEquals(AuthenticationType.OAUTH, offline.getAuthenticationType()); + assertEquals("dial-credentials-manager", offline.getClientId()); + assertEquals("http://idp/token", offline.getTokenEndpoint()); + assertEquals("http://idp/auth", offline.getAuthorizationEndpoint()); + assertEquals(List.of("openid", "offline_access", "dial"), offline.getScopesSupported()); + } + + @Test + public void testOfflineClientRejectsIncompleteSettings() { + settings.put("offlineClient", new JsonObject().put("clientId", "dial-credentials-manager")); + + assertThrows(NullPointerException.class, + () -> new IdentityProvider(settings, vertx, taskExecutor, client, url -> jwkProvider, factory, "DEBUG")); + } } diff --git a/server/src/test/resources/dial-config/external-service-credentials.json b/server/src/test/resources/dial-config/external-service-credentials.json index fd48b7c9e..81564c3e8 100644 --- a/server/src/test/resources/dial-config/external-service-credentials.json +++ b/server/src/test/resources/dial-config/external-service-credentials.json @@ -24,6 +24,12 @@ "authentication_type": "API_KEY", "api_key_header": "X-API-Key" } + }, + "dial": { + "display_name": "DIAL", + "auth_settings": { + "authentication_type": "DIAL_NATIVE" + } } } },