diff --git a/modules/openid-federation-common/build.gradle.kts b/modules/openid-federation-common/build.gradle.kts index 33e73307..f4e568ee 100644 --- a/modules/openid-federation-common/build.gradle.kts +++ b/modules/openid-federation-common/build.gradle.kts @@ -52,7 +52,6 @@ kotlin { implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.0") - implementation("com.nimbusds:nimbus-jose-jwt:9.40") } } val commonTest by getting { @@ -64,6 +63,7 @@ kotlin { val jvmMain by getting { dependencies { implementation("io.ktor:ktor-client-core-jvm:$ktorVersion") + implementation("com.nimbusds:nimbus-jose-jwt:9.40") } } val jvmTest by getting { @@ -109,6 +109,11 @@ kotlin { val jsMain by getting { dependencies { implementation("io.ktor:ktor-client-js:$ktorVersion") + implementation(npm("typescript", "5.5.3")) + implementation(npm("jose", "5.6.3")) + implementation(npm("uuid", "10.0.0")) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC") } } @@ -117,6 +122,8 @@ kotlin { dependencies { implementation(kotlin("test-js")) implementation(kotlin("test-annotations-common")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0-RC") } } } diff --git a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwks/JWKSGenerator.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwks/JWKSGenerator.kt deleted file mode 100644 index aa2f732e..00000000 --- a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwks/JWKSGenerator.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.sphereon.oid.fed.jwks - -import com.nimbusds.jose.jwk.JWK -import com.nimbusds.jose.jwk.JWKSet -import com.nimbusds.jose.jwk.KeyUse -import com.nimbusds.jose.jwk.gen.RSAKeyGenerator -import com.sphereon.oid.fed.kms.AbstractKeyStore -import java.util.* - -class JWKSGenerator ( - val kms: AbstractKeyStore -) { - fun generateJWKS(kid: String? = null): JWK { - val jwk = RSAKeyGenerator(2048) - .keyUse(KeyUse.SIGNATURE) - .keyID(kid ?: UUID.randomUUID().toString()) - .generate() - kms.importKey(jwk) - return jwk.toPublicJWK() - } - - fun getJWKSet(vararg kid: String): JWKSet { - val keys = kms.listKeys(*kid) - return JWKSet(keys.map { it.toPublicJWK() }) - } - - fun sign(kid: String, payload: String): String { - return kms.sign(kid, payload) - } -} diff --git a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.kt new file mode 100644 index 00000000..c5675a7b --- /dev/null +++ b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.kt @@ -0,0 +1,4 @@ +package com.sphereon.oid.fed.common.jwt + +expect fun sign(payload: String, opts: MutableMap?): String +expect fun verify(jwt: String, key: Any, opts: MutableMap? = mutableMapOf()): Boolean diff --git a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/AbstractKeyStore.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/AbstractKeyStore.kt deleted file mode 100644 index e2134dd3..00000000 --- a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/AbstractKeyStore.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.sphereon.oid.fed.kms - -import com.nimbusds.jose.jwk.JWK - -interface AbstractKeyStore { - fun importKey(key: JWK): Boolean - fun getKey(kid: String): JWK? - fun deleteKey(kid: String): Boolean - fun listKeys(vararg kid: String): List - fun sign(kid: String, payload: String): String -} \ No newline at end of file diff --git a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/MemoryKeyStore.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/MemoryKeyStore.kt deleted file mode 100644 index c4ebd0e3..00000000 --- a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/MemoryKeyStore.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.sphereon.oid.fed.kms - -import com.nimbusds.jose.JWSAlgorithm -import com.nimbusds.jose.JWSHeader -import com.nimbusds.jose.crypto.RSASSASigner -import com.nimbusds.jose.jwk.JWK -import com.nimbusds.jose.jwk.RSAKey -import com.nimbusds.jwt.JWTClaimsSet -import com.nimbusds.jwt.SignedJWT -import java.util.concurrent.ConcurrentHashMap - -class MemoryKeyStore : AbstractKeyStore { - - private val keyStore = ConcurrentHashMap() - - override fun importKey(key: JWK): Boolean { - if (key.keyID == null) throw IllegalArgumentException("Key ID cannot be null") - keyStore[key.keyID] = key - return keyStore.containsKey(key.keyID) - } - - override fun getKey(kid: String): JWK? { - return keyStore[kid] - } - - override fun deleteKey(kid: String): Boolean { - return keyStore.remove(kid) != null - } - - override fun listKeys(vararg kid: String): List { - if (kid.isNotEmpty()) { - return kid.mapNotNull { keyStore[it] } - } - return keyStore.values.toList() - } - - override fun sign(kid: String, payload: String): String { - val privateKey = (this.getKey(kid) as RSAKey).toRSAPrivateKey() - - val claims = JWTClaimsSet.parse(payload) - - val signer = RSASSASigner(privateKey) - val jwt = SignedJWT( - JWSHeader.Builder(JWSAlgorithm.RS256).keyID(kid).build(), - claims - ) - jwt.sign(signer) - return jwt.serialize() - } -} diff --git a/modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/jwks/JWKSGeneratorTest.kt b/modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/jwks/JWKSGeneratorTest.kt deleted file mode 100644 index e744a224..00000000 --- a/modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/jwks/JWKSGeneratorTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.sphereon.oid.fed.jwks - -import com.sphereon.oid.fed.kms.MemoryKeyStore -import kotlin.test.Test -import kotlin.test.BeforeTest -import kotlin.test.assertNotNull -import kotlin.test.assertEquals - - -class JWKSGenerationTest { - - private lateinit var jwksGenerator: JWKSGenerator - - @BeforeTest - fun setUp() { - jwksGenerator = JWKSGenerator(MemoryKeyStore()) - } - - @Test - fun `it should generate the JWKS` () { - assertNotNull(jwksGenerator.generateJWKS()) - } - - @Test - fun `It should generate JWKS with all keys` () { - jwksGenerator.generateJWKS() - jwksGenerator.generateJWKS() - assertTrue(jwksGenerator.getJWKSet().size() == 2) - } - - @Test - fun `It should generate JWKS with selected keys` () { - val keyOne = jwksGenerator.generateJWKS() - val keyTwo = jwksGenerator.generateJWKS() - jwksGenerator.generateJWKS() - jwksGenerator.generateJWKS() - assertTrue(jwksGenerator.getJWKSet(keyOne.keyID, keyTwo.keyID).size() == 2) - } - - @Test - fun `It should sign a JWT` () { - val key = jwksGenerator.generateJWKS() - val payload = "{\"iss\":\"test\",\"sub\":\"test\"}" - assertTrue(jwksGenerator.sign(key.keyID, payload).startsWith("ey")) - } -} \ No newline at end of file diff --git a/modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/kms/MemoryKeyStoreTest.kt b/modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/kms/MemoryKeyStoreTest.kt deleted file mode 100644 index 20854b7f..00000000 --- a/modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/kms/MemoryKeyStoreTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.sphereon.oid.fed.jwks - -import com.nimbusds.jose.jwk.KeyUse -import com.nimbusds.jose.jwk.gen.RSAKeyGenerator -import com.sphereon.oid.fed.kms.MemoryKeyStore -import kotlin.test.Test -import kotlin.test.BeforeTest -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import java.util.* - -class MemoryKeyStoreTest { - - lateinit var kms: MemoryKeyStore - lateinit var keyId: String - - @BeforeTest - fun setUp() { - kms = MemoryKeyStore() - keyId = UUID.randomUUID().toString() - val jwk = RSAKeyGenerator(2048) - .keyUse(KeyUse.SIGNATURE) - .keyID(keyId) - .generate() - kms.importKey(jwk) - } - - @Test - fun `It should import a key` () { - val jwk = RSAKeyGenerator(2048) - .keyUse(KeyUse.SIGNATURE) - .keyID(UUID.randomUUID().toString()) - .generate() - assertTrue(kms.importKey(jwk)) - } - - @Test - fun `It should retrieve a key` () { - assertNotNull(kms.getKey(keyId)) - } - - @Test - fun `It should retrieve a list of keys` () { - assertTrue(kms.listKeys().size == 1) - } - - @Test - fun `It should delete a key` () { - assertTrue(kms.deleteKey(keyId)) - } -} \ No newline at end of file diff --git a/modules/openid-federation-common/src/iosMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.ios.kt b/modules/openid-federation-common/src/iosMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.ios.kt new file mode 100644 index 00000000..49615003 --- /dev/null +++ b/modules/openid-federation-common/src/iosMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.ios.kt @@ -0,0 +1,16 @@ +package com.sphereon.oid.fed.common.jwt + +actual fun sign( + payload: String, + opts: MutableMap? +): String { + TODO("Not yet implemented") +} + +actual fun verify( + jwt: String, + key: Any, + opts: MutableMap? +): Boolean { + TODO("Not yet implemented") +} \ No newline at end of file diff --git a/modules/openid-federation-common/src/jsMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.js.kt b/modules/openid-federation-common/src/jsMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.js.kt new file mode 100644 index 00000000..18ffb941 --- /dev/null +++ b/modules/openid-federation-common/src/jsMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.js.kt @@ -0,0 +1,48 @@ +package com.sphereon.oid.fed.common.jwt + +@JsModule("jose") +@JsNonModule +external object Jose { + class SignJWT { + constructor(payload: dynamic) { + definedExternally + } + fun setProtectedHeader(protectedHeader: dynamic): SignJWT { + definedExternally + } + fun sign(key: Any?, signOptions: Any?): String { + definedExternally + } + } + fun generateKeyPair(alg: String, options: dynamic = definedExternally): dynamic + fun jwtVerify(jwt: String, key: Any, options: dynamic = definedExternally): dynamic +} + +@JsModule("uuid") +@JsNonModule +external object Uuid { + fun v4(): String +} + +@ExperimentalJsExport +@JsExport +actual fun sign( + payload: String, + opts: MutableMap? +): String { + val privateKey = opts?.get("privateKey") ?: throw IllegalArgumentException("JWK private key is required") + val header = opts["jwtHeader"] as String? ?: "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"${Uuid.v4()}\"}" + return Jose.SignJWT(JSON.parse(payload).asDynamic()) + .setProtectedHeader(JSON.parse(header).asDynamic()) + .sign(key = privateKey, signOptions = opts) +} + +@ExperimentalJsExport +@JsExport +actual fun verify( + jwt: String, + key: Any, + opts: MutableMap? +): Boolean { + return Jose.jwtVerify(jwt, key, opts) +} \ No newline at end of file diff --git a/modules/openid-federation-common/src/jsTest/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.js.kt b/modules/openid-federation-common/src/jsTest/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.js.kt new file mode 100644 index 00000000..22000311 --- /dev/null +++ b/modules/openid-federation-common/src/jsTest/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.js.kt @@ -0,0 +1,28 @@ +package com.sphereon.oid.fed.common.jwt + +import com.sphereon.oid.fed.common.jwt.Jose.generateKeyPair +import kotlinx.coroutines.async +import kotlinx.coroutines.await +import kotlinx.coroutines.test.runTest +import kotlin.js.Promise +import kotlin.test.Test +import kotlin.test.assertTrue + +class JoseJwtTest { + @OptIn(ExperimentalJsExport::class) + @Test + fun signTest() = runTest { + val keyPair = (generateKeyPair("RS256") as Promise).await() + val result = async { sign("{ \"iss\": \"test\" }", mutableMapOf("privateKey" to keyPair.privateKey)) } + assertTrue((result.await() as Promise).await().startsWith("ey")) + } + + @OptIn(ExperimentalJsExport::class) + @Test + fun verifyTest() = runTest { + val keyPair = (generateKeyPair("RS256") as Promise).await() + val signed = (sign("{ \"iss\": \"test\" }", mutableMapOf("privateKey" to keyPair.privateKey)) as Promise).await() + val result = async { verify(signed, keyPair.publicKey) } + assertTrue((result.await() as Promise).await()) + } +} \ No newline at end of file diff --git a/modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.jvm.kt b/modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.jvm.kt new file mode 100644 index 00000000..cbe94dec --- /dev/null +++ b/modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.jvm.kt @@ -0,0 +1,61 @@ +package com.sphereon.oid.fed.common.jwt + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.JWSSigner +import com.nimbusds.jose.JWSVerifier +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.crypto.RSASSAVerifier +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import java.util.* + +actual fun sign( + payload: String, + opts: MutableMap? +): String { + var rsaJWK = opts?.get("key") as RSAKey? + val kid = rsaJWK?.keyID ?: UUID.randomUUID().toString() + val header: JWSHeader? + if (opts?.get("jwtHeader") != null) { + header = JWSHeader.parse(opts["jwtHeader"] as String?) + } else { + header = JWSHeader.Builder(JWSAlgorithm.RS256).keyID(kid).build() + } + + if (rsaJWK == null) { + rsaJWK = RSAKeyGenerator(2048) + .keyID(kid) + .generate() + } + + val signer: JWSSigner = RSASSASigner(rsaJWK) + + val claimsSet = JWTClaimsSet.parse(payload) + + val signedJWT = SignedJWT( + header, + claimsSet + ) + + signedJWT.sign(signer) + return signedJWT.serialize() +} + +actual fun verify( + jwt: String, + key: Any, + opts: MutableMap? +): Boolean { + try { + val rsaKey = key as RSAKey + val verifier: JWSVerifier = RSASSAVerifier(rsaKey) + val signedJWT = SignedJWT.parse(jwt) + val verified = signedJWT.verify(verifier) + return verified + } catch (e: Exception) { + throw Exception("Couldn't verify the JWT Signature: ${e.message}") + } +} \ No newline at end of file diff --git a/modules/openid-federation-common/src/jvmTest/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.jvm.kt b/modules/openid-federation-common/src/jvmTest/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.jvm.kt new file mode 100644 index 00000000..aadf2a99 --- /dev/null +++ b/modules/openid-federation-common/src/jvmTest/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.jvm.kt @@ -0,0 +1,26 @@ +package com.sphereon.oid.fed.common.jwt + +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import kotlin.test.Test +import kotlin.test.assertTrue + +class JoseJwtTest { + + @Test + fun signTest() { + val signature = sign("{ \"iss\": \"test\" }", mutableMapOf()) + assertTrue { signature.startsWith("ey") } + } + + @Test + fun verifyTest() { + val kid = "key1" + val key: RSAKey = RSAKeyGenerator(2048).keyID(kid).generate() + val signature = sign("{ \"iss\": \"test\" }", mutableMapOf( + "key" to key, + "jwtHeader" to "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"${key.keyID}\"}" + )) + assertTrue { verify(signature, key) } + } +} \ No newline at end of file