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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

</details>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
);
}
}
1 change: 1 addition & 0 deletions docs/open_api_core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12891,6 +12891,7 @@ components:
- OAUTH
- API_KEY
- NONE
- DIAL_NATIVE
AzureEmbeddingsRequest:
required:
- input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Comment thread
astsiapanay marked this conversation as resolved.

/**
* The path to the claim to extract user display name
*/
Expand Down Expand Up @@ -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});
Expand All @@ -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<String> getAsStringList(JsonObject settings, String key, List<String> defaultValue) {
if (!settings.containsKey(key)) {
return defaultValue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
{
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
"authentication_type": "API_KEY",
"api_key_header": "X-API-Key"
}
},
"dial": {
"display_name": "DIAL",
"auth_settings": {
"authentication_type": "DIAL_NATIVE"
}
}
}
},
Expand Down
Loading