From c99c2f2dc8e4659a5a6036a41543e7016d038f81 Mon Sep 17 00:00:00 2001 From: John Melati Date: Sun, 26 Jan 2025 18:08:42 +0100 Subject: [PATCH 1/3] feat: implement trust mark validation --- build.gradle.kts | 2 +- .../oid/fed/client/FederationClient.kt | 39 ++-- .../fed/client/context/FederationContext.kt | 92 +++++++++ .../EntityConfigurationStatementService.kt | 177 ++++++++++-------- .../trustChainService/TrustChainService.kt | 47 +++-- .../trustMarkService/TrustMarkService.kt | 164 ++++++++++++++++ .../trustMarkService/TrustMarkServiceConst.kt | 8 + .../types/TrustMarkValidationResponse.kt | 12 ++ ...EntityConfigurationStatementServiceTest.kt | 20 +- .../oid/fed/client/FederationClient.js.kt | 13 +- 10 files changed, 453 insertions(+), 121 deletions(-) create mode 100644 modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/context/FederationContext.kt create mode 100644 modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustMarkService/TrustMarkService.kt create mode 100644 modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustMarkService/TrustMarkServiceConst.kt create mode 100644 modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/types/TrustMarkValidationResponse.kt diff --git a/build.gradle.kts b/build.gradle.kts index a4fe7d02..25f9e46f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -92,7 +92,7 @@ fun getNpmVersion(): String { allprojects { group = "com.sphereon.oid.fed" - version = "0.4.8-SNAPSHOT" + version = "0.4.9-SNAPSHOT" val npmVersion by extra { getNpmVersion() } // Common repository configuration for all projects diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/FederationClient.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/FederationClient.kt index c7352ff1..c64abe60 100644 --- a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/FederationClient.kt +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/FederationClient.kt @@ -1,12 +1,13 @@ package com.sphereon.oid.fed.client +import com.sphereon.oid.fed.client.context.FederationContext import com.sphereon.oid.fed.client.crypto.cryptoService import com.sphereon.oid.fed.client.fetch.fetchService import com.sphereon.oid.fed.client.services.entityConfigurationStatementService.EntityConfigurationStatementService import com.sphereon.oid.fed.client.services.trustChainService.TrustChainService +import com.sphereon.oid.fed.client.services.trustMarkService.TrustMarkService import com.sphereon.oid.fed.client.types.* import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatementDTO -import com.sphereon.oid.fed.openapi.models.JWT import kotlin.js.JsExport /** @@ -17,13 +18,13 @@ class FederationClient( override val fetchServiceCallback: IFetchService? = null, override val cryptoServiceCallback: ICryptoService? = null ) : IFederationClient { - private val fetchService: IFetchService = - fetchServiceCallback ?: fetchService() - private val cryptoService: ICryptoService = cryptoServiceCallback ?: cryptoService() - - private val trustChainService: TrustChainService = TrustChainService(fetchService, cryptoService) - private val entity: EntityConfigurationStatementService = - EntityConfigurationStatementService(fetchService, cryptoService) + private val context = FederationContext( + fetchService = fetchServiceCallback ?: fetchService(), + cryptoService = cryptoServiceCallback ?: cryptoService() + ) + private val trustChainService = TrustChainService(context) + private val entityConfigurationService = EntityConfigurationStatementService(context) + private val trustMarkService = TrustMarkService(context) /** * Builds a trust chain for the given entity identifier using the provided trust anchors. @@ -63,9 +64,25 @@ class FederationClient( * Get an Entity Configuration Statement from an entity. * * @param entityIdentifier The entity identifier for which to get the statement. - * @return [JWT] ]A JWT object containing the entity configuration statement. + * @return EntityConfigurationStatementDTO containing the entity configuration statement. */ suspend fun entityConfigurationStatementGet(entityIdentifier: String): EntityConfigurationStatementDTO { - return entity.getEntityConfigurationStatement(entityIdentifier) + return entityConfigurationService.fetchEntityConfigurationStatement(entityIdentifier) + } + + /** + * Verifies a Trust Mark according to the OpenID Federation specification. + * + * @param trustMark The Trust Mark JWT string to validate + * @param trustAnchorConfig The Trust Anchor's Entity Configuration + * @param currentTime Optional timestamp for validation (defaults to current time) + * @return TrustMarkValidationResponse containing the validation result and any error message + */ + suspend fun trustMarksVerify( + trustMark: String, + trustAnchorConfig: EntityConfigurationStatementDTO, + currentTime: Long? = null + ): TrustMarkValidationResponse { + return trustMarkService.validateTrustMark(trustMark, trustAnchorConfig, currentTime) } -} +} \ No newline at end of file diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/context/FederationContext.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/context/FederationContext.kt new file mode 100644 index 00000000..5e689c32 --- /dev/null +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/context/FederationContext.kt @@ -0,0 +1,92 @@ +package com.sphereon.oid.fed.client.context + +import com.sphereon.oid.fed.client.crypto.cryptoService +import com.sphereon.oid.fed.client.fetch.fetchService +import com.sphereon.oid.fed.client.helpers.findKeyInJwks +import com.sphereon.oid.fed.client.mapper.decodeJWTComponents +import com.sphereon.oid.fed.client.types.ICryptoService +import com.sphereon.oid.fed.client.types.IFetchService +import com.sphereon.oid.fed.logger.Logger +import com.sphereon.oid.fed.openapi.models.Jwk +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject + +class FederationContext( + val fetchService: IFetchService = fetchService(), + val cryptoService: ICryptoService = cryptoService(), + val json: Json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true + } +) { + private val logger = Logger.tag("sphereon:oidf:client:context") + + /** + * Fetches and verifies a JWT from a given endpoint + */ + suspend fun fetchAndVerifyJwt(endpoint: String, verifyWithKey: Jwk? = null): String { + logger.debug("Fetching JWT from endpoint: $endpoint") + val jwt = fetchService.fetchStatement(endpoint) + + if (verifyWithKey != null) { + verifyJwt(jwt, verifyWithKey) + } + + return jwt + } + + /** + * Verifies a JWT signature with a given key + */ + + suspend fun verifyJwt(jwt: String, key: Jwk) { + logger.debug("Verifying JWT signature with key: ${key.kid}") + if (!cryptoService.verify(jwt, key)) { + throw IllegalStateException("JWT signature verification failed") + } + logger.debug("JWT signature verified successfully") + } + + /** + * Verifies a JWT is self-signed using its own JWKS + */ + suspend fun verifySelfSignedJwt(jwt: String): Jwk { + val decodedJwt = decodeJWTComponents(jwt) + logger.debug("Verifying self-signed JWT with kid: ${decodedJwt.header.kid}") + + val jwks = decodedJwt.payload["jwks"]?.jsonObject?.get("keys")?.jsonArray + ?: throw IllegalStateException("No JWKS found in JWT payload") + + val key = findKeyInJwks(jwks, decodedJwt.header.kid) + ?: throw IllegalStateException("No matching key found for kid: ${decodedJwt.header.kid}") + + verifyJwt(jwt, key) + return key + } + + /** + * Decodes a JSON element into a specific type + */ + fun decodeJsonElement(serializer: KSerializer, element: JsonElement): T { + return json.decodeFromJsonElement(serializer, element) + } + + /** + * Creates a new JSON decoder with custom settings if needed + */ + fun createJsonDecoder( + ignoreUnknownKeys: Boolean = true, + coerceInputValues: Boolean = true, + isLenient: Boolean = true + ): Json { + return Json { + this.ignoreUnknownKeys = ignoreUnknownKeys + this.coerceInputValues = coerceInputValues + this.isLenient = isLenient + } + } +} \ No newline at end of file diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementService.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementService.kt index 07b0de84..50465b1a 100644 --- a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementService.kt +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementService.kt @@ -1,25 +1,17 @@ package com.sphereon.oid.fed.client.services.entityConfigurationStatementService -import com.sphereon.oid.fed.client.crypto.cryptoService -import com.sphereon.oid.fed.client.helpers.findKeyInJwks +import com.sphereon.oid.fed.client.context.FederationContext import com.sphereon.oid.fed.client.helpers.getEntityConfigurationEndpoint import com.sphereon.oid.fed.client.mapper.decodeJWTComponents -import com.sphereon.oid.fed.client.types.ICryptoService -import com.sphereon.oid.fed.client.types.IFetchService import com.sphereon.oid.fed.logger.Logger -import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatementDTO -import com.sphereon.oid.fed.openapi.models.FederationEntityMetadata -import com.sphereon.oid.fed.openapi.models.JWT -import kotlinx.serialization.json.jsonArray +import com.sphereon.oid.fed.openapi.models.* import kotlinx.serialization.json.jsonObject private val logger: Logger = EntityConfigurationStatemenServiceConst.LOG class EntityConfigurationStatementService( - private val fetchService: IFetchService, - private val cryptoService: ICryptoService = cryptoService(), + private val context: FederationContext ) { - /** * Resolves and fetches an Entity Configuration Statement for the given entity identifier. * @@ -27,51 +19,20 @@ class EntityConfigurationStatementService( * @return [JWT] A JWT object containing the entity configuration statement. * @throws IllegalStateException if the JWT is invalid or signature verification fails */ - suspend fun getEntityConfigurationStatement(entityIdentifier: String): EntityConfigurationStatementDTO { + suspend fun fetchEntityConfigurationStatement(entityIdentifier: String): EntityConfigurationStatementDTO { logger.info("Starting entity configuration resolution for: $entityIdentifier") val endpoint = getEntityConfigurationEndpoint(entityIdentifier) logger.debug("Generated endpoint URL: $endpoint") - logger.debug("Fetching JWT from endpoint") - val jwt = fetchService.fetchStatement(endpoint) - logger.debug("Successfully fetched JWT") - - logger.debug("Decoding JWT components") + // Fetch and verify the JWT is self-signed + val jwt = context.fetchAndVerifyJwt(endpoint) val decodedJwt = decodeJWTComponents(jwt) - logger.debug("JWT decoded successfully. Header kid: ${decodedJwt.header.kid}") - - // Verify the JWT is self-signed using its own JWKS - logger.debug("Extracting JWKS from payload") - val jwks = decodedJwt.payload["jwks"]?.jsonObject?.get("keys")?.jsonArray - ?: run { - logger.error("No JWKS found in entity configuration") - throw IllegalStateException("No JWKS found in entity configuration") - } - - logger.debug("Finding matching key in JWKS for kid: ${decodedJwt.header.kid}") - val key = findKeyInJwks(jwks, decodedJwt.header.kid) - ?: run { - logger.error("No matching key found for kid: ${decodedJwt.header.kid}") - throw IllegalStateException("No matching key found for kid: ${decodedJwt.header.kid}") - } - - logger.debug("Verifying JWT signature") - if (!cryptoService.verify(jwt, key)) { - logger.error("Entity configuration signature verification failed") - throw IllegalStateException("Entity configuration signature verification failed") - } - logger.debug("JWT signature verified successfully") + context.verifySelfSignedJwt(jwt) return try { - logger.debug("Creating JSON decoder with relaxed settings") - val json = kotlinx.serialization.json.Json { - ignoreUnknownKeys = true - coerceInputValues = true - } - logger.debug("Decoding JWT payload into EntityConfigurationStatementDTO") - val result = json.decodeFromJsonElement( + val result = context.decodeJsonElement( EntityConfigurationStatementDTO.serializer(), decodedJwt.payload ) @@ -82,43 +43,103 @@ class EntityConfigurationStatementService( throw IllegalStateException("Failed to decode entity configuration: ${e.message}", e) } } -} -fun EntityConfigurationStatementDTO.getFederationEndpoints(): FederationEntityMetadata { - logger.debug("Extracting federation endpoints from EntityConfigurationStatementDTO") + /** + * Gets federation endpoints from an EntityConfigurationStatementDTO + */ + fun getFederationEndpoints(dto: EntityConfigurationStatementDTO): FederationEntityMetadata { + logger.debug("Extracting federation endpoints from EntityConfigurationStatementDTO") - // Check if metadata exists - val metadata = this.metadata - ?: run { - logger.error("No metadata found in entity configuration") - throw IllegalStateException("No metadata found in entity configuration") - } + val metadata = dto.metadata + ?: run { + logger.error("No metadata found in entity configuration") + throw IllegalStateException("No metadata found in entity configuration") + } + + val federationMetadata = metadata["federation_entity"]?.jsonObject + ?: run { + logger.error("No federation_entity metadata found in entity configuration") + throw IllegalStateException("No federation_entity metadata found in entity configuration") + } - // Extract the federation_entity metadata from the metadata object - logger.debug("Extracting federation_entity metadata") - val federationMetadata = metadata["federation_entity"]?.jsonObject - ?: run { - logger.error("No federation_entity metadata found in entity configuration") - throw IllegalStateException("No federation_entity metadata found in entity configuration") + return try { + logger.debug("Decoding federation metadata into FederationEntityMetadata") + val result = context.decodeJsonElement( + FederationEntityMetadata.serializer(), + federationMetadata + ) + logger.debug("Successfully extracted federation endpoints") + result + } catch (e: Exception) { + logger.error("Failed to parse federation_entity metadata", e) + throw IllegalStateException("Failed to parse federation_entity metadata: ${e.message}", e) } + } - // Deserialize the federation metadata into our DTO - return try { - logger.debug("Creating JSON decoder for federation metadata") - val json = kotlinx.serialization.json.Json { - ignoreUnknownKeys = true - coerceInputValues = true + /** + * Retrieves the historical keys from the federation entity's historical keys endpoint. + */ + suspend fun getHistoricalKeys(dto: EntityConfigurationStatementDTO): Array { + logger.debug("Retrieving historical keys") + val historicalKeysJwt = fetchHistoricalKeysJwt(dto) + val verifiedJwt = verifyHistoricalKeysJwt(dto, historicalKeysJwt) + return decodeHistoricalKeys(verifiedJwt) + } + + /** + * Fetches the historical keys JWT from the federation endpoint + */ + private suspend fun fetchHistoricalKeysJwt(dto: EntityConfigurationStatementDTO): String { + val federationEndpoints = getFederationEndpoints(dto) + val historicalKeysEndpoint = federationEndpoints.federationHistoricalKeysEndpoint + ?: run { + logger.error("No historical keys endpoint found in federation metadata") + throw IllegalStateException("No historical keys endpoint found in federation metadata") + } + + logger.debug("Fetching historical keys from endpoint: $historicalKeysEndpoint") + return try { + val jwt = context.fetchAndVerifyJwt(historicalKeysEndpoint) + logger.debug("Successfully fetched historical keys JWT") + jwt + } catch (e: Exception) { + logger.error("Failed to fetch historical keys", e) + throw IllegalStateException("Failed to fetch historical keys: ${e.message}", e) } + } + + /** + * Verifies the historical keys JWT signature using the entity's current JWKS + */ + private suspend fun verifyHistoricalKeysJwt(dto: EntityConfigurationStatementDTO, jwt: String): String { + val decodedJwt = decodeJWTComponents(jwt) + logger.debug("Successfully decoded historical keys JWT") - logger.debug("Decoding federation metadata into FederationEntityMetadata") - val result = json.decodeFromJsonElement( - FederationEntityMetadata.serializer(), - federationMetadata - ) - logger.debug("Successfully extracted federation endpoints") - result - } catch (e: Exception) { - logger.error("Failed to parse federation_entity metadata", e) - throw IllegalStateException("Failed to parse federation_entity metadata: ${e.message}", e) + val signingKey = dto.jwks.propertyKeys?.find { it.kid == decodedJwt.header.kid } + ?: run { + logger.error("No matching key found for kid: ${decodedJwt.header.kid}") + throw IllegalStateException("No matching key found for kid: ${decodedJwt.header.kid}") + } + + context.verifyJwt(jwt, signingKey) + return jwt + } + + /** + * Decodes the JWT payload into an array of historical keys + */ + private fun decodeHistoricalKeys(jwt: String): Array { + return try { + val decodedJwt = decodeJWTComponents(jwt) + val historicalKeysResponse = context.decodeJsonElement( + FederationHistoricalKeysResponse.serializer(), + decodedJwt.payload + ) + logger.debug("Successfully decoded historical keys response") + historicalKeysResponse.propertyKeys + } catch (e: Exception) { + logger.error("Failed to decode historical keys response", e) + throw IllegalStateException("Failed to decode historical keys response: ${e.message}", e) + } } } \ No newline at end of file diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustChainService/TrustChainService.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustChainService/TrustChainService.kt index fe066d9e..6129a440 100644 --- a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustChainService/TrustChainService.kt +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustChainService/TrustChainService.kt @@ -1,10 +1,10 @@ package com.sphereon.oid.fed.client.services.trustChainService +import com.sphereon.oid.fed.client.context.FederationContext import com.sphereon.oid.fed.client.helpers.* import com.sphereon.oid.fed.client.mapper.decodeJWTComponents import com.sphereon.oid.fed.client.mapper.mapEntityStatement -import com.sphereon.oid.fed.client.types.ICryptoService -import com.sphereon.oid.fed.client.types.IFetchService +import com.sphereon.oid.fed.client.services.entityConfigurationStatementService.EntityConfigurationStatementService import com.sphereon.oid.fed.client.types.TrustChainResolveResponse import com.sphereon.oid.fed.client.types.VerifyTrustChainResponse import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatementDTO @@ -14,18 +14,17 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import kotlin.collections.set var logger = TrustChainServiceConst.LOG /* * TrustChain is a class that implements the logic to resolve and validate a trust chain. */ -class TrustChainService - ( - private val fetchService: IFetchService, - private val cryptoService: ICryptoService +class TrustChainService( + private val context: FederationContext ) { + private val entityConfigurationStatementService = EntityConfigurationStatementService(context) + /* * This function verifies a trust chain. * The function follows the steps defined in the OpenID Federation 1.0 specification. @@ -144,6 +143,20 @@ class TrustChainService logger.error("Trust anchor signature verification failed") return VerifyTrustChainResponse(false, "Trust anchor signature verification failed") } + + // 10. Verify kid is in trust anchor's Historical Keys + val trustAnchorEntityConfiguration = + entityConfigurationStatementService.fetchEntityConfigurationStatement( + statement.payload["iss"]?.jsonPrimitive?.content!! + ) + + val historicalKeys = + entityConfigurationStatementService.getHistoricalKeys(trustAnchorEntityConfiguration) + + if (historicalKeys.find { it.kid == statement.header.kid } == null) { + logger.error("Trust anchor kid not found in historical keys") + return VerifyTrustChainResponse(false, "Trust anchor kid not found in historical keys") + } } } @@ -208,7 +221,7 @@ class TrustChainService val entityConfigurationEndpoint = getEntityConfigurationEndpoint(entityIdentifier) logger.debug("Fetching entity configuration from: $entityConfigurationEndpoint") - val entityConfigurationJwt = this.fetchService.fetchStatement(entityConfigurationEndpoint) + val entityConfigurationJwt = context.fetchAndVerifyJwt(entityConfigurationEndpoint) val decodedEntityConfiguration = decodeJWTComponents(entityConfigurationJwt) logger.debug("Decoded entity configuration JWT header kid: ${decodedEntityConfiguration.header.kid}") @@ -223,10 +236,7 @@ class TrustChainService return null } - if (!this.cryptoService.verify(entityConfigurationJwt, key)) { - logger.debug("Entity configuration JWT signature verification failed") - return null - } + context.verifyJwt(entityConfigurationJwt, key) val entityStatement: EntityConfigurationStatementDTO = mapEntityStatement(entityConfigurationJwt, EntityConfigurationStatementDTO::class) ?: run { @@ -342,7 +352,7 @@ class TrustChainService // Avoid processing the same entity twice if (cache.get(authorityConfigurationEndpoint) != null) return null - val authorityEntityConfigurationJwt = fetchService.fetchStatement(authorityConfigurationEndpoint) + val authorityEntityConfigurationJwt = context.fetchAndVerifyJwt(authorityConfigurationEndpoint) cache.put(authorityConfigurationEndpoint, authorityEntityConfigurationJwt) val decodedJwt = decodeJWTComponents(authorityEntityConfigurationJwt) @@ -351,7 +361,7 @@ class TrustChainService decodedJwt.header.kid ) ?: return null - if (!cryptoService.verify(authorityEntityConfigurationJwt, key)) return null + context.verifyJwt(authorityEntityConfigurationJwt, key) val authorityEntityConfiguration = mapEntityStatement( authorityEntityConfigurationJwt, @@ -378,7 +388,7 @@ class TrustChainService ): Pair? { val subordinateStatementEndpoint = getSubordinateStatementEndpoint(authorityEntityFetchEndpoint, entityIdentifier) - val subordinateStatementJwt = fetchService.fetchStatement(subordinateStatementEndpoint) + val subordinateStatementJwt = context.fetchAndVerifyJwt(subordinateStatementEndpoint) val decodedSubordinateStatement = decodeJWTComponents(subordinateStatementJwt) // Find and verify the key for the subordinate statement @@ -388,7 +398,7 @@ class TrustChainService decodedSubordinateStatement.header.kid ) ?: return null - if (!cryptoService.verify(subordinateStatementJwt, subordinateStatementKey)) return null + context.verifyJwt(subordinateStatementJwt, subordinateStatementKey) val subordinateStatement = mapEntityStatement( subordinateStatementJwt, @@ -432,7 +442,6 @@ class TrustChainService return null } - private fun hasRequiredClaims(statement: JWT): Boolean { return statement.payload["sub"] != null && statement.payload["iss"] != null && @@ -447,7 +456,7 @@ class TrustChainService decoded.payload["jwks"]?.jsonObject?.get("keys")?.jsonArray ?: return false, decoded.header.kid ) ?: return false - return cryptoService.verify(jwt, key) + return context.cryptoService.verify(jwt, key) } private suspend fun verifySignatureWithNextJwks(jwt: String, nextJwt: String): Boolean { @@ -457,7 +466,7 @@ class TrustChainService decodedNext.payload["jwks"]?.jsonObject?.get("keys")?.jsonArray ?: return false, decoded.header.kid ) ?: return false - return cryptoService.verify(jwt, key) + return context.cryptoService.verify(jwt, key) } } diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustMarkService/TrustMarkService.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustMarkService/TrustMarkService.kt new file mode 100644 index 00000000..10e866ef --- /dev/null +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustMarkService/TrustMarkService.kt @@ -0,0 +1,164 @@ +package com.sphereon.oid.fed.client.services.trustMarkService + +import com.sphereon.oid.fed.client.context.FederationContext +import com.sphereon.oid.fed.client.helpers.findKeyInJwks +import com.sphereon.oid.fed.client.helpers.getCurrentEpochTimeSeconds +import com.sphereon.oid.fed.client.mapper.decodeJWTComponents +import com.sphereon.oid.fed.client.services.entityConfigurationStatementService.EntityConfigurationStatementService +import com.sphereon.oid.fed.client.types.TrustMarkValidationResponse +import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatementDTO +import com.sphereon.oid.fed.openapi.models.JWT +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +private val logger = TrustMarkServiceConst.LOG + +/** + * Service for validating Trust Marks according to the OpenID Federation specification. + */ +class TrustMarkService( + private val context: FederationContext, + private val entityConfigurationStatementService: EntityConfigurationStatementService = EntityConfigurationStatementService( + context + ) +) { + /** + * Validates a Trust Mark according to the OpenID Federation specification. + * + * @param trustMark The Trust Mark to validate + * @param trustAnchorConfig The Trust Anchor's Entity Configuration + * @param currentTime Optional timestamp for validation (defaults to current epoch time in seconds)s + * @return TrustMarkValidationResponse containing the validation result and any error message + */ + suspend fun validateTrustMark( + trustMark: String, + trustAnchorConfig: EntityConfigurationStatementDTO, + currentTime: Long? = null + ): TrustMarkValidationResponse { + logger.debug("Starting Trust Mark validation") + val timeToUse = currentTime ?: getCurrentEpochTimeSeconds() + + try { + // 1. Decode the Trust Mark JWT + val decodedTrustMark = decodeJWTComponents(trustMark) + + // 2. Check if Trust Mark has expired + val exp = decodedTrustMark.payload["exp"]?.jsonPrimitive?.content?.toLongOrNull() + if (exp == null || exp <= timeToUse) { + logger.error("Trust Mark has expired") + return TrustMarkValidationResponse(false, "Trust Mark has expired") + } + + // 3. Get Trust Mark issuer for signature verification + val trustMarkIssuer = decodedTrustMark.payload["iss"]?.jsonPrimitive?.content + ?: return TrustMarkValidationResponse(false, "Trust Mark missing required issuer claim") + + // 4. Get Trust Mark identifier + val trustMarkId = decodedTrustMark.payload["id"]?.jsonPrimitive?.content + ?: return TrustMarkValidationResponse(false, "Trust Mark missing required 'id' claim") + + // 5. Fetch issuer's configuration and verify signature + logger.debug("Fetching issuer configuration for signature verification") + val issuerConfig = entityConfigurationStatementService.fetchEntityConfigurationStatement(trustMarkIssuer) + val signingKey = issuerConfig.jwks.propertyKeys?.find { it.kid == decodedTrustMark.header.kid } + ?: return TrustMarkValidationResponse(false, "Trust Mark signing key not found in issuer's JWKS") + + if (!context.cryptoService.verify(trustMark, signingKey)) { + logger.error("Trust Mark signature verification failed") + return TrustMarkValidationResponse(false, "Trust Mark signature verification failed") + } + logger.debug("Trust Mark signature verified successfully") + + // 6. Check if Trust Mark is recognized in Trust Anchor's configuration + val trustMarkOwners = trustAnchorConfig.metadata?.get("trust_mark_owners")?.jsonObject + if (trustMarkOwners != null) { + logger.debug("Validating Trust Mark using trust_mark_owners claim") + return validateWithTrustMarkOwners(trustMarkId, trustMarkOwners, decodedTrustMark) + } + + // 7. Check if Trust Mark issuer is in Trust Anchor's trust_mark_issuers + val trustMarkIssuers = trustAnchorConfig.metadata?.get("trust_mark_issuers")?.jsonObject + if (trustMarkIssuers != null) { + logger.debug("Validating Trust Mark using trust_mark_issuers claim") + return validateWithTrustMarkIssuers( + trustMarkId, + trustMarkIssuers, + decodedTrustMark + ) + } + + // If neither trust_mark_owners nor trust_mark_issuers is present + logger.debug("Trust Mark not recognized in federation - no trust_mark_owners or trust_mark_issuers found") + return TrustMarkValidationResponse( + false, + "Trust Mark not recognized in federation - no trust_mark_owners or trust_mark_issuers found" + ) + + } catch (e: Exception) { + logger.error("Trust Mark validation failed", e) + return TrustMarkValidationResponse(false, "Trust Mark validation failed: ${e.message}") + } + } + + private suspend fun validateWithTrustMarkOwners( + trustMarkId: String, + trustMarkOwners: JsonObject, + decodedTrustMark: JWT + ): TrustMarkValidationResponse { + val ownerClaims = trustMarkOwners[trustMarkId]?.jsonObject + ?: return TrustMarkValidationResponse(false, "Trust Mark identifier not found in trust_mark_owners") + + // Verify delegation claim exists + val delegation = decodedTrustMark.payload["delegation"]?.jsonPrimitive?.content + ?: return TrustMarkValidationResponse(false, "Trust Mark missing required delegation claim") + + // Verify delegation JWT signature with owner's JWKS + val ownerJwks = ownerClaims["jwks"]?.jsonObject + ?: return TrustMarkValidationResponse(false, "No JWKS found for Trust Mark owner") + + val decodedDelegation = decodeJWTComponents(delegation) + val delegationKey = findKeyInJwks( + ownerJwks["keys"]?.jsonArray ?: return TrustMarkValidationResponse(false, "Invalid JWKS format"), + decodedDelegation.header.kid + ) ?: return TrustMarkValidationResponse(false, "Delegation signing key not found in owner's JWKS") + + if (!context.cryptoService.verify(delegation, delegationKey)) { + return TrustMarkValidationResponse(false, "Delegation signature verification failed") + } + + // Verify delegation issuer matches owner's sub + val ownerSub = ownerClaims["sub"]?.jsonPrimitive?.content + ?: return TrustMarkValidationResponse(false, "Trust Mark owner missing sub claim") + + if (decodedDelegation.payload["iss"]?.jsonPrimitive?.content != ownerSub) { + return TrustMarkValidationResponse(false, "Delegation issuer does not match Trust Mark owner") + } + + return TrustMarkValidationResponse(true) + } + + private suspend fun validateWithTrustMarkIssuers( + trustMarkId: String, + trustMarkIssuers: JsonObject, + decodedTrustMark: JWT + ): TrustMarkValidationResponse { + val issuerClaims = trustMarkIssuers[trustMarkId]?.jsonObject + ?: return TrustMarkValidationResponse(false, "Trust Mark identifier not found in trust_mark_issuers") + + // Verify Trust Mark issuer is authorized + val trustMarkIssuer = decodedTrustMark.payload["iss"]?.jsonPrimitive?.content + ?: return TrustMarkValidationResponse(false, "Trust Mark missing required issuer claim") + + val isAuthorizedIssuer = issuerClaims["issuers"]?.jsonArray?.any { + it.jsonPrimitive.content == trustMarkIssuer + } ?: false + + if (!isAuthorizedIssuer) { + return TrustMarkValidationResponse(false, "Trust Mark issuer not authorized") + } + // Signature has already been verified in validateTrustMark + return TrustMarkValidationResponse(true) + } +} \ No newline at end of file diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustMarkService/TrustMarkServiceConst.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustMarkService/TrustMarkServiceConst.kt new file mode 100644 index 00000000..91a71004 --- /dev/null +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustMarkService/TrustMarkServiceConst.kt @@ -0,0 +1,8 @@ +package com.sphereon.oid.fed.client.services.trustMarkService + +import com.sphereon.oid.fed.logger.Logger + +object TrustMarkServiceConst { + private const val LOG_NAMESPACE = "sphereon:oidf:client:trustMarkService" + val LOG = Logger.tag(LOG_NAMESPACE) +} \ No newline at end of file diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/types/TrustMarkValidationResponse.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/types/TrustMarkValidationResponse.kt new file mode 100644 index 00000000..b6a234d0 --- /dev/null +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/types/TrustMarkValidationResponse.kt @@ -0,0 +1,12 @@ +package com.sphereon.oid.fed.client.types + +/** + * Response class for Trust Mark validation results + * + * @property isValid Whether the Trust Mark is valid + * @property errorMessage Optional error message if validation failed + */ +data class TrustMarkValidationResponse( + val isValid: Boolean, + val errorMessage: String? = null +) \ No newline at end of file diff --git a/modules/openid-federation-client/src/commonTest/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementServiceTest.kt b/modules/openid-federation-client/src/commonTest/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementServiceTest.kt index eebf6ed3..0358db93 100644 --- a/modules/openid-federation-client/src/commonTest/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementServiceTest.kt +++ b/modules/openid-federation-client/src/commonTest/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementServiceTest.kt @@ -1,5 +1,6 @@ package com.sphereon.oid.fed.client.services.entityConfigurationStatementService +import com.sphereon.oid.fed.client.context.FederationContext import com.sphereon.oid.fed.client.mockResponses.mockResponses import com.sphereon.oid.fed.client.types.ICryptoService import com.sphereon.oid.fed.client.types.IFetchService @@ -22,8 +23,11 @@ object TestCryptoService : ICryptoService { } class EntityConfigurationStatementServiceTest { - private val entityConfigurationStatementService = - EntityConfigurationStatementService(TestFetchService, TestCryptoService) + private val context = FederationContext( + fetchService = TestFetchService, + cryptoService = TestCryptoService + ) + private val entityConfigurationStatementService = EntityConfigurationStatementService(context) @BeforeTest fun setupTests() = runTest { @@ -31,8 +35,8 @@ class EntityConfigurationStatementServiceTest { } @Test - fun testGetEntityConfigurationStatement() = runTest { - val result = entityConfigurationStatementService.getEntityConfigurationStatement( + fun testFetchEntityConfigurationStatement() = runTest { + val result = entityConfigurationStatementService.fetchEntityConfigurationStatement( "https://oidc.registry.servizicie.interno.gov.it" ) @@ -43,11 +47,11 @@ class EntityConfigurationStatementServiceTest { @Test fun testGetFederationEndpoints() = runTest { - val config = entityConfigurationStatementService.getEntityConfigurationStatement( + val config = entityConfigurationStatementService.fetchEntityConfigurationStatement( "https://oidc.registry.servizicie.interno.gov.it" ) - val endpoints = config.getFederationEndpoints() + val endpoints = entityConfigurationStatementService.getFederationEndpoints(config) assertEquals("https://oidc.registry.servizicie.interno.gov.it/fetch", endpoints.federationFetchEndpoint) assertEquals("https://oidc.registry.servizicie.interno.gov.it/resolve", endpoints.federationResolveEndpoint) @@ -59,9 +63,9 @@ class EntityConfigurationStatementServiceTest { } @Test - fun testGetEntityConfigurationStatementInvalidUrl() = runTest { + fun testFetchEntityConfigurationStatementInvalidUrl() = runTest { assertFailsWith { - entityConfigurationStatementService.getEntityConfigurationStatement("invalid-url") + entityConfigurationStatementService.fetchEntityConfigurationStatement("invalid-url") } } } \ No newline at end of file diff --git a/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/FederationClient.js.kt b/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/FederationClient.js.kt index 059aacee..96954d7c 100644 --- a/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/FederationClient.js.kt +++ b/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/FederationClient.js.kt @@ -1,3 +1,4 @@ +import com.sphereon.oid.fed.client.context.FederationContext import com.sphereon.oid.fed.client.crypto.CryptoServiceAdapter import com.sphereon.oid.fed.client.crypto.cryptoService import com.sphereon.oid.fed.client.fetch.FetchServiceAdapter @@ -41,10 +42,14 @@ class FederationClientJS( if (fetchServiceCallback != null) FetchServiceAdapter(fetchServiceCallback) else fetchService() private val cryptoService: ICryptoService = if (cryptoServiceCallback != null) CryptoServiceAdapter(cryptoServiceCallback) else cryptoService() - private val entityService = EntityConfigurationStatementService(fetchService, cryptoService) - private val trustChainService: TrustChainService = TrustChainService(fetchService, cryptoService) + private val context = FederationContext( + fetchService = fetchService, + cryptoService = cryptoService + ) + private val entityService = EntityConfigurationStatementService(context) + private val trustChainService = TrustChainService(context) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @JsName("resolveTrustChain") @@ -78,7 +83,7 @@ class FederationClientJS( entityIdentifier: String ): Promise { return scope.promise { - entityService.getEntityConfigurationStatement(entityIdentifier) + entityService.fetchEntityConfigurationStatement(entityIdentifier) } } -} +} \ No newline at end of file From 00d8b11dbe987c702d884486d13b97cfa2ce2756 Mon Sep 17 00:00:00 2001 From: John Melati Date: Sun, 26 Jan 2025 18:10:01 +0100 Subject: [PATCH 2/3] feat: implement trust mark verify in js client --- .../oid/fed/client/FederationClient.js.kt | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/FederationClient.js.kt b/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/FederationClient.js.kt index 96954d7c..b983d216 100644 --- a/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/FederationClient.js.kt +++ b/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/FederationClient.js.kt @@ -5,10 +5,8 @@ import com.sphereon.oid.fed.client.fetch.FetchServiceAdapter import com.sphereon.oid.fed.client.fetch.fetchService import com.sphereon.oid.fed.client.services.entityConfigurationStatementService.EntityConfigurationStatementService import com.sphereon.oid.fed.client.services.trustChainService.TrustChainService -import com.sphereon.oid.fed.client.types.ICryptoService -import com.sphereon.oid.fed.client.types.IFetchService -import com.sphereon.oid.fed.client.types.TrustChainResolveResponse -import com.sphereon.oid.fed.client.types.VerifyTrustChainResponse +import com.sphereon.oid.fed.client.services.trustMarkService.TrustMarkService +import com.sphereon.oid.fed.client.types.* import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatementDTO import com.sphereon.oid.fed.openapi.models.Jwk import kotlinx.coroutines.CoroutineScope @@ -50,6 +48,7 @@ class FederationClientJS( private val entityService = EntityConfigurationStatementService(context) private val trustChainService = TrustChainService(context) + private val trustMarkService = TrustMarkService(context) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @JsName("resolveTrustChain") @@ -86,4 +85,19 @@ class FederationClientJS( entityService.fetchEntityConfigurationStatement(entityIdentifier) } } + + @JsName("verifyTrustMark") + fun verifyTrustMarkJS( + trustMark: String, + trustAnchorConfig: EntityConfigurationStatementDTO, + currentTime: Int? = null + ): Promise { + return scope.promise { + trustMarkService.validateTrustMark( + trustMark, + trustAnchorConfig, + currentTime?.toLong() + ) + } + } } \ No newline at end of file From 2adafbd6f5c79cbf92a8ed09f4f1f43462075876 Mon Sep 17 00:00:00 2001 From: John Melati Date: Wed, 29 Jan 2025 23:17:14 +0100 Subject: [PATCH 3/3] Implement Http Resolver with Cache (#57) * feat: implement http resolver and cache * fix: httpclient mock * fix: remove deprecated files --- build.gradle.kts | 2 +- gradle.properties | 7 +- modules/cache/build.gradle.kts | 84 +++++++++++ .../com/sphereon/oid/fed/cache/Cache.kt | 45 ++++++ .../com/sphereon/oid/fed/cache/CacheConfig.kt | 16 ++ .../sphereon/oid/fed/cache/CacheOptions.kt | 11 ++ .../sphereon/oid/fed/cache/InMemoryCache.kt | 138 ++++++++++++++++++ .../com/sphereon/oid/fed/cache/CacheTest.kt | 125 ++++++++++++++++ .../oid/fed/cache/InMemoryCacheTest.kt | 13 ++ modules/http-resolver/build.gradle.kts | 97 ++++++++++++ .../oid/fed/httpResolver/HttpMetadata.kt | 7 + .../oid/fed/httpResolver/HttpResolver.kt | 93 ++++++++++++ .../oid/fed/httpResolver/HttpResolverConst.kt | 8 + .../config/DefaultHttpResolverConfig.kt | 12 ++ .../httpResolver/config/HttpResolverConfig.kt | 14 ++ .../config/HttpResolverDefaults.kt | 8 + .../openid-federation-client/build.gradle.kts | 7 + .../oid/fed/client/FederationClient.kt | 21 ++- .../fed/client/context/FederationContext.kt | 120 +++++---------- .../sphereon/oid/fed/client/fetch/Fetch.kt | 5 - .../oid/fed/client/helpers/Helpers.kt | 6 +- .../EntityConfigurationStatementService.kt | 18 +-- .../client/services/jwtService/JwtService.kt | 54 +++++++ .../trustChainService/TrustChainService.kt | 65 ++++++--- .../trustMarkService/TrustMarkService.kt | 2 +- .../oid/fed/client/types/IFederationClient.kt | 5 +- .../oid/fed/client/types/IFetchService.kt | 7 - ...EntityConfigurationStatementServiceTest.kt | 47 ++++-- .../TrustChainServiceTest.kt | 35 +++-- .../oid/fed/client/FederationClient.js.kt | 25 ++-- .../sphereon/oid/fed/client/fetch/Fetch.js.kt | 30 ---- .../oid/fed/client/fetch/Fetch.jvm.kt | 22 --- settings.gradle.kts | 9 +- 33 files changed, 930 insertions(+), 228 deletions(-) create mode 100644 modules/cache/build.gradle.kts create mode 100644 modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/Cache.kt create mode 100644 modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/CacheConfig.kt create mode 100644 modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/CacheOptions.kt create mode 100644 modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/InMemoryCache.kt create mode 100644 modules/cache/src/commonTest/kotlin/com/sphereon/oid/fed/cache/CacheTest.kt create mode 100644 modules/cache/src/commonTest/kotlin/com/sphereon/oid/fed/cache/InMemoryCacheTest.kt create mode 100644 modules/http-resolver/build.gradle.kts create mode 100644 modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/HttpMetadata.kt create mode 100644 modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/HttpResolver.kt create mode 100644 modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/HttpResolverConst.kt create mode 100644 modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/config/DefaultHttpResolverConfig.kt create mode 100644 modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/config/HttpResolverConfig.kt create mode 100644 modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/config/HttpResolverDefaults.kt delete mode 100644 modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/fetch/Fetch.kt create mode 100644 modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/jwtService/JwtService.kt delete mode 100644 modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/types/IFetchService.kt delete mode 100644 modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/fetch/Fetch.js.kt delete mode 100644 modules/openid-federation-client/src/jvmMain/kotlin/com/sphereon/oid/fed/client/fetch/Fetch.jvm.kt diff --git a/build.gradle.kts b/build.gradle.kts index 25f9e46f..dd7f7802 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -92,7 +92,7 @@ fun getNpmVersion(): String { allprojects { group = "com.sphereon.oid.fed" - version = "0.4.9-SNAPSHOT" + version = "0.5.1-SNAPSHOT" val npmVersion by extra { getNpmVersion() } // Common repository configuration for all projects diff --git a/gradle.properties b/gradle.properties index 05c62c42..2707b681 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,15 +1,12 @@ kotlin.code.style=official - #Gradle org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" - #Android android.nonTransitiveRClass=true android.useAndroidX=true - #Ktor io.ktor.development=true - #MPP kotlin.mpp.androidSourceSetLayoutVersion=2 -kotlin.mpp.enableCInteropCommonization=true \ No newline at end of file +kotlin.mpp.enableCInteropCommonization=true +ktor_version=3.0.3 \ No newline at end of file diff --git a/modules/cache/build.gradle.kts b/modules/cache/build.gradle.kts new file mode 100644 index 00000000..964aa62c --- /dev/null +++ b/modules/cache/build.gradle.kts @@ -0,0 +1,84 @@ +import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig + +plugins { + alias(libs.plugins.kotlinMultiplatform) + kotlin("plugin.serialization") version libs.versions.kotlin.get() +} + +group = "com.sphereon.oid.fed" + +repositories { + mavenCentral() + mavenLocal() + google() + maven("https://jitpack.io") +} + +kotlin { + jvm() + + js(IR) { + browser { + commonWebpackConfig { + devServer = KotlinWebpackConfig.DevServer().apply { + port = 8083 + } + } + } + nodejs { + testTask { + useMocha { + timeout = "5000" + } + } + } + binaries.library() + generateTypeScriptDefinitions() + compilations["main"].packageJson { + name = "@sphereon/openid-federation-cache" + version = rootProject.extra["npmVersion"] as String + description = "OpenID Federation Cache Module" + customField("description", "OpenID Federation Cache Module") + customField("license", "Apache-2.0") + customField("author", "Sphereon International") + customField( + "repository", mapOf( + "type" to "git", + "url" to "https://github.com/Sphereon-Opensource/openid-federation" + ) + ) + + customField( + "publishConfig", mapOf( + "access" to "public" + ) + ) + + types = "./index.d.ts" + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation("com.mayakapps.kache:kache:2.1.0") + implementation("com.mayakapps.kache:file-kache:2.1.0") + } + } + + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(kotlin("test-common")) + implementation(kotlin("test-annotations-common")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0") + implementation("org.jetbrains.kotlin:kotlin-stdlib:2.0.0") + } + } + } +} + +tasks.named("jvmTest") { + useJUnitPlatform() +} \ No newline at end of file diff --git a/modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/Cache.kt b/modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/Cache.kt new file mode 100644 index 00000000..b4525d83 --- /dev/null +++ b/modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/Cache.kt @@ -0,0 +1,45 @@ +package com.sphereon.oid.fed.cache + +import kotlinx.coroutines.Deferred + +interface Cache { + suspend fun clear() + suspend fun close() + + suspend fun evictAll() + suspend fun evictExpired() + suspend fun trimToSize(size: Long) + + suspend fun get(key: K, options: CacheOptions = CacheOptions()): V? + suspend fun getAllKeys(options: CacheOptions = CacheOptions()): Set + fun getIfAvailable(key: K, options: CacheOptions = CacheOptions()): V? + fun getIfAvailableOrDefault(key: K, defaultValue: V, options: CacheOptions = CacheOptions()): V + suspend fun getKeys(options: CacheOptions = CacheOptions()): Set + suspend fun getOrDefault(key: K, defaultValue: V, options: CacheOptions = CacheOptions()): V + suspend fun getOrPut( + key: K, + creationFunction: suspend (key: K) -> V?, + options: CacheOptions = CacheOptions() + ): V? + + suspend fun getUnderCreationKeys(options: CacheOptions = CacheOptions()): Set + + suspend fun put(key: K, value: V): V? + suspend fun put( + key: K, + creationFunction: suspend (key: K) -> V? + ): V? + + suspend fun putAll(from: Map) + suspend fun putAsync( + key: K, + creationFunction: suspend (key: K) -> V? + ): Deferred + + suspend fun remove(key: K): V? + suspend fun removeAllUnderCreation() + + suspend fun resize(maxSize: Long) + suspend fun getCurrentSize(options: CacheOptions = CacheOptions()): Long + suspend fun getMaxSize(options: CacheOptions = CacheOptions()): Long +} \ No newline at end of file diff --git a/modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/CacheConfig.kt b/modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/CacheConfig.kt new file mode 100644 index 00000000..7daa27b4 --- /dev/null +++ b/modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/CacheConfig.kt @@ -0,0 +1,16 @@ +package com.sphereon.oid.fed.cache + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +data class CacheConfig( + val maxSize: Long = 1000, + val expireAfterWrite: Duration = 1800.seconds, + val expireAfterAccess: Duration? = null, + val persistentCacheEnabled: Boolean = false, + val persistentCachePath: String? = null, + val evictOnClose: Boolean = false, + val cleanupOnStart: Boolean = true, + val compressionEnabled: Boolean = false, + val compressionThresholdBytes: Long = 1024L +) \ No newline at end of file diff --git a/modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/CacheOptions.kt b/modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/CacheOptions.kt new file mode 100644 index 00000000..8dfd2ee7 --- /dev/null +++ b/modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/CacheOptions.kt @@ -0,0 +1,11 @@ +package com.sphereon.oid.fed.cache + +enum class CacheStrategy { + CACHE_FIRST, + CACHE_ONLY, + FORCE_REMOTE +} + +data class CacheOptions( + val strategy: CacheStrategy = CacheStrategy.CACHE_FIRST +) \ No newline at end of file diff --git a/modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/InMemoryCache.kt b/modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/InMemoryCache.kt new file mode 100644 index 00000000..7c44cc8f --- /dev/null +++ b/modules/cache/src/commonMain/kotlin/com/sphereon/oid/fed/cache/InMemoryCache.kt @@ -0,0 +1,138 @@ +package com.sphereon.oid.fed.cache + +import com.mayakapps.kache.InMemoryKache +import com.mayakapps.kache.KacheStrategy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlin.time.Duration + +class InMemoryCache( + private val maxSize: Long = 1000, + private val expireAfterWrite: Duration? = null, + private val expireAfterAccess: Duration? = null, + private val strategy: KacheStrategy = KacheStrategy.LRU, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default) +) : Cache { + + private val cache: InMemoryKache = InMemoryKache(maxSize) { + creationScope = scope + strategy = this@InMemoryCache.strategy + expireAfterWrite?.let { expireAfterWriteDuration = it } + expireAfterAccess?.let { expireAfterAccessDuration = it } + maxSize = this@InMemoryCache.maxSize + } + + override suspend fun clear() { + cache.clear() + } + + override suspend fun close() { + // InMemoryKache doesn't require explicit closing + } + + override suspend fun evictAll() { + cache.evictAll() + } + + override suspend fun evictExpired() { + cache.evictExpired() + } + + override suspend fun trimToSize(size: Long) { + cache.trimToSize(size) + } + + override suspend fun get(key: K, options: CacheOptions): V? { + // Check cache first unless FORCE_REMOTE + if (options.strategy != CacheStrategy.FORCE_REMOTE) { + val cachedValue = cache.get(key) + if (cachedValue != null) { + return cachedValue + } + + // If cache-only, return null + if (options.strategy == CacheStrategy.CACHE_ONLY) { + return null + } + } + + // At this point we either have FORCE_REMOTE or CACHE_FIRST with no valid cache entry + return null // The actual value should be created using getOrPut with a creationFunction + } + + override suspend fun getAllKeys(options: CacheOptions): Set { + val kacheKeys = cache.getAllKeys() + return (kacheKeys.keys + kacheKeys.underCreationKeys).toSet() + } + + override fun getIfAvailable(key: K, options: CacheOptions): V? { + return cache.getIfAvailable(key) + } + + override fun getIfAvailableOrDefault(key: K, defaultValue: V, options: CacheOptions): V { + return getIfAvailable(key, options) ?: defaultValue + } + + override suspend fun getKeys(options: CacheOptions): Set { + return cache.getKeys().toSet() + } + + override suspend fun getOrDefault(key: K, defaultValue: V, options: CacheOptions): V { + return get(key, options) ?: defaultValue + } + + override suspend fun getOrPut( + key: K, + creationFunction: suspend (key: K) -> V?, + options: CacheOptions + ): V? { + return cache.getOrPut(key, creationFunction) + } + + override suspend fun getUnderCreationKeys(options: CacheOptions): Set { + return cache.getUnderCreationKeys() + } + + override suspend fun put(key: K, value: V): V? { + return cache.put(key, value) + } + + override suspend fun put( + key: K, + creationFunction: suspend (key: K) -> V? + ): V? { + return cache.put(key, creationFunction) + } + + override suspend fun putAll(from: Map) { + cache.putAll(from) + } + + override suspend fun putAsync( + key: K, + creationFunction: suspend (key: K) -> V? + ): Deferred { + return cache.putAsync(key, creationFunction) + } + + override suspend fun remove(key: K): V? { + return cache.remove(key) + } + + override suspend fun removeAllUnderCreation() { + cache.getUnderCreationKeys().forEach { cache.remove(it) } + } + + override suspend fun resize(maxSize: Long) { + trimToSize(maxSize) + } + + override suspend fun getCurrentSize(options: CacheOptions): Long { + return cache.getKeys().size.toLong() + } + + override suspend fun getMaxSize(options: CacheOptions): Long { + return cache.maxSize + } +} \ No newline at end of file diff --git a/modules/cache/src/commonTest/kotlin/com/sphereon/oid/fed/cache/CacheTest.kt b/modules/cache/src/commonTest/kotlin/com/sphereon/oid/fed/cache/CacheTest.kt new file mode 100644 index 00000000..cc8c984d --- /dev/null +++ b/modules/cache/src/commonTest/kotlin/com/sphereon/oid/fed/cache/CacheTest.kt @@ -0,0 +1,125 @@ +package com.sphereon.oid.fed.cache + +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlin.test.* + +abstract class CacheTest { + data class TestData(val value: String, val number: Int) { + override fun toString(): String = "$value:$number" + } + + protected abstract fun createCache(): Cache + protected abstract fun cleanup() + + private lateinit var cache: Cache + protected val testDispatcher = StandardTestDispatcher() + protected val testScope = TestScope(testDispatcher) + + @BeforeTest + fun setup() { + cache = createCache() + } + + @AfterTest + fun teardown() { + testDispatcher.scheduler.advanceUntilIdle() + cleanup() + } + + @Test + fun testBasicOperations() = testScope.runTest { + val testData = TestData("test value", 42) + val key = "test-key" + + cache.put(key, testData) + testDispatcher.scheduler.advanceUntilIdle() + + val retrieved = cache.get(key) + assertEquals(testData, retrieved) + + val removed = cache.remove(key) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(testData, removed) + assertNull(cache.get(key)) + + cache.put(key, testData) + testDispatcher.scheduler.advanceUntilIdle() + + cache.clear() + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(cache.get(key)) + } + + @Test + fun testGetOrPut() = testScope.runTest { + val testData = TestData("test value", 42) + val key = "test-key" + + val result = cache.getOrPut(key, creationFunction = { _ -> testData }, options = CacheOptions()) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(testData, result) + + val cachedResult = + cache.getOrPut(key, creationFunction = { _ -> TestData("different value", 100) }, options = CacheOptions()) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(testData, cachedResult) + } + + @Test + fun testCacheStrategies() = testScope.runTest { + val testData = TestData("test value", 42) + val key = "test-key" + + cache.put(key, testData) + testDispatcher.scheduler.advanceUntilIdle() + + val cachedValue = cache.get(key, CacheOptions(strategy = CacheStrategy.CACHE_FIRST)) + assertEquals(testData, cachedValue) + + val cacheOnlyValue = cache.get(key, CacheOptions(strategy = CacheStrategy.CACHE_ONLY)) + assertEquals(testData, cacheOnlyValue) + + val forceRemoteValue = cache.get(key, CacheOptions(strategy = CacheStrategy.FORCE_REMOTE)) + assertNull(forceRemoteValue) + } + + @Test + fun testSizeOperations() = testScope.runTest { + val initialSize = cache.getCurrentSize() + assertEquals(0, initialSize) + + repeat(5) { i -> + cache.put("key$i", TestData("value$i", i)) + testDispatcher.scheduler.advanceUntilIdle() + } + + val newSize = cache.getCurrentSize() + assertEquals(5, newSize) + + cache.resize(3) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(cache.getCurrentSize() <= 3) + } + + @Test + fun testKeyOperations() = testScope.runTest { + val entries = (1..3).associate { i -> + "key$i" to TestData("value$i", i) + } + cache.putAll(entries) + testDispatcher.scheduler.advanceUntilIdle() + + val allKeys = cache.getAllKeys() + assertEquals(entries.keys, allKeys) + + val keys = cache.getKeys() + assertEquals(entries.keys, keys) + } +} \ No newline at end of file diff --git a/modules/cache/src/commonTest/kotlin/com/sphereon/oid/fed/cache/InMemoryCacheTest.kt b/modules/cache/src/commonTest/kotlin/com/sphereon/oid/fed/cache/InMemoryCacheTest.kt new file mode 100644 index 00000000..65d5b524 --- /dev/null +++ b/modules/cache/src/commonTest/kotlin/com/sphereon/oid/fed/cache/InMemoryCacheTest.kt @@ -0,0 +1,13 @@ +package com.sphereon.oid.fed.cache + +class InMemoryCacheTest : CacheTest() { + override fun createCache(): Cache { + return InMemoryCache( + maxSize = 1000, + scope = testScope + ) + } + + override fun cleanup() { + } +} \ No newline at end of file diff --git a/modules/http-resolver/build.gradle.kts b/modules/http-resolver/build.gradle.kts new file mode 100644 index 00000000..f7837864 --- /dev/null +++ b/modules/http-resolver/build.gradle.kts @@ -0,0 +1,97 @@ +import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig + +plugins { + alias(libs.plugins.kotlinMultiplatform) + kotlin("plugin.serialization") version libs.versions.kotlin.get() +} + +group = "com.sphereon.oid.fed" + +repositories { + mavenCentral() + mavenLocal() + google() + maven("https://jitpack.io") +} + +kotlin { + jvm() + + js(IR) { + browser { + commonWebpackConfig { + devServer = KotlinWebpackConfig.DevServer().apply { + port = 8083 + } + } + } + nodejs { + testTask { + useMocha { + timeout = "5000" + } + } + } + binaries.library() + generateTypeScriptDefinitions() + compilations["main"].packageJson { + name = "@sphereon/openid-federation-http-resolver" + version = rootProject.extra["npmVersion"] as String + description = "OpenID Federation HTTP Resolver Module" + customField("description", "OpenID Federation HTTP Resolver Module") + customField("license", "Apache-2.0") + customField("author", "Sphereon International") + customField( + "repository", mapOf( + "type" to "git", + "url" to "https://github.com/Sphereon-Opensource/openid-federation" + ) + ) + + customField( + "publishConfig", mapOf( + "access" to "public" + ) + ) + + types = "./index.d.ts" + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.5.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") + implementation("io.ktor:ktor-client-core:2.3.7") + implementation(project(":modules:cache")) + implementation(project(":modules:logger")) + } + } + + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + implementation("io.ktor:ktor-client-mock:2.3.7") + } + } + val jvmMain by getting { + dependencies { + implementation("io.ktor:ktor-client-cio:2.3.7") + } + } + + val jvmTest by getting { + dependencies { + implementation(kotlin("test-junit5")) + implementation("org.junit.jupiter:junit-jupiter:5.10.1") + } + } + } +} + +tasks.named("jvmTest") { + useJUnitPlatform() +} \ No newline at end of file diff --git a/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/HttpMetadata.kt b/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/HttpMetadata.kt new file mode 100644 index 00000000..fb830020 --- /dev/null +++ b/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/HttpMetadata.kt @@ -0,0 +1,7 @@ +package com.sphereon.oid.fed.httpResolver + +data class HttpMetadata( + val value: V, + val etag: String? = null, + val lastModified: String? = null +) \ No newline at end of file diff --git a/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/HttpResolver.kt b/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/HttpResolver.kt new file mode 100644 index 00000000..e64c5138 --- /dev/null +++ b/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/HttpResolver.kt @@ -0,0 +1,93 @@ +package com.sphereon.oid.fed.httpResolver + +import com.sphereon.oid.fed.cache.Cache +import com.sphereon.oid.fed.cache.CacheOptions +import com.sphereon.oid.fed.cache.CacheStrategy +import com.sphereon.oid.fed.httpResolver.config.DefaultHttpResolverConfig +import com.sphereon.oid.fed.httpResolver.config.HttpResolverConfig +import io.ktor.client.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.delay + +private val logger = HttpResolverConst.LOG + +class HttpResolver( + private val config: HttpResolverConfig = DefaultHttpResolverConfig(), + private val httpClient: HttpClient, + private val cache: Cache>, + private val responseMapper: suspend (HttpResponse) -> V +) { + private suspend fun fetchWithRetry(url: String, attempt: Int = 1): HttpResponse { + try { + logger.debug("Attempting HTTP request for $url (attempt $attempt/${config.httpRetries})") + return httpClient.get(url) { + config.apply { + if (enableHttpCaching) { + headers { + // Add conditional request headers if we have them + cache.getIfAvailable(url, CacheOptions())?.let { metadata -> + metadata.etag?.let { etag -> + logger.debug("Adding If-None-Match header: $etag") + append(HttpHeaders.IfNoneMatch, etag) + } + metadata.lastModified?.let { lastModified -> + logger.debug("Adding If-Modified-Since header: $lastModified") + append(HttpHeaders.IfModifiedSince, lastModified) + } + } + } + } + timeout { + requestTimeoutMillis = httpTimeoutMs + } + } + } + } catch (e: Exception) { + if (attempt < config.httpRetries) { + val delayMs = 1000L * (1 shl (attempt - 1)) + logger.warn("HTTP request failed for $url, will retry after ${delayMs}ms (attempt $attempt): ${e.message ?: "Unknown error"}") + delay(delayMs) + return fetchWithRetry(url, attempt + 1) + } + logger.error("HTTP request failed for $url after $attempt attempts", e) + throw e + } + } + + private suspend fun fetchFromRemote(url: String): HttpMetadata { + logger.debug("Fetching from remote: $url") + val response = fetchWithRetry(url) + val value = responseMapper(response) + + return HttpMetadata( + value = value, + etag = if (config.enableHttpCaching) response.headers[HttpHeaders.ETag] else null, + lastModified = if (config.enableHttpCaching) response.headers[HttpHeaders.LastModified] else null + ) + } + + suspend fun get(url: String, options: CacheOptions = CacheOptions()): String { + logger.debug("Getting resource from $url using strategy ${options.strategy}") + + if (options.strategy == CacheStrategy.CACHE_ONLY) { + logger.debug("Using cache-only strategy for $url") + return cache.getIfAvailable(url, options)?.value?.toString() + ?: throw IllegalStateException("No cached value available") + } + + if (options.strategy == CacheStrategy.FORCE_REMOTE) { + logger.debug("Using force-remote strategy for $url") + val metadata = fetchFromRemote(url) + cache.put(url) { metadata } + return metadata.value.toString() + } + + // CACHE_FIRST strategy + logger.debug("Using cache-first strategy for $url") + return cache.getOrPut(url, { fetchFromRemote(url) }, options)?.value?.toString() + ?: throw IllegalStateException("Failed to get or put value in cache") + } +} \ No newline at end of file diff --git a/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/HttpResolverConst.kt b/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/HttpResolverConst.kt new file mode 100644 index 00000000..8377d34a --- /dev/null +++ b/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/HttpResolverConst.kt @@ -0,0 +1,8 @@ +package com.sphereon.oid.fed.httpResolver + +import com.sphereon.oid.fed.logger.Logger + +object HttpResolverConst { + private const val LOG_NAMESPACE = "sphereon:oidf:http:resolver" + val LOG = Logger.tag(LOG_NAMESPACE) +} \ No newline at end of file diff --git a/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/config/DefaultHttpResolverConfig.kt b/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/config/DefaultHttpResolverConfig.kt new file mode 100644 index 00000000..1f32cbee --- /dev/null +++ b/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/config/DefaultHttpResolverConfig.kt @@ -0,0 +1,12 @@ +package com.sphereon.oid.fed.httpResolver.config + +import com.sphereon.oid.fed.cache.CacheConfig + +data class DefaultHttpResolverConfig( + override val enableHttpCaching: Boolean = HttpResolverDefaults.DEFAULT_ENABLE_HTTP_CACHING, + override val enableEtagSupport: Boolean = HttpResolverDefaults.DEFAULT_ENABLE_ETAG_SUPPORT, + override val httpTimeoutMs: Long = HttpResolverDefaults.DEFAULT_HTTP_TIMEOUT_MS, + override val httpRetries: Int = HttpResolverDefaults.DEFAULT_HTTP_RETRIES, + + override val cacheConfig: CacheConfig = CacheConfig() +) : HttpResolverConfig \ No newline at end of file diff --git a/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/config/HttpResolverConfig.kt b/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/config/HttpResolverConfig.kt new file mode 100644 index 00000000..7a3af55f --- /dev/null +++ b/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/config/HttpResolverConfig.kt @@ -0,0 +1,14 @@ +package com.sphereon.oid.fed.httpResolver.config + +import com.sphereon.oid.fed.cache.CacheConfig + +interface HttpResolverConfig { + // HTTP settings + val enableHttpCaching: Boolean + val enableEtagSupport: Boolean + val httpTimeoutMs: Long + val httpRetries: Int + + // Cache configuration + val cacheConfig: CacheConfig +} \ No newline at end of file diff --git a/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/config/HttpResolverDefaults.kt b/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/config/HttpResolverDefaults.kt new file mode 100644 index 00000000..1a055417 --- /dev/null +++ b/modules/http-resolver/src/commonMain/kotlin/com/sphereon/oid/fed/httpResolver/config/HttpResolverDefaults.kt @@ -0,0 +1,8 @@ +package com.sphereon.oid.fed.httpResolver.config + +object HttpResolverDefaults { + const val DEFAULT_HTTP_TIMEOUT_MS = 30000L + const val DEFAULT_HTTP_RETRIES = 3 + const val DEFAULT_ENABLE_HTTP_CACHING = true + const val DEFAULT_ENABLE_ETAG_SUPPORT = true +} \ No newline at end of file diff --git a/modules/openid-federation-client/build.gradle.kts b/modules/openid-federation-client/build.gradle.kts index 4db63435..fe36cbb9 100644 --- a/modules/openid-federation-client/build.gradle.kts +++ b/modules/openid-federation-client/build.gradle.kts @@ -13,6 +13,8 @@ repositories { google() } + + kotlin { jvm() @@ -58,6 +60,7 @@ kotlin { } sourceSets { + val ktor_version: String by project all { languageSettings.optIn("kotlin.js.ExperimentalJsExport") @@ -67,6 +70,10 @@ kotlin { val commonMain by getting { dependencies { + implementation("com.mayakapps.kache:kache:2.1.0") + implementation("com.mayakapps.kache:file-kache:2.1.0") + api(projects.modules.cache) + api(projects.modules.httpResolver) api(projects.modules.openapi) api(projects.modules.logger) implementation(libs.ktor.client.core) diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/FederationClient.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/FederationClient.kt index c64abe60..bf17cbc2 100644 --- a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/FederationClient.kt +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/FederationClient.kt @@ -1,13 +1,17 @@ package com.sphereon.oid.fed.client +import com.sphereon.oid.fed.cache.InMemoryCache import com.sphereon.oid.fed.client.context.FederationContext import com.sphereon.oid.fed.client.crypto.cryptoService -import com.sphereon.oid.fed.client.fetch.fetchService import com.sphereon.oid.fed.client.services.entityConfigurationStatementService.EntityConfigurationStatementService import com.sphereon.oid.fed.client.services.trustChainService.TrustChainService import com.sphereon.oid.fed.client.services.trustMarkService.TrustMarkService import com.sphereon.oid.fed.client.types.* +import com.sphereon.oid.fed.httpResolver.config.DefaultHttpResolverConfig import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatementDTO +import io.ktor.client.* +import io.ktor.client.plugins.* +import kotlinx.serialization.json.Json import kotlin.js.JsExport /** @@ -15,12 +19,17 @@ import kotlin.js.JsExport */ @JsExport.Ignore class FederationClient( - override val fetchServiceCallback: IFetchService? = null, - override val cryptoServiceCallback: ICryptoService? = null + override val cryptoServiceCallback: ICryptoService? = null, + override val httpClient: HttpClient? = null ) : IFederationClient { - private val context = FederationContext( - fetchService = fetchServiceCallback ?: fetchService(), - cryptoService = cryptoServiceCallback ?: cryptoService() + private val context = FederationContext.create( + httpClient = httpClient ?: HttpClient() { + install(HttpTimeout) + }, + cryptoService = cryptoServiceCallback ?: cryptoService(), + json = Json { ignoreUnknownKeys = true }, + cache = InMemoryCache(), + httpResolverConfig = DefaultHttpResolverConfig() ) private val trustChainService = TrustChainService(context) private val entityConfigurationService = EntityConfigurationStatementService(context) diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/context/FederationContext.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/context/FederationContext.kt index 5e689c32..f7db055c 100644 --- a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/context/FederationContext.kt +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/context/FederationContext.kt @@ -1,92 +1,50 @@ package com.sphereon.oid.fed.client.context +import com.sphereon.oid.fed.cache.Cache import com.sphereon.oid.fed.client.crypto.cryptoService -import com.sphereon.oid.fed.client.fetch.fetchService -import com.sphereon.oid.fed.client.helpers.findKeyInJwks -import com.sphereon.oid.fed.client.mapper.decodeJWTComponents +import com.sphereon.oid.fed.client.services.jwtService.JwtService import com.sphereon.oid.fed.client.types.ICryptoService -import com.sphereon.oid.fed.client.types.IFetchService +import com.sphereon.oid.fed.httpResolver.HttpMetadata +import com.sphereon.oid.fed.httpResolver.HttpResolver +import com.sphereon.oid.fed.httpResolver.config.DefaultHttpResolverConfig +import com.sphereon.oid.fed.httpResolver.config.HttpResolverConfig import com.sphereon.oid.fed.logger.Logger -import com.sphereon.oid.fed.openapi.models.Jwk -import kotlinx.serialization.KSerializer +import io.ktor.client.* +import io.ktor.client.statement.* import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -class FederationContext( - val fetchService: IFetchService = fetchService(), - val cryptoService: ICryptoService = cryptoService(), - val json: Json = Json { - ignoreUnknownKeys = true - coerceInputValues = true - isLenient = true - } +class FederationContext private constructor( + val cryptoService: ICryptoService, + val json: Json, + val logger: Logger = Logger.tag("sphereon:oidf:client:context"), + val httpResolver: HttpResolver ) { - private val logger = Logger.tag("sphereon:oidf:client:context") - - /** - * Fetches and verifies a JWT from a given endpoint - */ - suspend fun fetchAndVerifyJwt(endpoint: String, verifyWithKey: Jwk? = null): String { - logger.debug("Fetching JWT from endpoint: $endpoint") - val jwt = fetchService.fetchStatement(endpoint) - - if (verifyWithKey != null) { - verifyJwt(jwt, verifyWithKey) - } - - return jwt - } - - /** - * Verifies a JWT signature with a given key - */ - - suspend fun verifyJwt(jwt: String, key: Jwk) { - logger.debug("Verifying JWT signature with key: ${key.kid}") - if (!cryptoService.verify(jwt, key)) { - throw IllegalStateException("JWT signature verification failed") - } - logger.debug("JWT signature verified successfully") - } - - /** - * Verifies a JWT is self-signed using its own JWKS - */ - suspend fun verifySelfSignedJwt(jwt: String): Jwk { - val decodedJwt = decodeJWTComponents(jwt) - logger.debug("Verifying self-signed JWT with kid: ${decodedJwt.header.kid}") - - val jwks = decodedJwt.payload["jwks"]?.jsonObject?.get("keys")?.jsonArray - ?: throw IllegalStateException("No JWKS found in JWT payload") - - val key = findKeyInJwks(jwks, decodedJwt.header.kid) - ?: throw IllegalStateException("No matching key found for kid: ${decodedJwt.header.kid}") - - verifyJwt(jwt, key) - return key - } - - /** - * Decodes a JSON element into a specific type - */ - fun decodeJsonElement(serializer: KSerializer, element: JsonElement): T { - return json.decodeFromJsonElement(serializer, element) - } - - /** - * Creates a new JSON decoder with custom settings if needed - */ - fun createJsonDecoder( - ignoreUnknownKeys: Boolean = true, - coerceInputValues: Boolean = true, - isLenient: Boolean = true - ): Json { - return Json { - this.ignoreUnknownKeys = ignoreUnknownKeys - this.coerceInputValues = coerceInputValues - this.isLenient = isLenient + val jwtService: JwtService = JwtService(this) + + companion object { + fun create( + cryptoService: ICryptoService = cryptoService(), + httpClient: HttpClient, + cache: Cache>, + httpResolverConfig: HttpResolverConfig = DefaultHttpResolverConfig(), + json: Json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true + } + ): FederationContext { + val resolver = HttpResolver( + config = httpResolverConfig, + httpClient = httpClient, + cache = cache, + responseMapper = { response -> response.bodyAsText() } + ) + + return FederationContext( + cryptoService = cryptoService, + json = json, + httpResolver = resolver + ) } } } \ No newline at end of file diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/fetch/Fetch.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/fetch/Fetch.kt deleted file mode 100644 index ec99c844..00000000 --- a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/fetch/Fetch.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.sphereon.oid.fed.client.fetch - -import com.sphereon.oid.fed.client.types.IFetchService - -expect fun fetchService(): IFetchService diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/helpers/Helpers.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/helpers/Helpers.kt index b7cf1e06..4fb183bd 100644 --- a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/helpers/Helpers.kt +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/helpers/Helpers.kt @@ -15,12 +15,12 @@ fun getSubordinateStatementEndpoint(fetchEndpoint: String, sub: String): String return "${fetchEndpoint}?sub=$sub" } -fun findKeyInJwks(keys: JsonArray, kid: String): Jwk? { +fun findKeyInJwks(keys: JsonArray, kid: String, json: Json): Jwk? { val key = keys.firstOrNull { it.jsonObject["kid"]?.jsonPrimitive?.content?.trim() == kid.trim() } if (key == null) return null - return Json.decodeFromJsonElement(Jwk.serializer(), key) + return json.decodeFromJsonElement(Jwk.serializer(), key) } fun checkKidInJwks(keys: Array, kid: String): Boolean { @@ -34,4 +34,4 @@ fun checkKidInJwks(keys: Array, kid: String): Boolean { fun getCurrentEpochTimeSeconds(): Long { return Clock.System.now().epochSeconds -} +} \ No newline at end of file diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementService.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementService.kt index 50465b1a..448a104b 100644 --- a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementService.kt +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementService.kt @@ -26,16 +26,14 @@ class EntityConfigurationStatementService( logger.debug("Generated endpoint URL: $endpoint") // Fetch and verify the JWT is self-signed - val jwt = context.fetchAndVerifyJwt(endpoint) + val jwt = context.jwtService.fetchAndVerifyJwt(endpoint) val decodedJwt = decodeJWTComponents(jwt) - context.verifySelfSignedJwt(jwt) + context.jwtService.verifySelfSignedJwt(jwt) return try { logger.debug("Decoding JWT payload into EntityConfigurationStatementDTO") - val result = context.decodeJsonElement( - EntityConfigurationStatementDTO.serializer(), - decodedJwt.payload - ) + val result = + context.json.decodeFromJsonElement(EntityConfigurationStatementDTO.serializer(), decodedJwt.payload) logger.info("Successfully resolved entity configuration for: $entityIdentifier") result } catch (e: Exception) { @@ -64,7 +62,7 @@ class EntityConfigurationStatementService( return try { logger.debug("Decoding federation metadata into FederationEntityMetadata") - val result = context.decodeJsonElement( + val result = context.json.decodeFromJsonElement( FederationEntityMetadata.serializer(), federationMetadata ) @@ -99,7 +97,7 @@ class EntityConfigurationStatementService( logger.debug("Fetching historical keys from endpoint: $historicalKeysEndpoint") return try { - val jwt = context.fetchAndVerifyJwt(historicalKeysEndpoint) + val jwt = context.jwtService.fetchAndVerifyJwt(historicalKeysEndpoint) logger.debug("Successfully fetched historical keys JWT") jwt } catch (e: Exception) { @@ -121,7 +119,7 @@ class EntityConfigurationStatementService( throw IllegalStateException("No matching key found for kid: ${decodedJwt.header.kid}") } - context.verifyJwt(jwt, signingKey) + context.jwtService.verifyJwt(jwt, signingKey) return jwt } @@ -131,7 +129,7 @@ class EntityConfigurationStatementService( private fun decodeHistoricalKeys(jwt: String): Array { return try { val decodedJwt = decodeJWTComponents(jwt) - val historicalKeysResponse = context.decodeJsonElement( + val historicalKeysResponse = context.json.decodeFromJsonElement( FederationHistoricalKeysResponse.serializer(), decodedJwt.payload ) diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/jwtService/JwtService.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/jwtService/JwtService.kt new file mode 100644 index 00000000..03d98e97 --- /dev/null +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/jwtService/JwtService.kt @@ -0,0 +1,54 @@ +package com.sphereon.oid.fed.client.services.jwtService + +import com.sphereon.oid.fed.client.context.FederationContext +import com.sphereon.oid.fed.client.helpers.findKeyInJwks +import com.sphereon.oid.fed.client.mapper.decodeJWTComponents +import com.sphereon.oid.fed.openapi.models.Jwk +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject + +class JwtService(private val context: FederationContext) { + /** + * Fetches and verifies a JWT from a given endpoint + */ + suspend fun fetchAndVerifyJwt(endpoint: String, verifyWithKey: Jwk? = null): String { + context.logger.debug("Fetching JWT from endpoint: $endpoint") + val jwt = context.httpResolver.get(endpoint) + + if (verifyWithKey != null) { + verifyJwt(jwt, verifyWithKey) + } else { + verifySelfSignedJwt(jwt) + } + + return jwt + } + + /** + * Verifies a JWT signature with a given key + */ + suspend fun verifyJwt(jwt: String, key: Jwk) { + context.logger.debug("Verifying JWT signature with key: ${key.kid}") + if (!context.cryptoService.verify(jwt, key)) { + throw IllegalStateException("JWT signature verification failed") + } + context.logger.debug("JWT signature verified successfully") + } + + /** + * Verifies a JWT is self-signed using its own JWKS + */ + suspend fun verifySelfSignedJwt(jwt: String): Jwk { + val decodedJwt = decodeJWTComponents(jwt) + context.logger.debug("Verifying self-signed JWT with kid: ${decodedJwt.header.kid}") + + val jwks = decodedJwt.payload["jwks"]?.jsonObject?.get("keys")?.jsonArray + ?: throw IllegalStateException("No JWKS found in JWT payload") + + val key = findKeyInJwks(jwks, decodedJwt.header.kid, context.json) + ?: throw IllegalStateException("No matching key found for kid: ${decodedJwt.header.kid}") + + verifyJwt(jwt, key) + return key + } +} \ No newline at end of file diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustChainService/TrustChainService.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustChainService/TrustChainService.kt index 6129a440..6685304f 100644 --- a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustChainService/TrustChainService.kt +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustChainService/TrustChainService.kt @@ -144,18 +144,29 @@ class TrustChainService( return VerifyTrustChainResponse(false, "Trust anchor signature verification failed") } - // 10. Verify kid is in trust anchor's Historical Keys + // 10. First check if key is in Trust Anchor's Entity Configuration Statement val trustAnchorEntityConfiguration = entityConfigurationStatementService.fetchEntityConfigurationStatement( statement.payload["iss"]?.jsonPrimitive?.content!! ) - val historicalKeys = - entityConfigurationStatementService.getHistoricalKeys(trustAnchorEntityConfiguration) - - if (historicalKeys.find { it.kid == statement.header.kid } == null) { - logger.error("Trust anchor kid not found in historical keys") - return VerifyTrustChainResponse(false, "Trust anchor kid not found in historical keys") + // First try to find the key in the current JWKS + val jwks = trustAnchorEntityConfiguration.jwks?.propertyKeys + if (jwks != null && jwks.find { it.kid == statement.header.kid } != null) { + logger.debug("Trust anchor key found in Entity Configuration Statement JWKS") + } else { + // If not found in current JWKS, check historical keys + logger.debug("Key not found in current JWKS, checking historical keys") + val historicalKeys = + entityConfigurationStatementService.getHistoricalKeys(trustAnchorEntityConfiguration) + if (historicalKeys.find { it.kid == statement.header.kid } == null) { + logger.error("Trust anchor kid not found in current JWKS or historical keys") + return VerifyTrustChainResponse( + false, + "Trust anchor kid not found in current JWKS or historical keys" + ) + } + logger.debug("Trust anchor key found in historical keys") } } } @@ -187,6 +198,7 @@ class TrustChainService( logger.info("Resolving trust chain for entity: $entityIdentifier with max depth: $maxDepth") val cache = SimpleCache() val chain: MutableList = arrayListOf() + return try { val trustChain = buildTrustChain(entityIdentifier, trustAnchors, chain, cache, 0, maxDepth) if (trustChain != null) { @@ -194,14 +206,14 @@ class TrustChainService( "Successfully resolved trust chain for entity: $entityIdentifier", context = mapOf("trustChain" to trustChain.toString()) ) - TrustChainResolveResponse(trustChain, false, null) + TrustChainResolveResponse(trustChain, error = false, errorMessage = null) } else { logger.error("Could not establish trust chain for entity: $entityIdentifier") - TrustChainResolveResponse(null, true, "A Trust chain could not be established") + TrustChainResolveResponse(null, error = true, errorMessage = "A Trust chain could not be established") } } catch (e: Throwable) { logger.error("Trust chain resolution failed for entity: $entityIdentifier", e) - TrustChainResolveResponse(null, true, e.message) + TrustChainResolveResponse(null, error = true, errorMessage = e.message) } } @@ -221,7 +233,7 @@ class TrustChainService( val entityConfigurationEndpoint = getEntityConfigurationEndpoint(entityIdentifier) logger.debug("Fetching entity configuration from: $entityConfigurationEndpoint") - val entityConfigurationJwt = context.fetchAndVerifyJwt(entityConfigurationEndpoint) + val entityConfigurationJwt = context.jwtService.fetchAndVerifyJwt(entityConfigurationEndpoint) val decodedEntityConfiguration = decodeJWTComponents(entityConfigurationJwt) logger.debug("Decoded entity configuration JWT header kid: ${decodedEntityConfiguration.header.kid}") @@ -230,13 +242,14 @@ class TrustChainService( logger.debug("No JWKS found in entity configuration payload") return null }, - decodedEntityConfiguration.header.kid + decodedEntityConfiguration.header.kid, + context.json ) ?: run { logger.debug("Could not find key with kid: ${decodedEntityConfiguration.header.kid} in JWKS") return null } - context.verifyJwt(entityConfigurationJwt, key) + context.jwtService.verifyJwt(entityConfigurationJwt, key) val entityStatement: EntityConfigurationStatementDTO = mapEntityStatement(entityConfigurationJwt, EntityConfigurationStatementDTO::class) ?: run { @@ -352,16 +365,17 @@ class TrustChainService( // Avoid processing the same entity twice if (cache.get(authorityConfigurationEndpoint) != null) return null - val authorityEntityConfigurationJwt = context.fetchAndVerifyJwt(authorityConfigurationEndpoint) + val authorityEntityConfigurationJwt = context.jwtService.fetchAndVerifyJwt(authorityConfigurationEndpoint) cache.put(authorityConfigurationEndpoint, authorityEntityConfigurationJwt) val decodedJwt = decodeJWTComponents(authorityEntityConfigurationJwt) val key = findKeyInJwks( decodedJwt.payload["jwks"]?.jsonObject?.get("keys")?.jsonArray ?: return null, - decodedJwt.header.kid + decodedJwt.header.kid, + context.json ) ?: return null - context.verifyJwt(authorityEntityConfigurationJwt, key) + context.jwtService.verifyJwt(authorityEntityConfigurationJwt, key) val authorityEntityConfiguration = mapEntityStatement( authorityEntityConfigurationJwt, @@ -386,19 +400,22 @@ class TrustChainService( authorityConfigurationJwt: String, lastStatementKid: String ): Pair? { + // Find and verify the key for the subordinate statement + val decodedAuthorityConfiguration = decodeJWTComponents(authorityConfigurationJwt) + val subordinateStatementEndpoint = getSubordinateStatementEndpoint(authorityEntityFetchEndpoint, entityIdentifier) - val subordinateStatementJwt = context.fetchAndVerifyJwt(subordinateStatementEndpoint) + + val subordinateStatementJwt = context.httpResolver.get(subordinateStatementEndpoint) val decodedSubordinateStatement = decodeJWTComponents(subordinateStatementJwt) - // Find and verify the key for the subordinate statement - val decodedAuthorityConfiguration = decodeJWTComponents(authorityConfigurationJwt) val subordinateStatementKey = findKeyInJwks( decodedAuthorityConfiguration.payload["jwks"]?.jsonObject?.get("keys")?.jsonArray ?: return null, - decodedSubordinateStatement.header.kid + decodedSubordinateStatement.header.kid, + context.json ) ?: return null - context.verifyJwt(subordinateStatementJwt, subordinateStatementKey) + context.jwtService.verifyJwt(subordinateStatementJwt, subordinateStatementKey) val subordinateStatement = mapEntityStatement( subordinateStatementJwt, @@ -454,7 +471,8 @@ class TrustChainService( val decoded = decodeJWTComponents(jwt) val key = findKeyInJwks( decoded.payload["jwks"]?.jsonObject?.get("keys")?.jsonArray ?: return false, - decoded.header.kid + decoded.header.kid, + context.json ) ?: return false return context.cryptoService.verify(jwt, key) } @@ -464,7 +482,8 @@ class TrustChainService( val decodedNext = decodeJWTComponents(nextJwt) val key = findKeyInJwks( decodedNext.payload["jwks"]?.jsonObject?.get("keys")?.jsonArray ?: return false, - decoded.header.kid + decoded.header.kid, + context.json ) ?: return false return context.cryptoService.verify(jwt, key) } diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustMarkService/TrustMarkService.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustMarkService/TrustMarkService.kt index 10e866ef..daa8df61 100644 --- a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustMarkService/TrustMarkService.kt +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/services/trustMarkService/TrustMarkService.kt @@ -121,7 +121,7 @@ class TrustMarkService( val decodedDelegation = decodeJWTComponents(delegation) val delegationKey = findKeyInJwks( ownerJwks["keys"]?.jsonArray ?: return TrustMarkValidationResponse(false, "Invalid JWKS format"), - decodedDelegation.header.kid + decodedDelegation.header.kid, context.json ) ?: return TrustMarkValidationResponse(false, "Delegation signing key not found in owner's JWKS") if (!context.cryptoService.verify(delegation, delegationKey)) { diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/types/IFederationClient.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/types/IFederationClient.kt index 058b2e32..f3b0d288 100644 --- a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/types/IFederationClient.kt +++ b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/types/IFederationClient.kt @@ -1,5 +1,6 @@ package com.sphereon.oid.fed.client.types +import io.ktor.client.* import kotlin.js.JsExport /** @@ -7,6 +8,6 @@ import kotlin.js.JsExport */ @JsExport.Ignore interface IFederationClient { - val fetchServiceCallback: IFetchService? val cryptoServiceCallback: ICryptoService? -} + val httpClient: HttpClient? +} \ No newline at end of file diff --git a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/types/IFetchService.kt b/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/types/IFetchService.kt deleted file mode 100644 index e3eac2ab..00000000 --- a/modules/openid-federation-client/src/commonMain/kotlin/com/sphereon/oid/fed/client/types/IFetchService.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.sphereon.oid.fed.client.types - -interface IFetchService { - suspend fun fetchStatement( - endpoint: String - ): String -} \ No newline at end of file diff --git a/modules/openid-federation-client/src/commonTest/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementServiceTest.kt b/modules/openid-federation-client/src/commonTest/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementServiceTest.kt index 0358db93..e988ffaa 100644 --- a/modules/openid-federation-client/src/commonTest/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementServiceTest.kt +++ b/modules/openid-federation-client/src/commonTest/kotlin/com/sphereon/oid/fed/client/services/entityConfigurationStatementService/EntityConfigurationStatementServiceTest.kt @@ -1,20 +1,19 @@ package com.sphereon.oid.fed.client.services.entityConfigurationStatementService +import com.sphereon.oid.fed.cache.InMemoryCache import com.sphereon.oid.fed.client.context.FederationContext +import com.sphereon.oid.fed.client.mapper.InvalidJwtException import com.sphereon.oid.fed.client.mockResponses.mockResponses import com.sphereon.oid.fed.client.types.ICryptoService -import com.sphereon.oid.fed.client.types.IFetchService import com.sphereon.oid.fed.logger.Logger import com.sphereon.oid.fed.openapi.models.Jwk +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.* +import io.ktor.http.* import kotlinx.coroutines.test.runTest import kotlin.test.* - -object TestFetchService : IFetchService { - override suspend fun fetchStatement(endpoint: String): String { - return mockResponses.find { it[0] == endpoint }?.get(1) - ?: throw IllegalStateException("Invalid endpoint: $endpoint") - } -} +import kotlin.time.Duration.Companion.seconds object TestCryptoService : ICryptoService { override suspend fun verify(jwt: String, key: Jwk): Boolean { @@ -23,9 +22,33 @@ object TestCryptoService : ICryptoService { } class EntityConfigurationStatementServiceTest { - private val context = FederationContext( - fetchService = TestFetchService, - cryptoService = TestCryptoService + private val mockEngine = MockEngine { request -> + val endpoint = request.url.toString() + val response = mockResponses.find { it[0] == endpoint }?.get(1) + if (response != null) { + respond( + content = response, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } else { + respond( + content = "Not found", + status = HttpStatusCode.NotFound + ) + } + } + private val httpClient = HttpClient(mockEngine) { + install(HttpTimeout) { + requestTimeoutMillis = 5.seconds.inWholeMilliseconds + connectTimeoutMillis = 5.seconds.inWholeMilliseconds + socketTimeoutMillis = 5.seconds.inWholeMilliseconds + } + } + private val context = FederationContext.create( + cryptoService = TestCryptoService, + cache = InMemoryCache(), + httpClient = httpClient ) private val entityConfigurationStatementService = EntityConfigurationStatementService(context) @@ -64,7 +87,7 @@ class EntityConfigurationStatementServiceTest { @Test fun testFetchEntityConfigurationStatementInvalidUrl() = runTest { - assertFailsWith { + assertFailsWith { entityConfigurationStatementService.fetchEntityConfigurationStatement("invalid-url") } } diff --git a/modules/openid-federation-client/src/commonTest/kotlin/com/sphereon/oid/fed/client/services/trustChainService/TrustChainServiceTest.kt b/modules/openid-federation-client/src/commonTest/kotlin/com/sphereon/oid/fed/client/services/trustChainService/TrustChainServiceTest.kt index 2609f732..f6bb09db 100644 --- a/modules/openid-federation-client/src/commonTest/kotlin/com/sphereon/oid/fed/client/services/trustChainService/TrustChainServiceTest.kt +++ b/modules/openid-federation-client/src/commonTest/kotlin/com/sphereon/oid/fed/client/services/trustChainService/TrustChainServiceTest.kt @@ -3,29 +3,46 @@ package com.sphereon.oid.fed.client.services.trustChainService import com.sphereon.oid.fed.client.FederationClient import com.sphereon.oid.fed.client.mockResponses.mockResponses import com.sphereon.oid.fed.client.types.ICryptoService -import com.sphereon.oid.fed.client.types.IFetchService import com.sphereon.oid.fed.logger.Logger import com.sphereon.oid.fed.openapi.models.Jwk +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.* +import io.ktor.http.* import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -object FetchService : IFetchService { - override suspend fun fetchStatement(endpoint: String): String { - return mockResponses.find { it[0] == endpoint }?.get(1) ?: throw Exception("Not found") - } -} - object CryptoService : ICryptoService { override suspend fun verify(jwt: String, key: Jwk): Boolean { return true } } +private fun createMockHttpClient(): HttpClient { + return HttpClient(MockEngine) { + engine { + addHandler { request -> + val requestUrl = request.url.toString() + val mockResponse = mockResponses.find { it[0] == requestUrl } + ?: error("Unhandled request: $requestUrl") + + respond( + content = mockResponse[1], + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + } + install(HttpTimeout) + } +} + class TrustChainServiceTest { - private val client = FederationClient(FetchService, CryptoService) + private val mockHttpClient = createMockHttpClient() + private val client = FederationClient(CryptoService, mockHttpClient) @BeforeTest fun setupTests() = runTest { @@ -165,4 +182,4 @@ class TrustChainServiceTest { assertEquals(false, notYetValidResponse.isValid) assertEquals("Statement at position 0 has invalid iat", notYetValidResponse.error) } -} +} \ No newline at end of file diff --git a/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/FederationClient.js.kt b/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/FederationClient.js.kt index b983d216..4c8d0361 100644 --- a/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/FederationClient.js.kt +++ b/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/FederationClient.js.kt @@ -1,14 +1,19 @@ +import com.sphereon.oid.fed.cache.InMemoryCache import com.sphereon.oid.fed.client.context.FederationContext import com.sphereon.oid.fed.client.crypto.CryptoServiceAdapter import com.sphereon.oid.fed.client.crypto.cryptoService -import com.sphereon.oid.fed.client.fetch.FetchServiceAdapter -import com.sphereon.oid.fed.client.fetch.fetchService import com.sphereon.oid.fed.client.services.entityConfigurationStatementService.EntityConfigurationStatementService import com.sphereon.oid.fed.client.services.trustChainService.TrustChainService import com.sphereon.oid.fed.client.services.trustMarkService.TrustMarkService -import com.sphereon.oid.fed.client.types.* +import com.sphereon.oid.fed.client.types.ICryptoService +import com.sphereon.oid.fed.client.types.TrustChainResolveResponse +import com.sphereon.oid.fed.client.types.TrustMarkValidationResponse +import com.sphereon.oid.fed.client.types.VerifyTrustChainResponse import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatementDTO import com.sphereon.oid.fed.openapi.models.Jwk +import io.ktor.client.* +import io.ktor.client.engine.js.* +import io.ktor.client.plugins.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -33,18 +38,18 @@ external interface IFetchServiceJS { @JsExport @JsName("FederationClient") class FederationClientJS( - fetchServiceCallback: IFetchServiceJS?, cryptoServiceCallback: ICryptoServiceJS?, + httpClient: HttpClient? = null ) { - private val fetchService: IFetchService = - if (fetchServiceCallback != null) FetchServiceAdapter(fetchServiceCallback) else fetchService() private val cryptoService: ICryptoService = if (cryptoServiceCallback != null) CryptoServiceAdapter(cryptoServiceCallback) else cryptoService() - private val context = FederationContext( - fetchService = fetchService, - cryptoService = cryptoService - ) + private val context = FederationContext.create( + cryptoService = cryptoService, + cache = InMemoryCache(), + httpClient = httpClient ?: HttpClient(Js) { + install(HttpTimeout) + }) private val entityService = EntityConfigurationStatementService(context) private val trustChainService = TrustChainService(context) diff --git a/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/fetch/Fetch.js.kt b/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/fetch/Fetch.js.kt deleted file mode 100644 index a8a4e771..00000000 --- a/modules/openid-federation-client/src/jsMain/kotlin/com/sphereon/oid/fed/client/fetch/Fetch.js.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.sphereon.oid.fed.client.fetch - -import IFetchServiceJS -import com.sphereon.oid.fed.client.types.IFetchService -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.engine.js.* -import io.ktor.client.request.* -import io.ktor.http.* -import kotlinx.coroutines.await - -class FetchServiceAdapter(private val jsFetchService: IFetchServiceJS) : IFetchService { - override suspend fun fetchStatement(endpoint: String): String { - return jsFetchService.fetchStatement(endpoint).await() - } -} - -actual fun fetchService(): IFetchService { - return object : IFetchService { - private val httpClient = HttpClient(Js) - - override suspend fun fetchStatement(endpoint: String): String { - return httpClient.get(endpoint) { - headers { - append(HttpHeaders.Accept, "application/entity-statement+jwt") - } - }.body() - } - } -} diff --git a/modules/openid-federation-client/src/jvmMain/kotlin/com/sphereon/oid/fed/client/fetch/Fetch.jvm.kt b/modules/openid-federation-client/src/jvmMain/kotlin/com/sphereon/oid/fed/client/fetch/Fetch.jvm.kt deleted file mode 100644 index 32c76e05..00000000 --- a/modules/openid-federation-client/src/jvmMain/kotlin/com/sphereon/oid/fed/client/fetch/Fetch.jvm.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.sphereon.oid.fed.client.fetch - -import com.sphereon.oid.fed.client.types.IFetchService -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.engine.java.* -import io.ktor.client.request.* -import io.ktor.http.* - -actual fun fetchService(): IFetchService { - return object : IFetchService { - private val httpClient = HttpClient(Java) - - override suspend fun fetchStatement(endpoint: String): String { - return httpClient.get(endpoint) { - headers { - append(HttpHeaders.Accept, "application/entity-statement+jwt") - } - }.body() - } - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0c5bfaa4..a4b8bca3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,9 @@ pluginManagement { maven { url = uri("https://nexus.sphereon.com/repository/sphereon-opensource-releases") } + maven { + url = uri("https://jitpack.io") + } } } plugins { @@ -42,9 +45,11 @@ dependencyResolutionManagement { maven { url = uri("https://nexus.sphereon.com/repository/sphereon-opensource-releases") } + maven { + url = uri("https://jitpack.io") + } } } - include(":modules:openid-federation-common") include(":modules:openid-federation-client") include(":modules:admin-server") @@ -54,3 +59,5 @@ include(":modules:persistence") include(":modules:services") include(":modules:local-kms") include(":modules:logger") +include(":modules:http-resolver") +include(":modules:cache")