diff --git a/compatibility-tests/sdk-test-java/pom.xml b/compatibility-tests/sdk-test-java/pom.xml
index e22aca3..cca3fee 100644
--- a/compatibility-tests/sdk-test-java/pom.xml
+++ b/compatibility-tests/sdk-test-java/pom.xml
@@ -105,6 +105,10 @@
com.google.cloud
google-cloud-service-usage
+
+ com.google.cloud
+ google-cloud-iamcredentials
+
com.google.firebase
firebase-admin
diff --git a/compatibility-tests/sdk-test-java/src/main/java/io/floci/gcp/test/TestFixtures.java b/compatibility-tests/sdk-test-java/src/main/java/io/floci/gcp/test/TestFixtures.java
index c2eca00..8ae981b 100644
--- a/compatibility-tests/sdk-test-java/src/main/java/io/floci/gcp/test/TestFixtures.java
+++ b/compatibility-tests/sdk-test-java/src/main/java/io/floci/gcp/test/TestFixtures.java
@@ -9,6 +9,8 @@
import com.google.cloud.firestore.FirestoreOptions;
import com.google.cloud.functions.v2.FunctionServiceClient;
import com.google.cloud.functions.v2.FunctionServiceSettings;
+import com.google.cloud.iam.credentials.v1.IamCredentialsClient;
+import com.google.cloud.iam.credentials.v1.IamCredentialsSettings;
import com.google.cloud.run.v2.RevisionsClient;
import com.google.cloud.run.v2.RevisionsSettings;
import com.google.cloud.run.v2.ServicesClient;
@@ -215,6 +217,14 @@ public static SQLAdmin sqlAdminClient() {
.build();
}
+ public static IamCredentialsClient iamCredentialsClient() throws IOException {
+ IamCredentialsSettings settings = IamCredentialsSettings.newHttpJsonBuilder()
+ .setEndpoint(endpoint())
+ .setCredentialsProvider(NoCredentialsProvider.create())
+ .build();
+ return IamCredentialsClient.create(settings);
+ }
+
/**
* Creates a Cloud Logging client pointing at the emulator.
* No standard emulator env var exists; configure explicitly via plaintext gRPC channel.
diff --git a/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/IamCredentialsTest.java b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/IamCredentialsTest.java
new file mode 100644
index 0000000..aa0df86
--- /dev/null
+++ b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/IamCredentialsTest.java
@@ -0,0 +1,30 @@
+package io.floci.gcp.test;
+
+import com.google.cloud.iam.credentials.v1.GenerateAccessTokenResponse;
+import com.google.cloud.iam.credentials.v1.IamCredentialsClient;
+import com.google.protobuf.Duration;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class IamCredentialsTest {
+
+ @Test
+ void generateAccessTokenWithHttpJsonClient() throws Exception {
+ String email = "compat@" + TestFixtures.projectId() + ".iam.gserviceaccount.com";
+
+ try (IamCredentialsClient client = TestFixtures.iamCredentialsClient()) {
+ GenerateAccessTokenResponse response = client.generateAccessToken(
+ "projects/-/serviceAccounts/" + email,
+ List.of(),
+ List.of("https://www.googleapis.com/auth/cloud-platform"),
+ Duration.newBuilder().setSeconds(3600).build());
+
+ assertThat(response.getAccessToken()).startsWith("floci-gcp-impersonated-");
+ assertThat(response.getExpireTime().getSeconds()).isGreaterThan(Instant.now().getEpochSecond());
+ }
+ }
+}
diff --git a/src/main/java/io/floci/gcp/config/EmulatorConfig.java b/src/main/java/io/floci/gcp/config/EmulatorConfig.java
index 9bc8647..43f12d0 100644
--- a/src/main/java/io/floci/gcp/config/EmulatorConfig.java
+++ b/src/main/java/io/floci/gcp/config/EmulatorConfig.java
@@ -83,6 +83,8 @@ interface ServicesConfig {
IamServiceConfig iam();
+ IamCredentialsServiceConfig iamcredentials();
+
SecretManagerServiceConfig secretmanager();
LoggingServiceConfig logging();
@@ -186,6 +188,11 @@ interface IamServiceConfig {
boolean enabled();
}
+ interface IamCredentialsServiceConfig {
+ @WithDefault("true")
+ boolean enabled();
+ }
+
interface SecretManagerServiceConfig {
@WithDefault("true")
boolean enabled();
diff --git a/src/main/java/io/floci/gcp/services/iamcredentials/IamCredentialsController.java b/src/main/java/io/floci/gcp/services/iamcredentials/IamCredentialsController.java
new file mode 100644
index 0000000..d941ce1
--- /dev/null
+++ b/src/main/java/io/floci/gcp/services/iamcredentials/IamCredentialsController.java
@@ -0,0 +1,45 @@
+package io.floci.gcp.services.iamcredentials;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Path("/v1/projects/-/serviceAccounts")
+@ApplicationScoped
+@Produces(MediaType.APPLICATION_JSON)
+@Consumes(MediaType.APPLICATION_JSON)
+public class IamCredentialsController {
+
+ private final IamCredentialsService service;
+
+ @Inject
+ public IamCredentialsController(IamCredentialsService service) {
+ this.service = service;
+ }
+
+ @POST
+ @Path("/{serviceAccount}:generateAccessToken")
+ public Response generateAccessToken(@PathParam("serviceAccount") String serviceAccount,
+ Map body) {
+ List> scopes = body != null && body.get("scope") instanceof List> scopeList
+ ? scopeList
+ : null;
+ IamCredentialsService.GeneratedAccessToken token =
+ service.generateAccessToken(serviceAccount, scopes, body != null ? body.get("lifetime") : null);
+
+ Map response = new LinkedHashMap<>();
+ response.put("accessToken", token.accessToken());
+ response.put("expireTime", token.expireTime().toString());
+ return Response.ok(response).build();
+ }
+}
diff --git a/src/main/java/io/floci/gcp/services/iamcredentials/IamCredentialsService.java b/src/main/java/io/floci/gcp/services/iamcredentials/IamCredentialsService.java
new file mode 100644
index 0000000..3586f35
--- /dev/null
+++ b/src/main/java/io/floci/gcp/services/iamcredentials/IamCredentialsService.java
@@ -0,0 +1,104 @@
+package io.floci.gcp.services.iamcredentials;
+
+import io.floci.gcp.config.EmulatorConfig;
+import io.floci.gcp.core.common.GcpException;
+import io.floci.gcp.core.common.ServiceDescriptor;
+import io.floci.gcp.core.common.ServiceProtocol;
+import io.floci.gcp.core.common.ServiceRegistry;
+import io.quarkus.runtime.StartupEvent;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.event.Observes;
+import jakarta.inject.Inject;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+@ApplicationScoped
+public class IamCredentialsService {
+
+ static final String DEVSTORAGE_READ_WRITE_SCOPE = "https://www.googleapis.com/auth/devstorage.read_write";
+ static final String CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
+ static final String TOKEN_PREFIX = "floci-gcp-impersonated-";
+ static final long DEFAULT_LIFETIME_SECONDS = 3600;
+ static final long MAX_LIFETIME_SECONDS = 3600;
+ private static final Set SUPPORTED_SCOPES = Set.of(DEVSTORAGE_READ_WRITE_SCOPE, CLOUD_PLATFORM_SCOPE);
+
+ private final ServiceRegistry serviceRegistry;
+ private final EmulatorConfig config;
+ private final Clock clock;
+
+ @Inject
+ public IamCredentialsService(ServiceRegistry serviceRegistry, EmulatorConfig config) {
+ this(serviceRegistry, config, Clock.systemUTC());
+ }
+
+ IamCredentialsService(Clock clock) {
+ this(null, null, clock);
+ }
+
+ private IamCredentialsService(ServiceRegistry serviceRegistry, EmulatorConfig config, Clock clock) {
+ this.serviceRegistry = serviceRegistry;
+ this.config = config;
+ this.clock = clock;
+ }
+
+ void onStart(@Observes StartupEvent ev) {
+ serviceRegistry.register(ServiceDescriptor.builder("iamcredentials")
+ .enabled(config.services().iamcredentials().enabled())
+ .storageKey("iamcredentials")
+ .protocol(ServiceProtocol.REST)
+ .resourceClasses(IamCredentialsController.class)
+ .build());
+ }
+
+ public GeneratedAccessToken generateAccessToken(String serviceAccount, List> scopes, Object lifetime) {
+ if (serviceAccount == null || serviceAccount.isBlank()) {
+ throw GcpException.invalidArgument("service account is required");
+ }
+ validateScopes(scopes);
+
+ long lifetimeSeconds = parseLifetimeSeconds(lifetime);
+ Instant expireTime = clock.instant().plusSeconds(lifetimeSeconds);
+ return new GeneratedAccessToken(TOKEN_PREFIX + UUID.randomUUID(), expireTime);
+ }
+
+ private static void validateScopes(List> scopes) {
+ if (scopes == null || scopes.isEmpty()) {
+ throw GcpException.invalidArgument("scope is required");
+ }
+ boolean supported = scopes.stream()
+ .anyMatch(SUPPORTED_SCOPES::contains);
+ if (!supported) {
+ throw GcpException.invalidArgument("unsupported scope");
+ }
+ }
+
+ private static long parseLifetimeSeconds(Object lifetime) {
+ if (lifetime == null) {
+ return DEFAULT_LIFETIME_SECONDS;
+ }
+ if (!(lifetime instanceof String value)) {
+ throw GcpException.invalidArgument("lifetime must be a duration string");
+ }
+ if (!value.endsWith("s") || value.length() == 1) {
+ throw GcpException.invalidArgument("lifetime must be a duration string ending in seconds");
+ }
+
+ long requestedSeconds;
+ try {
+ requestedSeconds = Long.parseLong(value.substring(0, value.length() - 1));
+ } catch (NumberFormatException e) {
+ throw GcpException.invalidArgument("lifetime must be a duration string ending in seconds");
+ }
+
+ if (requestedSeconds <= 0) {
+ throw GcpException.invalidArgument("lifetime must be positive");
+ }
+ return Math.min(requestedSeconds, MAX_LIFETIME_SECONDS);
+ }
+
+ public record GeneratedAccessToken(String accessToken, Instant expireTime) {}
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 5751f51..2a7c47a 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -55,6 +55,8 @@ floci-gcp:
enabled: true
iam:
enabled: true
+ iamcredentials:
+ enabled: true
secretmanager:
enabled: true
logging:
diff --git a/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsDisabledRestIntegrationTest.java b/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsDisabledRestIntegrationTest.java
new file mode 100644
index 0000000..ff86a39
--- /dev/null
+++ b/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsDisabledRestIntegrationTest.java
@@ -0,0 +1,37 @@
+package io.floci.gcp.services.iamcredentials;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.QuarkusTestProfile;
+import io.quarkus.test.junit.TestProfile;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.equalTo;
+
+@QuarkusTest
+@TestProfile(IamCredentialsDisabledRestIntegrationTest.DisabledIamCredentialsProfile.class)
+class IamCredentialsDisabledRestIntegrationTest {
+
+ @Test
+ void disabledIamCredentialsReturnsUnavailableWrapper() {
+ given()
+ .urlEncodingEnabled(false)
+ .contentType("application/json")
+ .body(Map.of("scope", List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE)))
+ .when().post("/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken")
+ .then()
+ .statusCode(503)
+ .body("error.status", equalTo("UNAVAILABLE"))
+ .body("error.message", equalTo("Service iamcredentials is not enabled."));
+ }
+
+ public static class DisabledIamCredentialsProfile implements QuarkusTestProfile {
+ @Override
+ public Map getConfigOverrides() {
+ return Map.of("floci-gcp.services.iamcredentials.enabled", "false");
+ }
+ }
+}
diff --git a/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsRestIntegrationTest.java b/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsRestIntegrationTest.java
new file mode 100644
index 0000000..e832aba
--- /dev/null
+++ b/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsRestIntegrationTest.java
@@ -0,0 +1,145 @@
+package io.floci.gcp.services.iamcredentials;
+
+import io.quarkus.test.junit.QuarkusTest;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.startsWith;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@QuarkusTest
+class IamCredentialsRestIntegrationTest {
+
+ private static final String PATH =
+ "/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken";
+ private static final Map VALID_BODY = Map.of(
+ "delegates", List.of(),
+ "scope", List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE),
+ "lifetime", "3600s");
+
+ @Test
+ void generateAccessTokenReturnsGoogleCompatibleJson() {
+ String expireTime = given()
+ .urlEncodingEnabled(false)
+ .contentType("application/json")
+ .body(VALID_BODY)
+ .when().post(PATH)
+ .then()
+ .statusCode(200)
+ .body("accessToken", startsWith(IamCredentialsService.TOKEN_PREFIX))
+ .body("expireTime", notNullValue())
+ .extract().path("expireTime");
+
+ assertTrue(Instant.parse(expireTime).isAfter(Instant.now().minusSeconds(1)));
+ }
+
+ @Test
+ void specificRouteDoesNotFallThroughToIamCatchAll() {
+ given()
+ .urlEncodingEnabled(false)
+ .contentType("application/json")
+ .body(Map.of("scope", List.of("https://www.googleapis.com/auth/userinfo.email")))
+ .when().post(PATH)
+ .then()
+ .statusCode(400)
+ .body("error.status", equalTo("INVALID_ARGUMENT"))
+ .body("error.message", equalTo("unsupported scope"));
+ }
+
+ @Test
+ void missingScopeReturnsGcpStyleError() {
+ given()
+ .urlEncodingEnabled(false)
+ .contentType("application/json")
+ .body(Map.of("lifetime", "60s"))
+ .when().post(PATH)
+ .then()
+ .statusCode(400)
+ .body("error.status", equalTo("INVALID_ARGUMENT"))
+ .body("error.errors[0].reason", equalTo("invalid"));
+ }
+
+ @Test
+ void emptyScopeReturnsGcpStyleError() {
+ given()
+ .urlEncodingEnabled(false)
+ .contentType("application/json")
+ .body(Map.of("scope", List.of(), "lifetime", "60s"))
+ .when().post(PATH)
+ .then()
+ .statusCode(400)
+ .body("error.status", equalTo("INVALID_ARGUMENT"));
+ }
+
+ @Test
+ void unsupportedScopeReturnsGcpStyleError() {
+ given()
+ .urlEncodingEnabled(false)
+ .contentType("application/json")
+ .body(Map.of("scope", List.of("https://www.googleapis.com/auth/userinfo.email")))
+ .when().post(PATH)
+ .then()
+ .statusCode(400)
+ .body("error.status", equalTo("INVALID_ARGUMENT"));
+ }
+
+ @Test
+ void cloudPlatformScopeReturnsAccessToken() {
+ given()
+ .urlEncodingEnabled(false)
+ .contentType("application/json")
+ .body(Map.of("scope", List.of(IamCredentialsService.CLOUD_PLATFORM_SCOPE)))
+ .when().post(PATH)
+ .then()
+ .statusCode(200)
+ .body("accessToken", startsWith(IamCredentialsService.TOKEN_PREFIX));
+ }
+
+ @Test
+ void malformedLifetimeReturnsGcpStyleError() {
+ given()
+ .urlEncodingEnabled(false)
+ .contentType("application/json")
+ .body(Map.of(
+ "scope", List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE),
+ "lifetime", "not-a-duration"))
+ .when().post(PATH)
+ .then()
+ .statusCode(400)
+ .body("error.status", equalTo("INVALID_ARGUMENT"));
+ }
+
+ @Test
+ void zeroLifetimeReturnsGcpStyleError() {
+ given()
+ .urlEncodingEnabled(false)
+ .contentType("application/json")
+ .body(Map.of(
+ "scope", List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE),
+ "lifetime", "0s"))
+ .when().post(PATH)
+ .then()
+ .statusCode(400)
+ .body("error.status", equalTo("INVALID_ARGUMENT"));
+ }
+
+ @Test
+ void negativeLifetimeReturnsGcpStyleError() {
+ given()
+ .urlEncodingEnabled(false)
+ .contentType("application/json")
+ .body(Map.of(
+ "scope", List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE),
+ "lifetime", "-1s"))
+ .when().post(PATH)
+ .then()
+ .statusCode(400)
+ .body("error.status", equalTo("INVALID_ARGUMENT"));
+ }
+}
diff --git a/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsServiceTest.java b/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsServiceTest.java
new file mode 100644
index 0000000..e8c3a31
--- /dev/null
+++ b/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsServiceTest.java
@@ -0,0 +1,144 @@
+package io.floci.gcp.services.iamcredentials;
+
+import io.floci.gcp.core.common.GcpException;
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+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;
+
+class IamCredentialsServiceTest {
+
+ private static final Instant NOW = Instant.parse("2026-07-15T12:00:00Z");
+ private static final String SERVICE_ACCOUNT = "test@test-project.iam.gserviceaccount.com";
+
+ private final IamCredentialsService service =
+ new IamCredentialsService(Clock.fixed(NOW, ZoneOffset.UTC));
+
+ @Test
+ void generatesTokenAndExpiryForNormalRequest() {
+ IamCredentialsService.GeneratedAccessToken token = service.generateAccessToken(
+ SERVICE_ACCOUNT,
+ List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE),
+ "1200s");
+
+ assertTrue(token.accessToken().startsWith(IamCredentialsService.TOKEN_PREFIX));
+ assertEquals(NOW.plusSeconds(1200), token.expireTime());
+ }
+
+ @Test
+ void defaultsLifetimeWhenAbsent() {
+ IamCredentialsService.GeneratedAccessToken token = service.generateAccessToken(
+ SERVICE_ACCOUNT,
+ List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE),
+ null);
+
+ assertEquals(NOW.plusSeconds(IamCredentialsService.DEFAULT_LIFETIME_SECONDS), token.expireTime());
+ }
+
+ @Test
+ void capsLifetimeAtOneHour() {
+ IamCredentialsService.GeneratedAccessToken token = service.generateAccessToken(
+ SERVICE_ACCOUNT,
+ List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE),
+ "7200s");
+
+ assertEquals(NOW.plusSeconds(IamCredentialsService.MAX_LIFETIME_SECONDS), token.expireTime());
+ }
+
+ @Test
+ void acceptsArbitraryServiceAccountWithoutPreCreation() {
+ IamCredentialsService.GeneratedAccessToken token = service.generateAccessToken(
+ "not-created@example.iam.gserviceaccount.com",
+ List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE),
+ "60s");
+
+ assertTrue(token.accessToken().startsWith(IamCredentialsService.TOKEN_PREFIX));
+ }
+
+ @Test
+ void acceptsCloudPlatformScope() {
+ IamCredentialsService.GeneratedAccessToken token = service.generateAccessToken(
+ SERVICE_ACCOUNT,
+ List.of(IamCredentialsService.CLOUD_PLATFORM_SCOPE),
+ "60s");
+
+ assertTrue(token.accessToken().startsWith(IamCredentialsService.TOKEN_PREFIX));
+ }
+
+ @Test
+ void rejectsBlankServiceAccount() {
+ GcpException ex = assertThrows(GcpException.class,
+ () -> service.generateAccessToken(
+ " ",
+ List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE),
+ "60s"));
+
+ assertEquals("INVALID_ARGUMENT", ex.getGcpStatus());
+ }
+
+ @Test
+ void rejectsMissingScope() {
+ GcpException ex = assertThrows(GcpException.class,
+ () -> service.generateAccessToken(SERVICE_ACCOUNT, null, "60s"));
+
+ assertEquals("INVALID_ARGUMENT", ex.getGcpStatus());
+ }
+
+ @Test
+ void rejectsEmptyScope() {
+ GcpException ex = assertThrows(GcpException.class,
+ () -> service.generateAccessToken(SERVICE_ACCOUNT, List.of(), "60s"));
+
+ assertEquals("INVALID_ARGUMENT", ex.getGcpStatus());
+ }
+
+ @Test
+ void rejectsUnsupportedScope() {
+ GcpException ex = assertThrows(GcpException.class,
+ () -> service.generateAccessToken(
+ SERVICE_ACCOUNT,
+ List.of("https://www.googleapis.com/auth/userinfo.email"),
+ "60s"));
+
+ assertEquals("INVALID_ARGUMENT", ex.getGcpStatus());
+ }
+
+ @Test
+ void rejectsMalformedLifetime() {
+ GcpException ex = assertThrows(GcpException.class,
+ () -> service.generateAccessToken(
+ SERVICE_ACCOUNT,
+ List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE),
+ "one-hour"));
+
+ assertEquals("INVALID_ARGUMENT", ex.getGcpStatus());
+ }
+
+ @Test
+ void rejectsZeroLifetime() {
+ GcpException ex = assertThrows(GcpException.class,
+ () -> service.generateAccessToken(
+ SERVICE_ACCOUNT,
+ List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE),
+ "0s"));
+
+ assertEquals("INVALID_ARGUMENT", ex.getGcpStatus());
+ }
+
+ @Test
+ void rejectsNegativeLifetime() {
+ GcpException ex = assertThrows(GcpException.class,
+ () -> service.generateAccessToken(
+ SERVICE_ACCOUNT,
+ List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE),
+ "-1s"));
+
+ assertEquals("INVALID_ARGUMENT", ex.getGcpStatus());
+ }
+}
diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml
index e8a1463..1906602 100644
--- a/src/test/resources/application.yml
+++ b/src/test/resources/application.yml
@@ -11,6 +11,8 @@ floci-gcp:
storage:
mode: memory
services:
+ iamcredentials:
+ enabled: true
datastore:
enabled: true
kafka: