From ca3eff75680243935f032e55ba4a75196fa7d6d8 Mon Sep 17 00:00:00 2001 From: Enrico Risa Date: Mon, 24 Aug 2026 10:23:55 +0200 Subject: [PATCH] feat: support token exchange auth for token api --- charts/certo/Chart.yaml | 2 +- charts/certo/templates/deployment.yaml | 46 ++++- charts/certo/values.yaml | 41 +++++ .../common/security/SecurityProperties.java | 30 +++- .../exchange/TokenExchangeClient.java | 136 ++++++++++++++ .../security/inbound/SigletTokenVerifier.java | 21 ++- .../security/outbound/SigletTokenSource.java | 18 +- src/main/resources/application.yaml | 19 ++ .../exchange/TokenExchangeClientTest.java | 168 ++++++++++++++++++ .../inbound/SigletTokenVerifierTest.java | 158 ++++++++++++++++ .../outbound/SigletTokenSourceTest.java | 91 ++++++++++ 11 files changed, 718 insertions(+), 12 deletions(-) create mode 100644 src/main/java/org/metaform/certo/common/security/exchange/TokenExchangeClient.java create mode 100644 src/test/java/org/metaform/certo/common/security/exchange/TokenExchangeClientTest.java create mode 100644 src/test/java/org/metaform/certo/common/security/inbound/SigletTokenVerifierTest.java create mode 100644 src/test/java/org/metaform/certo/common/security/outbound/SigletTokenSourceTest.java diff --git a/charts/certo/Chart.yaml b/charts/certo/Chart.yaml index d931329..8184cc0 100644 --- a/charts/certo/Chart.yaml +++ b/charts/certo/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: certo description: Certo - certificate exchange service (CX-0135) type: application -version: 0.1.0 +version: 0.0.4 appVersion: "0.1.0" diff --git a/charts/certo/templates/deployment.yaml b/charts/certo/templates/deployment.yaml index 8958802..1bcbb0c 100644 --- a/charts/certo/templates/deployment.yaml +++ b/charts/certo/templates/deployment.yaml @@ -67,6 +67,10 @@ spec: env: - name: SPRING_PROFILES_ACTIVE value: {{ .Values.springProfile | quote }} + {{- if .Values.debug.enabled }} + - name: JAVA_TOOL_OPTIONS + value: "-agentlib:jdwp=transport=dt_socket,server=y,suspend={{ ternary "y" "n" .Values.debug.suspend }},address=*:{{ .Values.debug.port }}" + {{- end }} - name: CERTO_DB_URL value: {{ .Values.database.url | quote }} - name: CERTO_DB_USER @@ -84,6 +88,24 @@ spec: - name: CERTO_SECURITY_SIGLETBASEURL value: {{ .Values.sigletBaseUrl | quote }} {{- end }} + {{- if .Values.tokenExchange.enabled }} + - name: CERTO_SECURITY_TOKENEXCHANGE_ENABLED + value: "true" + - name: CERTO_SECURITY_TOKENEXCHANGE_URL + value: {{ .Values.tokenExchange.url | quote }} + - name: CERTO_SECURITY_TOKENEXCHANGE_SCOPE + value: {{ .Values.tokenExchange.scope | quote }} + - name: CERTO_SECURITY_TOKENEXCHANGE_AUDIENCE + value: {{ .Values.tokenExchange.audience | quote }} + - name: CERTO_SECURITY_TOKENEXCHANGE_VERIFYRESOURCE + value: {{ .Values.tokenExchange.verifyResource | quote }} + {{- if .Values.tokenExchange.subjectTokenAudience }} + # Projected ServiceAccount token minted for the broker's expected audience; the default + # auto-mounted token is addressed to the API server and its TokenReview would reject it. + - name: CERTO_SECURITY_TOKENEXCHANGE_SUBJECTTOKENPATH + value: /var/run/secrets/certo/token-exchange/token + {{- end }} + {{- end }} {{- if .Values.events.nats.enabled }} - name: CERTO_EVENTS_NATS_ENABLED value: "true" @@ -113,20 +135,40 @@ spec: readinessProbe: tcpSocket: port: http - {{- if and .Values.events.nats.enabled .Values.natsAuth.enabled }} + {{- if or (and .Values.events.nats.enabled .Values.natsAuth.enabled) (and .Values.tokenExchange.enabled .Values.tokenExchange.subjectTokenAudience) }} volumeMounts: + {{- if and .Values.events.nats.enabled .Values.natsAuth.enabled }} - name: nats-nkey mountPath: /vault/secrets readOnly: true + {{- end }} + {{- if and .Values.tokenExchange.enabled .Values.tokenExchange.subjectTokenAudience }} + - name: token-exchange-sa-token + mountPath: /var/run/secrets/certo/token-exchange + readOnly: true + {{- end }} {{- end }} {{- with .Values.resources }} resources: {{- toYaml . | nindent 12 }} {{- end }} - {{- if and .Values.events.nats.enabled .Values.natsAuth.enabled }} + {{- if or (and .Values.events.nats.enabled .Values.natsAuth.enabled) (and .Values.tokenExchange.enabled .Values.tokenExchange.subjectTokenAudience) }} volumes: + {{- if and .Values.events.nats.enabled .Values.natsAuth.enabled }} # Pod-private in-memory volume the NKey seed is delivered on (never hits disk) - name: nats-nkey emptyDir: medium: Memory + {{- end }} + {{- if and .Values.tokenExchange.enabled .Values.tokenExchange.subjectTokenAudience }} + # ServiceAccount token minted for the token-exchange broker's audience, which it checks with a + # TokenReview. The kubelet rotates it in place, so certo re-reads the file on every exchange. + - name: token-exchange-sa-token + projected: + sources: + - serviceAccountToken: + path: token + audience: {{ .Values.tokenExchange.subjectTokenAudience | quote }} + expirationSeconds: 3600 + {{- end }} {{- end }} diff --git a/charts/certo/values.yaml b/charts/certo/values.yaml index 8cb1cb3..7ccdeb8 100644 --- a/charts/certo/values.yaml +++ b/charts/certo/values.yaml @@ -12,6 +12,19 @@ service: type: ClusterIP port: 8080 +# Remote JVM debugging (JDWP). When enabled the container starts the debug agent (via +# JAVA_TOOL_OPTIONS, honored by any JVM launcher) and exposes the port on the pod only — +# deliberately not on the Service. Attach with +# kubectl port-forward deploy/ 5005:5005 +# and an IDE "Remote JVM Debug" run configuration pointed at localhost:5005. +debug: + enabled: false + port: 5005 + # Suspend the JVM until a debugger attaches (to step through startup code). While suspended the + # app cannot answer health probes, so enabling this also disables liveness/readiness probes. + suspend: false + + # Gateway API: when `name` is set, the chart renders an HTTPRoute binding the service to # that Gateway; when empty, no HTTPRoute is created. gateway: @@ -33,6 +46,34 @@ springProfile: prod # layer; point it at a mock siglet for dev/test. sigletBaseUrl: "" +# Authentication for the calls certo makes TO siglet — both the inbound POST /tokens/verify and the +# outbound token lookup. OFF by default, leaving those calls unauthenticated. +# +# When enabled, each siglet call first runs an RFC 8693 token exchange at `url`, sending the pod's +# Kubernetes ServiceAccount JWT as subject_token and getting back a short-lived bearer for siglet. +# The exchange's `resource` is the participant context the token is for: the outbound lookup passes +# its own participantContextId, while /tokens/verify is not bound to a tenant and uses +# `verifyResource`. +# +# `url`, `scope`, `audience` and `verifyResource` are all required once enabled — certo fails at +# startup if one is missing. +tokenExchange: + enabled: false + # The broker's RFC 8693 token endpoint, e.g. http://jwtlet:8080/token + url: "" + # Space-separated scopes requested for the exchanged token. Must be a subset of what the broker's + # resource mapping grants this ServiceAccount, or the exchange is rejected. + scope: "" + # The `aud` requested for the exchanged token — the siglet it is presented to. + audience: "" + # The participant context used as `resource` for the tenant-independent /tokens/verify call. + verifyResource: "" + # Audience of the ServiceAccount token sent as subject_token. The broker verifies it with a + # TokenReview against its own configured client audience, so the default auto-mounted token + # (addressed to the API server) is normally rejected. Setting this mounts a projected token minted + # for the given audience and points certo at it; leaving it empty falls back to the default mount. + subjectTokenAudience: "" + # Certificate-exchange status changes (CX-0135 §2.1.3) published as CloudEvents on NATS JetStream, # on subjects events.certificate.exchange.* — the same shape and stream the platform's EDC runtimes # publish to, so consumers of `edc-events` see certo's events alongside the connector's. diff --git a/src/main/java/org/metaform/certo/common/security/SecurityProperties.java b/src/main/java/org/metaform/certo/common/security/SecurityProperties.java index 0528c42..6078b7d 100644 --- a/src/main/java/org/metaform/certo/common/security/SecurityProperties.java +++ b/src/main/java/org/metaform/certo/common/security/SecurityProperties.java @@ -1,6 +1,7 @@ package org.metaform.certo.common.security; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; /** * Configuration for the CCM protocol security-token layer. Security is always on and tokens always @@ -13,7 +14,34 @@ * DID), resolved to a participant context after verification. * * @param sigletBaseUrl base URL of the siglet STS ({@code /tokens/verify}, {@code /tokens/{pcid}/{flowId}}) + * @param tokenExchange how calls to that siglet authenticate themselves; off by default */ @ConfigurationProperties(prefix = "certo.security") -public record SecurityProperties(String sigletBaseUrl) { +public record SecurityProperties(String sigletBaseUrl, @DefaultValue TokenExchange tokenExchange) { + + /** + * RFC 8693 token exchange against the siglet's token broker. When {@code enabled}, every call this + * runtime makes to siglet first exchanges the pod's Kubernetes ServiceAccount token for a short-lived + * access token and sends it as the bearer; when disabled the siglet calls stay unauthenticated. + * + *

The exchange's {@code resource} names the participant context the token is requested for. Outbound + * calls are per-tenant so they pass their own {@code participantContextId}; the inbound verification call + * is not bound to any tenant, so it uses the configured {@code verifyResource}. + * + * @param enabled whether siglet calls authenticate at all + * @param url the broker's token endpoint (e.g. {@code https://jwtlet:8080/token}) + * @param scope space-separated scopes requested for the exchanged token + * @param audience the {@code aud} requested for the exchanged token (siglet) + * @param verifyResource the {@code resource} used by the tenant-independent {@code /tokens/verify} call + * @param subjectTokenPath file holding the Kubernetes ServiceAccount JWT sent as {@code subject_token}; + * re-read on every exchange because the kubelet rotates a projected token in place + */ + public record TokenExchange(@DefaultValue("false") boolean enabled, + String url, + String scope, + String audience, + String verifyResource, + @DefaultValue("/var/run/secrets/kubernetes.io/serviceaccount/token") + String subjectTokenPath) { + } } diff --git a/src/main/java/org/metaform/certo/common/security/exchange/TokenExchangeClient.java b/src/main/java/org/metaform/certo/common/security/exchange/TokenExchangeClient.java new file mode 100644 index 0000000..7ff0271 --- /dev/null +++ b/src/main/java/org/metaform/certo/common/security/exchange/TokenExchangeClient.java @@ -0,0 +1,136 @@ +package org.metaform.certo.common.security.exchange; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import okhttp3.FormBody; +import okhttp3.Request; +import org.metaform.certo.common.http.RetryingHttpClient; +import org.metaform.certo.common.security.SecurityProperties; +import org.metaform.certo.common.web.ApiException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +/** + * Mints the bearer this runtime sends to siglet, by exchanging the pod's Kubernetes ServiceAccount token at + * the broker's RFC 8693 endpoint ({@code POST /token}, form-encoded, grant type + * {@code urn:ietf:params:oauth:grant-type:token-exchange}). The {@code resource} names the participant + * context the token is requested for — the caller supplies it, because it differs per siglet call site. + * + *

Both siglet call sites share this one component. When + * {@code certo.security.token-exchange.enabled} is false every exchange returns {@link Optional#empty()} and + * the siglet calls go out unauthenticated, exactly as before the feature existed. + * + *

Nothing is cached: an exchange runs per siglet call, and the subject token is re-read from disk each + * time because the kubelet rotates a projected ServiceAccount token in place. + */ +@Component +public class TokenExchangeClient { + + private static final Logger LOG = LoggerFactory.getLogger(TokenExchangeClient.class); + + private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; + private static final String SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"; + + private final RetryingHttpClient http; + private final ObjectMapper mapper; + private final SecurityProperties.TokenExchange config; + + public TokenExchangeClient(SecurityProperties properties, RetryingHttpClient http, ObjectMapper mapper) { + this.http = http; + this.mapper = mapper; + this.config = properties.tokenExchange(); + if (config.enabled()) { + // Fail fast: a half-configured exchange would otherwise surface as a 502 on the first + // protocol call rather than at startup. + require(config.url(), "url"); + require(config.scope(), "scope"); + require(config.audience(), "audience"); + require(config.verifyResource(), "verify-resource"); + require(config.subjectTokenPath(), "subject-token-path"); + } + } + + private static void require(String value, String key) { + if (value == null || value.isBlank()) { + throw new IllegalStateException( + "certo.security.token-exchange." + key + " must be set when token exchange is enabled"); + } + } + + /** Whether siglet calls authenticate at all. */ + public boolean enabled() { + return config.enabled(); + } + + /** The {@code resource} the tenant-independent {@code /tokens/verify} call exchanges for. */ + public String verifyResource() { + return config.verifyResource(); + } + + /** + * Exchanges the subject token for an access token scoped to {@code resource}, or empty when token + * exchange is disabled. Throws {@link ApiException} with {@code 502} when the broker cannot be reached or + * rejects the exchange — an infrastructure failure, not a caller error. + */ + public Optional accessTokenFor(String resource) { + if (!config.enabled()) { + return Optional.empty(); + } + var form = new FormBody.Builder() + .add("grant_type", GRANT_TYPE) + .add("subject_token", subjectToken()) + .add("subject_token_type", SUBJECT_TOKEN_TYPE) + .add("resource", resource) + .add("scope", config.scope()) + .add("audience", config.audience()) + .build(); + var request = new Request.Builder().url(config.url()).post(form).build(); + try (var response = http.execute(request)) { + if (!response.isSuccessful()) { + throw new ApiException(HttpStatus.BAD_GATEWAY, + "Token exchange returned HTTP " + response.code() + " for resource " + resource); + } + var body = response.body() == null ? "" : response.body().string(); + var parsed = mapper.readValue(body, TokenExchangeResponse.class); + if (parsed.accessToken() == null || parsed.accessToken().isBlank()) { + throw new ApiException(HttpStatus.BAD_GATEWAY, + "Token exchange response for resource " + resource + " is missing an access_token"); + } + return Optional.of(parsed.accessToken()); + } catch (IOException e) { + // Log the connectivity detail (broker host/port) server-side only, as the siglet clients do. + LOG.warn("Could not reach the token-exchange broker: {}", e.getMessage()); + throw new ApiException(HttpStatus.BAD_GATEWAY, + "Could not obtain a token-exchange token for resource " + resource); + } + } + + /** Reads the ServiceAccount JWT afresh — a projected token is rotated in place by the kubelet. */ + private String subjectToken() { + try { + var token = Files.readString(Path.of(config.subjectTokenPath())).strip(); + if (token.isEmpty()) { + throw new ApiException(HttpStatus.BAD_GATEWAY, + "The token-exchange subject token is empty"); + } + return token; + } catch (IOException e) { + // The path is deployment topology; keep it out of the response. + LOG.warn("Could not read the token-exchange subject token from {}: {}", + config.subjectTokenPath(), e.getMessage()); + throw new ApiException(HttpStatus.BAD_GATEWAY, "Could not read the token-exchange subject token"); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record TokenExchangeResponse(@JsonProperty("access_token") String accessToken) { + } +} diff --git a/src/main/java/org/metaform/certo/common/security/inbound/SigletTokenVerifier.java b/src/main/java/org/metaform/certo/common/security/inbound/SigletTokenVerifier.java index a6b2760..76326d0 100644 --- a/src/main/java/org/metaform/certo/common/security/inbound/SigletTokenVerifier.java +++ b/src/main/java/org/metaform/certo/common/security/inbound/SigletTokenVerifier.java @@ -6,8 +6,10 @@ import okhttp3.MediaType; import okhttp3.Request; import okhttp3.RequestBody; +import org.metaform.certo.common.http.OutboundJsonClient; import org.metaform.certo.common.http.RetryingHttpClient; import org.metaform.certo.common.pc.store.ParticipantContextStore; +import org.metaform.certo.common.security.exchange.TokenExchangeClient; import org.metaform.certo.common.web.ApiException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,8 +32,13 @@ * *

The endpoint requires an {@code audience} in the request; it is read from the (unverified) token * locally — the local read only names the tenant DID to check against, while siglet remains the authority on - * the signature. This runtime does not send a caller JWT, matching {@link SigletTokenSource}'s assumption - * that siglet's token-API auth is disabled for this deployment. + * the signature. + * + *

When token exchange is enabled the verify call itself authenticates with a bearer from + * {@link TokenExchangeClient}. Unlike {@link SigletTokenSource}, verification is not bound to a participant + * context — the tenant is only known after siglet answers — so the RFC 8693 {@code resource} comes + * from configuration ({@code certo.security.token-exchange.verify-resource}) rather than from the call. With + * exchange disabled no caller JWT is sent, as before. */ @Component public class SigletTokenVerifier implements SecurityTokenVerifier { @@ -42,13 +49,15 @@ public class SigletTokenVerifier implements SecurityTokenVerifier { private final RetryingHttpClient http; private final ObjectMapper mapper; private final ParticipantContextStore contexts; + private final TokenExchangeClient exchange; private final String verifyUrl; public SigletTokenVerifier(SecurityProperties properties, ParticipantContextStore contexts, - RetryingHttpClient http, ObjectMapper mapper) { + RetryingHttpClient http, ObjectMapper mapper, TokenExchangeClient exchange) { this.contexts = contexts; this.http = http; this.mapper = mapper; + this.exchange = exchange; var base = properties.sigletBaseUrl(); if (base == null || base.isBlank()) { throw new IllegalStateException("certo.security.siglet-base-url must be set for the siglet backend"); @@ -97,8 +106,10 @@ private Map validateWithSiglet(String token, String audience) { throw new ApiException(HttpStatus.BAD_GATEWAY, "Could not serialize siglet verify request"); } var request = new Request.Builder().url(verifyUrl) - .post(RequestBody.create(requestBody, JSON)).build(); - try (var response = http.execute(request)) { + .post(RequestBody.create(requestBody, JSON)); + exchange.accessTokenFor(exchange.verifyResource()) + .ifPresent(bearer -> OutboundJsonClient.authorize(request, bearer)); + try (var response = http.execute(request.build())) { if (response.code() == HttpStatus.UNAUTHORIZED.value()) { throw ApiException.unauthorized("Invalid security token: rejected by siglet (expired, revoked, or bad signature)"); } diff --git a/src/main/java/org/metaform/certo/common/security/outbound/SigletTokenSource.java b/src/main/java/org/metaform/certo/common/security/outbound/SigletTokenSource.java index 1663a28..3521fcb 100644 --- a/src/main/java/org/metaform/certo/common/security/outbound/SigletTokenSource.java +++ b/src/main/java/org/metaform/certo/common/security/outbound/SigletTokenSource.java @@ -4,7 +4,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import okhttp3.Request; +import org.metaform.certo.common.http.OutboundJsonClient; import org.metaform.certo.common.http.RetryingHttpClient; +import org.metaform.certo.common.security.exchange.TokenExchangeClient; import org.metaform.certo.common.web.ApiException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; @@ -19,17 +21,25 @@ * {@code GET /tokens/{participant_context_id}/{flow_id}} returns {@code { token, endpoint }} — the bearer * JWT (minted by the counterparty's siglet, already scoped to the counterparty) and the counterparty URL to * call. Siglet is the only token backend. + * + *

When token exchange is enabled the call authenticates with a bearer obtained from + * {@link TokenExchangeClient}, exchanged for the calling participant context as the RFC 8693 + * {@code resource} — this lookup is per-tenant, so the token is too. With it disabled the call goes out + * unauthenticated. */ @Component public class SigletTokenSource implements SecurityTokenSource { private final RetryingHttpClient http; private final ObjectMapper mapper; + private final TokenExchangeClient exchange; private final String baseUrl; - public SigletTokenSource(RetryingHttpClient http, ObjectMapper mapper, SecurityProperties properties) { + public SigletTokenSource(RetryingHttpClient http, ObjectMapper mapper, SecurityProperties properties, + TokenExchangeClient exchange) { this.http = http; this.mapper = mapper; + this.exchange = exchange; var base = properties.sigletBaseUrl(); this.baseUrl = (base != null && base.endsWith("/")) ? base.substring(0, base.length() - 1) : base; } @@ -40,8 +50,10 @@ public ResolvedToken resolve(String participantContextId, String counterpartyDid requireText(flowId, "A secured outbound call requires a flowId"); // The cached token is already scoped to the counterparty, so counterpartyDid is not needed here. var url = baseUrl + "/tokens/" + participantContextId + "/" + flowId; - var request = new Request.Builder().url(url).get().build(); - try (var response = http.execute(request)) { + var request = new Request.Builder().url(url).get(); + exchange.accessTokenFor(participantContextId) + .ifPresent(token -> OutboundJsonClient.authorize(request, token)); + try (var response = http.execute(request.build())) { if (!response.isSuccessful()) { throw new ApiException(HttpStatus.BAD_GATEWAY, "Siglet returned HTTP " + response.code() + " for flow " + flowId); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8214574..3c569b5 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -40,6 +40,25 @@ certo: # GET /tokens/{participantContextId}/{flowId}. A siglet is required (point at a mock siglet for dev/test). security: siglet-base-url: http://localhost:8090 + # Authentication for the calls certo makes TO siglet (both /tokens/verify and the outbound token + # lookup). OFF by default, which keeps those calls unauthenticated exactly as before. When enabled, + # each siglet call first performs an RFC 8693 token exchange at `url`, sending the pod's Kubernetes + # ServiceAccount JWT as subject_token and receiving a short-lived bearer for siglet. Nothing is + # cached — one exchange per siglet call — and the subject token is re-read from disk each time + # because the kubelet rotates a projected ServiceAccount token in place. + # + # The exchange's `resource` names the participant context the token is for: outbound lookups are + # per-tenant and pass their own participantContextId, while /tokens/verify is not bound to a tenant + # (the tenant is only known after siglet answers) and so uses `verify-resource`. + # + # Enabling it requires url, scope, audience and verify-resource; a missing one fails at startup. + token-exchange: + enabled: ${CERTO_SECURITY_TOKENEXCHANGE_ENABLED:false} + url: ${CERTO_SECURITY_TOKENEXCHANGE_URL:} + scope: ${CERTO_SECURITY_TOKENEXCHANGE_SCOPE:} + audience: ${CERTO_SECURITY_TOKENEXCHANGE_AUDIENCE:} + verify-resource: ${CERTO_SECURITY_TOKENEXCHANGE_VERIFYRESOURCE:} + subject-token-path: ${CERTO_SECURITY_TOKENEXCHANGE_SUBJECTTOKENPATH:/var/run/secrets/kubernetes.io/serviceaccount/token} # Certificate-exchange status changes (CX-0135 §2.1.3) published as CloudEvents onto a NATS JetStream # stream, the way the platform's EDC runtimes publish theirs — subjects events.certificate.exchange.*, # so a consumer of the shared `edc-events` stream sees them alongside the connector's. OFF by default: diff --git a/src/test/java/org/metaform/certo/common/security/exchange/TokenExchangeClientTest.java b/src/test/java/org/metaform/certo/common/security/exchange/TokenExchangeClientTest.java new file mode 100644 index 0000000..38eb78a --- /dev/null +++ b/src/test/java/org/metaform/certo/common/security/exchange/TokenExchangeClientTest.java @@ -0,0 +1,168 @@ +package org.metaform.certo.common.security.exchange; + +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.metaform.certo.common.http.HttpClientProperties; +import org.metaform.certo.common.http.RetryingHttpClient; +import org.metaform.certo.common.security.SecurityProperties; +import org.metaform.certo.common.web.ApiException; +import org.springframework.http.HttpStatus; +import tools.jackson.databind.json.JsonMapper; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * {@link TokenExchangeClient}: the RFC 8693 exchange that authenticates certo's calls to siglet — off by + * default, and when on, form-encoded with the ServiceAccount token as {@code subject_token}. + */ +class TokenExchangeClientTest { + + private static final String SUBJECT_TOKEN = "k8s.sa.jwt"; + + @TempDir + Path tempDir; + + private MockWebServer broker; + private Path subjectTokenFile; + + @BeforeEach + void setUp() throws Exception { + broker = new MockWebServer(); + broker.start(); + subjectTokenFile = tempDir.resolve("token"); + Files.writeString(subjectTokenFile, SUBJECT_TOKEN + "\n"); + } + + @AfterEach + void tearDown() throws Exception { + broker.shutdown(); + } + + private TokenExchangeClient client(SecurityProperties.TokenExchange config) { + var http = new RetryingHttpClient(new OkHttpClient(), new HttpClientProperties(2, 2, 2, 5, 0, 1L, 5L)); + return new TokenExchangeClient(new SecurityProperties("http://siglet", config), http, + JsonMapper.builder().build()); + } + + private SecurityProperties.TokenExchange enabled() { + return new SecurityProperties.TokenExchange(true, broker.url("/token").toString(), + "siglet:read siglet:verify", "did:web:siglet", "verify-context", + subjectTokenFile.toString()); + } + + /** Decodes a form-encoded body by parsing it as a query string. */ + private static String field(String body, String name) { + return HttpUrl.parse("http://form/?" + body).queryParameter(name); + } + + /** The body may only be drained once, so every assertion reads from this snapshot. */ + private static String bodyOf(RecordedRequest request) { + return request.getBody().readUtf8(); + } + + @Test + void disabled_returnsEmpty_andCallsNoBroker() { + var client = client(new SecurityProperties.TokenExchange(false, null, null, null, null, null)); + + assertThat(client.enabled()).isFalse(); + assertThat(client.accessTokenFor("some-context")).isEmpty(); + assertThat(broker.getRequestCount()).isZero(); + } + + @Test + void enabled_sendsRfc8693Exchange_andReturnsTheAccessToken() throws Exception { + broker.enqueue(new MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(""" + {"access_token":"exchanged.jwt","issued_token_type":"urn:ietf:params:oauth:token-type:jwt", + "token_type":"Bearer","expires_in":3600}""")); + + var token = client(enabled()).accessTokenFor("provider-context"); + + assertThat(token).contains("exchanged.jwt"); + var request = broker.takeRequest(); + assertThat(request.getMethod()).isEqualTo("POST"); + assertThat(request.getPath()).isEqualTo("/token"); + assertThat(request.getHeader("Content-Type")).startsWith("application/x-www-form-urlencoded"); + var body = bodyOf(request); + assertThat(field(body, "grant_type")).isEqualTo("urn:ietf:params:oauth:grant-type:token-exchange"); + assertThat(field(body, "subject_token")).isEqualTo(SUBJECT_TOKEN); // trailing newline stripped + assertThat(field(body, "subject_token_type")).isEqualTo("urn:ietf:params:oauth:token-type:jwt"); + assertThat(field(body, "resource")).isEqualTo("provider-context"); + assertThat(field(body, "scope")).isEqualTo("siglet:read siglet:verify"); + assertThat(field(body, "audience")).isEqualTo("did:web:siglet"); + } + + @Test + void subjectToken_isReReadOnEveryExchange() throws Exception { + broker.enqueue(new MockResponse().setResponseCode(200).setBody("{\"access_token\":\"a\"}")); + broker.enqueue(new MockResponse().setResponseCode(200).setBody("{\"access_token\":\"b\"}")); + var client = client(enabled()); + + client.accessTokenFor("ctx"); + // The kubelet rotates a projected ServiceAccount token in place; nothing may be cached. + Files.writeString(subjectTokenFile, "rotated.sa.jwt"); + client.accessTokenFor("ctx"); + + broker.takeRequest(); + assertThat(field(bodyOf(broker.takeRequest()), "subject_token")).isEqualTo("rotated.sa.jwt"); + } + + @Test + void brokerRejection_isABadGateway_withoutLeakingTheBrokerHost() { + broker.enqueue(new MockResponse().setResponseCode(401)); + + assertThatThrownBy(() -> client(enabled()).accessTokenFor("ctx")) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.status()).isEqualTo(HttpStatus.BAD_GATEWAY)) + .hasMessageContaining("HTTP 401") + .hasMessageNotContaining(broker.getHostName() + ":" + broker.getPort()); + } + + @Test + void missingAccessToken_isABadGateway() { + broker.enqueue(new MockResponse().setResponseCode(200).setBody("{\"token_type\":\"Bearer\"}")); + + assertThatThrownBy(() -> client(enabled()).accessTokenFor("ctx")) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.status()).isEqualTo(HttpStatus.BAD_GATEWAY)) + .hasMessageContaining("missing an access_token"); + } + + @Test + void unreadableSubjectToken_isABadGateway_withoutLeakingThePath() { + var missing = tempDir.resolve("absent/token"); + var config = new SecurityProperties.TokenExchange(true, broker.url("/token").toString(), + "scope", "aud", "verify-context", missing.toString()); + + assertThatThrownBy(() -> client(config).accessTokenFor("ctx")) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.status()).isEqualTo(HttpStatus.BAD_GATEWAY)) + .hasMessageNotContaining(missing.toString()); + assertThat(broker.getRequestCount()).isZero(); + } + + @Test + void enabledWithoutRequiredConfig_failsFast() { + assertThatThrownBy(() -> client(new SecurityProperties.TokenExchange(true, null, "scope", "aud", + "verify-context", "/tmp/token"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("certo.security.token-exchange.url"); + + assertThatThrownBy(() -> client(new SecurityProperties.TokenExchange(true, "http://broker/token", + "scope", "aud", " ", "/tmp/token"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("certo.security.token-exchange.verify-resource"); + } +} diff --git a/src/test/java/org/metaform/certo/common/security/inbound/SigletTokenVerifierTest.java b/src/test/java/org/metaform/certo/common/security/inbound/SigletTokenVerifierTest.java new file mode 100644 index 0000000..8a74f50 --- /dev/null +++ b/src/test/java/org/metaform/certo/common/security/inbound/SigletTokenVerifierTest.java @@ -0,0 +1,158 @@ +package org.metaform.certo.common.security.inbound; + +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.metaform.certo.common.http.HttpClientProperties; +import org.metaform.certo.common.http.RetryingHttpClient; +import org.metaform.certo.common.pc.domain.ParticipantContext; +import org.metaform.certo.common.pc.store.ParticipantContextStore; +import org.metaform.certo.common.security.SecurityProperties; +import org.metaform.certo.common.security.exchange.TokenExchangeClient; +import org.metaform.certo.testsupport.MockSiglet; +import tools.jackson.databind.json.JsonMapper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link SigletTokenVerifier} over real HTTP: the verify call, and the token exchange that authenticates it + * — whose {@code resource} is configured, because verification is not bound to a participant + * context (the tenant is only known once siglet has answered). + */ +class SigletTokenVerifierTest { + + private static final String PROVIDER_DID = "did:web:provider"; + private static final String CONSUMER_DID = "did:web:consumer"; + private static final String CONSUMER_BPN = "BPNL0000000002CD"; + + @TempDir + Path tempDir; + + private MockWebServer siglet; + private MockWebServer broker; + private RetryingHttpClient http; + private InMemoryContexts contexts; + private String token; + + @BeforeEach + void setUp() throws Exception { + siglet = new MockWebServer(); + siglet.start(); + broker = new MockWebServer(); + broker.start(); + http = new RetryingHttpClient(new OkHttpClient(), new HttpClientProperties(2, 2, 2, 5, 0, 1L, 5L)); + contexts = new InMemoryContexts(); + contexts.save(new ParticipantContext("provider-context", "BPNL0000000001AB", + "urn:bpn:BPNL0000000001AB", PROVIDER_DID)); + // Only the local `aud` read parses this token; siglet (here, the mock server) is the signature authority. + token = new MockSiglet(contexts, "http://counterparty").mint(PROVIDER_DID, CONSUMER_DID, CONSUMER_BPN); + } + + @AfterEach + void tearDown() throws Exception { + siglet.shutdown(); + broker.shutdown(); + } + + private SigletTokenVerifier verifier(SecurityProperties.TokenExchange exchange) { + var mapper = JsonMapper.builder().build(); + var properties = new SecurityProperties(siglet.url("/").toString(), exchange); + return new SigletTokenVerifier(properties, contexts, http, mapper, + new TokenExchangeClient(properties, http, mapper)); + } + + private void enqueueVerifiedClaims() { + siglet.enqueue(new MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"aud\":\"" + PROVIDER_DID + "\",\"sub\":\"" + CONSUMER_DID + + "\",\"bpn\":\"" + CONSUMER_BPN + "\"}")); + } + + @Test + void exchangeDisabled_callsSigletUnauthenticated() throws Exception { + enqueueVerifiedClaims(); + + var verified = verifier(new SecurityProperties.TokenExchange(false, null, null, null, null, null)) + .verify(token); + + assertThat(verified.participantContextId()).isEqualTo("provider-context"); + assertThat(verified.subject()).isEqualTo(CONSUMER_DID); + var request = siglet.takeRequest(); + assertThat(request.getPath()).isEqualTo("/tokens/verify"); + assertThat(request.getHeader("Authorization")).isNull(); + assertThat(broker.getRequestCount()).isZero(); + } + + @Test + void exchangeEnabled_exchangesForTheConfiguredResource_thenAuthenticatesTheVerifyCall() throws Exception { + var subjectToken = tempDir.resolve("token"); + Files.writeString(subjectToken, "k8s.sa.jwt"); + broker.enqueue(new MockResponse().setResponseCode(200).setBody("{\"access_token\":\"exchanged.jwt\"}")); + enqueueVerifiedClaims(); + + var exchange = new SecurityProperties.TokenExchange(true, broker.url("/token").toString(), + "siglet:verify", "did:web:siglet", "verify-context", subjectToken.toString()); + var verified = verifier(exchange).verify(token); + + assertThat(verified.participantContextId()).isEqualTo("provider-context"); + assertThat(verified.bpn()).isEqualTo(CONSUMER_BPN); + // Not a participant context id: verification has no tenant until siglet answers. + var exchangeBody = broker.takeRequest().getBody().readUtf8(); + assertThat(HttpUrl.parse("http://form/?" + exchangeBody).queryParameter("resource")) + .isEqualTo("verify-context"); + assertThat(siglet.takeRequest().getHeader("Authorization")).isEqualTo("Bearer exchanged.jwt"); + } + + /** Minimal store stand-in — Mockito is excluded from this build. */ + private static final class InMemoryContexts implements ParticipantContextStore { + + private final Map byId = new LinkedHashMap<>(); + + @Override + public void save(ParticipantContext context) { + byId.put(context.participantContextId(), context); + } + + @Override + public Optional find(String participantContextId) { + return Optional.ofNullable(byId.get(participantContextId)); + } + + @Override + public boolean exists(String participantContextId) { + return byId.containsKey(participantContextId); + } + + @Override + public Optional findByDid(String did) { + return byId.values().stream().filter(c -> c.did().equals(did)).findFirst(); + } + + @Override + public boolean existsByDid(String did) { + return findByDid(did).isPresent(); + } + + @Override + public Collection all() { + return byId.values(); + } + + @Override + public void delete(String participantContextId) { + byId.remove(participantContextId); + } + } +} diff --git a/src/test/java/org/metaform/certo/common/security/outbound/SigletTokenSourceTest.java b/src/test/java/org/metaform/certo/common/security/outbound/SigletTokenSourceTest.java new file mode 100644 index 0000000..c57af11 --- /dev/null +++ b/src/test/java/org/metaform/certo/common/security/outbound/SigletTokenSourceTest.java @@ -0,0 +1,91 @@ +package org.metaform.certo.common.security.outbound; + +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.metaform.certo.common.http.HttpClientProperties; +import org.metaform.certo.common.http.RetryingHttpClient; +import org.metaform.certo.common.security.SecurityProperties; +import org.metaform.certo.common.security.exchange.TokenExchangeClient; +import tools.jackson.databind.json.JsonMapper; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link SigletTokenSource} over real HTTP: the outbound token lookup, and the token exchange that + * authenticates it — whose {@code resource} is the calling participant context. + */ +class SigletTokenSourceTest { + + @TempDir + Path tempDir; + + private MockWebServer siglet; + private MockWebServer broker; + private RetryingHttpClient http; + + @BeforeEach + void setUp() throws Exception { + siglet = new MockWebServer(); + siglet.start(); + broker = new MockWebServer(); + broker.start(); + http = new RetryingHttpClient(new OkHttpClient(), new HttpClientProperties(2, 2, 2, 5, 0, 1L, 5L)); + } + + @AfterEach + void tearDown() throws Exception { + siglet.shutdown(); + broker.shutdown(); + } + + private SigletTokenSource source(SecurityProperties.TokenExchange exchange) { + var mapper = JsonMapper.builder().build(); + var properties = new SecurityProperties(siglet.url("/").toString(), exchange); + return new SigletTokenSource(http, mapper, properties, + new TokenExchangeClient(properties, http, mapper)); + } + + @Test + void exchangeDisabled_callsSigletUnauthenticated() throws Exception { + siglet.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"token\":\"siglet.jwt\",\"endpoint\":\"http://counterparty\"}")); + + var resolved = source(new SecurityProperties.TokenExchange(false, null, null, null, null, null)) + .resolve("provider-context", "did:web:consumer", "flow-1"); + + assertThat(resolved).isEqualTo(new ResolvedToken("siglet.jwt", "http://counterparty")); + var request = siglet.takeRequest(); + assertThat(request.getPath()).isEqualTo("/tokens/provider-context/flow-1"); + assertThat(request.getHeader("Authorization")).isNull(); + assertThat(broker.getRequestCount()).isZero(); + } + + @Test + void exchangeEnabled_exchangesForTheCallersContext_thenAuthenticatesTheLookup() throws Exception { + var subjectToken = tempDir.resolve("token"); + Files.writeString(subjectToken, "k8s.sa.jwt"); + broker.enqueue(new MockResponse().setResponseCode(200).setBody("{\"access_token\":\"exchanged.jwt\"}")); + siglet.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"token\":\"siglet.jwt\",\"endpoint\":\"http://counterparty\"}")); + + var exchange = new SecurityProperties.TokenExchange(true, broker.url("/token").toString(), + "siglet:read", "did:web:siglet", "verify-context", subjectToken.toString()); + var resolved = source(exchange).resolve("provider-context", "did:web:consumer", "flow-1"); + + assertThat(resolved.bearerToken()).isEqualTo("siglet.jwt"); + // The outbound lookup is per-tenant, so the exchange is scoped to that tenant. + var exchangeBody = broker.takeRequest().getBody().readUtf8(); + assertThat(HttpUrl.parse("http://form/?" + exchangeBody).queryParameter("resource")) + .isEqualTo("provider-context"); + assertThat(siglet.takeRequest().getHeader("Authorization")).isEqualTo("Bearer exchanged.jwt"); + } +}