From 89f38b020c6c98626fefa013019dd0d634c2d58d Mon Sep 17 00:00:00 2001 From: Yaroslav Boiko Date: Fri, 4 Sep 2026 11:58:35 +0200 Subject: [PATCH 1/2] [OPIK-8252] [BE] feat: Report expires_at from MCP OAuth token introspection opik-mcp, the resource server for the hosted MCP connector, now validates every OAuth access token against POST /opik/auth-oauth and caches a "valid" answer for a short TTL. Without knowing when the token actually expires it can only guess, and a token that dies inside that window is still forwarded once. ValidatedToken now carries the token row's expiresAt (ISO-8601 on the wire as expires_at), so the resource server caches until the real expiry and that window disappears. Purely additive: resource servers that predate the field ignore it. The OpenAPI spec and generated SDKs pick the field up through the usual automated update. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LwQuKKW95kFTeHAW3jVyTj --- .../opik/domain/mcpoauth/McpOAuthService.java | 1 + .../opik/domain/mcpoauth/ValidatedToken.java | 11 +++++++- .../oauth/OAuthValidateTokenResourceTest.java | 25 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/opik-backend/src/main/java/com/comet/opik/domain/mcpoauth/McpOAuthService.java b/apps/opik-backend/src/main/java/com/comet/opik/domain/mcpoauth/McpOAuthService.java index 421d0eaaa1c..79c9d63d986 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/domain/mcpoauth/McpOAuthService.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/domain/mcpoauth/McpOAuthService.java @@ -183,6 +183,7 @@ public Optional validateAccessToken(@NonNull String token) { .workspaceId(row.workspaceId()) .workspaceName(row.workspaceName()) .resource(row.resource()) + .expiresAt(row.expiresAt()) .build()); }); } diff --git a/apps/opik-backend/src/main/java/com/comet/opik/domain/mcpoauth/ValidatedToken.java b/apps/opik-backend/src/main/java/com/comet/opik/domain/mcpoauth/ValidatedToken.java index 18331e935e4..b58e18e15b9 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/domain/mcpoauth/ValidatedToken.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/domain/mcpoauth/ValidatedToken.java @@ -5,6 +5,14 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming; import lombok.Builder; +import java.time.Instant; + +/** + * What the introspection endpoint ({@code POST /opik/auth-oauth}) tells a resource server about a live access + * token. {@code expiresAt} is the token row's expiry: opik-mcp caches a "valid" answer until then instead of + * re-asking on a fixed timer, which closes the window in which an expired token is still forwarded (OPIK-8252). + * Additive — resource servers that predate the field ignore it. + */ @Builder(toBuilder = true) @JsonInclude(JsonInclude.Include.NON_NULL) @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @@ -12,5 +20,6 @@ public record ValidatedToken( String userName, String workspaceId, String workspaceName, - String resource) { + String resource, + Instant expiresAt) { } diff --git a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/oauth/OAuthValidateTokenResourceTest.java b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/oauth/OAuthValidateTokenResourceTest.java index 032c7ed1080..52c53b09b93 100644 --- a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/oauth/OAuthValidateTokenResourceTest.java +++ b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/oauth/OAuthValidateTokenResourceTest.java @@ -18,6 +18,9 @@ import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Optional; import java.util.UUID; @@ -94,6 +97,7 @@ void acceptsValidToken(String schemePrefix) { .workspaceId(UUID.randomUUID().toString()) .workspaceName(RandomStringUtils.secure().nextAlphanumeric(10)) .resource("http://localhost/api/v1/mcp/%s".formatted(RandomStringUtils.secure().nextAlphanumeric(8))) + .expiresAt(Instant.now().plus(Duration.ofHours(1)).truncatedTo(ChronoUnit.MILLIS)) .build(); when(mcpOAuthService.validateAccessToken(ACCESS_TOKEN)).thenReturn(Optional.of(validated)); @@ -102,4 +106,25 @@ void acceptsValidToken(String schemePrefix) { assertThat(response.readEntity(ValidatedToken.class)).isEqualTo(validated); } } + + @Test + @DisplayName("reports the token expiry as an ISO-8601 expires_at so resource servers can cache until then") + void reportsExpiresAtOnTheWire() { + // opik-mcp caches a "valid" answer until this instant (OPIK-8252); an + // epoch number or a camelCase key would silently fall back to its TTL. + var expiresAt = Instant.parse("2026-09-04T10:15:30.123Z"); + var validated = ValidatedToken.builder() + .userName(RandomStringUtils.secure().nextAlphanumeric(10)) + .workspaceId(UUID.randomUUID().toString()) + .workspaceName(RandomStringUtils.secure().nextAlphanumeric(10)) + .resource("http://localhost/api/v1/mcp") + .expiresAt(expiresAt) + .build(); + when(mcpOAuthService.validateAccessToken(ACCESS_TOKEN)).thenReturn(Optional.of(validated)); + + try (Response response = validate("Bearer " + ACCESS_TOKEN)) { + assertThat(response.getStatus()).isEqualTo(Response.Status.OK.getStatusCode()); + assertThat(response.readEntity(String.class)).contains("\"expires_at\":\"2026-09-04T10:15:30.123Z\""); + } + } } From 5759852f9c001b13c1940ad39e6f9c9e1f0bd753 Mon Sep 17 00:00:00 2001 From: Yaroslav Boiko Date: Fri, 4 Sep 2026 15:43:41 +0200 Subject: [PATCH 2/2] test(mcpoauth): cover expires_at end to end and add it to the OpenAPI schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review on the previous commit. The mocked resource test only proved the wire format of a hand-built DTO; OAuthValidateTokenIntegrationTest now drives the real register → consent → code-exchange flow against MySQL and asserts that POST /opik/auth-oauth reports exactly the persisted row's expiry (plus resource and workspace). ValidatedToken fixtures in the resource test come from Podam like the rest of the suite. The checked-in OpenAPI schemas gain expires_at (string, date-time) so generated clients stop lagging the backend. Co-Authored-By: Claude Fable 5.1 --- .../oauth/OAuthValidateTokenResourceTest.java | 22 +--- .../OAuthValidateTokenIntegrationTest.java | 120 ++++++++++++++++++ .../documentation/fern/openapi/opik.yaml | 3 + .../code_generation/fern/openapi/openapi.yaml | 3 + 4 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 apps/opik-backend/src/test/java/com/comet/opik/domain/mcpoauth/OAuthValidateTokenIntegrationTest.java diff --git a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/oauth/OAuthValidateTokenResourceTest.java b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/oauth/OAuthValidateTokenResourceTest.java index 52c53b09b93..ca122f20c5c 100644 --- a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/oauth/OAuthValidateTokenResourceTest.java +++ b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/oauth/OAuthValidateTokenResourceTest.java @@ -3,6 +3,7 @@ import com.comet.opik.domain.mcpoauth.McpOAuthService; import com.comet.opik.domain.mcpoauth.McpOAuthTokenUtils; import com.comet.opik.domain.mcpoauth.ValidatedToken; +import com.comet.opik.podam.PodamFactoryUtils; import com.comet.opik.utils.JsonUtils; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.ResourceExtension; @@ -17,12 +18,11 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; +import uk.co.jemos.podam.api.PodamFactory; import java.time.Duration; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.Optional; -import java.util.UUID; import static com.comet.opik.domain.mcpoauth.OAuthConstants.OAUTH_VALIDATE_TOKEN_RESOURCE_BASE_PATH; import static org.assertj.core.api.Assertions.assertThat; @@ -41,6 +41,7 @@ class OAuthValidateTokenResourceTest { + RandomStringUtils.secure().nextAlphanumeric(24); private static final McpOAuthService mcpOAuthService = mock(McpOAuthService.class); + private final PodamFactory factory = PodamFactoryUtils.newPodamFactory(); private static final ResourceExtension EXT = ResourceExtension.builder() .setMapper(JsonUtils.getMapper()) @@ -92,12 +93,8 @@ void rejectsUnvalidatedAccessToken() { @ValueSource(strings = {"Bearer ", "bearer ", "BEARER "}) @DisplayName("accepts a valid token and returns the validated identity, case-insensitively on the scheme") void acceptsValidToken(String schemePrefix) { - var validated = ValidatedToken.builder() - .userName(RandomStringUtils.secure().nextAlphanumeric(10)) - .workspaceId(UUID.randomUUID().toString()) - .workspaceName(RandomStringUtils.secure().nextAlphanumeric(10)) - .resource("http://localhost/api/v1/mcp/%s".formatted(RandomStringUtils.secure().nextAlphanumeric(8))) - .expiresAt(Instant.now().plus(Duration.ofHours(1)).truncatedTo(ChronoUnit.MILLIS)) + var validated = factory.manufacturePojo(ValidatedToken.class).toBuilder() + .expiresAt(Instant.now().plus(Duration.ofHours(1))) .build(); when(mcpOAuthService.validateAccessToken(ACCESS_TOKEN)).thenReturn(Optional.of(validated)); @@ -112,13 +109,8 @@ void acceptsValidToken(String schemePrefix) { void reportsExpiresAtOnTheWire() { // opik-mcp caches a "valid" answer until this instant (OPIK-8252); an // epoch number or a camelCase key would silently fall back to its TTL. - var expiresAt = Instant.parse("2026-09-04T10:15:30.123Z"); - var validated = ValidatedToken.builder() - .userName(RandomStringUtils.secure().nextAlphanumeric(10)) - .workspaceId(UUID.randomUUID().toString()) - .workspaceName(RandomStringUtils.secure().nextAlphanumeric(10)) - .resource("http://localhost/api/v1/mcp") - .expiresAt(expiresAt) + var validated = factory.manufacturePojo(ValidatedToken.class).toBuilder() + .expiresAt(Instant.parse("2026-09-04T10:15:30.123Z")) .build(); when(mcpOAuthService.validateAccessToken(ACCESS_TOKEN)).thenReturn(Optional.of(validated)); diff --git a/apps/opik-backend/src/test/java/com/comet/opik/domain/mcpoauth/OAuthValidateTokenIntegrationTest.java b/apps/opik-backend/src/test/java/com/comet/opik/domain/mcpoauth/OAuthValidateTokenIntegrationTest.java new file mode 100644 index 00000000000..979d7846f40 --- /dev/null +++ b/apps/opik-backend/src/test/java/com/comet/opik/domain/mcpoauth/OAuthValidateTokenIntegrationTest.java @@ -0,0 +1,120 @@ +package com.comet.opik.domain.mcpoauth; + +import com.comet.opik.api.resources.utils.ClickHouseContainerUtils; +import com.comet.opik.api.resources.utils.MigrationUtils; +import com.comet.opik.api.resources.utils.MySQLContainerUtils; +import com.comet.opik.api.resources.utils.RedisContainerUtils; +import com.comet.opik.api.resources.utils.TestDropwizardAppExtensionUtils; +import com.comet.opik.api.resources.utils.TestDropwizardAppExtensionUtils.AppContextConfig; +import com.comet.opik.api.resources.utils.TestDropwizardAppExtensionUtils.CustomConfig; +import com.comet.opik.api.resources.utils.TestUtils; +import com.comet.opik.api.resources.utils.resources.OAuthResourceClient; +import com.comet.opik.extensions.DropwizardAppExtensionProvider; +import com.comet.opik.extensions.RegisterApp; +import com.redis.testcontainers.RedisContainer; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.clickhouse.ClickHouseContainer; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.lifecycle.Startables; +import org.testcontainers.mysql.MySQLContainer; +import ru.vyarus.dropwizard.guice.test.ClientSupport; +import ru.vyarus.dropwizard.guice.test.jupiter.ext.TestDropwizardAppExtension; +import ru.vyarus.guicey.jdbi3.tx.TransactionTemplate; + +import java.time.Instant; +import java.util.List; + +import static com.comet.opik.api.resources.utils.ClickHouseContainerUtils.DATABASE_NAME; +import static com.comet.opik.domain.mcpoauth.OAuthConstants.OAUTH_VALIDATE_TOKEN_RESOURCE_BASE_PATH; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Drives the real OAuth flow (register → consent → code exchange) against the app and MySQL, then introspects the + * minted access token through {@code POST /opik/auth-oauth}. The mocked resource test proves the wire format of a + * hand-built DTO; this one proves the value on the wire is the persisted row's expiry, which is what a resource + * server (opik-mcp, OPIK-8252) caches against. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@ExtendWith(DropwizardAppExtensionProvider.class) +@DisplayName("OAuth Validate Token Integration Test") +class OAuthValidateTokenIntegrationTest { + + private static final String REDIRECT_URI = "http://localhost:1234/callback"; + private static final String RESOURCE_URI = "http://localhost:8080/api/v1/mcp"; + + private final RedisContainer REDIS = RedisContainerUtils.newRedisContainer(); + private final GenericContainer ZOOKEEPER = ClickHouseContainerUtils.newZookeeperContainer(); + private final ClickHouseContainer CLICKHOUSE = ClickHouseContainerUtils.newClickHouseContainer(ZOOKEEPER); + private final MySQLContainer MYSQL = MySQLContainerUtils.newMySQLContainer(); + + @RegisterApp + private final TestDropwizardAppExtension app; + + { + Startables.deepStart(REDIS, CLICKHOUSE, MYSQL, ZOOKEEPER).join(); + + var databaseAnalyticsFactory = ClickHouseContainerUtils.newDatabaseAnalyticsFactory(CLICKHOUSE, DATABASE_NAME); + + MigrationUtils.runMysqlDbMigration(MYSQL); + MigrationUtils.runClickhouseDbMigration(CLICKHOUSE); + + // Local auth, so consent resolves to the default workspace without a session. A one-hour access token + // keeps the minted token alive for the whole test. + app = TestDropwizardAppExtensionUtils.newTestDropwizardAppExtension( + AppContextConfig.builder() + .jdbcUrl(MYSQL.getJdbcUrl()) + .databaseAnalyticsFactory(databaseAnalyticsFactory) + .redisUrl(REDIS.getRedisURI()) + .customConfigs(List.of( + new CustomConfig("mcpOAuth.enabled", "true"), + new CustomConfig("mcpOAuth.baseUrl", "http://localhost:8080"), + new CustomConfig("mcpOAuth.mcpResourceUri", RESOURCE_URI), + new CustomConfig("mcpOAuth.accessTokenTtl", "PT1H"))) + .build()); + } + + private String baseURI; + private ClientSupport client; + private TransactionTemplate transactionTemplate; + private OAuthResourceClient oauthClient; + + @BeforeAll + void setUpAll(ClientSupport clientSupport, TransactionTemplate transactionTemplate) { + this.client = clientSupport; + this.baseURI = TestUtils.getBaseUrl(clientSupport); + this.transactionTemplate = transactionTemplate; + this.oauthClient = new OAuthResourceClient(clientSupport, baseURI, REDIRECT_URI, RESOURCE_URI); + } + + @Test + @DisplayName("introspection reports the persisted expiry of a token minted through the OAuth endpoints") + void introspectionReportsPersistedExpiry() { + var minted = oauthClient.mintArtifacts(); + String accessToken = minted.tokens().accessToken(); + Instant persistedExpiry = fetchTokenExpiry(McpOAuthTokenUtils.hash(accessToken)); + assertThat(persistedExpiry).isNotNull(); + + try (Response response = client.target(baseURI + OAUTH_VALIDATE_TOKEN_RESOURCE_BASE_PATH).request() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .post(Entity.json(""))) { + assertThat(response.getStatus()).isEqualTo(Response.Status.OK.getStatusCode()); + var validated = response.readEntity(ValidatedToken.class); + assertThat(validated.expiresAt()).isEqualTo(persistedExpiry); + assertThat(validated.resource()).isEqualTo(RESOURCE_URI); + assertThat(validated.workspaceName()).isEqualTo(minted.tokens().workspaceName()); + } + } + + private Instant fetchTokenExpiry(String tokenHash) { + return transactionTemplate.inTransaction( + handle -> handle.attach(McpOAuthTokenDAO.class).fetch(tokenHash).map(McpOAuthToken::expiresAt) + .orElse(null)); + } +} diff --git a/apps/opik-documentation/documentation/fern/openapi/opik.yaml b/apps/opik-documentation/documentation/fern/openapi/opik.yaml index b0131a46b66..84d189ae676 100644 --- a/apps/opik-documentation/documentation/fern/openapi/opik.yaml +++ b/apps/opik-documentation/documentation/fern/openapi/opik.yaml @@ -9729,6 +9729,9 @@ components: type: string resource: type: string + expires_at: + type: string + format: date-time AnalyticsQueryResponse: type: object properties: diff --git a/sdks/code_generation/fern/openapi/openapi.yaml b/sdks/code_generation/fern/openapi/openapi.yaml index b0131a46b66..84d189ae676 100644 --- a/sdks/code_generation/fern/openapi/openapi.yaml +++ b/sdks/code_generation/fern/openapi/openapi.yaml @@ -9729,6 +9729,9 @@ components: type: string resource: type: string + expires_at: + type: string + format: date-time AnalyticsQueryResponse: type: object properties: