diff --git a/compatibility-tests/sdk-test-java/pom.xml b/compatibility-tests/sdk-test-java/pom.xml
index e22aca3..31464bd 100644
--- a/compatibility-tests/sdk-test-java/pom.xml
+++ b/compatibility-tests/sdk-test-java/pom.xml
@@ -105,6 +105,14 @@
com.google.cloud
google-cloud-service-usage
+
+ com.google.cloud
+ google-cloud-iamcredentials
+
+
+ com.google.auth
+ google-auth-library-oauth2-http
+
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..6d1aece 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
@@ -2,6 +2,7 @@
import com.google.api.gax.core.NoCredentialsProvider;
import com.google.api.gax.grpc.InstantiatingGrpcChannelProvider;
+import com.google.auth.Credentials;
import com.google.cloud.NoCredentials;
import com.google.cloud.datastore.Datastore;
import com.google.cloud.datastore.DatastoreOptions;
@@ -9,6 +10,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;
@@ -64,6 +67,15 @@ public static Storage storageClient() {
.getService();
}
+ public static Storage storageClient(Credentials credentials) {
+ return StorageOptions.newBuilder()
+ .setHost(endpoint())
+ .setProjectId(projectId())
+ .setCredentials(credentials)
+ .build()
+ .getService();
+ }
+
/**
* Creates a Firestore client pointing at the emulator.
* GrpcFirestoreRpc uses plaintext when host contains "localhost"; setHost routes traffic there.
@@ -215,6 +227,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/GcsDownscopedTokenTest.java b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/GcsDownscopedTokenTest.java
new file mode 100644
index 0000000..b0e6172
--- /dev/null
+++ b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/GcsDownscopedTokenTest.java
@@ -0,0 +1,104 @@
+package io.floci.gcp.test;
+
+import com.google.api.client.http.javanet.NetHttpTransport;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.oauth2.AccessToken;
+import com.google.auth.oauth2.CredentialAccessBoundary;
+import com.google.auth.oauth2.DownscopedCredentials;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.auth.oauth2.OAuth2Credentials;
+import com.google.cloud.storage.BlobId;
+import com.google.cloud.storage.BlobInfo;
+import com.google.cloud.storage.BucketInfo;
+import com.google.cloud.storage.Storage;
+import com.google.cloud.storage.StorageException;
+import org.junit.jupiter.api.Test;
+
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Date;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class GcsDownscopedTokenTest {
+
+ @Test
+ void storageClientEnforcesDownscopedTokenPrefix() throws Exception {
+ String bucket = TestFixtures.uniqueName("downscoped-bucket");
+ try (Storage setup = TestFixtures.storageClient()) {
+ setup.create(BucketInfo.of(bucket));
+ }
+
+ AccessToken accessToken = downscopedAccessToken(bucket);
+ try (Storage scoped = TestFixtures.storageClient(OAuth2Credentials.create(accessToken))) {
+ BlobId allowed = BlobId.of(bucket, "allowed/file.txt");
+ scoped.create(BlobInfo.newBuilder(allowed).build(), "allowed".getBytes(StandardCharsets.UTF_8));
+
+ assertThat(new String(scoped.readAllBytes(allowed), StandardCharsets.UTF_8)).isEqualTo("allowed");
+ assertThat(scoped.list(bucket, Storage.BlobListOption.prefix("allowed/")).iterateAll())
+ .extracting(blob -> blob.getName())
+ .contains("allowed/file.txt");
+
+ assertThatThrownBy(() -> scoped.create(
+ BlobInfo.newBuilder(BlobId.of(bucket, "allowed_sibling/file.txt")).build(),
+ "denied".getBytes(StandardCharsets.UTF_8)))
+ .isInstanceOfSatisfying(StorageException.class,
+ exception -> assertThat(exception.getCode()).isEqualTo(403));
+ assertThatThrownBy(() -> scoped.readAllBytes(BlobId.of(bucket, "allowed_sibling/file.txt")))
+ .isInstanceOfSatisfying(StorageException.class,
+ exception -> assertThat(exception.getCode()).isEqualTo(403));
+ assertThatThrownBy(() -> scoped.list(bucket, Storage.BlobListOption.prefix("allowed_sibling/"))
+ .iterateAll().iterator().hasNext())
+ .isInstanceOfSatisfying(StorageException.class,
+ exception -> assertThat(exception.getCode()).isEqualTo(403));
+
+ assertThat(scoped.delete(allowed)).isTrue();
+ }
+ }
+
+ private static AccessToken downscopedAccessToken(String bucket) throws Exception {
+ GoogleCredentials sourceCredentials = GoogleCredentials.create(new AccessToken(
+ "source-token",
+ Date.from(Instant.now().plusSeconds(3600))));
+ CredentialAccessBoundary cab = CredentialAccessBoundary.newBuilder()
+ .addRule(CredentialAccessBoundary.AccessBoundaryRule.newBuilder()
+ .setAvailableResource("//storage.googleapis.com/projects/_/buckets/" + bucket)
+ .setAvailablePermissions(List.of(
+ "inRole:roles/storage.legacyObjectReader",
+ "inRole:roles/storage.objectViewer",
+ "inRole:roles/storage.legacyBucketWriter"))
+ .setAvailabilityCondition(
+ CredentialAccessBoundary.AccessBoundaryRule.AvailabilityCondition.newBuilder()
+ .setExpression("resource.name.startsWith("
+ + "'projects/_/buckets/" + bucket + "/objects/allowed/')"
+ + " || api.getAttribute("
+ + "'storage.googleapis.com/objectListPrefix', '').startsWith("
+ + "'allowed/')")
+ .build())
+ .build())
+ .build();
+
+ DownscopedCredentials credentials = DownscopedCredentials.newBuilder()
+ .setSourceCredential(sourceCredentials)
+ .setCredentialAccessBoundary(cab)
+ .setHttpTransportFactory(stsTransportFactory())
+ .build();
+ return credentials.refreshAccessToken();
+ }
+
+ private static HttpTransportFactory stsTransportFactory() {
+ return () -> new NetHttpTransport.Builder()
+ .setConnectionFactory(url -> {
+ if ("sts.googleapis.com".equals(url.getHost())) {
+ URL rewritten = new URL(TestFixtures.endpoint() + url.getFile());
+ return (HttpURLConnection) rewritten.openConnection();
+ }
+ return (HttpURLConnection) url.openConnection();
+ })
+ .build();
+ }
+}
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/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/StsTokenExchangeTest.java b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/StsTokenExchangeTest.java
new file mode 100644
index 0000000..c6da5b6
--- /dev/null
+++ b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/StsTokenExchangeTest.java
@@ -0,0 +1,61 @@
+package io.floci.gcp.test;
+
+import com.google.api.client.http.javanet.NetHttpTransport;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.oauth2.AccessToken;
+import com.google.auth.oauth2.CredentialAccessBoundary;
+import com.google.auth.oauth2.DownscopedCredentials;
+import com.google.auth.oauth2.GoogleCredentials;
+import org.junit.jupiter.api.Test;
+
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.time.Instant;
+import java.util.Date;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class StsTokenExchangeTest {
+
+ @Test
+ void downscopedCredentialsExchangeTokenWithCustomStsTransport() throws Exception {
+ GoogleCredentials sourceCredentials = GoogleCredentials.create(new AccessToken(
+ "source-token",
+ Date.from(Instant.now().plusSeconds(3600))));
+ CredentialAccessBoundary cab = CredentialAccessBoundary.newBuilder()
+ .addRule(CredentialAccessBoundary.AccessBoundaryRule.newBuilder()
+ .setAvailableResource("//storage.googleapis.com/projects/_/buckets/compat-bucket")
+ .setAvailablePermissions(List.of("inRole:roles/storage.legacyObjectReader"))
+ .setAvailabilityCondition(
+ CredentialAccessBoundary.AccessBoundaryRule.AvailabilityCondition.newBuilder()
+ .setExpression("resource.name.startsWith("
+ + "'projects/_/buckets/compat-bucket/objects/allowed/')")
+ .build())
+ .build())
+ .build();
+
+ DownscopedCredentials credentials = DownscopedCredentials.newBuilder()
+ .setSourceCredential(sourceCredentials)
+ .setCredentialAccessBoundary(cab)
+ .setHttpTransportFactory(stsTransportFactory())
+ .build();
+
+ AccessToken accessToken = credentials.refreshAccessToken();
+
+ assertThat(accessToken.getTokenValue()).startsWith("floci-gcp-downscoped-");
+ assertThat(accessToken.getExpirationTime()).isAfter(Date.from(Instant.now()));
+ }
+
+ private static HttpTransportFactory stsTransportFactory() {
+ return () -> new NetHttpTransport.Builder()
+ .setConnectionFactory(url -> {
+ if ("sts.googleapis.com".equals(url.getHost())) {
+ URL rewritten = new URL(TestFixtures.endpoint() + url.getFile());
+ return (HttpURLConnection) rewritten.openConnection();
+ }
+ return (HttpURLConnection) url.openConnection();
+ })
+ .build();
+ }
+}
diff --git a/src/main/java/io/floci/gcp/config/EmulatorConfig.java b/src/main/java/io/floci/gcp/config/EmulatorConfig.java
index 9bc8647..d51a15c 100644
--- a/src/main/java/io/floci/gcp/config/EmulatorConfig.java
+++ b/src/main/java/io/floci/gcp/config/EmulatorConfig.java
@@ -83,6 +83,10 @@ interface ServicesConfig {
IamServiceConfig iam();
+ IamCredentialsServiceConfig iamcredentials();
+
+ StsServiceConfig sts();
+
SecretManagerServiceConfig secretmanager();
LoggingServiceConfig logging();
@@ -186,6 +190,16 @@ interface IamServiceConfig {
boolean enabled();
}
+ interface IamCredentialsServiceConfig {
+ @WithDefault("true")
+ boolean enabled();
+ }
+
+ interface StsServiceConfig {
+ @WithDefault("true")
+ boolean enabled();
+ }
+
interface SecretManagerServiceConfig {
@WithDefault("true")
boolean enabled();
diff --git a/src/main/java/io/floci/gcp/core/common/GcpException.java b/src/main/java/io/floci/gcp/core/common/GcpException.java
index d1c9df6..6fc3535 100644
--- a/src/main/java/io/floci/gcp/core/common/GcpException.java
+++ b/src/main/java/io/floci/gcp/core/common/GcpException.java
@@ -71,6 +71,10 @@ public static GcpException permissionDenied(String message) {
return new GcpException(403, "PERMISSION_DENIED", Status.Code.PERMISSION_DENIED, message);
}
+ public static GcpException unauthenticated(String message) {
+ return new GcpException(401, "UNAUTHENTICATED", Status.Code.UNAUTHENTICATED, message);
+ }
+
public static GcpException resourceExhausted(String message) {
return new GcpException(429, "RESOURCE_EXHAUSTED", Status.Code.RESOURCE_EXHAUSTED, message);
}
diff --git a/src/main/java/io/floci/gcp/core/common/GcpExceptionMapper.java b/src/main/java/io/floci/gcp/core/common/GcpExceptionMapper.java
index 32a63be..b196735 100644
--- a/src/main/java/io/floci/gcp/core/common/GcpExceptionMapper.java
+++ b/src/main/java/io/floci/gcp/core/common/GcpExceptionMapper.java
@@ -42,6 +42,7 @@ private static String reasonFor(String gcpStatus) {
case "FAILED_PRECONDITION" -> "failedPrecondition";
case "CONDITION_NOT_MET" -> "conditionNotMet";
case "PERMISSION_DENIED" -> "forbidden";
+ case "UNAUTHENTICATED" -> "authError";
case "RESOURCE_EXHAUSTED" -> "rateLimitExceeded";
case "UNIMPLEMENTED" -> "notImplemented";
case "DEADLINE_EXCEEDED" -> "deadlineExceeded";
diff --git a/src/main/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryParser.java b/src/main/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryParser.java
new file mode 100644
index 0000000..904774c
--- /dev/null
+++ b/src/main/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryParser.java
@@ -0,0 +1,231 @@
+package io.floci.gcp.services.credentials;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.floci.gcp.core.common.GcpException;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@ApplicationScoped
+public class CredentialAccessBoundaryParser {
+
+ static final String LEGACY_OBJECT_READER = "inRole:roles/storage.legacyObjectReader";
+ static final String OBJECT_VIEWER = "inRole:roles/storage.objectViewer";
+ static final String LEGACY_BUCKET_WRITER = "inRole:roles/storage.legacyBucketWriter";
+
+ private static final Set SUPPORTED_PERMISSIONS = Set.of(
+ LEGACY_OBJECT_READER,
+ OBJECT_VIEWER,
+ LEGACY_BUCKET_WRITER);
+ private static final Pattern RESOURCE_PATTERN =
+ Pattern.compile("^//storage\\.googleapis\\.com/projects/_/buckets/([^/]+)$");
+ private static final Pattern RESOURCE_NAME_PREFIX_PATTERN =
+ Pattern.compile("^resource\\.name\\.startsWith\\((.+)\\)$");
+ private static final Pattern LIST_PREFIX_PATTERN =
+ Pattern.compile("^api\\.getAttribute\\((.+),\\s*(.+)\\)\\.startsWith\\((.+)\\)$");
+
+ private final ObjectMapper objectMapper;
+
+ @Inject
+ public CredentialAccessBoundaryParser(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ public List parse(String options) {
+ if (options == null || options.isBlank()) {
+ throw invalidGrant("options is required");
+ }
+
+ JsonNode root;
+ try {
+ root = objectMapper.readTree(options);
+ } catch (IOException e) {
+ throw invalidGrant("options must be valid Credential Access Boundary JSON");
+ }
+
+ JsonNode rulesNode = root.path("accessBoundary").path("accessBoundaryRules");
+ if (!rulesNode.isArray() || rulesNode.isEmpty()) {
+ throw invalidGrant("accessBoundaryRules is required");
+ }
+
+ List rules = new ArrayList<>();
+ for (JsonNode ruleNode : rulesNode) {
+ String bucket = parseBucket(requiredText(ruleNode, "availableResource"));
+ List permissions = parsePermissions(ruleNode.path("availablePermissions"));
+ String expression = ruleNode.path("availabilityCondition").path("expression").asText(null);
+ if (expression == null || expression.isBlank()) {
+ throw invalidGrant("availabilityCondition.expression is required");
+ }
+ String prefix = parsePrefixExpression(bucket, expression);
+ rules.add(new CredentialAccessBoundaryRule(bucket, prefix, permissions));
+ }
+ return rules;
+ }
+
+ private static String requiredText(JsonNode node, String field) {
+ JsonNode value = node.get(field);
+ if (value == null || !value.isTextual() || value.asText().isBlank()) {
+ throw invalidGrant(field + " is required");
+ }
+ return value.asText();
+ }
+
+ private static String parseBucket(String availableResource) {
+ Matcher matcher = RESOURCE_PATTERN.matcher(availableResource);
+ if (!matcher.matches() || matcher.group(1).isBlank()) {
+ throw invalidGrant("unsupported availableResource");
+ }
+ return matcher.group(1);
+ }
+
+ private static List parsePermissions(JsonNode permissionsNode) {
+ if (!permissionsNode.isArray() || permissionsNode.isEmpty()) {
+ throw invalidGrant("availablePermissions is required");
+ }
+ List permissions = new ArrayList<>();
+ for (JsonNode permissionNode : permissionsNode) {
+ if (!permissionNode.isTextual()) {
+ throw invalidGrant("availablePermissions entries must be strings");
+ }
+ String permission = permissionNode.asText();
+ if (!SUPPORTED_PERMISSIONS.contains(permission)) {
+ throw invalidGrant("unsupported availablePermission");
+ }
+ permissions.add(permission);
+ }
+ return permissions;
+ }
+
+ private static String parsePrefixExpression(String bucket, String expression) {
+ Set prefixes = new LinkedHashSet<>();
+ for (String part : splitOrExpression(expression)) {
+ prefixes.add(parseSingleExpression(bucket, part.trim()));
+ }
+ if (prefixes.size() != 1) {
+ throw invalidGrant("all supported expressions must use the same object prefix");
+ }
+ return prefixes.iterator().next();
+ }
+
+ private static List splitOrExpression(String expression) {
+ List parts = new ArrayList<>();
+ int start = 0;
+ Character quote = null;
+ boolean escaped = false;
+ for (int i = 0; i < expression.length(); i++) {
+ char ch = expression.charAt(i);
+ if (escaped) {
+ escaped = false;
+ continue;
+ }
+ if (quote != null) {
+ if (ch == '\\') {
+ escaped = true;
+ } else if (ch == quote) {
+ quote = null;
+ }
+ continue;
+ }
+ if (ch == '\'' || ch == '"') {
+ quote = ch;
+ } else if (ch == '|' && i + 1 < expression.length() && expression.charAt(i + 1) == '|') {
+ parts.add(expression.substring(start, i));
+ start = i + 2;
+ i++;
+ }
+ }
+ if (quote != null) {
+ throw invalidGrant("unterminated string literal in expression");
+ }
+ parts.add(expression.substring(start));
+ return parts;
+ }
+
+ private static String parseSingleExpression(String bucket, String expression) {
+ Matcher resourceMatcher = RESOURCE_NAME_PREFIX_PATTERN.matcher(expression);
+ if (resourceMatcher.matches()) {
+ String resourcePrefix = parseOnlyStringArgument(resourceMatcher.group(1));
+ String expectedStart = "projects/_/buckets/" + bucket + "/objects/";
+ if (!resourcePrefix.startsWith(expectedStart)) {
+ throw invalidGrant("resource.name prefix does not match availableResource bucket");
+ }
+ return normalizePrefix(resourcePrefix.substring(expectedStart.length()));
+ }
+
+ Matcher listMatcher = LIST_PREFIX_PATTERN.matcher(expression);
+ if (listMatcher.matches()) {
+ String attribute = parseOnlyStringArgument(listMatcher.group(1));
+ String defaultValue = parseOnlyStringArgument(listMatcher.group(2));
+ String objectPrefix = parseOnlyStringArgument(listMatcher.group(3));
+ if (!"storage.googleapis.com/objectListPrefix".equals(attribute) || !defaultValue.isEmpty()) {
+ throw invalidGrant("unsupported api.getAttribute expression");
+ }
+ return normalizePrefix(objectPrefix);
+ }
+
+ throw invalidGrant("unsupported availabilityCondition expression");
+ }
+
+ private static String parseOnlyStringArgument(String argument) {
+ String trimmed = argument.trim();
+ if (trimmed.length() < 2) {
+ throw invalidGrant("expected string literal");
+ }
+ char quote = trimmed.charAt(0);
+ if ((quote != '\'' && quote != '"') || trimmed.charAt(trimmed.length() - 1) != quote) {
+ throw invalidGrant("expected string literal");
+ }
+ return decodeCelString(trimmed.substring(1, trimmed.length() - 1));
+ }
+
+ private static String decodeCelString(String value) {
+ StringBuilder decoded = new StringBuilder(value.length());
+ boolean escaped = false;
+ for (int i = 0; i < value.length(); i++) {
+ char ch = value.charAt(i);
+ if (!escaped) {
+ if (ch == '\\') {
+ escaped = true;
+ } else {
+ decoded.append(ch);
+ }
+ continue;
+ }
+ switch (ch) {
+ case '\\' -> decoded.append('\\');
+ case '\'' -> decoded.append('\'');
+ case '"' -> decoded.append('"');
+ case 'n' -> decoded.append('\n');
+ case 'r' -> decoded.append('\r');
+ case 't' -> decoded.append('\t');
+ case 'b' -> decoded.append('\b');
+ case 'f' -> decoded.append('\f');
+ default -> throw invalidGrant("unsupported string escape");
+ }
+ escaped = false;
+ }
+ if (escaped) {
+ throw invalidGrant("unterminated string escape");
+ }
+ return decoded.toString();
+ }
+
+ private static String normalizePrefix(String prefix) {
+ if (prefix == null || prefix.isBlank()) {
+ throw invalidGrant("object prefix is required");
+ }
+ return prefix.endsWith("/") ? prefix : prefix + "/";
+ }
+
+ private static GcpException invalidGrant(String message) {
+ return GcpException.invalidArgument(message).withReason("invalid_grant");
+ }
+}
diff --git a/src/main/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryRule.java b/src/main/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryRule.java
new file mode 100644
index 0000000..e4e65ed
--- /dev/null
+++ b/src/main/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryRule.java
@@ -0,0 +1,49 @@
+package io.floci.gcp.services.credentials;
+
+import io.quarkus.runtime.annotations.RegisterForReflection;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RegisterForReflection
+public class CredentialAccessBoundaryRule {
+
+ private String bucket;
+ private String objectPrefix;
+ private List availablePermissions = new ArrayList<>();
+
+ public CredentialAccessBoundaryRule() {
+ }
+
+ public CredentialAccessBoundaryRule(String bucket, String objectPrefix, List availablePermissions) {
+ this.bucket = bucket;
+ this.objectPrefix = objectPrefix;
+ this.availablePermissions = new ArrayList<>(availablePermissions);
+ }
+
+ public String getBucket() {
+ return bucket;
+ }
+
+ public void setBucket(String bucket) {
+ this.bucket = bucket;
+ }
+
+ public String getObjectPrefix() {
+ return objectPrefix;
+ }
+
+ public void setObjectPrefix(String objectPrefix) {
+ this.objectPrefix = objectPrefix;
+ }
+
+ public List getAvailablePermissions() {
+ return availablePermissions;
+ }
+
+ public void setAvailablePermissions(List availablePermissions) {
+ this.availablePermissions = availablePermissions == null
+ ? new ArrayList<>()
+ : new ArrayList<>(availablePermissions);
+ }
+}
diff --git a/src/main/java/io/floci/gcp/services/credentials/CredentialTokenService.java b/src/main/java/io/floci/gcp/services/credentials/CredentialTokenService.java
new file mode 100644
index 0000000..b0a815d
--- /dev/null
+++ b/src/main/java/io/floci/gcp/services/credentials/CredentialTokenService.java
@@ -0,0 +1,98 @@
+package io.floci.gcp.services.credentials;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import io.floci.gcp.core.common.GcpException;
+import io.floci.gcp.core.storage.StorageBackend;
+import io.floci.gcp.core.storage.StorageFactory;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+@ApplicationScoped
+public class CredentialTokenService {
+
+ public static final String FLOCI_TOKEN_PREFIX = "floci-gcp-";
+ public static final String IMPERSONATED_TOKEN_PREFIX = "floci-gcp-impersonated-";
+ public static final String DOWNSCOPED_TOKEN_PREFIX = "floci-gcp-downscoped-";
+ public static final long DEFAULT_LIFETIME_SECONDS = 3600;
+
+ private final StorageBackend tokenStore;
+ private final Clock clock;
+
+ @Inject
+ public CredentialTokenService(StorageFactory storageFactory) {
+ this(storageFactory.createGlobal("credential-tokens", "credential-tokens.json",
+ new TypeReference