From a35e4306e39101ed62feb6ce4cc0295a0369948b Mon Sep 17 00:00:00 2001 From: Geoffrey Claro Date: Tue, 21 Apr 2026 10:44:15 -0400 Subject: [PATCH 1/2] Fix attributes() calling /userinfo instead of /attributes.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attributes() method was incorrectly calling APIEndpoint.userInfo(), which maps to /api/public/v3/userinfo. That endpoint returns OIDC standard claims with no status block, so callers (e.g. CVS) received empty verification statuses and could not filter on subgroup. Fix: add APIEndpoint.attributes() → /api/public/v3/attributes.json and wire attributes() in IDmeAuth to use it. Co-Authored-By: Claude Sonnet 4.6 --- sdk/src/main/kotlin/com/idme/auth/IDmeAuth.kt | 2 +- sdk/src/main/kotlin/com/idme/auth/networking/APIEndpoint.kt | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/src/main/kotlin/com/idme/auth/IDmeAuth.kt b/sdk/src/main/kotlin/com/idme/auth/IDmeAuth.kt index c3f0bb5..4a0fe34 100644 --- a/sdk/src/main/kotlin/com/idme/auth/IDmeAuth.kt +++ b/sdk/src/main/kotlin/com/idme/auth/IDmeAuth.kt @@ -267,7 +267,7 @@ class IDmeAuth( */ suspend fun attributes(): AttributeResponse { val creds = tokenManager.validCredentials(60) - val url = APIEndpoint.userInfo(configuration.environment) + val url = APIEndpoint.attributes(configuration.environment) val headers = mapOf("Authorization" to "Bearer ${creds.accessToken}") diff --git a/sdk/src/main/kotlin/com/idme/auth/networking/APIEndpoint.kt b/sdk/src/main/kotlin/com/idme/auth/networking/APIEndpoint.kt index cbae213..9fab1c9 100644 --- a/sdk/src/main/kotlin/com/idme/auth/networking/APIEndpoint.kt +++ b/sdk/src/main/kotlin/com/idme/auth/networking/APIEndpoint.kt @@ -21,6 +21,10 @@ object APIEndpoint { fun userInfo(environment: IDmeEnvironment): String = "${environment.apiBaseURL}api/public/v3/userinfo" + /** Attributes endpoint (OAuth mode). */ + fun attributes(environment: IDmeEnvironment): String = + "${environment.apiBaseURL}api/public/v3/attributes.json" + /** Policies endpoint. */ fun policies(environment: IDmeEnvironment): String = "${environment.apiBaseURL}api/public/v3/policies" From 7d427820018e725203e16fba56f101dc7d5ffa3c Mon Sep 17 00:00:00 2001 From: Geoffrey Claro Date: Tue, 21 Apr 2026 11:02:08 -0400 Subject: [PATCH 2/2] Add unit tests for attributes() endpoint routing Verifies that IDmeAuth.attributes() calls /api/public/v3/attributes.json (not /userinfo), returns a populated status block, and deserializes the attributes list correctly. Co-Authored-By: Claude Sonnet 4.6 --- .../com/idme/auth/IDmeAuthAttributesTest.kt | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 sdk/src/test/kotlin/com/idme/auth/IDmeAuthAttributesTest.kt diff --git a/sdk/src/test/kotlin/com/idme/auth/IDmeAuthAttributesTest.kt b/sdk/src/test/kotlin/com/idme/auth/IDmeAuthAttributesTest.kt new file mode 100644 index 0000000..8a0c795 --- /dev/null +++ b/sdk/src/test/kotlin/com/idme/auth/IDmeAuthAttributesTest.kt @@ -0,0 +1,78 @@ +package com.idme.auth + +import com.idme.auth.mocks.MockCredentialStore +import com.idme.auth.mocks.MockHTTPClient +import com.idme.auth.mocks.MockJWKSFetcher +import com.idme.auth.mocks.MockTokenRefresher +import com.idme.auth.mocks.TestFixtures +import com.idme.auth.token.TokenManager +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class IDmeAuthAttributesTest { + + private val attributesJson = """ + { + "attributes": [ + {"handle": "email", "name": "Email", "value": "test@example.com"}, + {"handle": "fname", "name": "First Name", "value": "John"} + ], + "status": [ + {"group": "military", "subgroups": ["Veteran"], "verified": true} + ] + } + """.trimIndent() + + private fun buildAuth(httpClient: MockHTTPClient): IDmeAuth { + val store = MockCredentialStore() + store.save(TestFixtures.makeCredentials(expiresInMs = 3_600_000)) + return IDmeAuth( + configuration = TestFixtures.singleConfig, + tokenManager = TokenManager(store, MockTokenRefresher()), + httpClient = httpClient, + jwksFetcher = MockJWKSFetcher() + ) + } + + @Test + fun `attributes calls attributes endpoint not userinfo`() = runTest { + val httpClient = MockHTTPClient() + httpClient.enqueue(body = attributesJson, statusCode = 200) + + buildAuth(httpClient).attributes() + + val calledURL = httpClient.capturedRequests.single().url + assertTrue( + "Expected /api/public/v3/attributes.json but got: $calledURL", + calledURL.endsWith("api/public/v3/attributes.json") + ) + } + + @Test + fun `attributes returns populated status block`() = runTest { + val httpClient = MockHTTPClient() + httpClient.enqueue(body = attributesJson, statusCode = 200) + + val result = buildAuth(httpClient).attributes() + + assertEquals(1, result.status.size) + val status = result.status[0] + assertEquals("military", status.group) + assertTrue(status.verified) + assertEquals(listOf("Veteran"), status.subgroups) + } + + @Test + fun `attributes returns populated attributes list`() = runTest { + val httpClient = MockHTTPClient() + httpClient.enqueue(body = attributesJson, statusCode = 200) + + val result = buildAuth(httpClient).attributes() + + assertEquals(2, result.attributes.size) + val email = result.attributes.first { it.handle == "email" } + assertEquals("test@example.com", email.value) + } +}