diff --git a/docs/open_api_core.yaml b/docs/open_api_core.yaml index 4b1df620e..27f6f5160 100644 --- a/docs/open_api_core.yaml +++ b/docs/open_api_core.yaml @@ -14753,12 +14753,12 @@ components: OfflineCredentialsStatus: type: object properties: - available: - type: boolean connect: $ref: "#/components/schemas/OfflineCredentialsStatusConnect" connected: type: boolean + available: + type: boolean OfflineCredentialsStatusConnect: type: object properties: diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ApplicationController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ApplicationController.java index 4d733da3c..a771e97f6 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ApplicationController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ApplicationController.java @@ -27,6 +27,7 @@ import com.epam.aidial.core.server.service.ApplicationSchemaService; import com.epam.aidial.core.server.service.ApplicationService; import com.epam.aidial.core.server.service.DeploymentService; +import com.epam.aidial.core.server.service.ExternalServiceStatusEnricher; import com.epam.aidial.core.server.service.PermissionDeniedException; import com.epam.aidial.core.server.service.UserExternalServiceService; import com.epam.aidial.core.server.util.CredentialsLocatorFactory; @@ -461,6 +462,7 @@ private void enrichExternalServiceStatuses(ApplicationData data) { return; } String appId = appPart(data.getId()); + ExternalServiceStatusEnricher enricher = new ExternalServiceStatusEnricher(context, resourceAuthSettingsService); for (Map.Entry entry : services.entrySet()) { ResourceAuthSettings authSettings = entry.getValue().getAuthSettings(); if (authSettings == null) { @@ -470,7 +472,7 @@ private void enrichExternalServiceStatuses(ApplicationData data) { String scopeId = CredentialsLocatorFactory.APPLICATIONS_PREFIX + appId + CredentialsLocatorFactory.EXTERNAL_SERVICES_SEPARATOR + entry.getKey(); CredentialsLocator locator = CredentialsLocatorFactory.fromExternalServiceScope(scopeId, context); - resourceAuthSettingsService.setExternalServiceAuthStatuses(locator, authSettings, context.getUserId()); + enricher.enrich(locator, authSettings); } catch (RuntimeException e) { log.warn("Failed to compute external-service status for '{}' on '{}'", entry.getKey(), data.getId(), e); } 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 a2b2f4df7..0fa393f2a 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 @@ -8,6 +8,7 @@ import com.epam.aidial.core.config.ResourceAccessType; import com.epam.aidial.core.config.ResourceAuthSettings; import com.epam.aidial.core.credentials.data.credentials.AuthorizationHeader; +import com.epam.aidial.core.credentials.data.credentials.BucketInfo; import com.epam.aidial.core.credentials.data.credentials.CredentialsDescriptor; import com.epam.aidial.core.credentials.data.credentials.CredentialsLocator; import com.epam.aidial.core.credentials.data.credentials.ResourceCredentials; @@ -29,12 +30,15 @@ import com.epam.aidial.core.server.data.OboCredentialsRequest; import com.epam.aidial.core.server.log.ExternalServiceAuditLog; import com.epam.aidial.core.server.security.AccessService; +import com.epam.aidial.core.server.security.AccessTokenValidator; import com.epam.aidial.core.server.security.AppIdentityMatcher; import com.epam.aidial.core.server.security.EncryptionService; import com.epam.aidial.core.server.service.ApplicationService; import com.epam.aidial.core.server.service.ConsentRequiredException; +import com.epam.aidial.core.server.service.OfflineCredentialsRequiredException; import com.epam.aidial.core.server.service.PermissionDeniedException; import com.epam.aidial.core.server.service.UserExternalServiceService; +import com.epam.aidial.core.server.util.CredentialsDescriptorFactory; import com.epam.aidial.core.server.util.CredentialsLocatorFactory; import com.epam.aidial.core.server.util.ProxyUtil; import com.epam.aidial.core.server.util.ResourceDescriptorFactory; @@ -64,6 +68,7 @@ public class ExternalServiceCredentialsController { private final EncryptionService encryptionService; private final ApplicationService applicationService; private final UserExternalServiceService userExternalServiceService; + private final AccessTokenValidator accessTokenValidator; public ExternalServiceCredentialsController(Proxy proxy, ProxyContext context) { this.context = context; @@ -74,6 +79,7 @@ public ExternalServiceCredentialsController(Proxy proxy, ProxyContext context) { this.resourceCredentialsService = proxy.getResourceCredentialsService(); this.authorizationHeaderProvider = proxy.getAuthorizationHeaderProvider(); this.userExternalServiceService = proxy.getUserExternalServiceService(); + this.accessTokenValidator = proxy.getTokenValidator(); } @ApiOperation( @@ -290,6 +296,14 @@ public Future getOboCredentials() { ExternalService externalService = resolveExternalServiceDefinition( app.application, scope[0], scope[1], request.getOwnerUserId()); ResourceAuthSettings authSettings = externalService.getAuthSettings(); + + if (AuthenticationType.DIAL_NATIVE.equals(authSettings.getAuthenticationType())) { + ExternalServiceCredentialsResponse dialNative = + redeemOfflineCredentials(request, scope[0], scope[1]); + ExternalServiceAuditLog.oboRetrieval(context, scope[0], scope[1], request.getOwnerUserId(), null); + return dialNative; + } + CredentialsLocator locator = CredentialsLocatorFactory.fromExternalServiceScopeForOwner( request.getUrl(), request.getOwnerUserId(), context); ResourceCredentials credentials = resourceCredentialsService.getRefreshedUserCredentials( @@ -319,6 +333,95 @@ public Future getOboCredentials() { return Future.succeededFuture(); } + /** + * Redemption for a DIAL-native service: the owner acts through their own offline credentials, and the + * application is authorized by an administrator's consent rather than by anything it holds. + */ + private ExternalServiceCredentialsResponse redeemOfflineCredentials(OboCredentialsRequest request, + String appPart, + String serviceId) { + requireAdminConsent(request.getUrl(), appPart, serviceId); + + CredentialsDescriptor descriptor = + CredentialsDescriptorFactory.offlineCredentialsForUser(context, request.getOwnerUserId()); + ResourceCredentials stored = resourceCredentialsService.getResourceCredentials(descriptor); + if (stored == null) { + throw new OfflineCredentialsRequiredException( + "The owner has not enabled offline access, so nothing can act on their behalf"); + } + + ResourceAuthSettings offlineClient = resolveOfflineClient(stored.getIssuer()); + + CredentialsLocator locator = new CredentialsLocator(descriptor.getResourceId(), + Map.of(CredentialsLevel.USER, new BucketInfo(descriptor.getBucketName(), descriptor.getBucketLocation()))); + ResourceCredentials credentials; + try { + credentials = resourceCredentialsService.getRefreshedUserCredentials( + locator, offlineClient, request.getOwnerUserId()); + } catch (HttpException e) { + throw translateRefreshFailure(e); + } + if (credentials == null) { + throw new OfflineCredentialsRequiredException( + "The owner's offline credentials are no longer valid; they must connect again"); + } + // getRefreshedUserCredentials returns the record un-refreshed without consent, leaving the refusal here. + if (!credentials.isOfflineUsageConsent()) { + throw new OfflineCredentialsRequiredException( + "The owner's credentials do not permit offline use; they must connect again"); + } + return toCredentialsResponse(credentials, request.getUrl()); + } + + /** + * The offline client of the identity provider that issued the stored credentials. Both failures are server-side + * state — a provider dropped from the settings, or one never configured for offline use — not a bad request. + */ + private ResourceAuthSettings resolveOfflineClient(String issuer) { + ResourceAuthSettings offlineClient; + try { + offlineClient = accessTokenValidator.resolveOfflineClientByIssuer(issuer); + } catch (IllegalArgumentException e) { + throw new HttpException(HttpStatus.SERVICE_UNAVAILABLE, + "The identity provider that issued the owner's offline credentials is no longer configured"); + } + if (offlineClient == null) { + throw new HttpException(HttpStatus.SERVICE_UNAVAILABLE, + "Offline credentials are not configured for the identity provider that issued them"); + } + return offlineClient; + } + + /** An administrator must have approved this application's use of the service; declaring it grants nothing. */ + private void requireAdminConsent(String url, String appPart, String serviceId) { + CredentialsLocator locator = CredentialsLocatorFactory.fromExternalServiceScope(url, context); + CredentialsDescriptor consent = locator.getCredentialsDescriptors().get(CredentialsLevel.APPLICATION); + ResourceCredentials record = consent == null ? null : resourceCredentialsService.getResourceCredentials(consent); + // Only a record the consent endpoint wrote counts. The same APPLICATION-level slot holds ordinary + // credentials while a service is OAUTH/API_KEY, and a leftover from before a switch to DIAL_NATIVE + // would otherwise pass as an administrator's approval the app owner granted themselves. + if (record == null || !AuthenticationType.DIAL_NATIVE.equals(record.getAuthenticationType())) { + throw new ConsentRequiredException( + "Application '%s' is not approved to use '%s'".formatted(appPart, serviceId)); + } + } + + /** + * Keeps a permanent failure distinguishable from a retryable one. A 401 here means the owner's token was + * rejected, not the caller's, so it must not surface as one. + */ + private RuntimeException translateRefreshFailure(HttpException e) { + if (e.getStatus() == HttpStatus.UNAUTHORIZED) { + return new OfflineCredentialsRequiredException( + "The owner's offline credentials were rejected by the identity provider; they must connect again"); + } + if (e.getStatus().getCode() >= 500) { + return new HttpException(HttpStatus.BAD_GATEWAY, + "The identity provider could not be reached; this run may be retried"); + } + return e; + } + private ExternalServiceCredentialsResponse toCredentialsResponse(ResourceCredentials credentials, String url) { AuthorizationHeader header = authorizationHeaderProvider.createAuthorizationHeader(credentials); if (header == null) { diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java index fe8f63898..de27a1e57 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java @@ -25,6 +25,7 @@ import com.epam.aidial.core.server.security.EncryptionService; import com.epam.aidial.core.server.service.ApplicationService; import com.epam.aidial.core.server.service.ExternalServiceService; +import com.epam.aidial.core.server.service.ExternalServiceStatusEnricher; import com.epam.aidial.core.server.service.PermissionDeniedException; import com.epam.aidial.core.server.service.UserExternalServiceService; import com.epam.aidial.core.server.util.CredentialsLocatorFactory; @@ -60,6 +61,7 @@ public class ExternalServiceManagementController { private final EncryptionService encryptionService; private final ResourceCredentialsService resourceCredentialsService; private final ResourceAuthSettingsService resourceAuthSettingsService; + private final ExternalServiceStatusEnricher statusEnricher; public ExternalServiceManagementController(Proxy proxy, ProxyContext context) { this.context = context; @@ -71,6 +73,7 @@ public ExternalServiceManagementController(Proxy proxy, ProxyContext context) { this.encryptionService = proxy.getEncryptionService(); this.resourceCredentialsService = proxy.getResourceCredentialsService(); this.resourceAuthSettingsService = proxy.getResourceAuthSettingsService(); + this.statusEnricher = new ExternalServiceStatusEnricher(context, resourceAuthSettingsService); } @ApiOperation( @@ -333,7 +336,7 @@ private ExternalServiceData toData(String appId, String serviceId, ExternalServi ResourceAuthSettings safe = authSettings == null ? null : authSettings.withoutSecrets(); if (withStatus && safe != null) { CredentialsLocator locator = CredentialsLocatorFactory.fromExternalServiceScope(scopeId(appId, serviceId), context); - resourceAuthSettingsService.setExternalServiceAuthStatuses(locator, safe, context.getUserId()); + statusEnricher.enrich(locator, safe); } return new ExternalServiceData() .setId(serviceId) diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java index ebf423017..16a49ae3a 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java @@ -24,6 +24,7 @@ import com.epam.aidial.core.server.service.ApplicationSchemaService; import com.epam.aidial.core.server.service.ApplicationService; import com.epam.aidial.core.server.service.DeploymentService; +import com.epam.aidial.core.server.service.ExternalServiceStatusEnricher; import com.epam.aidial.core.server.service.ExternalServicesWriteMode; import com.epam.aidial.core.server.service.PermissionDeniedException; import com.epam.aidial.core.server.service.ToolSetService; @@ -590,6 +591,8 @@ private void enrichExternalServiceStatuses(ResourceDescriptor descriptor, Applic if (services == null || services.isEmpty()) { return; } + ExternalServiceStatusEnricher enricher = new ExternalServiceStatusEnricher( + context, proxy.getResourceAuthSettingsService()); for (Map.Entry entry : services.entrySet()) { ResourceAuthSettings authSettings = entry.getValue() == null ? null : entry.getValue().getAuthSettings(); if (authSettings == null) { @@ -598,7 +601,7 @@ private void enrichExternalServiceStatuses(ResourceDescriptor descriptor, Applic try { String scopeId = descriptor.getUrl() + CredentialsLocatorFactory.EXTERNAL_SERVICES_SEPARATOR + entry.getKey(); CredentialsLocator locator = CredentialsLocatorFactory.fromExternalServiceScope(scopeId, context); - proxy.getResourceAuthSettingsService().setExternalServiceAuthStatuses(locator, authSettings, context.getUserId()); + enricher.enrich(locator, authSettings); } catch (RuntimeException e) { log.warn("Failed to compute external-service status for '{}' on '{}'", entry.getKey(), descriptor.getUrl(), e); } diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceStatusEnricher.java b/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceStatusEnricher.java new file mode 100644 index 000000000..c824b07f0 --- /dev/null +++ b/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceStatusEnricher.java @@ -0,0 +1,46 @@ +package com.epam.aidial.core.server.service; + +import com.epam.aidial.core.config.AuthenticationType; +import com.epam.aidial.core.config.ResourceAuthSettings; +import com.epam.aidial.core.config.ResourceAuthStatus; +import com.epam.aidial.core.credentials.data.credentials.CredentialsLocator; +import com.epam.aidial.core.credentials.service.ResourceAuthSettingsService; +import com.epam.aidial.core.server.ProxyContext; +import com.epam.aidial.core.server.util.CredentialsDescriptorFactory; + +/** + * Fills in the auth statuses of an application's external services for one response. + * + *

A DIAL-native service has no per-service credential, so its user level is answered from the caller's + * platform-wide offline credentials instead of its (always empty) USER-level records. + * + *

Memoizes that lookup, so create one per response rather than per service. + */ +public class ExternalServiceStatusEnricher { + + private final ProxyContext context; + private final ResourceAuthSettingsService resourceAuthSettingsService; + private Boolean offlineCredentials; + + public ExternalServiceStatusEnricher(ProxyContext context, ResourceAuthSettingsService resourceAuthSettingsService) { + this.context = context; + this.resourceAuthSettingsService = resourceAuthSettingsService; + } + + public void enrich(CredentialsLocator credentialsLocator, ResourceAuthSettings authSettings) { + resourceAuthSettingsService.setExternalServiceAuthStatuses(credentialsLocator, authSettings, context.getUserId()); + if (authSettings.getAuthenticationType() == AuthenticationType.DIAL_NATIVE) { + authSettings.setUserLevelAuthStatus(hasOfflineCredentials() + ? ResourceAuthStatus.SIGNED_IN : ResourceAuthStatus.SIGNED_OUT); + } + } + + private boolean hasOfflineCredentials() { + if (offlineCredentials == null) { + // A userless caller (an API key) is not a person, so it holds no offline credentials by definition. + offlineCredentials = context.getUserId() != null && resourceAuthSettingsService.hasUnexpiredCredentials( + CredentialsDescriptorFactory.offlineCredentials(context)); + } + return offlineCredentials; + } +} diff --git a/server/src/main/java/com/epam/aidial/core/server/service/OfflineCredentialsRequiredException.java b/server/src/main/java/com/epam/aidial/core/server/service/OfflineCredentialsRequiredException.java new file mode 100644 index 000000000..e3a6d8f11 --- /dev/null +++ b/server/src/main/java/com/epam/aidial/core/server/service/OfflineCredentialsRequiredException.java @@ -0,0 +1,12 @@ +package com.epam.aidial.core.server.service; + +import com.epam.aidial.core.storage.http.HttpException; +import com.epam.aidial.core.storage.http.HttpStatus; + +/** The owner has no usable offline credentials — permanent until they connect again, unlike an IdP outage. */ +public class OfflineCredentialsRequiredException extends HttpException { + + public OfflineCredentialsRequiredException(String message) { + super(HttpStatus.CONFLICT, message); + } +} diff --git a/server/src/main/java/com/epam/aidial/core/server/util/CredentialsDescriptorFactory.java b/server/src/main/java/com/epam/aidial/core/server/util/CredentialsDescriptorFactory.java index e980cff25..aac3ffebc 100644 --- a/server/src/main/java/com/epam/aidial/core/server/util/CredentialsDescriptorFactory.java +++ b/server/src/main/java/com/epam/aidial/core/server/util/CredentialsDescriptorFactory.java @@ -85,6 +85,12 @@ public static CredentialsDescriptor offlineCredentials(ProxyContext proxyContext return new CredentialsDescriptor(OFFLINE_CREDENTIALS_ID, bucket.name(), bucket.location()); } + /** The same record for an arbitrary owner — the redemption path, where the caller is not the owner. */ + public static CredentialsDescriptor offlineCredentialsForUser(ProxyContext proxyContext, String ownerUserId) { + BucketInfo bucket = getUserBucketInfoForUser(proxyContext, ownerUserId); + return new CredentialsDescriptor(OFFLINE_CREDENTIALS_ID, bucket.name(), bucket.location()); + } + public static BucketInfo getPublicBucketInfo() { return new BucketInfo(ResourceDescriptor.PUBLIC_BUCKET, ResourceDescriptor.PUBLIC_LOCATION); } 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 2437b3450..69a6eee7b 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 @@ -17,6 +17,7 @@ import io.vertx.core.http.HttpMethod; import okhttp3.mockwebserver.MockResponse; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import org.slf4j.LoggerFactory; import java.util.List; @@ -1706,6 +1707,51 @@ void testDialNativeAppLevelStatusFollowsConsent() { assertEquals("SIGNED_OUT", dialNativeStatus("app_level_auth_status", "admin")); } + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testDialNativeUserLevelStatusFollowsOfflineCredentials() throws Exception { + // The user-facing surface is the deployment GET: listing an app's services is admin/owner-only. + assertEquals("SIGNED_OUT", dialNativeStatusOnApp("user")); + + Mockito.when(validator.resolveOfflineClient(Mockito.any())).thenReturn(ResourceAuthSettings.builder() + .authenticationType(AuthenticationType.OAUTH) + .clientId("dial-credentials-manager") + .clientSecret("secret") + .authorizationEndpoint("http://localhost:9876/authorize") + .tokenEndpoint("http://localhost:9876/token") + .redirectUri("http://localhost:3000/callback") + .scopesSupported(List.of("openid", "offline_access")) + .build()); + Mockito.when(validator.resolveIdTokenUserId(Mockito.any(), Mockito.eq("id-token-for-user"))).thenReturn("user"); + Mockito.when(validator.extractIdTokenIssuer(Mockito.any())).thenReturn("http://idp/realms/dial"); + + TestWebServer.Handler handler = request -> new MockResponse() + .setBody(""" + { + "access_token": "offline-access-token", + "refresh_token": "offline-refresh-token", + "id_token": "id-token-for-user", + "expires_in": 3600 + } + """) + .setHeader("Content-Type", "application/json"); + try (TestWebServer ignore = new TestWebServer(9876, handler)) { + Response signIn = send(HttpMethod.POST, "/v1/user/offline-credentials/signin", null, """ + { "code": "auth-code", "redirect_uri": "http://localhost:3000/callback" } + """, "authorization", "user"); + assertEquals(200, signIn.status(), signIn.body()); + } + + assertEquals("SIGNED_IN", dialNativeStatusOnApp("user")); + // Another identity is unaffected — offline credentials live in the caller's own bucket. + assertEquals("SIGNED_OUT", dialNativeStatusOnApp("admin")); + + Response signOut = send(HttpMethod.POST, "/v1/user/offline-credentials/signout", + null, "", "authorization", "user"); + assertEquals(200, signOut.status(), signOut.body()); + assertEquals("SIGNED_OUT", dialNativeStatusOnApp("user")); + } + @Test @DialConfigLocation("dial-config/external-service-credentials.json") void testConsentDoesNotAffectOtherServicesStatus() { @@ -1721,6 +1767,13 @@ private String dialNativeStatus(String field, String user) { return externalService("dial", user).get("auth_settings").get(field).asText(); } + private String dialNativeStatusOnApp(String user) { + Response app = send(HttpMethod.GET, "/openai/applications/app-with-services", null, "", "authorization", user); + assertEquals(200, app.status(), app.body()); + return ProxyUtil.convertToObject(app.body(), JsonNode.class) + .get("external_services").get("dial").get("auth_settings").get("user_level_auth_status").asText(); + } + private JsonNode externalService(String serviceId, String user) { Response list = send(HttpMethod.GET, "/v1/applications/app-with-services/external-services", null, "", "authorization", user); diff --git a/server/src/test/java/com/epam/aidial/core/server/ExternalServiceOboCredentialsApiTest.java b/server/src/test/java/com/epam/aidial/core/server/ExternalServiceOboCredentialsApiTest.java index ae8a55b5d..9771b145c 100644 --- a/server/src/test/java/com/epam/aidial/core/server/ExternalServiceOboCredentialsApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/ExternalServiceOboCredentialsApiTest.java @@ -5,6 +5,11 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import com.epam.aidial.core.config.Application; +import com.epam.aidial.core.config.AuthenticationType; +import com.epam.aidial.core.config.CredentialsLevel; +import com.epam.aidial.core.config.ResourceAuthSettings; +import com.epam.aidial.core.credentials.data.credentials.CredentialsDescriptor; +import com.epam.aidial.core.credentials.data.credentials.ResourceCredentials; import com.epam.aidial.core.server.data.ApiKeyData; import com.epam.aidial.core.server.service.AdminManagedFieldsWriteMode; import com.epam.aidial.core.server.service.ApplicationService; @@ -16,6 +21,7 @@ import io.vertx.core.http.HttpMethod; import okhttp3.mockwebserver.MockResponse; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import org.slf4j.LoggerFactory; import java.util.List; @@ -1112,6 +1118,138 @@ private static int count(String haystack, String needle) { return haystack.split(Pattern.quote(needle), -1).length - 1; } + // --------------------------------------------------------------------------------------------- + // DIAL-native redemption (§5): admin consent + the owner's offline credentials + // --------------------------------------------------------------------------------------------- + + private static final String DIAL_NATIVE_SCOPE = "applications/app-with-services/external_services/dial"; + private static final String DIAL_NATIVE_CONSENT = + "/v1/applications/app-with-services/external-services/dial/consent"; + + @Test + @DialConfigLocation("dial-config/external-service-obo.json") + void testDialNativeRedemptionRefusedWithoutAdminConsent() { + Response obo = obo(DIAL_NATIVE_SCOPE, "user", SCHEDULER_KEY); + assertEquals(403, obo.status(), () -> obo.body()); + assertTrue(obo.body().contains("not approved"), obo.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-obo.json") + void testDialNativeRedemptionRefusedWhenOwnerHasNoOfflineCredentials() { + verify(send(HttpMethod.POST, DIAL_NATIVE_CONSENT, null, "", "authorization", "admin"), 200, "true"); + + Response obo = obo(DIAL_NATIVE_SCOPE, "user", SCHEDULER_KEY); + assertEquals(409, obo.status(), () -> obo.body()); + assertTrue(obo.body().contains("offline access"), obo.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-obo.json") + void testDialNativeRedemptionRefusedForUntrustedCaller() { + send(HttpMethod.POST, DIAL_NATIVE_CONSENT, null, "", "authorization", "admin"); + + Response obo = obo(DIAL_NATIVE_SCOPE, "user", UNTRUSTED_KEY); + assertEquals(403, obo.status(), () -> obo.body()); + // The identity gate runs first, so an untrusted caller learns nothing about consent or the owner. + assertTrue(obo.body().contains("app_identity"), obo.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-obo.json") + void testWithdrawnConsentStopsRedemption() { + send(HttpMethod.POST, DIAL_NATIVE_CONSENT, null, "", "authorization", "admin"); + assertEquals(409, obo(DIAL_NATIVE_SCOPE, "user", SCHEDULER_KEY).status()); + + verify(send(HttpMethod.DELETE, DIAL_NATIVE_CONSENT, null, "", "authorization", "admin"), 200, "true"); + + Response after = obo(DIAL_NATIVE_SCOPE, "user", SCHEDULER_KEY); + assertEquals(403, after.status(), () -> after.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-obo.json") + void testDialNativeRedemptionReturnsOwnerToken() { + send(HttpMethod.POST, DIAL_NATIVE_CONSENT, null, "", "authorization", "admin"); + stubOfflineProvider(); + putOfflineCredentials(true, "owner-access-token"); + + Response obo = obo(DIAL_NATIVE_SCOPE, "user", SCHEDULER_KEY); + assertEquals(200, obo.status(), () -> obo.body()); + assertTrue(obo.body().contains("Bearer owner-access-token"), obo.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-obo.json") + void testDialNativeRedemptionRefusedWithoutOwnerOfflineConsent() { + // getRefreshedUserCredentials returns a record lacking offline consent UN-refreshed, leaving the refusal to + // this caller — so serving it would hand out a stale token the owner never agreed to offline use of. + send(HttpMethod.POST, DIAL_NATIVE_CONSENT, null, "", "authorization", "admin"); + stubOfflineProvider(); + putOfflineCredentials(false, "stale-access-token"); + + Response obo = obo(DIAL_NATIVE_SCOPE, "user", SCHEDULER_KEY); + assertEquals(409, obo.status(), () -> obo.body()); + assertFalse(obo.body().contains("stale-access-token"), obo.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-obo.json") + void testLeftoverAppLevelCredentialDoesNotPassAsAdminConsent() { + // The consent slot is the same APPLICATION-level storage ordinary credentials use while a service is + // OAUTH/API_KEY. A record left from before a switch to DIAL_NATIVE is not an administrator's approval, + // even though it sits exactly where the consent check looks. + CredentialsDescriptor consentSlot = new CredentialsDescriptor( + "applications/config/app-with-services/external_services/dial", + ResourceDescriptor.PUBLIC_BUCKET, ResourceDescriptor.PUBLIC_LOCATION); + dial.getProxy().getResourceCredentialsService().putCredentialsRecord(consentSlot, ResourceCredentials.builder() + .resourceId(consentSlot.getResourceId()) + .credentialsLevel(CredentialsLevel.APPLICATION) + .authenticationType(AuthenticationType.OAUTH) + .accessToken("leftover-app-token") + .build()); + + Response obo = obo(DIAL_NATIVE_SCOPE, "user", SCHEDULER_KEY); + assertEquals(403, obo.status(), () -> obo.body()); + assertTrue(obo.body().contains("not approved"), obo.body()); + } + + private void stubOfflineProvider() { + Mockito.when(validator.resolveOfflineClientByIssuer(Mockito.any())).thenReturn(ResourceAuthSettings.builder() + .authenticationType(AuthenticationType.OAUTH) + .clientId("dial-credentials-manager") + .tokenEndpoint("http://localhost:9876/token") + .build()); + } + + /** Writes the owner's platform-wide offline record directly; sign-in itself is covered elsewhere. */ + private void putOfflineCredentials(boolean offlineConsent, String accessToken) { + CredentialsDescriptor descriptor = new CredentialsDescriptor("offline", + encryptionService.encrypt("Users/user/"), "Users/user/"); + dial.getProxy().getResourceCredentialsService().putCredentialsRecord(descriptor, ResourceCredentials.builder() + .resourceId("offline") + .credentialsLevel(CredentialsLevel.USER) + .authenticationType(AuthenticationType.OAUTH) + .userId("user") + .accessToken(accessToken) + .issuer("http://idp/realms/dial") + .offlineUsageConsent(offlineConsent) + .build()); + } + + @Test + @DialConfigLocation("dial-config/external-service-obo.json") + void testDialNativeSignInIsRejected() { + 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(), () -> signIn.body()); + } + private Response obo(String url, String ownerUserId, String apiKeyValue) { return send(HttpMethod.POST, "/v1/ops/external-service/obo-credentials", null, "{\"url\":\"" + url + "\",\"owner_user_id\":\"" + ownerUserId + "\"}", diff --git a/server/src/test/resources/dial-config/external-service-obo.json b/server/src/test/resources/dial-config/external-service-obo.json index 68bb4d336..03603febf 100644 --- a/server/src/test/resources/dial-config/external-service-obo.json +++ b/server/src/test/resources/dial-config/external-service-obo.json @@ -22,6 +22,12 @@ "authentication_type": "API_KEY", "api_key_header": "X-API-Key" } + }, + "dial": { + "display_name": "DIAL", + "auth_settings": { + "authentication_type": "DIAL_NATIVE" + } } }, "app_identity": "935cd3c27ffd8bd295f5933665f298aad638a289c0cfe3a9a3e8685dade379c7"