From 21058465cff163296492d980384283c1a0543563 Mon Sep 17 00:00:00 2001 From: Zoe Maas Date: Fri, 12 Jul 2024 11:34:55 +0200 Subject: [PATCH 01/13] feat: Implemented KMS, JWKS generation and JWT sign --- .../openid-federation-common/build.gradle.kts | 1 + .../oid/fed/common/jwks/JWKSGenerator.kt | 30 +++++++++++ .../oid/fed/common/kms/AbstractKeyStore.kt | 11 ++++ .../oid/fed/common/kms/MemoryKeyStore.kt | 50 ++++++++++++++++++ .../oid/fed/common/jwks/JWKSGeneratorTest.kt | 47 +++++++++++++++++ .../oid/fed/common/kms/MemoryKeyStoreTest.kt | 51 +++++++++++++++++++ 6 files changed, 190 insertions(+) create mode 100644 modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwks/JWKSGenerator.kt create mode 100644 modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/AbstractKeyStore.kt create mode 100644 modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/MemoryKeyStore.kt create mode 100644 modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/jwks/JWKSGeneratorTest.kt create mode 100644 modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/kms/MemoryKeyStoreTest.kt diff --git a/modules/openid-federation-common/build.gradle.kts b/modules/openid-federation-common/build.gradle.kts index 62762b20..33e73307 100644 --- a/modules/openid-federation-common/build.gradle.kts +++ b/modules/openid-federation-common/build.gradle.kts @@ -52,6 +52,7 @@ 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 { 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 new file mode 100644 index 00000000..aa2f732e --- /dev/null +++ b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwks/JWKSGenerator.kt @@ -0,0 +1,30 @@ +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/kms/AbstractKeyStore.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/AbstractKeyStore.kt new file mode 100644 index 00000000..e2134dd3 --- /dev/null +++ b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/AbstractKeyStore.kt @@ -0,0 +1,11 @@ +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 new file mode 100644 index 00000000..c4ebd0e3 --- /dev/null +++ b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/MemoryKeyStore.kt @@ -0,0 +1,50 @@ +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 new file mode 100644 index 00000000..1071261c --- /dev/null +++ b/modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/jwks/JWKSGeneratorTest.kt @@ -0,0 +1,47 @@ +package com.sphereon.oid.fed.jwks + +import com.sphereon.oid.fed.kms.MemoryKeyStore +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + + +class JWKSGenerationTest { + + private lateinit var jwksGenerator: JWKSGenerator + + @BeforeEach + 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 new file mode 100644 index 00000000..e8f1f957 --- /dev/null +++ b/modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/kms/MemoryKeyStoreTest.kt @@ -0,0 +1,51 @@ +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 org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.* + +class MemoryKeyStoreTest { + + lateinit var kms: MemoryKeyStore + lateinit var keyId: String + + @BeforeEach + 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 From b3f03c12b6fb85f177777811bcebb02c015c5fb9 Mon Sep 17 00:00:00 2001 From: Zoe Maas Date: Fri, 12 Jul 2024 13:33:56 +0200 Subject: [PATCH 02/13] fix: Test dependencies --- .../sphereon/oid/fed/common/jwks/JWKSGeneratorTest.kt | 9 ++++----- .../sphereon/oid/fed/common/kms/MemoryKeyStoreTest.kt | 10 +++++----- 2 files changed, 9 insertions(+), 10 deletions(-) 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 index 1071261c..e744a224 100644 --- 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 @@ -1,10 +1,9 @@ package com.sphereon.oid.fed.jwks import com.sphereon.oid.fed.kms.MemoryKeyStore -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test +import kotlin.test.Test +import kotlin.test.BeforeTest +import kotlin.test.assertNotNull import kotlin.test.assertEquals @@ -12,7 +11,7 @@ class JWKSGenerationTest { private lateinit var jwksGenerator: JWKSGenerator - @BeforeEach + @BeforeTest fun setUp() { jwksGenerator = JWKSGenerator(MemoryKeyStore()) } 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 index e8f1f957..20854b7f 100644 --- 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 @@ -3,10 +3,10 @@ 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 org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test +import kotlin.test.Test +import kotlin.test.BeforeTest +import kotlin.test.assertNotNull +import kotlin.test.assertTrue import java.util.* class MemoryKeyStoreTest { @@ -14,7 +14,7 @@ class MemoryKeyStoreTest { lateinit var kms: MemoryKeyStore lateinit var keyId: String - @BeforeEach + @BeforeTest fun setUp() { kms = MemoryKeyStore() keyId = UUID.randomUUID().toString() From 14c6a807e52caf95ded2c5252ff68a04ac0142e5 Mon Sep 17 00:00:00 2001 From: Zoe Maas Date: Wed, 17 Jul 2024 18:23:10 +0200 Subject: [PATCH 03/13] feat: Created sign and verify jwt functions --- .../openid-federation-common/build.gradle.kts | 9 ++- .../oid/fed/common/jwks/JWKSGenerator.kt | 30 --------- .../sphereon/oid/fed/common/jwt/JoseJwt.kt | 4 ++ .../oid/fed/common/kms/AbstractKeyStore.kt | 11 ---- .../oid/fed/common/kms/MemoryKeyStore.kt | 50 --------------- .../oid/fed/common/jwks/JWKSGeneratorTest.kt | 46 -------------- .../oid/fed/common/kms/MemoryKeyStoreTest.kt | 51 ---------------- .../oid/fed/common/jwt/JoseJwt.ios.kt | 16 +++++ .../sphereon/oid/fed/common/jwt/JoseJwt.js.kt | 48 +++++++++++++++ .../oid/fed/common/jwt/JoseJwtTest.js.kt | 28 +++++++++ .../oid/fed/common/jwt/JoseJwt.jvm.kt | 61 +++++++++++++++++++ .../oid/fed/common/jwt/JoseJwtTest.jvm.kt | 26 ++++++++ 12 files changed, 191 insertions(+), 189 deletions(-) delete mode 100644 modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwks/JWKSGenerator.kt create mode 100644 modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.kt delete mode 100644 modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/AbstractKeyStore.kt delete mode 100644 modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/kms/MemoryKeyStore.kt delete mode 100644 modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/jwks/JWKSGeneratorTest.kt delete mode 100644 modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/kms/MemoryKeyStoreTest.kt create mode 100644 modules/openid-federation-common/src/iosMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.ios.kt create mode 100644 modules/openid-federation-common/src/jsMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.js.kt create mode 100644 modules/openid-federation-common/src/jsTest/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.js.kt create mode 100644 modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.jvm.kt create mode 100644 modules/openid-federation-common/src/jvmTest/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.jvm.kt 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 From 6b0a610f2586412968c09a6e77c5ee8a7845c428 Mon Sep 17 00:00:00 2001 From: Zoe Maas Date: Wed, 17 Jul 2024 18:29:39 +0200 Subject: [PATCH 04/13] refactor: Added trailing new line to the files --- .../kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.ios.kt | 2 +- .../jsMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.js.kt | 2 +- .../kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.js.kt | 2 +- .../kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.jvm.kt | 2 +- .../kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.jvm.kt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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 index 49615003..e1c6ca28 100644 --- 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 @@ -13,4 +13,4 @@ actual fun verify( 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 index 18ffb941..94cc41ab 100644 --- 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 @@ -45,4 +45,4 @@ actual fun verify( 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 index 22000311..73be959d 100644 --- 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 @@ -25,4 +25,4 @@ class JoseJwtTest { 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 index cbe94dec..398b32aa 100644 --- 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 @@ -58,4 +58,4 @@ actual fun verify( } 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 index aadf2a99..628fc3c2 100644 --- 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 @@ -23,4 +23,4 @@ class JoseJwtTest { )) assertTrue { verify(signature, key) } } -} \ No newline at end of file +} From d5c34e9a2af57745ba269f4d659d26d8aacf7fc2 Mon Sep 17 00:00:00 2001 From: Zoe Maas Date: Thu, 18 Jul 2024 10:55:35 +0200 Subject: [PATCH 05/13] fix: Removed some targets temporarily to fix build issues. --- .../openid-federation-common/build.gradle.kts | 88 ++++++++++--------- .../oid/fed/common/jwt/JoseJwt.ios.kt | 16 ---- 2 files changed, 45 insertions(+), 59 deletions(-) delete mode 100644 modules/openid-federation-common/src/iosMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.ios.kt diff --git a/modules/openid-federation-common/build.gradle.kts b/modules/openid-federation-common/build.gradle.kts index f4e568ee..82dc5098 100644 --- a/modules/openid-federation-common/build.gradle.kts +++ b/modules/openid-federation-common/build.gradle.kts @@ -33,16 +33,17 @@ kotlin { // wasmJs is not available yet for ktor until v3.x is released which is still in alpha - androidTarget { - @OptIn(ExperimentalKotlinGradlePluginApi::class) - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } - } - - iosX64() - iosArm64() - iosSimulatorArm64() +// TODO Should be placed back at a later point in time: https://sphereon.atlassian.net/browse/OIDF-50 +// androidTarget { +// @OptIn(ExperimentalKotlinGradlePluginApi::class) +// compilerOptions { +// jvmTarget.set(JvmTarget.JVM_11) +// } +// } +// +// iosX64() +// iosArm64() +// iosSimulatorArm64() jvm() @@ -72,39 +73,40 @@ kotlin { } } - val androidMain by getting { - dependencies { - implementation("io.ktor:ktor-client-core-jvm:$ktorVersion") - } - } - val androidUnitTest by getting { - dependencies { - implementation(kotlin("test-junit")) - } - } - - val iosMain by creating { - dependsOn(commonMain) - dependencies { - implementation("io.ktor:ktor-client-ios:$ktorVersion") - } - } - val iosX64Main by getting { - dependsOn(iosMain) - } - val iosArm64Main by getting { - dependsOn(iosMain) - } - val iosSimulatorArm64Main by getting { - dependsOn(iosMain) - } - - val iosTest by creating { - dependsOn(commonTest) - dependencies { - implementation(kotlin("test")) - } - } +// TODO Should be placed back at a later point in time: https://sphereon.atlassian.net/browse/OIDF-50 +// val androidMain by getting { +// dependencies { +// implementation("io.ktor:ktor-client-core-jvm:$ktorVersion") +// } +// } +// val androidUnitTest by getting { +// dependencies { +// implementation(kotlin("test-junit")) +// } +// } +// +// val iosMain by creating { +// dependsOn(commonMain) +// dependencies { +// implementation("io.ktor:ktor-client-ios:$ktorVersion") +// } +// } +// val iosX64Main by getting { +// dependsOn(iosMain) +// } +// val iosArm64Main by getting { +// dependsOn(iosMain) +// } +// val iosSimulatorArm64Main by getting { +// dependsOn(iosMain) +// } +// +// val iosTest by creating { +// dependsOn(commonTest) +// dependencies { +// implementation(kotlin("test")) +// } +// } val jsMain by getting { dependencies { 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 deleted file mode 100644 index e1c6ca28..00000000 --- a/modules/openid-federation-common/src/iosMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.ios.kt +++ /dev/null @@ -1,16 +0,0 @@ -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") -} From 440300ce75270b6cf95ad7776df317231aebfd86 Mon Sep 17 00:00:00 2001 From: Zoe Maas Date: Tue, 23 Jul 2024 10:06:23 +0200 Subject: [PATCH 06/13] refactor: made the second paramenter of functions a Map without default value and refactored the key generation --- .../sphereon/oid/fed/common/jwt/JoseJwt.kt | 4 ++-- .../sphereon/oid/fed/common/jwt/JoseJwt.js.kt | 6 +++--- .../oid/fed/common/jwt/JoseJwtTest.js.kt | 2 +- .../oid/fed/common/jwt/JoseJwt.jvm.kt | 20 +++++++++---------- 4 files changed, 15 insertions(+), 17 deletions(-) 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 index c5675a7b..b04da780 100644 --- 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 @@ -1,4 +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 +expect fun sign(payload: String, opts: Map): String +expect fun verify(jwt: String, key: Any, opts: Map): Boolean 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 index 94cc41ab..3878a50a 100644 --- 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 @@ -28,9 +28,9 @@ external object Uuid { @JsExport actual fun sign( payload: String, - opts: MutableMap? + opts: Map ): String { - val privateKey = opts?.get("privateKey") ?: throw IllegalArgumentException("JWK private key is required") + val privateKey = opts["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()) @@ -42,7 +42,7 @@ actual fun sign( actual fun verify( jwt: String, key: Any, - opts: MutableMap? + opts: Map ): Boolean { return Jose.jwtVerify(jwt, key, opts) } 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 index 73be959d..d418ad2e 100644 --- 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 @@ -22,7 +22,7 @@ class JoseJwtTest { 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) } + val result = async { verify(signed, keyPair.publicKey, emptyMap()) } assertTrue((result.await() as Promise).await()) } } 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 index 398b32aa..445fd525 100644 --- 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 @@ -14,23 +14,21 @@ import java.util.* actual fun sign( payload: String, - opts: MutableMap? + opts: Map ): String { - var rsaJWK = opts?.get("key") as RSAKey? - val kid = rsaJWK?.keyID ?: UUID.randomUUID().toString() + val rsaJWK = opts["key"] as RSAKey? ?: RSAKeyGenerator(2048) + .keyID(UUID.randomUUID().toString()) + .generate() + + val kid = rsaJWK?.keyID + val header: JWSHeader? - if (opts?.get("jwtHeader") != null) { + if (opts["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) @@ -47,7 +45,7 @@ actual fun sign( actual fun verify( jwt: String, key: Any, - opts: MutableMap? + opts: Map ): Boolean { try { val rsaKey = key as RSAKey From 859d7886f2816c6b43507575ebec0a23b92b5fb1 Mon Sep 17 00:00:00 2001 From: Zoe Maas Date: Tue, 23 Jul 2024 11:00:36 +0200 Subject: [PATCH 07/13] refactor: Fixed build issues and removed commented-out code --- modules/openid-federation-common/build.gradle.kts | 2 -- .../kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.jvm.kt | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/openid-federation-common/build.gradle.kts b/modules/openid-federation-common/build.gradle.kts index cccae2ec..3a11809a 100644 --- a/modules/openid-federation-common/build.gradle.kts +++ b/modules/openid-federation-common/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig 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 index 628fc3c2..f348308f 100644 --- 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 @@ -21,6 +21,6 @@ class JoseJwtTest { "key" to key, "jwtHeader" to "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"${key.keyID}\"}" )) - assertTrue { verify(signature, key) } + assertTrue { verify(signature, key, emptyMap()) } } } From 3fb5bb66a0dc8a81e2935592c36336640b0f6c58 Mon Sep 17 00:00:00 2001 From: Zoe Maas Date: Tue, 23 Jul 2024 11:33:23 +0200 Subject: [PATCH 08/13] fix: Fixed failing test and null pointer exception --- .../sphereon/oid/fed/common/jwt/JoseJwt.jvm.kt | 15 +++++---------- .../oid/fed/common/jwt/JoseJwtTest.jvm.kt | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) 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 index 445fd525..5fe9ff1e 100644 --- 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 @@ -20,15 +20,10 @@ actual fun sign( .keyID(UUID.randomUUID().toString()) .generate() - val kid = rsaJWK?.keyID - - val header: JWSHeader? - if (opts["jwtHeader"] != null) { - header = JWSHeader.parse(opts["jwtHeader"] as String?) - } else { - header = JWSHeader.Builder(JWSAlgorithm.RS256).keyID(kid).build() - } - + val header = opts["jwtHeader"]?.let { + JWSHeader.parse(it as String?) + } ?: JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJWK.keyID).build() + val signer: JWSSigner = RSASSASigner(rsaJWK) val claimsSet = JWTClaimsSet.parse(payload) @@ -54,6 +49,6 @@ actual fun verify( val verified = signedJWT.verify(verifier) return verified } catch (e: Exception) { - throw Exception("Couldn't verify the JWT Signature: ${e.message}") + throw Exception("Couldn't verify the JWT Signature: ${e.message}", e) } } 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 index f348308f..77c18d0b 100644 --- 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 @@ -9,7 +9,7 @@ class JoseJwtTest { @Test fun signTest() { - val signature = sign("{ \"iss\": \"test\" }", mutableMapOf()) + val signature = sign("{ \"iss\": \"test\" }", emptyMap()) assertTrue { signature.startsWith("ey") } } From 4222e59c4c0759ed3216b4b3036064b3e6acbd21 Mon Sep 17 00:00:00 2001 From: Zoe Maas Date: Tue, 30 Jul 2024 14:59:41 +0200 Subject: [PATCH 09/13] refactor: Fixed dependencies and made the protectedHeader a param --- modules/openid-federation-common/build.gradle.kts | 2 -- .../com/sphereon/oid/fed/common/jwt/JoseJwt.kt | 2 +- .../com/sphereon/oid/fed/common/jwt/JoseJwt.js.kt | 2 +- .../sphereon/oid/fed/common/jwt/JoseJwtTest.js.kt | 4 ++-- .../com/sphereon/oid/fed/common/jwt/JoseJwt.jvm.kt | 14 ++++---------- .../sphereon/oid/fed/common/jwt/JoseJwtTest.jvm.kt | 9 +++++++-- 6 files changed, 15 insertions(+), 18 deletions(-) diff --git a/modules/openid-federation-common/build.gradle.kts b/modules/openid-federation-common/build.gradle.kts index 3a11809a..22c2abb1 100644 --- a/modules/openid-federation-common/build.gradle.kts +++ b/modules/openid-federation-common/build.gradle.kts @@ -112,7 +112,6 @@ kotlin { 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") } @@ -123,7 +122,6 @@ 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/jwt/JoseJwt.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.kt index b04da780..fb1bc18a 100644 --- 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 @@ -1,4 +1,4 @@ package com.sphereon.oid.fed.common.jwt -expect fun sign(payload: String, opts: Map): String +expect fun sign(payload: String, header: String, opts: Map): String expect fun verify(jwt: String, key: Any, opts: Map): Boolean 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 index 3878a50a..c1405ab3 100644 --- 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 @@ -28,10 +28,10 @@ external object Uuid { @JsExport actual fun sign( payload: String, + header: String, opts: Map ): String { val privateKey = opts["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) 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 index d418ad2e..12cd971f 100644 --- 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 @@ -13,7 +13,7 @@ class JoseJwtTest { @Test fun signTest() = runTest { val keyPair = (generateKeyPair("RS256") as Promise).await() - val result = async { sign("{ \"iss\": \"test\" }", mutableMapOf("privateKey" to keyPair.privateKey)) } + val result = async { sign("{\"iss\":\"test\"}", "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"test\"}", mutableMapOf("privateKey" to keyPair.privateKey)) } assertTrue((result.await() as Promise).await().startsWith("ey")) } @@ -21,7 +21,7 @@ class JoseJwtTest { @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 signed = (sign("{\"iss\":\"test\"}", "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"test\" }", mutableMapOf("privateKey" to keyPair.privateKey)) as Promise).await() val result = async { verify(signed, keyPair.publicKey, emptyMap()) } assertTrue((result.await() as Promise).await()) } 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 index 5fe9ff1e..84ff9a6b 100644 --- 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 @@ -1,35 +1,29 @@ 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, + header: String, opts: Map ): String { - val rsaJWK = opts["key"] as RSAKey? ?: RSAKeyGenerator(2048) - .keyID(UUID.randomUUID().toString()) - .generate() + val rsaJWK = opts["key"] as RSAKey? ?: throw IllegalArgumentException("The RSA key pair is required") - val header = opts["jwtHeader"]?.let { - JWSHeader.parse(it as String?) - } ?: JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJWK.keyID).build() + val protectedHeader = JWSHeader.parse(header) val signer: JWSSigner = RSASSASigner(rsaJWK) val claimsSet = JWTClaimsSet.parse(payload) val signedJWT = SignedJWT( - header, + protectedHeader, claimsSet ) 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 index 77c18d0b..03715ce6 100644 --- 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 @@ -9,7 +9,12 @@ class JoseJwtTest { @Test fun signTest() { - val signature = sign("{ \"iss\": \"test\" }", emptyMap()) + val key = RSAKeyGenerator(2048).keyID("key1").generate() + val signature = sign( + "{ \"iss\": \"test\" }", + "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"${key.keyID}\"}", + mutableMapOf("key" to key) + ) assertTrue { signature.startsWith("ey") } } @@ -17,7 +22,7 @@ class JoseJwtTest { fun verifyTest() { val kid = "key1" val key: RSAKey = RSAKeyGenerator(2048).keyID(kid).generate() - val signature = sign("{ \"iss\": \"test\" }", mutableMapOf( + val signature = sign("{ \"iss\": \"test\" }","{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"test\"}", mutableMapOf( "key" to key, "jwtHeader" to "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"${key.keyID}\"}" )) From bc3ecc8a5936a16fdeb828ba67e73ce01990a9f6 Mon Sep 17 00:00:00 2001 From: Zoe Maas Date: Tue, 30 Jul 2024 15:00:48 +0200 Subject: [PATCH 10/13] refactor: Fixed code formatting --- .../com/sphereon/oid/fed/common/jwt/JoseJwtTest.js.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 index 12cd971f..4d3f41ac 100644 --- 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 @@ -13,7 +13,9 @@ class JoseJwtTest { @Test fun signTest() = runTest { val keyPair = (generateKeyPair("RS256") as Promise).await() - val result = async { sign("{\"iss\":\"test\"}", "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"test\"}", mutableMapOf("privateKey" to keyPair.privateKey)) } + val result = async { sign("{\"iss\":\"test\"}", + "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"test\"}", + mutableMapOf("privateKey" to keyPair.privateKey)) } assertTrue((result.await() as Promise).await().startsWith("ey")) } @@ -21,7 +23,10 @@ class JoseJwtTest { @Test fun verifyTest() = runTest { val keyPair = (generateKeyPair("RS256") as Promise).await() - val signed = (sign("{\"iss\":\"test\"}", "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"test\" }", mutableMapOf("privateKey" to keyPair.privateKey)) as Promise).await() + val signed = (sign( + "{\"iss\":\"test\"}", + "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"test\" }", + mutableMapOf("privateKey" to keyPair.privateKey)) as Promise).await() val result = async { verify(signed, keyPair.publicKey, emptyMap()) } assertTrue((result.await() as Promise).await()) } From db8e1162631eb75ede151c2d61db3f82f4c4b638 Mon Sep 17 00:00:00 2001 From: Zoe Maas Date: Fri, 2 Aug 2024 16:21:30 +0200 Subject: [PATCH 11/13] refactor: Made JWT payload and header classes to be used as input --- .../sphereon/oid/fed/common/jwt/JoseJwt.kt | 5 +++- .../sphereon/oid/fed/common/jwt/JoseJwt.js.kt | 21 +++++++++------- .../oid/fed/common/jwt/JoseJwtTest.js.kt | 12 +++++---- .../oid/fed/common/jwt/JoseJwt.jvm.kt | 15 ++++++----- .../oid/fed/common/jwt/JoseJwtTest.jvm.kt | 25 ++++++++++++++----- 5 files changed, 49 insertions(+), 29 deletions(-) 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 index fb1bc18a..a6ccd627 100644 --- 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 @@ -1,4 +1,7 @@ package com.sphereon.oid.fed.common.jwt -expect fun sign(payload: String, header: String, opts: Map): String +expect class JwtHeader +expect class JwtPayload + +expect fun sign(payload: JwtPayload, header: JwtHeader, opts: Map): String expect fun verify(jwt: String, key: Any, opts: Map): Boolean 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 index c1405ab3..4286f44f 100644 --- 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 @@ -1,5 +1,10 @@ package com.sphereon.oid.fed.common.jwt +import com.sphereon.oid.fed.openapi.models.EntityStatement +import com.sphereon.oid.fed.openapi.models.JWTHeader +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + @JsModule("jose") @JsNonModule external object Jose { @@ -18,22 +23,20 @@ external object Jose { fun jwtVerify(jwt: String, key: Any, options: dynamic = definedExternally): dynamic } -@JsModule("uuid") -@JsNonModule -external object Uuid { - fun v4(): String -} +actual typealias JwtPayload = EntityStatement +actual typealias JwtHeader = JWTHeader @ExperimentalJsExport @JsExport actual fun sign( - payload: String, - header: String, + payload: JwtPayload, + header: JwtHeader, opts: Map ): String { val privateKey = opts["privateKey"] ?: throw IllegalArgumentException("JWK private key is required") - return Jose.SignJWT(JSON.parse(payload).asDynamic()) - .setProtectedHeader(JSON.parse(header).asDynamic()) + + return Jose.SignJWT(JSON.parse(Json.encodeToString(payload))) + .setProtectedHeader(JSON.parse(Json.encodeToString(header))) .sign(key = privateKey, signOptions = opts) } 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 index 4d3f41ac..3f4c3e63 100644 --- 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 @@ -13,8 +13,10 @@ class JoseJwtTest { @Test fun signTest() = runTest { val keyPair = (generateKeyPair("RS256") as Promise).await() - val result = async { sign("{\"iss\":\"test\"}", - "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"test\"}", + val result = async { + sign( + JwtPayload(iss="test"), + JwtHeader(typ="JWT",alg="RS256",kid="test"), mutableMapOf("privateKey" to keyPair.privateKey)) } assertTrue((result.await() as Promise).await().startsWith("ey")) } @@ -24,10 +26,10 @@ class JoseJwtTest { fun verifyTest() = runTest { val keyPair = (generateKeyPair("RS256") as Promise).await() val signed = (sign( - "{\"iss\":\"test\"}", - "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"test\" }", + JwtPayload(iss="test"), + JwtHeader(typ="JWT",alg="RS256",kid="test"), mutableMapOf("privateKey" to keyPair.privateKey)) as Promise).await() val result = async { verify(signed, keyPair.publicKey, emptyMap()) } - assertTrue((result.await() as Promise).await()) + assertTrue((result.await())) } } 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 index 84ff9a6b..a0e9f17b 100644 --- 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 @@ -9,22 +9,21 @@ import com.nimbusds.jose.jwk.RSAKey import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.SignedJWT +actual typealias JwtPayload = JWTClaimsSet +actual typealias JwtHeader = JWSHeader + actual fun sign( - payload: String, - header: String, + payload: JwtPayload, + header: JwtHeader, opts: Map ): String { val rsaJWK = opts["key"] as RSAKey? ?: throw IllegalArgumentException("The RSA key pair is required") - - val protectedHeader = JWSHeader.parse(header) val signer: JWSSigner = RSASSASigner(rsaJWK) - val claimsSet = JWTClaimsSet.parse(payload) - val signedJWT = SignedJWT( - protectedHeader, - claimsSet + header, + payload ) signedJWT.sign(signer) 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 index 03715ce6..54e8ddc3 100644 --- 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 @@ -11,8 +11,15 @@ class JoseJwtTest { fun signTest() { val key = RSAKeyGenerator(2048).keyID("key1").generate() val signature = sign( - "{ \"iss\": \"test\" }", - "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"${key.keyID}\"}", + JwtPayload.parse( + mutableMapOf( + "iss" to "test" + ) + ), + JwtHeader.parse(mutableMapOf( + "typ" to "JWT", + "alg" to "RS256", + "kid" to key.keyID)), mutableMapOf("key" to key) ) assertTrue { signature.startsWith("ey") } @@ -22,10 +29,16 @@ class JoseJwtTest { fun verifyTest() { val kid = "key1" val key: RSAKey = RSAKeyGenerator(2048).keyID(kid).generate() - val signature = sign("{ \"iss\": \"test\" }","{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"test\"}", mutableMapOf( - "key" to key, - "jwtHeader" to "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"${key.keyID}\"}" - )) + val signature = sign( + JwtPayload.parse( + mutableMapOf("iss" to "test") + ), + JwtHeader.parse(mutableMapOf( + "typ" to "JWT", + "alg" to "RS256", + "kid" to key.keyID)), + mutableMapOf("key" to key) + ) assertTrue { verify(signature, key, emptyMap()) } } } From 4848ba3f748814d31b4e07fcba90d874a6c75dff Mon Sep 17 00:00:00 2001 From: John Melati Date: Mon, 5 Aug 2024 09:56:04 +0200 Subject: [PATCH 12/13] fix: add missing repositories for windows (#22) * fix: add missing repositories for windows * fix: update ci docker compose command --- .github/workflows/ci.yml | 2 +- .../openid-federation-common/build.gradle.kts | 43 ++++++++++--------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73b95060..66a729c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: java-version: 17 - name: Build the stack - run: docker-compose -f docker-compose.yaml up -d + run: docker compose -f docker-compose.yaml up -d env: DATASOURCE_USER: ${{ secrets.DATASOURCE_USER }} DATASOURCE_PASSWORD: ${{ secrets.DATASOURCE_PASSWORD }} diff --git a/modules/openid-federation-common/build.gradle.kts b/modules/openid-federation-common/build.gradle.kts index bfebdfeb..4ac9c5c8 100644 --- a/modules/openid-federation-common/build.gradle.kts +++ b/modules/openid-federation-common/build.gradle.kts @@ -9,9 +9,16 @@ plugins { val ktorVersion = "2.3.11" +repositories { + mavenCentral() + google() +} + kotlin { - @OptIn(ExperimentalWasmDsl::class) + jvm() + // wasmJs is not available yet for ktor until v3.x is released which is still in alpha + // @OptIn(ExperimentalWasmDsl::class) js { browser { commonWebpackConfig { @@ -29,33 +36,30 @@ kotlin { } } - // wasmJs is not available yet for ktor until v3.x is released which is still in alpha + // TODO Should be placed back at a later point in time: https://sphereon.atlassian.net/browse/OIDF-50 + // androidTarget { + // @OptIn(ExperimentalKotlinGradlePluginApi::class) + // compilerOptions { + // jvmTarget.set(JvmTarget.JVM_11) + // } + // } -// TODO Should be placed back at a later point in time: https://sphereon.atlassian.net/browse/OIDF-50 -// androidTarget { -// @OptIn(ExperimentalKotlinGradlePluginApi::class) -// compilerOptions { -// jvmTarget.set(JvmTarget.JVM_11) -// } -// } -// -// iosX64() -// iosArm64() -// iosSimulatorArm64() - - jvm() + // iosX64() + // iosArm64() + // iosSimulatorArm64() + // androidTarget() sourceSets { val commonMain by getting { dependencies { - implementation("com.sphereon.oid.fed:openapi:0.1.0-SNAPSHOT") + api(projects.modules.openapi) implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("io.ktor:ktor-client-logging:$ktorVersion") implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") implementation("io.ktor:ktor-client-auth:$ktorVersion") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.1") implementation(libs.kermit.logging) } } @@ -64,6 +68,7 @@ kotlin { implementation(kotlin("test-common")) implementation(kotlin("test-annotations-common")) implementation("io.ktor:ktor-client-mock:$ktorVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0-RC") } } val jvmMain by getting { @@ -138,11 +143,9 @@ kotlin { } val jsTest by getting { - dependsOn(commonTest) dependencies { implementation(kotlin("test-js")) implementation(kotlin("test-annotations-common")) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0-RC") } } } From e6e8527b37c444670f538072098b546bdea9acc7 Mon Sep 17 00:00:00 2001 From: John Melati Date: Fri, 16 Aug 2024 15:44:32 +0200 Subject: [PATCH 13/13] Feature/oidf 54 (#26) * chore: Removed redundant HTTPCache * chore: Uncommented ios targets back * refactor: refactored serializeNullable() * refactor: refactored deserialize() * refactor: refactored OutgoingEntityStatementContent.bytes() * refactor: refactored the tests to use assertEquals() * refactor: Changed the response body to jwt string * refactor: Removed unnecessary converter * feat: implement jwk persistence * fix: remove unused statement * fix: github CI * feat/OIDF-51 - Implement Persistence Module (#21) * merge oidf-7 * fix: models package * fix: openapi TrustMarkOwner property * fix: create account method return type * fix: rename file for consistency * feat: implement migration * fix: repository dependency * fix: add missing trailing new line * feat: implement services module * fix: package path * fix: remove unused file * fix: add missing entity to openapi spec * feat: persist generated keys * fix: typo * fix: missing deps * fix: ci docker command * fix: dependency * fix: remove unnecessary statement * feat: abstract jwk to its own module * feat: encrypt private keys when saving to database * feat: add note to README regarding usage of Local KMS in prod envs * fix: adapt key encryption test cases for when APP_KEY is null * fix: adjust function name * fix: add kotlin-js-store to gitignore * fix: clean common gradle file * fix: disable android build * fix: remove js implementation from services * feat: implement Subordinate repository (#29) * feat: implement federation server structure (#28) * feat: implement federation server structure * feat: implement Subordinate repository * fix: remove unused files * feat: implement federation list endpoint --------- Co-authored-by: Zoe Maas --- .env | 9 +- .gitignore | 1 + README.md | 165 +- docker-compose.yaml | 2 - modules/admin-server/build.gradle.kts | 5 +- .../oid/fed/server/admin/Application.kt | 4 +- .../admin/controllers/AccountController.kt | 22 + .../server/admin/controllers/KeyController.kt | 33 + .../controllers/SubordinateController.kt | 19 + .../src/main/resources/application.properties | 3 - .../oid/fed/server/admin/ApplicationTests.kt | 6 +- .../oid/fed/server/admin/DatabaseTest.kt | 18 - .../fed/server/admin/StatusEndpointTest.kt | 5 +- modules/federation-server/README.md | 27 + modules/federation-server/build.gradle.kts | 46 + .../oid/fed/server/federation/Application.kt | 11 + .../controllers/FederationController.kt | 38 + .../src/main/resources/application.properties | 8 + .../fed/server/federation/ApplicationTests.kt | 12 + .../server/federation/StatusEndpointTest.kt | 26 + modules/openapi/build.gradle.kts | 3 +- .../com/sphereon/oid/fed/openapi/openapi.yaml | 2924 +++++++++++++++-- .../openid-federation-common/build.gradle.kts | 68 +- .../httpclient/EntityStatementJwtConverter.kt | 45 - .../common/httpclient/OidFederationClient.kt | 21 +- .../com/sphereon/oid/fed/common/jwk/Jwk.kt | 6 + .../oid/fed/common/logic/EntityLogic.kt | 8 +- .../oid/fed/common/mapper/JsonMapper.kt | 7 +- .../oid/fed/common/logic/EntityLogicTest.kt | 21 +- .../com.sphereon.oid.fed.common.jwk/Jwk.kt | 20 + .../sphereon/oid/fed/common/jwt/JoseJwt.js.kt | 7 +- .../oid/fed/common/jwt/JoseJwtTest.js.kt | 16 +- .../sphereon/oid/fed/common/jwk/Jwk.jvm.kt | 32 + .../oid/fed/common/jwt/JoseJwt.jvm.kt | 4 +- .../httpclient/OidFederationClientTest.kt | 42 +- modules/persistence/build.gradle.kts | 49 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + .../sphereon/oid/fed/persistence/Constants.kt | 10 + .../oid/fed/persistence/Persistence.kt | 11 + .../persistence/database/PlatformSqlDriver.kt | 8 + .../repositories/AccountRepository.kt | 32 + .../persistence/repositories/KeyRepository.kt | 44 + .../repositories/SubordinateRepository.kt | 19 + .../commonMain/resources/db/migration/1.sql | 11 + .../commonMain/resources/db/migration/2.sql | 30 + .../sphereon/oid/fed/persistence/models/1.sqm | 11 + .../sphereon/oid/fed/persistence/models/2.sqm | 30 + .../sphereon/oid/fed/persistence/models/3.sqm | 12 + .../oid/fed/persistence/models/Account.sq | 18 + .../oid/fed/persistence/models/Key.sq | 33 + .../oid/fed/persistence/models/Subordinate.sq | 14 + .../Persistence.jvm.kt | 66 + .../database/PlatformSqlDriver.jvm.kt | 23 + modules/services/build.gradle.kts | 33 + .../oid/fed/services/AccountService.kt | 24 + .../sphereon/oid/fed/services/Constants.kt | 10 + .../sphereon/oid/fed/services/KeyService.kt | 63 + .../oid/fed/services/SubordinateService.kt | 21 + .../services/extensions/AccountExtensions.kt | 10 + .../fed/services/extensions/KeyExtensions.kt | 61 + .../services/extensions/KeyExtensions.js.kt | 9 + .../services/extensions/KeyExtensions.jvm.kt | 29 + .../oid/fed/services/KeyServiceTest.jvm.kt | 57 + settings.gradle.kts | 3 + 65 files changed, 3947 insertions(+), 484 deletions(-) create mode 100644 modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/AccountController.kt create mode 100644 modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/KeyController.kt create mode 100644 modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/SubordinateController.kt delete mode 100644 modules/admin-server/src/test/kotlin/com/sphereon/oid/fed/server/admin/DatabaseTest.kt create mode 100644 modules/federation-server/README.md create mode 100644 modules/federation-server/build.gradle.kts create mode 100644 modules/federation-server/src/main/kotlin/com/sphereon/oid/fed/server/federation/Application.kt create mode 100644 modules/federation-server/src/main/kotlin/com/sphereon/oid/fed/server/federation/controllers/FederationController.kt create mode 100644 modules/federation-server/src/main/resources/application.properties create mode 100644 modules/federation-server/src/test/kotlin/com/sphereon/oid/fed/server/federation/ApplicationTests.kt create mode 100644 modules/federation-server/src/test/kotlin/com/sphereon/oid/fed/server/federation/StatusEndpointTest.kt delete mode 100644 modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/httpclient/EntityStatementJwtConverter.kt create mode 100644 modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.kt create mode 100644 modules/openid-federation-common/src/jsMain/kotlin/com.sphereon.oid.fed.common.jwk/Jwk.kt create mode 100644 modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.jvm.kt create mode 100644 modules/persistence/build.gradle.kts create mode 100644 modules/persistence/gradle/wrapper/gradle-wrapper.jar create mode 100644 modules/persistence/gradle/wrapper/gradle-wrapper.properties create mode 100644 modules/persistence/src/commonMain/kotlin/com/sphereon/oid/fed/persistence/Constants.kt create mode 100644 modules/persistence/src/commonMain/kotlin/com/sphereon/oid/fed/persistence/Persistence.kt create mode 100644 modules/persistence/src/commonMain/kotlin/com/sphereon/oid/fed/persistence/database/PlatformSqlDriver.kt create mode 100644 modules/persistence/src/commonMain/kotlin/com/sphereon/oid/fed/persistence/repositories/AccountRepository.kt create mode 100644 modules/persistence/src/commonMain/kotlin/com/sphereon/oid/fed/persistence/repositories/KeyRepository.kt create mode 100644 modules/persistence/src/commonMain/kotlin/com/sphereon/oid/fed/persistence/repositories/SubordinateRepository.kt create mode 100644 modules/persistence/src/commonMain/resources/db/migration/1.sql create mode 100644 modules/persistence/src/commonMain/resources/db/migration/2.sql create mode 100644 modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/1.sqm create mode 100644 modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/2.sqm create mode 100644 modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/3.sqm create mode 100644 modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Account.sq create mode 100644 modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Key.sq create mode 100644 modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Subordinate.sq create mode 100644 modules/persistence/src/jvmMain/kotlin/com.sphereon.oid.fed.persistence/Persistence.jvm.kt create mode 100644 modules/persistence/src/jvmMain/kotlin/com/sphereon/oid/fed/persistence/database/PlatformSqlDriver.jvm.kt create mode 100644 modules/services/build.gradle.kts create mode 100644 modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/AccountService.kt create mode 100644 modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/Constants.kt create mode 100644 modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/KeyService.kt create mode 100644 modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/SubordinateService.kt create mode 100644 modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/AccountExtensions.kt create mode 100644 modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.kt create mode 100644 modules/services/src/jsMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.js.kt create mode 100644 modules/services/src/jvmMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.jvm.kt create mode 100644 modules/services/src/jvmTest/kotlin/com/sphereon/oid/fed/services/KeyServiceTest.jvm.kt diff --git a/.env b/.env index 34ab61a8..b5c1af1c 100644 --- a/.env +++ b/.env @@ -1,4 +1,5 @@ -DATASOURCE_URL=jdbc:postgresql://localhost:5432/openid-federation-db -DATASOURCE_USER=openid-federation-db-user -DATASOURCE_PASSWORD=openid-federation-db-password -DATASOURCE_DB=openid-federation-db \ No newline at end of file +DATASOURCE_URL=jdbc:postgresql://localhost:5432/openid-federation-db +DATASOURCE_USER=openid-federation-db-user +DATASOURCE_PASSWORD=openid-federation-db-password +DATASOURCE_DB=openid-federation-db +APP_KEY=Nit5tWts42QeCynT1Q476LyStDeSd4xb \ No newline at end of file diff --git a/.gitignore b/.gitignore index 161fec79..0a8ce54c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ captures /.temp/ /docker/.env /.run/* +kotlin-js-store/ \ No newline at end of file diff --git a/README.md b/README.md index 8b1fe329..feb4eedb 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,94 @@ -

-
- Sphereon -
OpenID Federation Monorepo -
-

- -# Background - -OpenID Federation is a framework designed to facilitate the secure and interoperable interaction of entities within a federation. This involves the use of JSON Web Tokens (JWTs) to represent and convey necessary information for entities to participate in federations, ensuring trust and security across different organizations and systems. - -In the context of OpenID Federation, Entity Statements play a crucial role. These are signed JWTs that contain details about the entity, such as its public keys and metadata. This framework allows entities to assert their identity and capabilities in a standardized manner, enabling seamless integration and interoperability within federations. - -## Key Concepts - -- **Federation**: A group of organizations that agree to interoperate under a set of common rules defined in a federation policy. -- **Entity Statements**: JSON objects that contain metadata about entities (IdPs, RPs) and their federation relationships. -- **Trust Chains**: Mechanisms by which parties in a federation verify each other’s trustworthiness through a chain of entity statements, leading back to a trusted authority. -- **Federation API**: Interfaces defined for entities to exchange information and perform operations necessary for federation management. - -## Core Components - -- **Federation Operator**: The central authority in a federation that manages policy and trust chain verification. -- **Identity Providers (IdPs)**: Entities that authenticate users and provide identity assertions to relying parties. -- **Relying Parties (RPs)**: Entities that rely on identity assertions provided by IdPs to offer services to users. - -## Technical Features - -- **JSON Web Tokens (JWT)**: Used for creating verifiable entity statements and security assertions. -- **JSON Object Signing and Encryption (JOSE)**: Standards for signing and encrypting JSON-based objects to ensure their integrity and confidentiality. - -## Operational Model - -- **Dynamic Federation**: Allows entities to join or adjust their federation relationships dynamically, based on real-time verification of entity statements. -- **Trust Model**: Establishes a model where trust is derived from known and verifiable sources and can be dynamically adjusted according to real-time interactions and policy evaluations. -- **Conflict Resolution**: Defines how disputes or mismatches in federation policies among entities are resolved. - -# Data Structure - -## Entity Statement Overview - -### 1. Definition -- An Entity Statement is a signed JWT containing information necessary for the Entity to participate in federations. -- **Entity Configuration**: An Entity Statement about itself. -- **Subordinate Statement**: An Entity Statement about an Immediate Subordinate Entity by a Superior Entity. - -### 2. Requirements and Structure -- **Type**: JWT must be explicitly typed as `entity-statement+jwt`. -- **Signature**: Signed using the issuer’s private key, preferably using ECDSA with P-256 and SHA-256 (ES256). -- **Key ID (kid)**: The header must include the Key ID of the signing key. - -### 3. Claims in an Entity Statement -- **iss (Issuer)**: Entity Identifier of the issuer. -- **sub (Subject)**: Entity Identifier of the subject. -- **iat (Issued At)**: Time the statement was issued. -- **exp (Expiration Time)**: Time after which the statement is no longer valid. -- **jwks (JSON Web Key Set)**: Public keys for verifying signatures. Required except in specific cases like Explicit Registration. -- **authority_hints** (Optional): Identifiers of Intermediate Entities or Trust Anchors that may issue Subordinate Statements. -- **metadata** (Optional): Represents the Entity’s Types and metadata. -- **metadata_policy** (Optional): Defines a metadata policy, applicable to the subject and its Subordinates. -- **constraints** (Optional): Defines Trust Chain constraints. -- **crit** (Optional): Specifies critical claims that must be understood and processed. -- **metadata_policy_crit** (Optional): Specifies critical metadata policy operators that must be understood and processed. -- **trust_marks** (Optional): Array of JSON objects, each representing a Trust Mark. -- **trust_mark_issuers** (Optional): Specifies trusted issuers of Trust Marks. -- **trust_mark_owners** (Optional): Specifies ownership of Trust Marks by different Entities. -- **source_endpoint** (Optional): URL to fetch the Entity Statement from the issuer. - -### 4. Usage and Flexibility -- Entity Statements can include additional claims as required by applications and protocols. -- Metadata in Subordinate Statements overrides that in the Entity’s own configuration. +

+
+ Sphereon +
OpenID Federation Monorepo +
+

+ +# Background + +OpenID Federation is a framework designed to facilitate the secure and interoperable interaction of entities within a +federation. This involves the use of JSON Web Tokens (JWTs) to represent and convey necessary information for entities +to participate in federations, ensuring trust and security across different organizations and systems. + +In the context of OpenID Federation, Entity Statements play a crucial role. These are signed JWTs that contain details +about the entity, such as its public keys and metadata. This framework allows entities to assert their identity and +capabilities in a standardized manner, enabling seamless integration and interoperability within federations. + +## Key Concepts + +- **Federation**: A group of organizations that agree to interoperate under a set of common rules defined in a + federation policy. +- **Entity Statements**: JSON objects that contain metadata about entities (IdPs, RPs) and their federation + relationships. +- **Trust Chains**: Mechanisms by which parties in a federation verify each other’s trustworthiness through a chain of + entity statements, leading back to a trusted authority. +- **Federation API**: Interfaces defined for entities to exchange information and perform operations necessary for + federation management. + +## Core Components + +- **Federation Operator**: The central authority in a federation that manages policy and trust chain verification. +- **Identity Providers (IdPs)**: Entities that authenticate users and provide identity assertions to relying parties. +- **Relying Parties (RPs)**: Entities that rely on identity assertions provided by IdPs to offer services to users. + +## Technical Features + +- **JSON Web Tokens (JWT)**: Used for creating verifiable entity statements and security assertions. +- **JSON Object Signing and Encryption (JOSE)**: Standards for signing and encrypting JSON-based objects to ensure their + integrity and confidentiality. + +## Operational Model + +- **Dynamic Federation**: Allows entities to join or adjust their federation relationships dynamically, based on + real-time verification of entity statements. +- **Trust Model**: Establishes a model where trust is derived from known and verifiable sources and can be dynamically + adjusted according to real-time interactions and policy evaluations. +- **Conflict Resolution**: Defines how disputes or mismatches in federation policies among entities are resolved. + +# Local Key Management System - Important Notice + +Local Key Management Service is designed primarily for testing, development, and local experimentation +purposes. **It is not intended for use in production environments** due to significant security and compliance risks. + +# Data Structure + +## Entity Statement Overview + +### 1. Definition + +- An Entity Statement is a signed JWT containing information necessary for the Entity to participate in federations. +- **Entity Configuration**: An Entity Statement about itself. +- **Subordinate Statement**: An Entity Statement about an Immediate Subordinate Entity by a Superior Entity. + +### 2. Requirements and Structure + +- **Type**: JWT must be explicitly typed as `entity-statement+jwt`. +- **Signature**: Signed using the issuer’s private key, preferably using ECDSA with P-256 and SHA-256 (ES256). +- **Key ID (kid)**: The header must include the Key ID of the signing key. + +### 3. Claims in an Entity Statement + +- **iss (Issuer)**: Entity Identifier of the issuer. +- **sub (Subject)**: Entity Identifier of the subject. +- **iat (Issued At)**: Time the statement was issued. +- **exp (Expiration Time)**: Time after which the statement is no longer valid. +- **jwks (JSON Web Key Set)**: Public keys for verifying signatures. Required except in specific cases like Explicit + Registration. +- **authority_hints** (Optional): Identifiers of Intermediate Entities or Trust Anchors that may issue Subordinate + Statements. +- **metadata** (Optional): Represents the Entity’s Types and metadata. +- **metadata_policy** (Optional): Defines a metadata policy, applicable to the subject and its Subordinates. +- **constraints** (Optional): Defines Trust Chain constraints. +- **crit** (Optional): Specifies critical claims that must be understood and processed. +- **metadata_policy_crit** (Optional): Specifies critical metadata policy operators that must be understood and + processed. +- **trust_marks** (Optional): Array of JSON objects, each representing a Trust Mark. +- **trust_mark_issuers** (Optional): Specifies trusted issuers of Trust Marks. +- **trust_mark_owners** (Optional): Specifies ownership of Trust Marks by different Entities. +- **source_endpoint** (Optional): URL to fetch the Entity Statement from the issuer. + +### 4. Usage and Flexibility + +- Entity Statements can include additional claims as required by applications and protocols. +- Metadata in Subordinate Statements overrides that in the Entity’s own configuration. diff --git a/docker-compose.yaml b/docker-compose.yaml index af8db708..3a726859 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.9' - services: db: image: postgres:latest diff --git a/modules/admin-server/build.gradle.kts b/modules/admin-server/build.gradle.kts index 06152f33..b512a212 100644 --- a/modules/admin-server/build.gradle.kts +++ b/modules/admin-server/build.gradle.kts @@ -16,7 +16,10 @@ java { } dependencies { + api(projects.modules.openapi) api(projects.modules.openidFederationCommon) + api(projects.modules.persistence) + api(projects.modules.services) implementation(libs.springboot.actuator) implementation(libs.springboot.web) implementation(libs.springboot.data.jdbc) @@ -42,4 +45,4 @@ tasks.withType { events("started", "skipped", "passed", "failed") showStandardStreams = true } -} \ No newline at end of file +} diff --git a/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/Application.kt b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/Application.kt index 654f00f7..019fd9c0 100644 --- a/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/Application.kt +++ b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/Application.kt @@ -7,5 +7,5 @@ import org.springframework.boot.runApplication class Application fun main(args: Array) { - runApplication(*args) -} \ No newline at end of file + runApplication(*args) +} diff --git a/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/AccountController.kt b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/AccountController.kt new file mode 100644 index 00000000..5b5b6a9c --- /dev/null +++ b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/AccountController.kt @@ -0,0 +1,22 @@ +package com.sphereon.oid.fed.server.admin.controllers + +import com.sphereon.oid.fed.openapi.models.AccountDTO +import com.sphereon.oid.fed.openapi.models.CreateAccountDTO +import com.sphereon.oid.fed.services.AccountService +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/accounts") +class AccountController { + private val accountService = AccountService() + + @GetMapping + fun getAccounts(): List { + return accountService.findAll() + } + + @PostMapping + fun createAccount(@RequestBody account: CreateAccountDTO): AccountDTO { + return accountService.create(account) + } +} diff --git a/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/KeyController.kt b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/KeyController.kt new file mode 100644 index 00000000..f8e0e0f8 --- /dev/null +++ b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/KeyController.kt @@ -0,0 +1,33 @@ +package com.sphereon.oid.fed.server.admin.controllers + +import com.sphereon.oid.fed.openapi.models.JwkAdminDTO +import com.sphereon.oid.fed.services.KeyService +import com.sphereon.oid.fed.services.extensions.toJwkAdminDTO +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/accounts/{accountUsername}/keys") +class KeyController { + private val keyService = KeyService() + + @PostMapping + fun create(@PathVariable accountUsername: String): JwkAdminDTO { + val key = keyService.create(accountUsername) + return key.toJwkAdminDTO() + } + + @GetMapping + fun getKeys(@PathVariable accountUsername: String): List { + val keys = keyService.getKeys(accountUsername) + return keys + } + + @DeleteMapping("/{keyId}") + fun revokeKey( + @PathVariable accountUsername: String, + @PathVariable keyId: Int, + @RequestParam reason: String? + ): JwkAdminDTO { + return keyService.revokeKey(accountUsername, keyId, reason) + } +} \ No newline at end of file diff --git a/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/SubordinateController.kt b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/SubordinateController.kt new file mode 100644 index 00000000..f11bbdff --- /dev/null +++ b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/SubordinateController.kt @@ -0,0 +1,19 @@ +package com.sphereon.oid.fed.server.admin.controllers + +import com.sphereon.oid.fed.persistence.models.Subordinate +import com.sphereon.oid.fed.services.SubordinateService +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/accounts/{accountUsername}/subordinates") +class SubordinateController { + private val subordinateService = SubordinateService() + + @GetMapping + fun getSubordinates(@PathVariable accountUsername: String): List { + return subordinateService.findSubordinatesByAccount(accountUsername) + } +} \ No newline at end of file diff --git a/modules/admin-server/src/main/resources/application.properties b/modules/admin-server/src/main/resources/application.properties index 683495f2..49841a4e 100644 --- a/modules/admin-server/src/main/resources/application.properties +++ b/modules/admin-server/src/main/resources/application.properties @@ -1,12 +1,9 @@ spring.config.import=optional:file:../../.env[.properties] - spring.application.name=OpenID Federation - spring.datasource.url=${DATASOURCE_URL} spring.datasource.username=${DATASOURCE_USER} spring.datasource.password=${DATASOURCE_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver - # Mapping /actuator/health to /status management.endpoints.web.base-path=/ management.endpoints.web.path-mapping.health=status \ No newline at end of file diff --git a/modules/admin-server/src/test/kotlin/com/sphereon/oid/fed/server/admin/ApplicationTests.kt b/modules/admin-server/src/test/kotlin/com/sphereon/oid/fed/server/admin/ApplicationTests.kt index 3f3e34e3..811206b1 100644 --- a/modules/admin-server/src/test/kotlin/com/sphereon/oid/fed/server/admin/ApplicationTests.kt +++ b/modules/admin-server/src/test/kotlin/com/sphereon/oid/fed/server/admin/ApplicationTests.kt @@ -6,8 +6,8 @@ import org.springframework.boot.test.context.SpringBootTest @SpringBootTest class ApplicationTests { - @Test - fun contextLoads() { - } + @Test + fun contextLoads() { + } } diff --git a/modules/admin-server/src/test/kotlin/com/sphereon/oid/fed/server/admin/DatabaseTest.kt b/modules/admin-server/src/test/kotlin/com/sphereon/oid/fed/server/admin/DatabaseTest.kt deleted file mode 100644 index 2c8b2b94..00000000 --- a/modules/admin-server/src/test/kotlin/com/sphereon/oid/fed/server/admin/DatabaseTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.sphereon.oid.fed.server.admin - -import org.junit.jupiter.api.Test -import org.testcontainers.containers.PostgreSQLContainer -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.junit.jupiter.Testcontainers - -@Testcontainers -class DatabaseTest { - - @Container - val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:14") - - @Test - fun `test database connection`() { - assert(postgres.isRunning) - } -} \ No newline at end of file diff --git a/modules/admin-server/src/test/kotlin/com/sphereon/oid/fed/server/admin/StatusEndpointTest.kt b/modules/admin-server/src/test/kotlin/com/sphereon/oid/fed/server/admin/StatusEndpointTest.kt index e1014b98..290b50d5 100644 --- a/modules/admin-server/src/test/kotlin/com/sphereon/oid/fed/server/admin/StatusEndpointTest.kt +++ b/modules/admin-server/src/test/kotlin/com/sphereon/oid/fed/server/admin/StatusEndpointTest.kt @@ -1,12 +1,13 @@ package com.sphereon.oid.fed.server.admin import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.web.servlet.MockMvc -import org.springframework.beans.factory.annotation.Autowired import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) diff --git a/modules/federation-server/README.md b/modules/federation-server/README.md new file mode 100644 index 00000000..66db3d08 --- /dev/null +++ b/modules/federation-server/README.md @@ -0,0 +1,27 @@ +# Federation Server + +API +
+```/status``` - To check health status + +
+ +Add environment file (.env) with following properties + +``` +DATASOURCE_USER= +DATASOURCE_PASSWORD= +DATASOURCE_URL= +``` + +To build +
+```./gradlew :modules:federation-server:build``` + +To run +
+```./gradlew :modules:federation-server:bootRun``` + +To run tests +
+```./gradlew :modules:federation-server:test``` \ No newline at end of file diff --git a/modules/federation-server/build.gradle.kts b/modules/federation-server/build.gradle.kts new file mode 100644 index 00000000..f94127e2 --- /dev/null +++ b/modules/federation-server/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(libs.plugins.springboot) + alias(libs.plugins.springDependencyManagement) + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.kotlinPluginSpring) + application +} + +group = "com.sphereon.oid.fed.server.federation" +version = "0.0.1" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +dependencies { + api(projects.modules.openapi) + api(projects.modules.openidFederationCommon) + api(projects.modules.persistence) + api(projects.modules.services) + implementation(libs.springboot.actuator) + implementation(libs.springboot.web) + implementation(libs.springboot.data.jdbc) + implementation(libs.kotlin.reflect) + testImplementation(libs.springboot.test) + testImplementation(libs.testcontainer.junit) + testImplementation(libs.springboot.testcontainer) + runtimeOnly(libs.springboot.devtools) +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +tasks.withType { + useJUnitPlatform() + testLogging { + setExceptionFormat("full") + events("started", "skipped", "passed", "failed") + showStandardStreams = true + } +} diff --git a/modules/federation-server/src/main/kotlin/com/sphereon/oid/fed/server/federation/Application.kt b/modules/federation-server/src/main/kotlin/com/sphereon/oid/fed/server/federation/Application.kt new file mode 100644 index 00000000..c5ba0f8a --- /dev/null +++ b/modules/federation-server/src/main/kotlin/com/sphereon/oid/fed/server/federation/Application.kt @@ -0,0 +1,11 @@ +package com.sphereon.oid.fed.server.federation + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class Application + +fun main(args: Array) { + runApplication(*args) +} diff --git a/modules/federation-server/src/main/kotlin/com/sphereon/oid/fed/server/federation/controllers/FederationController.kt b/modules/federation-server/src/main/kotlin/com/sphereon/oid/fed/server/federation/controllers/FederationController.kt new file mode 100644 index 00000000..53943c2d --- /dev/null +++ b/modules/federation-server/src/main/kotlin/com/sphereon/oid/fed/server/federation/controllers/FederationController.kt @@ -0,0 +1,38 @@ +package com.sphereon.oid.fed.server.federation.controllers + +import com.sphereon.oid.fed.services.SubordinateService +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping() +class FederationController { + private val subordinateService = SubordinateService() + + @GetMapping("/.well-known/openid-federation") + fun getRootEntityConfigurationStatement(): String { + throw NotImplementedError() + } + + @GetMapping("/{username}/.well-known/openid-federation") + fun getAccountEntityConfigurationStatement(@PathVariable username: String): String { + throw NotImplementedError() + } + + @GetMapping("/list") + fun getRootSubordinatesList(): List { + return subordinateService.findSubordinatesByAccountAsList("root") + } + + @GetMapping("/{username}/list") + fun getSubordinatesList(@PathVariable username: String): List { + return subordinateService.findSubordinatesByAccountAsList(username) + } + + @GetMapping("/fetch") + fun getSubordinateStatement(): List { + throw NotImplementedError() + } +} diff --git a/modules/federation-server/src/main/resources/application.properties b/modules/federation-server/src/main/resources/application.properties new file mode 100644 index 00000000..523035b3 --- /dev/null +++ b/modules/federation-server/src/main/resources/application.properties @@ -0,0 +1,8 @@ +spring.config.import=optional:file:../../.env[.properties] +spring.application.name=OpenID Federation Server +spring.datasource.url=${DATASOURCE_URL} +spring.datasource.username=${DATASOURCE_USER} +spring.datasource.password=${DATASOURCE_PASSWORD} +# Mapping /actuator/health to /status +management.endpoints.web.base-path=/ +management.endpoints.web.path-mapping.health=status \ No newline at end of file diff --git a/modules/federation-server/src/test/kotlin/com/sphereon/oid/fed/server/federation/ApplicationTests.kt b/modules/federation-server/src/test/kotlin/com/sphereon/oid/fed/server/federation/ApplicationTests.kt new file mode 100644 index 00000000..25835bbb --- /dev/null +++ b/modules/federation-server/src/test/kotlin/com/sphereon/oid/fed/server/federation/ApplicationTests.kt @@ -0,0 +1,12 @@ +package com.sphereon.oid.fed.server.federation + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class ApplicationTests { + + @Test + fun contextLoads() { + } +} diff --git a/modules/federation-server/src/test/kotlin/com/sphereon/oid/fed/server/federation/StatusEndpointTest.kt b/modules/federation-server/src/test/kotlin/com/sphereon/oid/fed/server/federation/StatusEndpointTest.kt new file mode 100644 index 00000000..8d79bb24 --- /dev/null +++ b/modules/federation-server/src/test/kotlin/com/sphereon/oid/fed/server/federation/StatusEndpointTest.kt @@ -0,0 +1,26 @@ +package com.sphereon.oid.fed.server.federation + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +class StatusEndpointTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Test + fun testStatusEndpoint() { + mockMvc.perform(get("/status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + } +} \ No newline at end of file diff --git a/modules/openapi/build.gradle.kts b/modules/openapi/build.gradle.kts index 71a13855..cc7ca19b 100644 --- a/modules/openapi/build.gradle.kts +++ b/modules/openapi/build.gradle.kts @@ -35,7 +35,8 @@ kotlin { filter { line: String -> line.replace( "kotlin.collections.Map", - "kotlinx.serialization.json.JsonObject") + "kotlinx.serialization.json.JsonObject" + ) } } diff --git a/modules/openapi/src/commonMain/kotlin/com/sphereon/oid/fed/openapi/openapi.yaml b/modules/openapi/src/commonMain/kotlin/com/sphereon/oid/fed/openapi/openapi.yaml index 02476887..7e4ac1a8 100644 --- a/modules/openapi/src/commonMain/kotlin/com/sphereon/oid/fed/openapi/openapi.yaml +++ b/modules/openapi/src/commonMain/kotlin/com/sphereon/oid/fed/openapi/openapi.yaml @@ -28,116 +28,2112 @@ servers: url: https://virtserver.swaggerhub.com/SphereonInt/OpenIDFederationAPI/1.0.0-d36 paths: + /status: + get: + tags: + - api + summary: Check node status + description: Check the status of the Federated Node. + responses: + '200': + description: Successful status check + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + /entity-statement: get: tags: - - federation - summary: Fetch Entity Statement - description: Fetch an Entity Statement for a specified issuer and optional subject. + - federation + summary: Fetch Entity Statement + description: Fetch an Entity Statement for a specified issuer and optional subject. + parameters: + - name: iss + in: query + description: The Entity Identifier of the issuer from which the Entity Statement is issued. Because of the normalization of the URL, multiple issuers MAY resolve to a shared fetch endpoint. This parameter makes it explicit exactly which issuer the Entity Statement must come from. + required: true + schema: + type: string + - name: sub + in: query + description: The Entity Identifier of the subject for which the Entity Statement is being requested. If this parameter is omitted, it is considered to be the same as the issuer and indicates a request for a self-signed Entity Configuration. + required: false + schema: + type: string + responses: + '200': + description: Successful fetch of Entity Statement + content: + application/entity-statement+jwt: + schema: + $ref: '#/components/schemas/EntityConfigurationStatement' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequest: + summary: Invalid request example + value: + error: invalid_request + error_description: The request is incomplete or does not comply with current specifications. + '404': + description: Entity Statement not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notFound: + summary: Entity Statement not found example + value: + error: not_found + error_description: The requested Entity Statement could not be found for the provided issuer and subject. + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /subordinates: + get: + tags: + - federation + summary: List Immediate Subordinates + description: List the Immediate Subordinates for the specified criteria. + parameters: + - name: entity_type + in: query + description: The value of this parameter is an Entity Type Identifier. If the responder knows the Entity Types of its Immediate Subordinates, the result MUST be filtered to include only those that include the specified Entity Type. + required: false + schema: + type: string + - name: trust_marked + in: query + description: If the parameter trust_marked is present and set to true, the result contains only the Immediate Subordinates for which at least one Trust Mark have been issued and is still valid. + required: false + schema: + type: boolean + - name: trust_mark_id + in: query + description: The value of this parameter is a Trust Mark identifier. If the responder has issued Trust Marks with the specified Trust Mark identifier, the list in the response is filtered to include only the Immediate Subordinates for which that Trust Mark identifier has been issued and is still valid. + required: false + schema: + type: string + - name: intermediate + in: query + description: If the parameter intermediate is present and set to true, then if the responder knows whether its Immediate Subordinates are Intermediates or not, the result MUST be filtered accordingly. + required: false + schema: + type: boolean + responses: + '200': + description: Successful fetch of Immediate Subordinates + content: + application/json: + schema: + type: array + items: + type: string + format: uri + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequest: + summary: Invalid request example + value: + error: invalid_request + error_description: The request is incomplete or does not comply with current specifications. + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /resolve-statement: + get: + tags: + - federation + summary: Resolve Entity Statement + description: Resolve metadata and Trust Marks for an Entity. + parameters: + - name: sub + in: query + description: The Entity Identifier of the Entity whose resolved data is requested. + required: true + schema: + type: string + - name: anchor + in: query + description: The Trust Anchor that the resolve endpoint MUST use when resolving the metadata. The value is an Entity identifier. + required: true + schema: + type: string + - name: type + in: query + description: A specific Entity Type to resolve. Its value is an Entity Type Identifier. If this parameter is not present, then all Entity Types are returned. + required: false + schema: + type: string + responses: + '200': + description: Successful resolve of Entity metadata + content: + application/resolve-response+jwt: + schema: + $ref: '#/components/schemas/ResolveResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequest: + summary: Invalid request example + value: + error: invalid_request + error_description: The request is incomplete or does not comply with current specifications. + '404': + description: Entity not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notFound: + summary: Entity not found example + value: + error: not_found + error_description: The requested Entity could not be found for the provided parameters. + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /trust-mark: + get: + tags: + - federation + summary: Get Trust Mark + description: Retrieve a specific Trust Mark. + parameters: + - name: trust_mark_id + in: query + description: Trust Mark identifier. + required: true + schema: + type: string + - name: sub + in: query + description: The Entity Identifier of the Entity to which the Trust Mark is issued. + required: true + schema: + type: string + responses: + '200': + description: Successful retrieval of Trust Mark + content: + application/trust-mark+jwt: + schema: + type: string + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequest: + summary: Invalid request example + value: + error: invalid_request + error_description: The request is incomplete or does not comply with current specifications. + '404': + description: Trust Mark not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notFound: + summary: Trust Mark not found example + value: + error: not_found + error_description: The requested Trust Mark could not be found for the provided parameters. + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /trust-mark/status: + post: + tags: + - federation + summary: Check Trust Mark Status + description: Check if a Trust Mark is still active. + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + sub: + type: string + description: The Entity Identifier of the Entity to which the Trust Mark was issued. + trust_mark_id: + type: string + description: Identifier of the Trust Mark. + iat: + type: integer + description: Time when the Trust Mark was issued. If iat is not specified and the Trust Mark issuer has issued several Trust Marks with the identifier specified in the request to the Entity identified by sub, the most recent one is assumed. + trust_mark: + type: string + description: The whole Trust Mark. + responses: + '200': + description: Trust Mark status + content: + application/json: + schema: + type: object + properties: + active: + type: boolean + description: Whether the Trust Mark is active or not. + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequest: + summary: Invalid request example + value: + error: invalid_request + error_description: The request is incomplete or does not comply with current specifications. + '404': + description: Trust Mark not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notFound: + summary: Trust Mark not found example + value: + error: not_found + error_description: The requested Trust Mark could not be found for the provided parameters. + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /trust-marked-entities: + get: + tags: + - federation + summary: List Trust Marked Entities + description: List all Entities for which Trust Marks have been issued and are still valid. + parameters: + - name: trust_mark_id + in: query + description: Trust Mark identifier to filter by. If the responder has issued Trust Marks with the specified Trust Mark identifier, the list in the response is filtered to include only the Entities for which that Trust Mark identifier has been issued and is still valid. + required: true + schema: + type: string + - name: sub + in: query + description: The Entity Identifier of the Entity to which the Trust Mark was issued. The list obtained in the response MUST be filtered to only the Entity matching this value. + required: false + schema: + type: string + responses: + '200': + description: Successful fetch of Trust Marked Entities + content: + application/json: + schema: + type: array + items: + type: string + format: uri + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequest: + summary: Invalid request example + value: + error: invalid_request + error_description: The request is incomplete or does not comply with current specifications. + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /historical-keys: + get: + tags: + - federation + summary: Get Historical Keys + description: Retrieve previously used keys for non-repudiation of statements. + responses: + '200': + description: Successful retrieval of historical keys + content: + application/jwk-set+jwt: + schema: + $ref: '#/components/schemas/FederationHistoricalKeysResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /{accountUsername}/entity-statement: + get: + tags: + - federation + summary: Fetch an Tenant Entity Statement + description: Fetch an Entity Statement for a specified issuer and optional subject. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the tenant account. + - name: iss + in: query + description: The Entity Identifier of the issuer from which the Entity Statement is issued. Because of the normalization of the URL, multiple issuers MAY resolve to a shared fetch endpoint. This parameter makes it explicit exactly which issuer the Entity Statement must come from. + required: true + schema: + type: string + - name: sub + in: query + description: The Entity Identifier of the subject for which the Entity Statement is being requested. If this parameter is omitted, it is considered to be the same as the issuer and indicates a request for a self-signed Entity Configuration. + required: false + schema: + type: string + responses: + '200': + description: Successful fetch of Entity Statement + content: + application/entity-statement+jwt: + schema: + $ref: '#/components/schemas/EntityConfigurationStatement' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequest: + summary: Invalid request example + value: + error: invalid_request + error_description: The request is incomplete or does not comply with current specifications. + '404': + description: Entity Statement not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notFound: + summary: Entity Statement not found example + value: + error: not_found + error_description: The requested Entity Statement could not be found for the provided issuer and subject. + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /{accountUsername}/subordinates: + get: + tags: + - federation + summary: List Tenant Immediate Subordinates + description: List the Immediate Subordinates for the specified criteria. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the tenant account. + + - name: entity_type + in: query + description: The value of this parameter is an Entity Type Identifier. If the responder knows the Entity Types of its Immediate Subordinates, the result MUST be filtered to include only those that include the specified Entity Type. + required: false + schema: + type: string + - name: trust_marked + in: query + description: If the parameter trust_marked is present and set to true, the result contains only the Immediate Subordinates for which at least one Trust Mark have been issued and is still valid. + required: false + schema: + type: boolean + - name: trust_mark_id + in: query + description: The value of this parameter is a Trust Mark identifier. If the responder has issued Trust Marks with the specified Trust Mark identifier, the list in the response is filtered to include only the Immediate Subordinates for which that Trust Mark identifier has been issued and is still valid. + required: false + schema: + type: string + - name: intermediate + in: query + description: If the parameter intermediate is present and set to true, then if the responder knows whether its Immediate Subordinates are Intermediates or not, the result MUST be filtered accordingly. + required: false + schema: + type: boolean + responses: + '200': + description: Successful fetch of Immediate Subordinates + content: + application/json: + schema: + type: array + items: + type: string + format: uri + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequest: + summary: Invalid request example + value: + error: invalid_request + error_description: The request is incomplete or does not comply with current specifications. + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /{accountUsername}/resolve-statement: + get: + tags: + - federation + summary: Resolve Tenant Entity Statement + description: Resolve metadata and Trust Marks for an Entity. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the tenant account. + + - name: sub + in: query + description: The Entity Identifier of the Entity whose resolved data is requested. + required: true + schema: + type: string + - name: anchor + in: query + description: The Trust Anchor that the resolve endpoint MUST use when resolving the metadata. The value is an Entity identifier. + required: true + schema: + type: string + - name: type + in: query + description: A specific Entity Type to resolve. Its value is an Entity Type Identifier. If this parameter is not present, then all Entity Types are returned. + required: false + schema: + type: string + responses: + '200': + description: Successful resolve of Entity metadata + content: + application/resolve-statement-response+jwt: + schema: + $ref: '#/components/schemas/ResolveResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequest: + summary: Invalid request example + value: + error: invalid_request + error_description: The request is incomplete or does not comply with current specifications. + '404': + description: Entity not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notFound: + summary: Entity not found example + value: + error: not_found + error_description: The requested Entity could not be found for the provided parameters. + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /{accountUsername}/trust-mark: + get: + tags: + - federation + summary: Get Tenant Trust Mark + description: Retrieve a specific Trust Mark. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the tenant account. + + - name: trust_mark_id + in: query + description: Trust Mark identifier. + required: true + schema: + type: string + - name: sub + in: query + description: The Entity Identifier of the Entity to which the Trust Mark is issued. + required: true + schema: + type: string + responses: + '200': + description: Successful retrieval of Trust Mark + content: + application/trust-mark+jwt: + schema: + type: string + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequest: + summary: Invalid request example + value: + error: invalid_request + error_description: The request is incomplete or does not comply with current specifications. + '404': + description: Trust Mark not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notFound: + summary: Trust Mark not found example + value: + error: not_found + error_description: The requested Trust Mark could not be found for the provided parameters. + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /{accountUsername}/trust-mark/status: + post: + tags: + - federation + summary: Check Tenant Trust Mark Status + description: Check if a Trust Mark is still active. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the tenant account. + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + sub: + type: string + description: The Entity Identifier of the Entity to which the Trust Mark was issued. + trust_mark_id: + type: string + description: Identifier of the Trust Mark. + iat: + type: integer + description: Time when the Trust Mark was issued. If iat is not specified and the Trust Mark issuer has issued several Trust Marks with the identifier specified in the request to the Entity identified by sub, the most recent one is assumed. + trust_mark: + type: string + description: The whole Trust Mark. + responses: + '200': + description: Trust Mark status + content: + application/json: + schema: + type: object + properties: + active: + type: boolean + description: Whether the Trust Mark is active or not. + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequest: + summary: Invalid request example + value: + error: invalid_request + error_description: The request is incomplete or does not comply with current specifications. + '404': + description: Trust Mark not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notFound: + summary: Trust Mark not found example + value: + error: not_found + error_description: The requested Trust Mark could not be found for the provided parameters. + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /{accountUsername}/trust-marked-entities: + get: + tags: + - federation + summary: List Tenant Trust Marked Entities + description: List all Entities for which Trust Marks have been issued and are still valid. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the tenant account. + + - name: trust_mark_id + in: query + description: Trust Mark identifier to filter by. If the responder has issued Trust Marks with the specified Trust Mark identifier, the list in the response is filtered to include only the Entities for which that Trust Mark identifier has been issued and is still valid. + required: true + schema: + type: string + - name: sub + in: query + description: The Entity Identifier of the Entity to which the Trust Mark was issued. The list obtained in the response MUST be filtered to only the Entity matching this value. + required: false + schema: + type: string + responses: + '200': + description: Successful fetch of Trust Marked Entities + content: + application/json: + schema: + type: array + items: + type: string + format: uri + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequest: + summary: Invalid request example + value: + error: invalid_request + error_description: The request is incomplete or does not comply with current specifications. + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /{accountUsername}/historical-keys: + get: + tags: + - federation + summary: Get Tenant Historical Keys + description: Retrieve previously used keys for non-repudiation of statements. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the tenant account. + responses: + '200': + description: Successful retrieval of historical keys + content: + application/jwk-set+jwt: + schema: + $ref: '#/components/schemas/FederationHistoricalKeysResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /stats: + get: + tags: + - Superadmin + summary: Get system statistics + description: Retrieve system statistics including uptime, CPU usage, memory usage, and disk usage. + responses: + '200': + description: Successful retrieval of system statistics + content: + application/json: + schema: + $ref: '#/components/schemas/SystemStatsResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + summary: Server error example + value: + error: server_error + error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + + /audit: + get: + tags: + - Superadmin + summary: Get audit logs + description: Retrieve audit logs with optional filtering by start and end dates. + parameters: + - name: startDate + in: query + description: The start date for filtering audit logs. + required: false + schema: + type: string + format: date-time + - name: endDate + in: query + description: The end date for filtering audit logs. + required: false + schema: + type: string + format: date-time + responses: + '200': + description: Successful retrieval of audit logs + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AuditLog' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /role: + get: + tags: + - Superadmin + - Account Admin + summary: Retrieve all available roles + description: Retrieve a list of all available roles and their descriptions. + responses: + '200': + description: Successful retrieval of roles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Role' + + /scope: + get: + tags: + - Superadmin + - Account Admin + summary: Retrieve all available scopes + description: Retrieve a list of all available scopes and their descriptions. + responses: + '200': + description: Successful retrieval of scopes + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Scope' + + /me: + get: + tags: + - auth + summary: Get logged-in user details + description: Retrieve information about the logged-in user, including linked accounts, roles, and scopes per account. + responses: + '200': + description: Successful retrieval of logged-in user details + content: + application/json: + schema: + $ref: '#/components/schemas/UserDetailsResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account: + get: + tags: + - Superadmin + summary: List all accounts + description: Retrieve a list of all accounts. + responses: + '200': + description: Accounts retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/AccountDTO' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Superadmin + summary: Register a new tenant account + description: Endpoint for a superadmin to create a new account. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAccountDTO' + responses: + '201': + description: Account created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/AccountDTO' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Conflict (e.g., slug already exists) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account/{accountUsername}: + delete: + tags: + - Superadmin + summary: Delete an account + description: Endpoint for a superadmin to delete an account. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the account to be deleted. + responses: + '200': + description: Account deleted successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Account deleted successfully + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Account not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account/{accountUsername}/user: + post: + tags: + - Superadmin + - Account Admin + summary: Add an user to an account + description: Endpoint to add an user to a specific account with a defined role. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the account. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddUserToAccountRequest' + responses: + '201': + description: User added to account successfully + content: + application/json: + schema: + $ref: '#/components/schemas/AddUserToAccountResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Account or user not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Conflict (e.g., user already in account) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + tags: + - Superadmin + - Account Admin + - Account User + summary: List users in an account + description: Endpoint to list all users in a specific account. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the account. + responses: + '200': + description: Users retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Account not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account/{accountUsername}/user/{userId}: + delete: + tags: + - Superadmin + - Account Admin + summary: Remove an user from an account + description: Endpoint to remove an user from a specific account. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the account. + - name: userId + in: path + required: true + schema: + type: string + description: The ID of the user to be removed. + responses: + '200': + description: User removed from account successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: User removed from account successfully + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Account or user not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account/{accountUsername}/user/{userId}/role: + post: + tags: + - Superadmin + - Account Admin + summary: Add a role to an user + description: Endpoint to add a role to an user. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the account. + - name: userId + in: path + required: true + schema: + type: string + description: The ID of the user. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRoleRequest' + responses: + '200': + description: Role added successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRoleResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account/{accountUsername}/user/{userId}/role/{roleId}: + delete: + tags: + - Superadmin + - Account Admin + summary: Remove a role from an user + description: Endpoint to remove a role from an user. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the account. + - name: userId + in: path + required: true + schema: + type: string + description: The ID of the user. + - name: roleId + in: path + required: true + schema: + type: string + description: The ID of the role to be removed from the user. + responses: + '200': + description: Role removed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRoleResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account/{accountUsername}/user/{userId}/scope: + post: + tags: + - Superadmin + - Account Admin + summary: Add a scope to an user + description: Endpoint to add a scope to an user. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the account. + - name: userId + in: path + required: true + schema: + type: string + description: The ID of the user. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserScopeRequest' + responses: + '200': + description: Scope added successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserScopeResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account/{accountUsername}/user/{userId}/scope/{scopeId}: + delete: + tags: + - Superadmin + - Account Admin + summary: Remove a scope from an user + description: Endpoint to remove a scope from an user. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the account. + - name: userId + in: path + required: true + schema: + type: string + description: The ID of the user. + - name: scopeId + in: path + required: true + schema: + type: string + description: The ID of the scope to be removed from the user. + responses: + '200': + description: Scope removed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserScopeResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account/{accountUsername}/statement: + post: + tags: + - Account Admin + - Account User + summary: Create an Entity Configuration Statement for the specified account + description: Create an Entity Configuration Statement for the specified account. If `dry-run` is true, it will return the generated entity statement without persisting it. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the account. + requestBody: + description: Entity Statement data + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEntityStatementRequest' + responses: + '200': + description: Entity Statement generated successfully (dry-run) + content: + application/json: + schema: + $ref: '#/components/schemas/EntityConfigurationStatement' + '201': + description: Entity Statement created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EntityConfigurationStatement' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account/{accountUsername}/subordinate-statement: + post: + tags: + - Account Admin + - Account User + summary: Create a new Subordinate Statement + description: Create a new Subordinate Statement. If `dry-run` is true, it will return the generated entity statement without persisting it. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the tenant account. + - name: dry-run + in: query + required: false + schema: + type: boolean + description: If true, the statement will be generated but not persisted. + requestBody: + description: Entity Statement data + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEntityStatementRequest' + responses: + '200': + description: Subordinate Statement dry-run successful + content: + application/json: + schema: + $ref: '#/components/schemas/SubordinateStatement' + '201': + description: Subordinate Statement created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SubordinateStatement' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + tags: + - Account Admin + - Account User + summary: List Subordinate Statements + description: List all active Subordinate Statements for the specified account. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the tenant account. + responses: + '200': + description: Successful fetch of Subordinate Statements + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SubordinateStatement' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account/{accountUsername}/subordinate-statement/{statementId}: + delete: + tags: + - Account Admin + summary: Delete a Subordinate Statement + description: Delete an existing Subordinate Statement and move it to historical data. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the tenant account. + - name: statementId + in: path + required: true + schema: + type: string + description: The ID of the Subordinate Statement to be deleted. + responses: + '200': + description: Subordinate Statement deleted successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Subordinate Statement deleted successfully + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Subordinate Statement not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account/{accountUsername}/trust-mark: + post: + tags: + - Account Admin + summary: Create or Update a Trust Mark + description: Create or update a Trust Mark for the specified account. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the tenant account. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + dry_run: # TO-DO Add correct required attributes + type: boolean + description: If true, the entity statement will be generated but not persisted. + default: false + responses: + '200': + description: Trust Mark dry-run successful + content: + application/json: + schema: + type: object + properties: + trustMarkId: + type: string + description: The identifier of the created or updated Trust Mark. + '201': + description: Trust Mark created or updated successfully + content: + application/json: + schema: + type: object + properties: + trustMarkId: + type: string + description: The identifier of the created or updated Trust Mark. + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + tags: + - Account Admin + - Account User + summary: List Trust Marks + description: List all Trust Marks for the specified account. parameters: - - name: iss - in: query - description: The Entity Identifier of the issuer from which the Entity Statement is issued. Because of the normalization of the URL, multiple issuers MAY resolve to a shared fetch endpoint. This parameter makes it explicit exactly which issuer the Entity Statement must come from. + - name: accountUsername + in: path required: true schema: type: string - - name: sub - in: query - description: The Entity Identifier of the subject for which the Entity Statement is being requested. If this parameter is omitted, it is considered to be the same as the issuer and indicates a request for a self-signed Entity Configuration. - required: false + description: The username of the tenant account. + responses: + '200': + description: Successful fetch of Trust Marks + content: + application/json: + schema: + type: array + items: + type: object + properties: + trustMarkId: + type: string + description: The identifier of the Trust Mark. + trustMark: + type: string + description: The JWT of the Trust Mark. + entityId: + type: string + description: The Entity Identifier of the entity to which the Trust Mark is issued. + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /account/{accountUsername}/trust-mark/{trustMarkId}: + delete: + tags: + - Account Admin + summary: Delete a Trust Mark + description: Delete an existing Trust Mark for the specified account. + parameters: + - name: accountUsername + in: path + required: true + schema: + type: string + description: The username of the tenant account. + - name: trustMarkId + in: path + required: true schema: type: string + description: The identifier of the Trust Mark to be deleted. responses: '200': - description: Successful fetch of Entity Statement + description: Trust Mark deleted successfully content: - application/entity-statement+jwt: + application/json: schema: - $ref: '#/components/schemas/EntityStatement' + type: object + properties: + message: + type: string + example: Trust Mark deleted successfully '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - examples: - invalidRequest: - summary: Invalid request example - value: - error: invalid_request - error_description: The request is incomplete or does not comply with current specifications. '404': - description: Entity Statement not found + description: Trust Mark not found content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - examples: - notFound: - summary: Entity Statement not found example - value: - error: not_found - error_description: The requested Entity Statement could not be found for the provided issuer and subject. '500': description: Server error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - examples: - serverError: - summary: Server error example - value: - error: server_error - error_description: The server encountered an unexpected condition that prevented it from fulfilling the request. + components: schemas: - JWK: + JwkDTO: type: object x-tags: - federation properties: kty: type: string - description: The "kty" (key type) parameter identifies the cryptographic algorithm family used with the key, such as "RSA" or "EC". + description: The key type (e.g., EC, RSA). example: RSA + crv: + type: string + description: The elliptic curve used (only for EC keys). + example: P-256 + nullable: true + kid: + type: string + description: The key ID (optional). + example: 12345 + nullable: true + x: + type: string + description: The X coordinate for EC keys (optional). + example: o-7zraXKDaoBte2PsuTXo-MSLzsyWdAElNptGgI4aH8 + nullable: true + y: + type: string + description: The Y coordinate for EC keys (optional). + example: Xr_wCzJ1XnsgAIV5qHruzSwaNnwy87UjmevVklTpIv8 + nullable: true + n: + type: string + description: The modulus for RSA keys. + example: modulus_value + nullable: true + e: + type: string + description: The exponent for RSA keys. + example: AQAB + nullable: true + alg: + type: string + description: The algorithm associated with the key. + example: ES256 + nullable: true use: type: string - description: The "use" (public key use) parameter identifies the intended use of the public key. + description: The intended use of the key (e.g., sig, enc). example: sig - key_ops: + nullable: true + x5u: type: string - description: The "key_ops" (key operations) parameter identifies the operation(s) for which the key is intended to be used. - example: encrypt - alg: + format: uri + description: A URL that points to an X.509 public key certificate or certificate chain. + example: https://example.com/cert.pem + nullable: true + x5c: + type: array + items: + type: string + description: A base64-encoded string representing an X.509 certificate. + example: MIICoTCCAYkCAQ... + description: The X.509 certificate chain. + nullable: true + x5t: + type: string + description: The SHA-1 thumbprint of the X.509 certificate. + example: dGhpcyBpcyBqdXN0IGEgdGh1bWJwcmludA + nullable: true + x5tS256: # Renamed to comply with OpenAPI restrictions + type: string + description: The SHA-256 thumbprint of the X.509 certificate. + example: sM4KhEI1Y2Sb6-EVr6tJabmJuoP-ZE... + nullable: true + revoked: + $ref: '#/components/schemas/JWTRevoked' + + Jwk: + type: object + x-tags: + - federation + required: + - kty + properties: + kty: + type: string + description: The key type (e.g., EC, RSA). + example: RSA + crv: type: string - description: The "alg" (algorithm) parameter identifies the algorithm intended for use with the key. - example: RS256 + description: The elliptic curve used (only for EC keys). + example: P-256 + nullable: true kid: type: string - description: The "kid" (key ID) parameter is used to match a specific key. + description: The key ID (optional). + example: 12345 + nullable: true + x: + type: string + description: The X coordinate for EC keys (optional). + example: o-7zraXKDaoBte2PsuTXo-MSLzsyWdAElNptGgI4aH8 + nullable: true + y: + type: string + description: The Y coordinate for EC keys (optional). + example: Xr_wCzJ1XnsgAIV5qHruzSwaNnwy87UjmevVklTpIv8 + nullable: true + n: + type: string + description: The modulus for RSA keys. + example: modulus_value + nullable: true + e: + type: string + description: The exponent for RSA keys. + example: AQAB + nullable: true + alg: + type: string + description: The algorithm associated with the key. + example: ES256 + use: + type: string + description: The intended use of the key (e.g., sig, enc). + example: sig + nullable: true + x5u: + type: string + format: uri + description: A URL that points to an X.509 public key certificate or certificate chain. + example: https://example.com/cert.pem + nullable: true + x5c: + type: array + items: + type: string + description: A base64-encoded string representing an X.509 certificate. + example: MIICoTCCAYkCAQ... + description: The X.509 certificate chain. + nullable: true + x5t: + type: string + description: The SHA-1 thumbprint of the X.509 certificate. + example: dGhpcyBpcyBqdXN0IGEgdGh1bWJwcmludA + nullable: true + x5tS256: + type: string + description: The SHA-256 thumbprint of the X.509 certificate. + example: sM4KhEI1Y2Sb6-EVr6tJabmJuoP-ZE... + nullable: true + d: + type: string + description: The private key value (for RSA and EC keys). + example: base64url_encoded_private_key + nullable: true + p: + type: string + description: The first prime factor (for RSA private key). + example: base64url_encoded_p + nullable: true + q: + type: string + description: The second prime factor (for RSA private key). + example: base64url_encoded_q + nullable: true + dp: + type: string + description: The first factor CRT exponent (for RSA private key). + example: base64url_encoded_dp + nullable: true + dq: + type: string + description: The second factor CRT exponent (for RSA private key). + example: base64url_encoded_dq + nullable: true + qi: + type: string + description: The first CRT coefficient (for RSA private key). + example: base64url_encoded_qi + nullable: true + + JwkAdminDTO: + type: object + x-tags: + - federation + properties: + id: + type: integer + description: The unique identifier for the JWK record. example: 1 + uuid: + type: string + format: uuid + description: The universally unique identifier for the JWK record. + example: 123e4567-e89b-12d3-a456-426614174000 + account_id: + type: integer + description: The ID of the account associated with this JWK. + example: 100 + kty: + type: string + description: The key type (e.g., EC, RSA). + example: RSA + crv: + type: string + description: The elliptic curve used (only for EC keys). + example: P-256 + nullable: true + kid: + type: string + description: The key ID (optional). + example: 12345 + nullable: true + x: + type: string + description: The X coordinate for EC keys (optional). + example: o-7zraXKDaoBte2PsuTXo-MSLzsyWdAElNptGgI4aH8 + nullable: true + y: + type: string + description: The Y coordinate for EC keys (optional). + example: Xr_wCzJ1XnsgAIV5qHruzSwaNnwy87UjmevVklTpIv8 + nullable: true + n: + type: string + description: The modulus for RSA keys. + example: modulus_value + nullable: true + e: + type: string + description: The exponent for RSA keys. + example: AQAB + nullable: true + alg: + type: string + description: The algorithm associated with the key. + example: ES256 + nullable: true + use: + type: string + description: The intended use of the key (e.g., sig, enc). + example: sig + nullable: true x5u: type: string - description: The "x5u" (X.509 URL) parameter is a URI that refers to a resource for an X.509 public key certificate or certificate chain. + format: uri + description: A URL that points to an X.509 public key certificate or certificate chain. example: https://example.com/cert.pem + nullable: true x5c: type: array - description: The "x5c" (X.509 certificate chain) parameter contains a chain of one or more PKIX certificates. items: type: string - example: - - MIIDQzCCA...+3whvMF1XEt0K2bA8wpPmSTPgQ== + description: A base64-encoded string representing an X.509 certificate. + example: MIICoTCCAYkCAQ... + description: The X.509 certificate chain. + nullable: true x5t: type: string - description: The "x5t" (X.509 certificate SHA-1 thumbprint) parameter is a base64url-encoded SHA-1 thumbprint of the DER encoding of an X.509 certificate. - example: 0fVuYF8jJ3onI+9Zk2/Iy+Oh5ZpE + description: The SHA-1 thumbprint of the X.509 certificate. + example: dGhpcyBpcyBqdXN0IGEgdGh1bWJwcmludA + nullable: true x5t#S256: type: string - description: The "x5t#S256" (X.509 certificate SHA-256 thumbprint) parameter is a base64url-encoded SHA-256 thumbprint of the DER encoding of an X.509 certificate. - example: 1MvI4/VhnEzTz7Jo/0Q/d/jI3rE7IMoMT34wvAjyLvs - revoked: - $ref: '#/components/schemas/JWTRevoked' + description: The SHA-256 thumbprint of the X.509 certificate. + example: sM4KhEI1Y2Sb6-EVr6tJabmJuoP-ZE... + nullable: true + revoked_at: + type: string + format: date-time + description: The timestamp when the JWK was revoked, if applicable. + example: 2024-09-01T12:34:56Z + nullable: true + revoked_reason: + type: string + description: The reason for revoking the JWK, if applicable. + example: Key compromise + nullable: true + created_at: + type: string + format: date-time + description: The timestamp when the JWK was created. + example: 2024-08-06T12:34:56Z + nullable: true + JWTRevoked: type: object @@ -148,6 +2144,7 @@ components: properties: revoked_at: type: string + format: date-time reason: type: string @@ -159,7 +2156,7 @@ components: keys: type: array items: - $ref: '#/components/schemas/JWK' + $ref: '#/components/schemas/JwkDTO' JWTHeader: type: object @@ -192,10 +2189,16 @@ components: type: string description: The encoded JWT signature value. - EntityStatement: + BaseEntityStatement: type: object x-tags: - federation + required: + - iss + - sub + - iat + - exp + - jwks properties: iss: type: string @@ -208,33 +2211,69 @@ components: description: Expiration time after which the statement MUST NOT be accepted for processing. iat: type: integer + format: date-time description: The time the statement was issued. jwks: $ref: '#/components/schemas/JWKS' - authority_hints: - type: array - items: - type: string - description: An array of strings representing the Entity Identifiers of Intermediate Entities or Trust Anchors metadata: $ref: '#/components/schemas/Metadata' - constraints: - $ref: '#/components/schemas/Constraint' crit: type: array - description: Extension of the JOSE header parameters that MUST be understood and processed. items: type: string - description: Claim names present in the JWT that use those extensions - source_endpoint: - type: string - format: uri - description: String containing the fetch endpoint URL from which the Entity Statement was issued. - additionalProperties: - type: object - additionalProperties: true - example: - "jti": "7l2lncFdY6SlhNia" + + EntityConfigurationStatement: + allOf: + - $ref: '#/components/schemas/BaseEntityStatement' + - type: object + properties: + authority_hints: + type: array + items: + type: string + metadata: + $ref: '#/components/schemas/Metadata' + crit: + type: array + items: + type: string + trust_marks: + type: array + description: An array of JSON objects, each representing a Trust Mark. + items: + $ref: '#/components/schemas/TrustMark' + trust_mark_issuers: + $ref: '#/components/schemas/TrustMarkIssuers' + trust_mark_owners: + $ref: '#/components/schemas/TrustMarkOwners' + + SubordinateStatement: + allOf: + - $ref: '#/components/schemas/BaseEntityStatement' + - type: object + required: + - iss + - sub + - iat + - exp + - jwks + properties: + metadata_policy: + $ref: '#/components/schemas/MetadataPolicy' + constraints: + $ref: '#/components/schemas/Constraint' + crit: + type: array + items: + type: string + metadata_policy_crit: + type: array + items: + type: string + source_endpoint: + type: string + format: uri + description: String containing the fetch endpoint URL from which the Entity Subordinate Statement was issued. Metadata: type: object @@ -254,6 +2293,91 @@ components: oauth_resource: $ref: '#/components/schemas/OAuthProtectedResourceMetadata' + MetadataPolicy: + type: object + x-tags: + - federation + properties: + federation_entity: + $ref: '#/components/schemas/MetadataParameterPolicy' + openid_relying_party: + $ref: '#/components/schemas/MetadataParameterPolicy' + openid_provider: + $ref: '#/components/schemas/MetadataParameterPolicy' + oauth_authorization_server: + $ref: '#/components/schemas/MetadataParameterPolicy' + oauth_client: + $ref: '#/components/schemas/MetadataParameterPolicy' + oauth_resource: + $ref: '#/components/schemas/MetadataParameterPolicy' + + MetadataParameterPolicy: + type: object + x-tags: + - federation + properties: + additionalProperties: + type: object + additionalProperties: true + + TrustMark: + type: object + x-tags: + - federation + properties: + id: + type: string + description: The Trust Mark identifier. It MUST be the same value as the id claim contained in the Trust Mark JWT. + example: "example-trust-mark-id" + trust_mark: + type: string + description: A signed JSON Web Token that represents a Trust Mark. + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + TrustMarkIssuers: + type: object + x-tags: + - federation + additionalProperties: + type: array + items: + type: string + example: + "https://openid.net/certification/op": [ ] + "https://refeds.org/wp-content/uploads/2016/01/Sirtfi-1.0.pdf": + - "https://swamid.se" + + TrustMarkOwners: + type: object + x-tags: + - federation + additionalProperties: + $ref: '#/components/schemas/TrustMarkOwner' + example: + "https://refeds.org/wp-content/uploads/2016/01/Sirtfi-1.0.pdf": + sub: "https://refeds.org/sirtfi" + jwks: + keys: + - alg: "RS256" + e: "AQAB" + kid: "key1" + kty: "RSA" + n: "pnXBOusEANuug6ewezb9J_..." + use: "sig" + + TrustMarkOwner: + type: object + x-tags: + - federation + properties: + sub: + type: string + description: Identifier of the Trust Mark owner + jwks: + $ref: '#/components/schemas/JWKS' + additionalProperties: + type: string + NamingConstraints: type: object x-tags: @@ -440,37 +2564,37 @@ components: items: type: string description: JSON array containing a list of the OAuth 2.0 "scope" values that this authorization server supports. - example: ["openid", "profile", "email"] + example: [ "openid", "profile", "email" ] response_types_supported: type: array items: type: string description: JSON array containing a list of the OAuth 2.0 "response_type" values that this authorization server supports. - example: ["code", "token", "id_token"] + example: [ "code", "token", "id_token" ] response_modes_supported: type: array items: type: string description: JSON array containing a list of the OAuth 2.0 "response_mode" values that this authorization server supports. - example: ["query", "fragment", "form_post"] + example: [ "query", "fragment", "form_post" ] grant_types_supported: type: array items: type: string description: JSON array containing a list of the OAuth 2.0 grant type values that this authorization server supports. - example: ["authorization_code", "implicit", "client_credentials", "refresh_token"] + example: [ "authorization_code", "implicit", "client_credentials", "refresh_token" ] token_endpoint_auth_methods_supported: type: array items: type: string description: JSON array containing a list of client authentication methods supported by this token endpoint. - example: ["client_secret_basic", "private_key_jwt"] + example: [ "client_secret_basic", "private_key_jwt" ] token_endpoint_auth_signing_alg_values_supported: type: array items: type: string description: JSON array containing a list of the JWS signing algorithms supported by the token endpoint for the signature on the JWT used to authenticate the client. - example: ["RS256", "ES256"] + example: [ "RS256", "ES256" ] service_documentation: type: string description: URL of a page containing human-readable information that developers might want or need to know when using the authorization server. @@ -480,7 +2604,7 @@ components: items: type: string description: Languages and scripts supported for the user interface, represented as a JSON array of language tag values from BCP 47. - example: ["en-US", "fr-FR"] + example: [ "en-US", "fr-FR" ] op_policy_uri: type: string description: URL that the authorization server provides to the person registering the client to read about the authorization server's requirements on how the client can use the data provided by the authorization server. @@ -498,13 +2622,13 @@ components: items: type: string description: JSON array containing a list of client authentication methods supported by this revocation endpoint. - example: ["client_secret_basic", "private_key_jwt"] + example: [ "client_secret_basic", "private_key_jwt" ] revocation_endpoint_auth_signing_alg_values_supported: type: array items: type: string description: JSON array containing a list of the JWS signing algorithms supported by the revocation endpoint for the signature on the JWT used to authenticate the client. - example: ["RS256", "ES256"] + example: [ "RS256", "ES256" ] introspection_endpoint: type: string description: URL of the authorization server's OAuth 2.0 introspection endpoint. @@ -514,19 +2638,19 @@ components: items: type: string description: JSON array containing a list of client authentication methods supported by this introspection endpoint. - example: ["client_secret_basic", "private_key_jwt"] + example: [ "client_secret_basic", "private_key_jwt" ] introspection_endpoint_auth_signing_alg_values_supported: type: array items: type: string description: JSON array containing a list of the JWS signing algorithms supported by the introspection endpoint for the signature on the JWT used to authenticate the client. - example: ["RS256", "ES256"] + example: [ "RS256", "ES256" ] code_challenge_methods_supported: type: array items: type: string description: JSON array containing a list of Proof Key for Code Exchange (PKCE) code challenge methods supported by this authorization server. - example: ["plain", "S256"] + example: [ "plain", "S256" ] OAuthClientMetadata: allOf: @@ -537,67 +2661,140 @@ components: x-tags: - federation - OAuthDynamicClientMetadata: - type: - object + OAuthProtectedResourceMetadata: + allOf: + - $ref: '#/components/schemas/CommonMetadata' + - $ref: '#/components/schemas/ProtectedResourceMetadata' + type: object + x-tags: + - federation + + ProtectedResourceMetadata: + type: object x-tags: - federation properties: - redirect_uris: + resource: + type: string + format: uri + description: URL identifier of the protected resource using the https scheme. + authorization_servers: type: array items: type: string - format: uri - description: Array of redirection URI strings for redirect-based flows. - token_endpoint_auth_method: - $ref: '#/components/schemas/OAuthDynamicClientTokenEndpointAuthMethod' - grant_types: + description: JSON array of OAuth authorization server issuer identifiers for servers that can be used with this protected resource. + jwks_uri: + type: string + format: uri + description: URL of the protected resource's JWK Set document, containing its public keys. + scopes_supported: type: array items: - $ref: '#/components/schemas/OAuthDynamicClientGrantTypes' - response_types: + type: string + description: JSON array of OAuth 2.0 scope values used in authorization requests to access this protected resource. + bearer_methods_supported: type: array items: - $ref: '#/components/schemas/OAuthDynamicClientResponseTypes' - client_name: + type: string + description: JSON array of supported methods for sending an OAuth 2.0 Bearer Token to the protected resource. Values are ["header", "body", "query"]. + resource_signing_alg_values_supported: + type: array + items: + type: string + description: JSON array of JWS signing algorithms supported by the protected resource for signing responses. + resource_documentation: type: string - description: Human-readable string name of the client to be presented to the end-user during authorization. - client_uri: + format: uri + description: URL of a page with human-readable information for developers using the protected resource. + resource_policy_uri: type: string format: uri - description: URL string of a web page providing information about the client. - logo_uri: + description: URL to the protected resource's policy document. + resource_tos_uri: type: string format: uri - description: URL string that references a logo for the client. - scope: + description: URL to the protected resource's terms of service. + + CommonMetadata: + type: object + x-tags: + - federation + properties: + organization_name: type: string - description: Space-separated list of scope values the client can use when requesting access tokens. + description: A human-readable name representing the organization owning this Entity. If the owner is a physical person, this MAY be, for example, the person's name. Note that this information will be publicly available. contacts: type: array items: type: string - description: Array of strings representing ways to contact people responsible for this client, typically email addresses. - tos_uri: + description: JSON array with one or more strings representing contact persons at the Entity. These MAY contain names, e-mail addresses, descriptions, phone numbers, etc. + logo_uri: type: string format: uri - description: URL string that points to a human-readable terms of service document for the client. + description: A URL that points to the logo of this Entity. The file containing the logo SHOULD be published in a format that can be viewed via the web. policy_uri: type: string format: uri - description: URL string that points to a human-readable privacy policy document. - jwks_uri: + description: URL of the documentation of conditions and policies relevant to this Entity. + homepage_uri: type: string format: uri - description: URL string referencing the client’s JSON Web Key (JWK) Set document, which contains the client’s public keys. - jwks: - $ref: '#/components/schemas/JWKS' - software_id: - type: string - description: Unique identifier string for the client software to be dynamically registered. - software_version: + description: URL of a Web page for the organization owning this Entity. + + ErrorType: + type: string + x-tags: + - federation + description: One of the predefined error codes. + example: invalid_request + enum: + - invalid_request + - invalid_client + - invalid_issuer + - not_found + - server_error + - temporary_unavailable + - unsupported_parameter + - invalid_token + - insufficient_scope + - unsupported_token_type + - interaction_required + - login_required + - account_selection_required + - consent_required + - invalid_request_uri + - invalid_request_object + - request_not_supported + - request_uri_not_supported + - registration_not_supported + - need_info + - request_denied + - request_submitted + - authorization_pending + - access_denied + - slow_down + - expired_token + - invalid_target + - unsupported_pop_key + - incompatible_ace_profiles + - invalid_authorization_details + - invalid_dpop_proof + - use_dpop_nonce + - insufficient_user_authentication + + ErrorResponse: + type: object + x-tags: + - federation + required: + - error + - error_description + properties: + error: + $ref: '#/components/schemas/ErrorType' + error_description: type: string - description: Version identifier string for the client software identified by software_id. + description: A human-readable short text describing the error. OAuthDynamicClientTokenEndpointAuthMethod: type: string @@ -632,85 +2829,89 @@ components: - code - token - OAuthProtectedResourceMetadata: - allOf: - - $ref: '#/components/schemas/CommonMetadata' - - $ref: '#/components/schemas/ProtectedResourceMetadata' - type: object - x-tags: - - federation - - ProtectedResourceMetadata: - type: object + OAuthDynamicClientMetadata: + type: + object x-tags: - federation properties: - resource: - type: string - format: uri - description: URL identifier of the protected resource using the https scheme. - authorization_servers: - type: array - items: - type: string - description: JSON array of OAuth authorization server issuer identifiers for servers that can be used with this protected resource. - jwks_uri: - type: string - format: uri - description: URL of the protected resource's JWK Set document, containing its public keys. - scopes_supported: + redirect_uris: type: array items: type: string - description: JSON array of OAuth 2.0 scope values used in authorization requests to access this protected resource. - bearer_methods_supported: + format: uri + description: Array of redirection URI strings for redirect-based flows. + token_endpoint_auth_method: + $ref: '#/components/schemas/OAuthDynamicClientTokenEndpointAuthMethod' + grant_types: type: array items: - type: string - description: JSON array of supported methods for sending an OAuth 2.0 Bearer Token to the protected resource. Values are ["header", "body", "query"]. - resource_signing_alg_values_supported: + $ref: '#/components/schemas/OAuthDynamicClientGrantTypes' + response_types: type: array items: - type: string - description: JSON array of JWS signing algorithms supported by the protected resource for signing responses. - resource_documentation: - type: string - format: uri - description: URL of a page with human-readable information for developers using the protected resource. - resource_policy_uri: + $ref: '#/components/schemas/OAuthDynamicClientResponseTypes' + client_name: type: string - format: uri - description: URL to the protected resource's policy document. - resource_tos_uri: + description: Human-readable string name of the client to be presented to the end-user during authorization. + client_uri: type: string format: uri - description: URL to the protected resource's terms of service. - - CommonMetadata: - type: object - x-tags: - - federation - properties: - organization_name: + description: URL string of a web page providing information about the client. + logo_uri: type: string - description: A human-readable name representing the organization owning this Entity. If the owner is a physical person, this MAY be, for example, the person's name. Note that this information will be publicly available. + format: uri + description: URL string that references a logo for the client. + scope: + type: string + description: Space-separated list of scope values the client can use when requesting access tokens. contacts: type: array items: type: string - description: JSON array with one or more strings representing contact persons at the Entity. These MAY contain names, e-mail addresses, descriptions, phone numbers, etc. - logo_uri: + description: Array of strings representing ways to contact people responsible for this client, typically email addresses. + tos_uri: type: string format: uri - description: A URL that points to the logo of this Entity. The file containing the logo SHOULD be published in a format that can be viewed via the web. + description: URL string that points to a human-readable terms of service document for the client. policy_uri: type: string format: uri - description: URL of the documentation of conditions and policies relevant to this Entity. - homepage_uri: + description: URL string that points to a human-readable privacy policy document. + jwks_uri: type: string format: uri - description: URL of a Web page for the organization owning this Entity. + description: URL string referencing the client’s JSON Web Key (JWK) Set document, which contains the client’s public keys. + jwks: + $ref: '#/components/schemas/JWKS' + software_id: + type: string + description: Unique identifier string for the client software to be dynamically registered. + software_version: + type: string + description: Version identifier string for the client software identified by software_id. + + OpenIDConnectDynamicClientRegistrationGrantTypes: + type: string + x-tags: + - federation + description: JSON array containing a list of the OAuth 2.0 Grant Types that the Client is declaring that it will restrict itself to using. + enum: + - authorization_code + - implicit + - refresh_token + example: [ "authorization_code", "implicit" ] + + OpenIDConnectDynamicClientRegistrationApplicationType: + type: string + x-tags: + - federation + description: Kind of the application. The default, if omitted, is web. + enum: + - native + - web + example: native + default: web OpenIDConnectDynamicClientRegistrationMetadata: type: object @@ -828,28 +3029,6 @@ components: required: - redirect_uris - OpenIDConnectDynamicClientRegistrationGrantTypes: - type: string - x-tags: - - federation - description: JSON array containing a list of the OAuth 2.0 Grant Types that the Client is declaring that it will restrict itself to using. - enum: - - authorization_code - - implicit - - refresh_token - example: [ "authorization_code", "implicit" ] - - OpenIDConnectDynamicClientRegistrationApplicationType: - type: string - x-tags: - - federation - description: Kind of the application. The default, if omitted, is web. - enum: - - native - - web - example: native - default: web - OpenIDConnectDiscoveryProviderMetadata: type: object x-tags: @@ -1509,57 +3688,328 @@ components: type: boolean description: Specifies whether the client always uses DPoP for token requests. - ErrorResponse: + FederationHistoricalKeysResponse: type: object x-tags: - federation required: - - error - - error_description + - iss + - iat + - keys properties: - error: - $ref: '#/components/schemas/ErrorType' - error_description: + iss: type: string - description: A human-readable short text describing the error. + format: date-time + description: The Entity's Entity Identifier. + iat: + type: string + format: date-time + description: Time when the signed JWT was issued, using the time format defined for the iat claim in RFC7519. + keys: + type: array + items: + $ref: '#/components/schemas/JwkDTO' - ErrorType: - type: string + ResolveResponse: + type: object x-tags: - federation - description: One of the predefined error codes. - example: invalid_request + required: + - iss + - sub + - iat + - exp + - metadata + properties: + iss: + type: string + format: date-time + description: Entity Identifier of the issuer of the resolve response. + sub: + type: string + format: date-time + description: Entity Identifier of the subject of the resolve response. + iat: + type: string + format: date-time + description: Time when this resolution was issued. This is expressed as Seconds Since the Epoch. + exp: + type: string + format: date-time + description: Time when this resolution is no longer valid. This is expressed as Seconds Since the Epoch. + metadata: + $ref: '#/components/schemas/Metadata' + trust_marks: + type: array + items: + $ref: '#/components/schemas/TrustMark' + trust_chain: + type: array + items: + type: string + + StatusResponse: + type: object + properties: + status: + type: string + description: The current status of the node. + example: "OK" + timestamp: + type: string + format: date-time + description: The time at which the status was checked. + example: "2024-06-05T12:34:56Z" + + SystemStatsResponse: + type: object + properties: + uptime: + type: string + description: The system uptime. + example: "5 days, 4:03:27" + + UpdateUserRoleRequest: + type: object + properties: + role: + type: string + description: The role to be added or removed from the user. + required: + - role + + UpdateUserRoleResponse: + type: object + properties: + roles: + type: array + items: + type: string + description: The updated list of roles for the user. + + UpdateUserScopeRequest: + type: object + properties: + scope: + type: string + description: The scope to be added or removed from the user. + required: + - scope + + UpdateUserScopeResponse: + type: object + properties: + scopes: + type: array + items: + type: string + description: The updated list of scopes for the user. + + CreateAccountDTO: + type: object + properties: + username: + type: string + description: The username of the account. + example: acmeco + required: + - username + + AddUserToAccountRequest: + type: object + properties: + email: + type: string + format: email + description: The email of the user to be added to the account. + example: user@acme-corp.com + role: + type: string + description: The role of the user within the account (e.g., admin, user). The default, if omitted, is user. + example: admin + required: + - email + + AddUserToAccountResponse: + type: object + properties: + accountId: + type: string + description: The unique identifier for the account. + example: account123 + userId: + type: string + description: The ID of the user added to the account. + example: user123 + role: + type: string + description: The role of the user within the account. + example: admin + + AccountDTO: + type: object + properties: + id: + type: string + description: The unique identifier for the account. + example: 12345 + username: + type: string + description: The username of the account. + example: acmecorp + + CreateEntityStatementRequest: + properties: + dry_run: # TO-DO Add correct required attributes + type: boolean + description: If true, the entity statement will be generated but not persisted. + default: false + + Scope: + type: object + properties: + id: + type: string + description: The unique identifier for the scope. + example: "1" + name: + type: string + description: The name of the scope. + example: "create:statement" + description: + type: string + description: A detailed description of what the scope allows. + example: "Permission to create Entity Statements" + + Role: + type: object + properties: + id: + type: string + description: The unique identifier for the role. + example: "1" + name: + type: string + description: The name of the role. + example: "admin" + description: + type: string + description: A detailed description of what the role allows. + example: "Administrator role with full access to account management" + scopes: + type: array + items: + $ref: "#/components/schemas/Scope" + + User: + type: object + properties: + id: + type: string + description: The unique identifier for the user. + example: "user123" + role: + type: string + description: The role assigned to the user within the account. + example: "admin" + scopes: + type: array + items: + type: string + description: The list of scopes assigned to the user. + example: + - "read:config" + - "write:config" + email: + type: string + format: email + description: The email address of the user. + example: "johndoe@gmail.com" + required: + - email + + UserAccount: + allOf: + - $ref: '#/components/schemas/AccountDTO' + type: object + properties: + roles: + type: array + items: + type: string + description: The roles assigned to the user within this account. + scopes: + type: array + items: + type: string + description: The scopes assigned to the user within this account. + + UserDetailsResponse: + type: object + properties: + id: + type: string + description: The unique identifier for the user. + email: + type: string + format: email + description: The email address of the user. + accounts: + type: array + items: + $ref: '#/components/schemas/UserAccount' + + AuditLog: + type: object + properties: + id: + type: string + description: The unique identifier for the audit log entry. + accountId: + type: string + description: The account ID from where the log was generated + timestamp: + type: string + format: date-time + description: The timestamp of the audit log entry. + errorLevel: + $ref: '#/components/schemas/LogLevel' + errorCode: + type: string + description: The error code or type. + errorMessage: + type: string + description: A meaningful explanation of what happened. + componentName: + type: string + description: The name of the component logging the error. + operation: + type: string + description: The operation performed when the error occurred. + sourceLineNumber: + type: integer + description: The source code line number. + details: + type: object + additionalProperties: true + description: Additional details about the audit log entry. + + LogLevel: + type: string enum: - - invalid_request - - invalid_client - - invalid_issuer - - not_found - - server_error - - temporary_unavailable - - unsupported_parameter - - invalid_token - - insufficient_scope - - unsupported_token_type - - interaction_required - - login_required - - account_selection_required - - consent_required - - invalid_request_uri - - invalid_request_object - - request_not_supported - - request_uri_not_supported - - registration_not_supported - - need_info - - request_denied - - request_submitted - - authorization_pending - - access_denied - - slow_down - - expired_token - - invalid_target - - unsupported_pop_key - - incompatible_ace_profiles - - invalid_authorization_details - - invalid_dpop_proof - - use_dpop_nonce - - insufficient_user_authentication + - TRACE + - DEBUG + - INFO + - NOTICE + - WARN + - ERROR + - FATAL + description: Enum for log levels. + example: ERROR + + KMS: + type: string + enum: + - LOCAL + description: Enum for KMS integrations. + example: LOCAL \ No newline at end of file diff --git a/modules/openid-federation-common/build.gradle.kts b/modules/openid-federation-common/build.gradle.kts index 4ac9c5c8..f4ffe611 100644 --- a/modules/openid-federation-common/build.gradle.kts +++ b/modules/openid-federation-common/build.gradle.kts @@ -1,9 +1,8 @@ -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig plugins { alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.androidLibrary) +// alias(libs.plugins.androidLibrary) kotlin("plugin.serialization") version "2.0.0" } @@ -11,14 +10,13 @@ val ktorVersion = "2.3.11" repositories { mavenCentral() + mavenLocal() google() } kotlin { jvm() - // wasmJs is not available yet for ktor until v3.x is released which is still in alpha - // @OptIn(ExperimentalWasmDsl::class) js { browser { commonWebpackConfig { @@ -36,18 +34,18 @@ kotlin { } } - // TODO Should be placed back at a later point in time: https://sphereon.atlassian.net/browse/OIDF-50 - // androidTarget { - // @OptIn(ExperimentalKotlinGradlePluginApi::class) - // compilerOptions { - // jvmTarget.set(JvmTarget.JVM_11) - // } - // } + // wasmJs is not available yet for ktor until v3.x is released which is still in alpha + +// androidTarget { +// @OptIn(ExperimentalKotlinGradlePluginApi::class) +// compilerOptions { +// jvmTarget.set(JvmTarget.JVM_11) +// } +// } - // iosX64() - // iosArm64() - // iosSimulatorArm64() - // androidTarget() +// iosX64() +// iosArm64() +// iosSimulatorArm64() sourceSets { val commonMain by getting { @@ -98,9 +96,6 @@ kotlin { // val iosMain by creating { // dependsOn(commonMain) -// dependencies { -// implementation("io.ktor:ktor-client-core-ios:$ktorVersion") -// } // } // val iosX64Main by getting { // dependsOn(iosMain) @@ -123,7 +118,7 @@ kotlin { // implementation("io.ktor:ktor-client-cio-iossimulatorarm64:$ktorVersion") // } // } - +// // val iosTest by creating { // dependsOn(commonTest) // dependencies { @@ -143,6 +138,7 @@ kotlin { } val jsTest by getting { + dependsOn(commonTest) dependencies { implementation(kotlin("test-js")) implementation(kotlin("test-annotations-common")) @@ -151,21 +147,21 @@ kotlin { } } -tasks.register("printSdkLocation") { - doLast { - println("Android SDK Location: ${android.sdkDirectory}") - } -} - -android { - namespace = "com.sphereon.oid.fed.common" - compileSdk = libs.versions.android.compileSdk.get().toInt() - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - defaultConfig { - minSdk = libs.versions.android.minSdk.get().toInt() - } -} +//tasks.register("printSdkLocation") { +// doLast { +// println("Android SDK Location: ${android.sdkDirectory}") +// } +//} +// +//android { +// namespace = "com.sphereon.oid.fed.common" +// compileSdk = libs.versions.android.compileSdk.get().toInt() +// compileOptions { +// sourceCompatibility = JavaVersion.VERSION_11 +// targetCompatibility = JavaVersion.VERSION_11 +// } +// defaultConfig { +// minSdk = libs.versions.android.minSdk.get().toInt() +// } +//} diff --git a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/httpclient/EntityStatementJwtConverter.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/httpclient/EntityStatementJwtConverter.kt deleted file mode 100644 index ed7c83d9..00000000 --- a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/httpclient/EntityStatementJwtConverter.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.sphereon.oid.fed.common.httpclient - -import com.sphereon.oid.fed.common.mapper.JsonMapper -import com.sphereon.oid.fed.openapi.models.EntityStatement -import io.ktor.http.* -import io.ktor.http.content.* -import io.ktor.serialization.* -import io.ktor.util.reflect.* -import io.ktor.utils.io.* -import io.ktor.utils.io.charsets.* -import io.ktor.utils.io.core.* -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -class EntityStatementJwtConverter: ContentConverter { - - override suspend fun serializeNullable( - contentType: ContentType, - charset: Charset, - typeInfo: TypeInfo, - value: Any? - ): OutgoingContent? { - if (value is EntityStatement) { - return OutgoingEntityStatementContent(value) - } else if (value is String) { - JsonMapper().mapEntityStatement(value)?.let { - return OutgoingEntityStatementContent(it) - } - } - return null - } - - override suspend fun deserialize(charset: Charset, typeInfo: TypeInfo, content: ByteReadChannel): Any? { - val text = content.readRemaining().readText(charset) - return Json.decodeFromString(EntityStatement.serializer(), text) - } -} - -class OutgoingEntityStatementContent(private val entityStatement: EntityStatement): OutgoingContent.ByteArrayContent() { - - override fun bytes(): ByteArray { - val serializedData = Json.encodeToString(entityStatement) - return serializedData.toByteArray(Charsets.UTF_8) - } -} diff --git a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/httpclient/OidFederationClient.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/httpclient/OidFederationClient.kt index 21b3c548..3b8f879e 100644 --- a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/httpclient/OidFederationClient.kt +++ b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/httpclient/OidFederationClient.kt @@ -1,20 +1,17 @@ package com.sphereon.oid.fed.common.httpclient -import com.sphereon.oid.fed.openapi.models.EntityStatement import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.* import io.ktor.client.plugins.auth.* import io.ktor.client.plugins.auth.providers.* import io.ktor.client.plugins.cache.* -import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.http.* import io.ktor.http.HttpMethod.Companion.Get import io.ktor.http.HttpMethod.Companion.Post -import io.ktor.serialization.kotlinx.json.* import io.ktor.utils.io.core.* class OidFederationClient( @@ -24,10 +21,6 @@ class OidFederationClient( ) { private val client: HttpClient = HttpClient(engine) { install(HttpCache) - install(ContentNegotiation) { - register(EntityStatementJwt, EntityStatementJwtConverter()) - json() - } install(Logging) { logger = Logger.DEFAULT level = LogLevel.INFO @@ -47,7 +40,11 @@ class OidFederationClient( } } - suspend fun fetchEntityStatement(url: String, httpMethod: HttpMethod = Get, parameters: Parameters = Parameters.Empty): EntityStatement { + suspend fun fetchEntityStatement( + url: String, + httpMethod: HttpMethod = Get, + parameters: Parameters = Parameters.Empty + ): String { return when (httpMethod) { Get -> getEntityStatement(url) Post -> postEntityStatement(url, parameters) @@ -55,15 +52,15 @@ class OidFederationClient( } } - private suspend fun getEntityStatement(url: String): EntityStatement { - return client.use { it.get(url).body() } + private suspend fun getEntityStatement(url: String): String { + return client.use { it.get(url).body() } } - private suspend fun postEntityStatement(url: String, parameters: Parameters): EntityStatement { + private suspend fun postEntityStatement(url: String, parameters: Parameters): String { return client.use { it.post(url) { setBody(FormDataContent(parameters)) - }.body() + }.body() } } } diff --git a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.kt new file mode 100644 index 00000000..03cbaee8 --- /dev/null +++ b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.kt @@ -0,0 +1,6 @@ +package com.sphereon.oid.fed.common.jwk + +import com.sphereon.oid.fed.openapi.models.Jwk + +expect fun generateKeyPair(): Jwk + diff --git a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/logic/EntityLogic.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/logic/EntityLogic.kt index 3ae15dd3..495680ca 100644 --- a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/logic/EntityLogic.kt +++ b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/logic/EntityLogic.kt @@ -1,20 +1,20 @@ package com.sphereon.oid.fed.common.logic -import com.sphereon.oid.fed.openapi.models.EntityStatement +import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatement class EntityLogic { - fun getEntityType(entityStatement: EntityStatement): EntityType = when { + fun getEntityType(entityStatement: EntityConfigurationStatement): EntityType = when { isFederationListEndpointPresent(entityStatement) && !isAuthorityHintPresent(entityStatement) -> EntityType.TRUST_ANCHOR isFederationListEndpointPresent(entityStatement) && isAuthorityHintPresent(entityStatement) -> EntityType.INTERMEDIATE !isFederationListEndpointPresent(entityStatement) && isAuthorityHintPresent(entityStatement) -> EntityType.LEAF else -> EntityType.UNDEFINED } - private fun isAuthorityHintPresent(entityStatement: EntityStatement): Boolean = + private fun isAuthorityHintPresent(entityStatement: EntityConfigurationStatement): Boolean = entityStatement.authorityHints?.isNotEmpty() ?: false - private fun isFederationListEndpointPresent(entityStatement: EntityStatement): Boolean = + private fun isFederationListEndpointPresent(entityStatement: EntityConfigurationStatement): Boolean = entityStatement.metadata?.federationEntity?.federationListEndpoint?.isNotEmpty() ?: false } diff --git a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/mapper/JsonMapper.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/mapper/JsonMapper.kt index 29de6d62..3c566d5c 100644 --- a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/mapper/JsonMapper.kt +++ b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/mapper/JsonMapper.kt @@ -1,6 +1,6 @@ package com.sphereon.oid.fed.common.mapper -import com.sphereon.oid.fed.openapi.models.EntityStatement +import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatement import com.sphereon.oid.fed.openapi.models.JWTHeader import com.sphereon.oid.fed.openapi.models.JWTSignature import kotlinx.serialization.json.Json @@ -14,13 +14,14 @@ class JsonMapper { /* * Used for mapping JWT token to EntityStatement object */ - fun mapEntityStatement(jwtToken: String): EntityStatement? = + fun mapEntityStatement(jwtToken: String): EntityConfigurationStatement? = decodeJWTComponents(jwtToken)?.payload?.let { Json.decodeFromJsonElement(it) } /* * Used for mapping trust chain */ - fun mapTrustChain(jwtTokenList: List): List = jwtTokenList.map { mapEntityStatement(it) } + fun mapTrustChain(jwtTokenList: List): List = + jwtTokenList.map { mapEntityStatement(it) } /* * Used for decoding JWT to an object of JWT with Header, Payload and Signature diff --git a/modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/logic/EntityLogicTest.kt b/modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/logic/EntityLogicTest.kt index 5f8b3e23..2dd51aea 100644 --- a/modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/logic/EntityLogicTest.kt +++ b/modules/openid-federation-common/src/commonTest/kotlin/com/sphereon/oid/fed/common/logic/EntityLogicTest.kt @@ -1,6 +1,7 @@ package com.sphereon.oid.fed.common.logic -import com.sphereon.oid.fed.openapi.models.EntityStatement +import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatement +import com.sphereon.oid.fed.openapi.models.JWKS import com.sphereon.oid.fed.openapi.models.Metadata import kotlinx.serialization.json.Json import kotlin.test.Test @@ -16,29 +17,37 @@ class EntityLogicTest { @Test fun shouldReturnTrustAnchor() { - val trustAnchorEntityStatement = json.decodeFromString(TRUST_ANCHOR_ENTITY_STATEMENT) + val trustAnchorEntityStatement = + json.decodeFromString(TRUST_ANCHOR_ENTITY_STATEMENT) assertEquals(EntityType.TRUST_ANCHOR, entityLogic.getEntityType(trustAnchorEntityStatement)) } @Test fun shouldReturnIntermediate() { - val intermediateEntityStatement = json.decodeFromString(INTERMEDIATE_ENTITY_STATEMENT) + val intermediateEntityStatement = + json.decodeFromString(INTERMEDIATE_ENTITY_STATEMENT) assertEquals(EntityType.INTERMEDIATE, entityLogic.getEntityType(intermediateEntityStatement)) } @Test fun shouldReturnLeafEntity() { - val leafEntityStatement = json.decodeFromString(LEAF_ENTITY_STATEMENT) + val leafEntityStatement = json.decodeFromString(LEAF_ENTITY_STATEMENT) assertEquals(EntityType.LEAF, entityLogic.getEntityType(leafEntityStatement)) } @Test fun shouldReturnUndefined() { - val entityStatement = EntityStatement( - metadata = Metadata(federationEntity = null), authorityHints = emptyList() + val entityStatement = EntityConfigurationStatement( + metadata = Metadata(federationEntity = null), + authorityHints = emptyList(), + exp = 0, + iat = 0, + iss = "", + sub = "", + jwks = JWKS(), ) assertEquals(EntityType.UNDEFINED, entityLogic.getEntityType(entityStatement)) diff --git a/modules/openid-federation-common/src/jsMain/kotlin/com.sphereon.oid.fed.common.jwk/Jwk.kt b/modules/openid-federation-common/src/jsMain/kotlin/com.sphereon.oid.fed.common.jwk/Jwk.kt new file mode 100644 index 00000000..f9c5208c --- /dev/null +++ b/modules/openid-federation-common/src/jsMain/kotlin/com.sphereon.oid.fed.common.jwk/Jwk.kt @@ -0,0 +1,20 @@ +package com.sphereon.oid.fed.common.jwk + +import com.sphereon.oid.fed.common.jwt.Jose +import com.sphereon.oid.fed.openapi.models.Jwk + +@ExperimentalJsExport +@JsExport +actual fun generateKeyPair(): Jwk { + val key = Jose.generateKeyPair("EC") + return Jwk( + d = key.d, + alg = key.alg, + crv = key.crv, + x = key.x, + y = key.y, + kid = key.kid, + kty = key.kty, + use = key.use, + ) +} 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 index 4286f44f..5429b9b5 100644 --- 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 @@ -1,6 +1,6 @@ package com.sphereon.oid.fed.common.jwt -import com.sphereon.oid.fed.openapi.models.EntityStatement +import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatement import com.sphereon.oid.fed.openapi.models.JWTHeader import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -12,18 +12,21 @@ external object Jose { 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 } -actual typealias JwtPayload = EntityStatement +actual typealias JwtPayload = EntityConfigurationStatement actual typealias JwtHeader = JWTHeader @ExperimentalJsExport 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 index 3f4c3e63..34a58e2f 100644 --- 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 @@ -1,6 +1,7 @@ package com.sphereon.oid.fed.common.jwt import com.sphereon.oid.fed.common.jwt.Jose.generateKeyPair +import com.sphereon.oid.fed.openapi.models.JWKS import kotlinx.coroutines.async import kotlinx.coroutines.await import kotlinx.coroutines.test.runTest @@ -15,9 +16,11 @@ class JoseJwtTest { val keyPair = (generateKeyPair("RS256") as Promise).await() val result = async { sign( - JwtPayload(iss="test"), - JwtHeader(typ="JWT",alg="RS256",kid="test"), - mutableMapOf("privateKey" to keyPair.privateKey)) } + JwtPayload(iss = "test", sub = "test", exp = 111111, iat = 111111, jwks = JWKS()), + JwtHeader(typ = "JWT", alg = "RS256", kid = "test"), + mutableMapOf("privateKey" to keyPair.privateKey) + ) + } assertTrue((result.await() as Promise).await().startsWith("ey")) } @@ -26,9 +29,10 @@ class JoseJwtTest { fun verifyTest() = runTest { val keyPair = (generateKeyPair("RS256") as Promise).await() val signed = (sign( - JwtPayload(iss="test"), - JwtHeader(typ="JWT",alg="RS256",kid="test"), - mutableMapOf("privateKey" to keyPair.privateKey)) as Promise).await() + JwtPayload(iss = "test", sub = "test", exp = 111111, iat = 111111, jwks = JWKS()), + JwtHeader(typ = "JWT", alg = "RS256", kid = "test"), + mutableMapOf("privateKey" to keyPair.privateKey) + ) as Promise).await() val result = async { verify(signed, keyPair.publicKey, emptyMap()) } assertTrue((result.await())) } diff --git a/modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.jvm.kt b/modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.jvm.kt new file mode 100644 index 00000000..873ddaba --- /dev/null +++ b/modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.jvm.kt @@ -0,0 +1,32 @@ +package com.sphereon.oid.fed.common.jwk + +import com.nimbusds.jose.Algorithm +import com.nimbusds.jose.jwk.Curve +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jose.jwk.gen.ECKeyGenerator +import com.sphereon.oid.fed.openapi.models.Jwk +import java.util.* + +actual fun generateKeyPair(): Jwk { + try { + val ecKey: ECKey = ECKeyGenerator(Curve.P_256) + .keyIDFromThumbprint(true) + .algorithm(Algorithm("EC")) + .issueTime(Date()) + .generate() + + return Jwk( + d = ecKey.d.toString(), + alg = ecKey.algorithm.name, + crv = ecKey.curve.name, + kid = ecKey.keyID, + kty = ecKey.keyType.value, + use = ecKey.keyUse?.value ?: "sig", + x = ecKey.x.toString(), + y = ecKey.y.toString() + ) + + } catch (e: Exception) { + throw Exception("Couldn't generate the EC Key Pair: ${e.message}", e) + } +} 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 index a0e9f17b..377697ad 100644 --- 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 @@ -18,7 +18,7 @@ actual fun sign( opts: Map ): String { val rsaJWK = opts["key"] as RSAKey? ?: throw IllegalArgumentException("The RSA key pair is required") - + val signer: JWSSigner = RSASSASigner(rsaJWK) val signedJWT = SignedJWT( @@ -44,4 +44,4 @@ actual fun verify( } catch (e: Exception) { throw Exception("Couldn't verify the JWT Signature: ${e.message}", e) } -} +} \ No newline at end of file diff --git a/modules/openid-federation-common/src/jvmTest/kotlin/com/sphereon/oid/fed/common/httpclient/OidFederationClientTest.kt b/modules/openid-federation-common/src/jvmTest/kotlin/com/sphereon/oid/fed/common/httpclient/OidFederationClientTest.kt index d95a7de8..8ce3813a 100644 --- a/modules/openid-federation-common/src/jvmTest/kotlin/com/sphereon/oid/fed/common/httpclient/OidFederationClientTest.kt +++ b/modules/openid-federation-common/src/jvmTest/kotlin/com/sphereon/oid/fed/common/httpclient/OidFederationClientTest.kt @@ -7,34 +7,28 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.test.Test +import kotlin.test.assertEquals class OidFederationClientTest { - private val entityStatement = EntityStatement( - iss = "https://edugain.org/federation", - sub = "https://openid.sunet.se", - exp = 1568397247, - iat = 1568310847, - sourceEndpoint = "https://edugain.org/federation/federation_fetch_endpoint", - jwks = JWKS( - propertyKeys = listOf( - JWK( - // missing e and n ? - kid = "dEEtRjlzY3djcENuT01wOGxrZlkxb3RIQVJlMTY0...", - kty = "RSA" - ) - ) - ), - metadata = Metadata( - federationEntity = FederationEntityMetadata( - organizationName = "SUNET" - ) - ) - ) + private val jwt = """ + eyJhbGciOiJSUzI1NiIsInR5cCI6ImVudGl0eS1zdGF0ZW1lbnQrand0In0.eyJpc3MiOiJodHRwczovL2VkdWdhaW4ub3JnL2ZlZGVyYXRpb24i + LCJzdWIiOiJodHRwczovL29wZW5pZC5zdW5ldC5zZSIsImV4cCI6MTU2ODM5NzI0NywiaWF0IjoxNTY4MzEwODQ3LCJzb3VyY2VfZW5kcG9pbnQi + OiJodHRwczovL2VkdWdhaW4ub3JnL2ZlZGVyYXRpb24vZmVkZXJhdGlvbl9mZXRjaF9lbmRwb2ludCIsImp3a3MiOnsia2V5cyI6W3siZSI6IkFR + QUIiLCJraWQiOiJkRUV0UmpselkzZGpjRU51VDAxd09HeHJabGt4YjNSSVFWSmxNVFkwLi4uIiwia3R5IjoiUlNBIiwibiI6Ing5N1lLcWM5Q3Mt + RE50RnJRN192aFhvSDlid2tEV1c2RW4yakowNDR5SC4uLiJ9XX0sIm1ldGFkYXRhIjp7ImZlZGVyYXRpb25fZW50aXR5Ijp7Im9yZ2FuaXphdGlv + bl9uYW1lIjoiU1VORVQifX0sIm1ldGFkYXRhX3BvbGljeSI6eyJvcGVuaWRfcHJvdmlkZXIiOnsic3ViamVjdF90eXBlc19zdXBwb3J0ZWQiOnsi + dmFsdWUiOlsicGFpcndpc2UiXX0sInRva2VuX2VuZHBvaW50X2F1dGhfbWV0aG9kc19zdXBwb3J0ZWQiOnsiZGVmYXVsdCI6WyJwcml2YXRlX2tl + eV9qd3QiXSwic3Vic2V0X29mIjpbInByaXZhdGVfa2V5X2p3dCIsImNsaWVudF9zZWNyZXRfand0Il0sInN1cGVyc2V0X29mIjpbInByaXZhdGVf + a2V5X2p3dCJdfX19fQ.Jdd45c8LKvdzUy3FXl66Dp_1MXCkcbkL_uO17kWP7bIeYHe-fKqPlV2stta3oUitxy3NB8U3abgmNWnSf60qEaF7YmiDr + j0u3WZE87QXYv6fAMW00TGvcPIC8qtoFcamK7OTrsi06eqKUJslCPSEXYl6couNkW70YSiJGUI0PUQ-TmD-vFFpQCFwtIfQeUUm47GxcCP0jBjjz + gg1D3rMCX49RhRdJWnH8yl6r1lZazcREVqNuuN6LBHhKA7asNNwtLkcJP1rCRioxIFQPn7g0POM6t50l4wNhDewXZ-NVENex4N7WeVTA1Jh9EcD_ + swTuR9X1AbD7vW80OXe_RrGmw + """ private val mockEngine = MockEngine { respond( - content = Json.encodeToString(entityStatement), + content = jwt, status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "application/entity-statement+jwt") ) @@ -45,7 +39,7 @@ class OidFederationClientTest { runBlocking { val client = OidFederationClient(mockEngine) val response = client.fetchEntityStatement("https://www.example.com?iss=https://edugain.org/federation&sub=https://openid.sunet.se", HttpMethod.Get) - assert(response == entityStatement) + assertEquals(jwt, response) } } @@ -58,7 +52,7 @@ class OidFederationClientTest { append("iss","https://edugain.org/federation") append("sub","https://openid.sunet.se") }) - assert(response == entityStatement) + assertEquals(jwt, response) } } } diff --git a/modules/persistence/build.gradle.kts b/modules/persistence/build.gradle.kts new file mode 100644 index 00000000..4f0e7de8 --- /dev/null +++ b/modules/persistence/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + kotlin("multiplatform") version "2.0.0" + id("app.cash.sqldelight") version "2.0.2" +} + +group = "com.sphereon.oid.fed.persistence" +version = "0.1.0" + +repositories { + google() + mavenCentral() + mavenLocal() +} + +sqldelight { + databases { + create("Database") { + packageName = "com.sphereon.oid.fed.persistence" + dialect("app.cash.sqldelight:postgresql-dialect:2.0.2") + schemaOutputDirectory = file("src/commonMain/resources/db/migration") + migrationOutputDirectory = file("src/commonMain/resources/db/migration") + deriveSchemaFromMigrations = true + migrationOutputFileFormat = ".sql" + srcDirs.from( + "src/commonMain/sqldelight" + ) + } + } +} + +kotlin { + jvm() + + sourceSets { + commonMain { + dependencies { + implementation(projects.modules.openapi) + } + } + + jvmMain { + dependencies { + implementation("app.cash.sqldelight:jdbc-driver:2.0.2") + implementation("com.zaxxer:HikariCP:5.1.0") + implementation("org.postgresql:postgresql:42.7.3") + } + } + } +} diff --git a/modules/persistence/gradle/wrapper/gradle-wrapper.jar b/modules/persistence/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 { + return accountQueries.create(username = account.username) + } + + fun findAll(): List { + return accountQueries.findAll().executeAsList() + } + + fun delete(id: Int) { + return accountQueries.delete(id) + } + + fun update(id: Int, account: Account) { + return accountQueries.update(account.username, id) + } +} diff --git a/modules/persistence/src/commonMain/kotlin/com/sphereon/oid/fed/persistence/repositories/KeyRepository.kt b/modules/persistence/src/commonMain/kotlin/com/sphereon/oid/fed/persistence/repositories/KeyRepository.kt new file mode 100644 index 00000000..394b74f3 --- /dev/null +++ b/modules/persistence/src/commonMain/kotlin/com/sphereon/oid/fed/persistence/repositories/KeyRepository.kt @@ -0,0 +1,44 @@ +package com.sphereon.oid.fed.persistence.repositories + +import com.sphereon.oid.fed.openapi.models.Jwk +import com.sphereon.oid.fed.persistence.models.KeyQueries +import com.sphereon.oid.fed.persistence.models.Jwk as JwkPersistence + +class KeyRepository(private val keyQueries: KeyQueries) { + fun findById(id: Int): JwkPersistence? { + return keyQueries.findById(id).executeAsOneOrNull() + } + + fun create(accountId: Int, jwk: Jwk): JwkPersistence { + return keyQueries.create( + account_id = accountId, + kty = jwk.kty, + e = jwk.e, + n = jwk.n, + x = jwk.x, + y = jwk.y, + alg = jwk.alg, + crv = jwk.crv, + kid = jwk.kid, + use = jwk.use, + x5c = jwk.x5c as Array?, + x5t = jwk.x5t, + x5u = jwk.x5u, + d = jwk.d, + p = jwk.p, + q = jwk.q, + dp = jwk.dp, + dq = jwk.dq, + qi = jwk.qi, + x5t_s256 = jwk.x5tS256 + ).executeAsOne() + } + + fun findByAccountId(accountId: Int): List { + return keyQueries.findByAccountId(accountId).executeAsList() + } + + fun revokeKey(id: Int, reason: String? = null) { + return keyQueries.revoke(reason, id) + } +} diff --git a/modules/persistence/src/commonMain/kotlin/com/sphereon/oid/fed/persistence/repositories/SubordinateRepository.kt b/modules/persistence/src/commonMain/kotlin/com/sphereon/oid/fed/persistence/repositories/SubordinateRepository.kt new file mode 100644 index 00000000..8cf9bc6e --- /dev/null +++ b/modules/persistence/src/commonMain/kotlin/com/sphereon/oid/fed/persistence/repositories/SubordinateRepository.kt @@ -0,0 +1,19 @@ +package com.sphereon.oid.fed.persistence.repositories + +import com.sphereon.oid.fed.persistence.models.Subordinate +import com.sphereon.oid.fed.persistence.models.SubordinateQueries + +class SubordinateRepository(private val subordinateQueries: SubordinateQueries) { + fun findByAccountId(accountId: Int): List { + return subordinateQueries.findByAccountId(accountId).executeAsList() + } + + fun create(accountId: Int, subordinateIdentifier: String): Subordinate { + return subordinateQueries.create(account_id = accountId, subordinate_identifier = subordinateIdentifier) + .executeAsOne() + } + + fun delete(id: Int) { + return subordinateQueries.delete(id) + } +} \ No newline at end of file diff --git a/modules/persistence/src/commonMain/resources/db/migration/1.sql b/modules/persistence/src/commonMain/resources/db/migration/1.sql new file mode 100644 index 00000000..43de324a --- /dev/null +++ b/modules/persistence/src/commonMain/resources/db/migration/1.sql @@ -0,0 +1,11 @@ +CREATE TABLE account ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE INDEX account_username_index ON account (username); + +INSERT INTO account (username) VALUES ('root'); \ No newline at end of file diff --git a/modules/persistence/src/commonMain/resources/db/migration/2.sql b/modules/persistence/src/commonMain/resources/db/migration/2.sql new file mode 100644 index 00000000..a41f2a95 --- /dev/null +++ b/modules/persistence/src/commonMain/resources/db/migration/2.sql @@ -0,0 +1,30 @@ +CREATE TABLE jwk ( + id SERIAL PRIMARY KEY, + uuid UUID DEFAULT gen_random_uuid(), + account_id INT NOT NULL, + kty VARCHAR(10) NOT NULL, + crv VARCHAR(10), + kid VARCHAR(255) UNIQUE, + x TEXT, + y TEXT, + d TEXT, + n TEXT, + e TEXT, + p TEXT, + q TEXT, + dp TEXT, + dq TEXT, + qi TEXT, + x5u TEXT, + x5c TEXT, + x5t TEXT, + x5t_s256 TEXT, + alg VARCHAR(10), + use VARCHAR(10) NULL, + revoked_at TIMESTAMP, + revoked_reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT FK_AccountJwk FOREIGN KEY (account_id) REFERENCES account (id) +); + +CREATE INDEX jwk_account_id_index ON jwk (account_id); \ No newline at end of file diff --git a/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/1.sqm b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/1.sqm new file mode 100644 index 00000000..0c59f113 --- /dev/null +++ b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/1.sqm @@ -0,0 +1,11 @@ +CREATE TABLE account ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE INDEX account_username_index ON account (username); + +INSERT INTO account (username) VALUES ('root'); diff --git a/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/2.sqm b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/2.sqm new file mode 100644 index 00000000..7b42cf9e --- /dev/null +++ b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/2.sqm @@ -0,0 +1,30 @@ +CREATE TABLE jwk ( + id SERIAL PRIMARY KEY, + uuid UUID DEFAULT gen_random_uuid(), + account_id INT NOT NULL, + kty VARCHAR(10) NOT NULL, + crv VARCHAR(10), + kid VARCHAR(255) UNIQUE, + x TEXT, + y TEXT, + d TEXT, + n TEXT, + e TEXT, + p TEXT, + q TEXT, + dp TEXT, + dq TEXT, + qi TEXT, + x5u TEXT, + x5c TEXT[], + x5t TEXT, + x5t_s256 TEXT, + alg VARCHAR(10), + use VARCHAR(10) NULL, + revoked_at TIMESTAMP, + revoked_reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT FK_AccountJwk FOREIGN KEY (account_id) REFERENCES account (id) +); + +CREATE INDEX jwk_account_id_index ON jwk (account_id); diff --git a/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/3.sqm b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/3.sqm new file mode 100644 index 00000000..e8764795 --- /dev/null +++ b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/3.sqm @@ -0,0 +1,12 @@ +CREATE TABLE subordinate ( + id SERIAL PRIMARY KEY, + account_id INT NOT NULL, + subordinate_identifier TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT FK_ParentSubordinate FOREIGN KEY (account_id) REFERENCES account (id), + UNIQUE (account_id, subordinate_identifier) +); + +CREATE INDEX subordinate_account_id_index ON subordinate (account_id); +CREATE INDEX subordinate_account_id_subordinate_identifier_index ON subordinate (account_id, subordinate_identifier); diff --git a/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Account.sq b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Account.sq new file mode 100644 index 00000000..ed78d03a --- /dev/null +++ b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Account.sq @@ -0,0 +1,18 @@ +findAll: +SELECT * FROM account; + +create: +INSERT INTO account (username) VALUES (?) RETURNING *; + +delete: +UPDATE account SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?; + +findByUsername: +SELECT * FROM account WHERE username = ?; + +findById: +SELECT * FROM account WHERE id = ?; + +update: +UPDATE account SET username = ? WHERE id = ?; + diff --git a/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Key.sq b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Key.sq new file mode 100644 index 00000000..04ff78c0 --- /dev/null +++ b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Key.sq @@ -0,0 +1,33 @@ +create: +INSERT INTO jwk ( + account_id, + kty, + crv, + kid, + x, + y, + d, + n, + e, + p, + q, + dp, + dq, + qi, + x5u, + x5c, + x5t, + x5t_s256, + alg, + use +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; + +revoke: +UPDATE jwk SET (revoked_at, revoked_reason) = (CURRENT_TIMESTAMP, ?) WHERE id = ?; + +findByAccountId: +SELECT * FROM jwk WHERE account_id = ?; + +findById: +SELECT * FROM jwk WHERE id = ?; diff --git a/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Subordinate.sq b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Subordinate.sq new file mode 100644 index 00000000..af7164d0 --- /dev/null +++ b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Subordinate.sq @@ -0,0 +1,14 @@ +create: +INSERT INTO subordinate ( + account_id, + subordinate_identifier +) VALUES (?, ?) RETURNING *; + +delete: +UPDATE subordinate SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL; + +findByAccountId: +SELECT * FROM subordinate WHERE account_id = ? AND deleted_at IS NULL; + +findById: +SELECT * FROM subordinate WHERE id = ? AND deleted_at IS NULL; diff --git a/modules/persistence/src/jvmMain/kotlin/com.sphereon.oid.fed.persistence/Persistence.jvm.kt b/modules/persistence/src/jvmMain/kotlin/com.sphereon.oid.fed.persistence/Persistence.jvm.kt new file mode 100644 index 00000000..913b31d5 --- /dev/null +++ b/modules/persistence/src/jvmMain/kotlin/com.sphereon.oid.fed.persistence/Persistence.jvm.kt @@ -0,0 +1,66 @@ +package com.sphereon.oid.fed.persistence + +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import app.cash.sqldelight.db.SqlDriver +import com.sphereon.oid.fed.persistence.database.PlatformSqlDriver +import com.sphereon.oid.fed.persistence.repositories.AccountRepository +import com.sphereon.oid.fed.persistence.repositories.KeyRepository +import com.sphereon.oid.fed.persistence.repositories.SubordinateRepository + +actual object Persistence { + actual val accountRepository: AccountRepository + actual val keyRepository: KeyRepository + actual val subordinateRepository: SubordinateRepository + + init { + val driver = getDriver() + runMigrations(driver) + + val database = Database(driver) + accountRepository = AccountRepository(database.accountQueries) + keyRepository = KeyRepository(database.keyQueries) + subordinateRepository = SubordinateRepository(database.subordinateQueries) + } + + private fun getDriver(): SqlDriver { + return PlatformSqlDriver().createPostgresDriver( + System.getenv(Constants.DATASOURCE_URL), + System.getenv(Constants.DATASOURCE_USER), + System.getenv(Constants.DATASOURCE_PASSWORD) + ) + } + + private fun runMigrations(driver: SqlDriver) { + setupSchemaVersioningTable(driver) + + val currentVersion = getCurrentDatabaseVersion(driver) + val newVersion = Database.Schema.version + + if (currentVersion < newVersion) { + Database.Schema.migrate(driver, currentVersion, newVersion) + updateDatabaseVersion(driver, newVersion) + } + } + + private fun setupSchemaVersioningTable(driver: SqlDriver) { + driver.execute(null, "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)", 0) + } + + private fun getCurrentDatabaseVersion(driver: SqlDriver): Long { + val versionQuery = "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1" + + val version = driver.executeQuery(null, versionQuery, parameters = 0, mapper = { cursor: SqlCursor -> + QueryResult.Value(if (cursor.next().value) cursor.getLong(0) else null) + }) + + return version.value ?: 0 + } + + private fun updateDatabaseVersion(driver: SqlDriver, newVersion: Long) { + val updateQuery = "INSERT INTO schema_version (version) VALUES (?)" + driver.execute(null, updateQuery, 1) { + bindLong(0, newVersion) + } + } +} diff --git a/modules/persistence/src/jvmMain/kotlin/com/sphereon/oid/fed/persistence/database/PlatformSqlDriver.jvm.kt b/modules/persistence/src/jvmMain/kotlin/com/sphereon/oid/fed/persistence/database/PlatformSqlDriver.jvm.kt new file mode 100644 index 00000000..a3c3e667 --- /dev/null +++ b/modules/persistence/src/jvmMain/kotlin/com/sphereon/oid/fed/persistence/database/PlatformSqlDriver.jvm.kt @@ -0,0 +1,23 @@ +package com.sphereon.oid.fed.persistence.database + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.asJdbcDriver +import com.sphereon.oid.fed.persistence.Constants +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource + +actual class PlatformSqlDriver { + actual fun createPostgresDriver(url: String, username: String, password: String): SqlDriver { + val config = HikariConfig() + config.jdbcUrl = url + config.username = username + config.password = password + + val dataSource = HikariDataSource(config) + return dataSource.asJdbcDriver() + } + + actual fun createSqliteDriver(path: String): SqlDriver { + throw UnsupportedOperationException(Constants.SQLITE_IS_NOT_SUPPORTED_IN_JVM) + } +} diff --git a/modules/services/build.gradle.kts b/modules/services/build.gradle.kts new file mode 100644 index 00000000..92d06037 --- /dev/null +++ b/modules/services/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + kotlin("multiplatform") version "2.0.0" + kotlin("plugin.serialization") version "2.0.0" +} + +group = "com.sphereon.oid.fed.services" +version = "0.1.0" + +repositories { + mavenCentral() + mavenLocal() + google() +} + +kotlin { + jvm() + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.modules.openapi) + api(projects.modules.persistence) + api(projects.modules.openidFederationCommon) + } + } + + val jvmTest by getting { + dependencies { + implementation(kotlin("test-junit")) + } + } + } +} diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/AccountService.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/AccountService.kt new file mode 100644 index 00000000..3adc608d --- /dev/null +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/AccountService.kt @@ -0,0 +1,24 @@ +package com.sphereon.oid.fed.services + +import com.sphereon.oid.fed.openapi.models.AccountDTO +import com.sphereon.oid.fed.openapi.models.CreateAccountDTO +import com.sphereon.oid.fed.persistence.Persistence +import com.sphereon.oid.fed.services.extensions.toAccountDTO + +class AccountService { + private val accountRepository = Persistence.accountRepository + + fun create(account: CreateAccountDTO): AccountDTO { + val accountAlreadyExists = accountRepository.findByUsername(account.username) != null + + if (accountAlreadyExists) { + throw IllegalArgumentException(Constants.ACCOUNT_ALREADY_EXISTS) + } + + return accountRepository.create(account).executeAsOne().toAccountDTO() + } + + fun findAll(): List { + return accountRepository.findAll().map { it.toAccountDTO() } + } +} diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/Constants.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/Constants.kt new file mode 100644 index 00000000..4d2511ad --- /dev/null +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/Constants.kt @@ -0,0 +1,10 @@ +package com.sphereon.oid.fed.services + +class Constants { + companion object { + const val ACCOUNT_ALREADY_EXISTS = "Account already exists" + const val ACCOUNT_NOT_FOUND = "Account not found" + const val KEY_NOT_FOUND = "Key not found" + const val KEY_ALREADY_REVOKED = "Key already revoked" + } +} diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/KeyService.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/KeyService.kt new file mode 100644 index 00000000..4d286a0c --- /dev/null +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/KeyService.kt @@ -0,0 +1,63 @@ +package com.sphereon.oid.fed.services + +import com.sphereon.oid.fed.common.jwk.generateKeyPair +import com.sphereon.oid.fed.openapi.models.JwkAdminDTO +import com.sphereon.oid.fed.persistence.Persistence +import com.sphereon.oid.fed.persistence.models.Jwk +import com.sphereon.oid.fed.services.extensions.decrypt +import com.sphereon.oid.fed.services.extensions.encrypt +import com.sphereon.oid.fed.services.extensions.toJwkAdminDTO + +class KeyService { + private val accountRepository = Persistence.accountRepository + private val keyRepository = Persistence.keyRepository + + fun create(accountUsername: String): Jwk { + val account = + accountRepository.findByUsername(accountUsername) + ?: throw IllegalArgumentException(Constants.ACCOUNT_NOT_FOUND) + + val key = keyRepository.create( + account.id, + generateKeyPair().encrypt() + ) + + return key + } + + fun getDecryptedKey(keyId: Int): Jwk { + var key = keyRepository.findById(keyId) ?: throw IllegalArgumentException(Constants.KEY_NOT_FOUND) + return key.decrypt() + } + + fun getKeys(accountUsername: String): List { + val account = + accountRepository.findByUsername(accountUsername) + ?: throw IllegalArgumentException(Constants.ACCOUNT_NOT_FOUND) + val accountId = account.id + return keyRepository.findByAccountId(accountId).map { it.toJwkAdminDTO() } + } + + fun revokeKey(accountUsername: String, keyId: Int, reason: String?): JwkAdminDTO { + val account = + accountRepository.findByUsername(accountUsername) + ?: throw IllegalArgumentException(Constants.ACCOUNT_NOT_FOUND) + val accountId = account.id + + var key = keyRepository.findById(keyId) ?: throw IllegalArgumentException(Constants.KEY_NOT_FOUND) + + if (key.account_id != accountId) { + throw IllegalArgumentException(Constants.KEY_NOT_FOUND) + } + + if (key.revoked_at != null) { + throw IllegalArgumentException(Constants.KEY_ALREADY_REVOKED) + } + + keyRepository.revokeKey(keyId, reason) + + key = keyRepository.findById(keyId) ?: throw IllegalArgumentException(Constants.KEY_NOT_FOUND) + + return key.toJwkAdminDTO() + } +} diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/SubordinateService.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/SubordinateService.kt new file mode 100644 index 00000000..d517598c --- /dev/null +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/SubordinateService.kt @@ -0,0 +1,21 @@ +package com.sphereon.oid.fed.services + +import com.sphereon.oid.fed.persistence.Persistence +import com.sphereon.oid.fed.persistence.models.Subordinate + +class SubordinateService { + private val accountRepository = Persistence.accountRepository + private val subordinateRepository = Persistence.subordinateRepository + + fun findSubordinatesByAccount(accountUsername: String): List { + val account = accountRepository.findByUsername(accountUsername) + ?: throw IllegalArgumentException(Constants.ACCOUNT_NOT_FOUND) + + return subordinateRepository.findByAccountId(account.id) + } + + fun findSubordinatesByAccountAsList(accountUsername: String): List { + val subordinates = findSubordinatesByAccount(accountUsername) + return subordinates.map { it.subordinate_identifier } + } +} diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/AccountExtensions.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/AccountExtensions.kt new file mode 100644 index 00000000..65d6dc90 --- /dev/null +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/AccountExtensions.kt @@ -0,0 +1,10 @@ +package com.sphereon.oid.fed.services.extensions + +import com.sphereon.oid.fed.openapi.models.AccountDTO +import com.sphereon.oid.fed.persistence.models.Account + +fun Account.toAccountDTO(): AccountDTO { + return AccountDTO( + username = this.username + ) +} diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.kt new file mode 100644 index 00000000..09e3c862 --- /dev/null +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.kt @@ -0,0 +1,61 @@ +package com.sphereon.oid.fed.services.extensions + +import com.sphereon.oid.fed.openapi.models.Jwk +import com.sphereon.oid.fed.openapi.models.JwkAdminDTO +import com.sphereon.oid.fed.persistence.models.Jwk as JwkPersistence + +fun JwkPersistence.toJwkAdminDTO(): JwkAdminDTO = JwkAdminDTO( + id = id, + accountId = account_id, + uuid = uuid.toString(), + e = e, + n = n, + x = x, + y = y, + alg = alg, + crv = crv, + kid = kid, + kty = kty, + use = use, + x5c = x5c as? List ?: null, + x5t = x5t, + x5u = x5u, + x5tHashS256 = x5t_s256, + createdAt = created_at.toString(), + revokedAt = revoked_at.toString(), + revokedReason = revoked_reason +) + +fun Jwk.encrypt(): Jwk { + if (System.getenv("APP_KEY") == null) return this + + fun String?.encryptOrNull() = this?.let { aesEncrypt(it, System.getenv("APP_KEY")) } + + return copy( + d = d.encryptOrNull(), + dq = dq.encryptOrNull(), + qi = qi.encryptOrNull(), + dp = dp.encryptOrNull(), + p = p.encryptOrNull(), + q = q.encryptOrNull() + ) +} + +fun JwkPersistence.decrypt(): JwkPersistence { + if (System.getenv("APP_KEY") == null) return this + + fun String?.decryptOrNull() = this?.let { aesDecrypt(it, System.getenv("APP_KEY")) } + + return copy( + d = d.decryptOrNull(), + dq = dq.decryptOrNull(), + qi = qi.decryptOrNull(), + dp = dp.decryptOrNull(), + p = p.decryptOrNull(), + q = q.decryptOrNull() + ) +} + +expect fun aesEncrypt(data: String, key: String): String +expect fun aesDecrypt(data: String, key: String): String + diff --git a/modules/services/src/jsMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.js.kt b/modules/services/src/jsMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.js.kt new file mode 100644 index 00000000..01f6164c --- /dev/null +++ b/modules/services/src/jsMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.js.kt @@ -0,0 +1,9 @@ +package com.sphereon.oid.fed.services.extensions + +actual fun aesEncrypt(data: String): String { + return data +} + +actual fun aesDecrypt(data: String): String { + return data +} \ No newline at end of file diff --git a/modules/services/src/jvmMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.jvm.kt b/modules/services/src/jvmMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.jvm.kt new file mode 100644 index 00000000..9aa632c6 --- /dev/null +++ b/modules/services/src/jvmMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.jvm.kt @@ -0,0 +1,29 @@ +package com.sphereon.oid.fed.services.extensions + +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +private const val ALGORITHM = "AES" +private const val KEY_SIZE = 32 + +actual fun aesEncrypt(data: String, key: String): String { + val secretKey = SecretKeySpec(key.padEnd(KEY_SIZE, '0').toByteArray(Charsets.UTF_8), ALGORITHM) + + val cipher = Cipher.getInstance(ALGORITHM) + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + + val encryptedValue = cipher.doFinal(data.toByteArray(Charsets.UTF_8)) + return Base64.getEncoder().encodeToString(encryptedValue) +} + +actual fun aesDecrypt(data: String, key: String): String { + val secretKey = SecretKeySpec(key.padEnd(KEY_SIZE, '0').toByteArray(Charsets.UTF_8), ALGORITHM) + + val cipher = Cipher.getInstance(ALGORITHM) + cipher.init(Cipher.DECRYPT_MODE, secretKey) + + val decodedValue = Base64.getDecoder().decode(data) + val decryptedValue = cipher.doFinal(decodedValue) + return String(decryptedValue, Charsets.UTF_8) +} diff --git a/modules/services/src/jvmTest/kotlin/com/sphereon/oid/fed/services/KeyServiceTest.jvm.kt b/modules/services/src/jvmTest/kotlin/com/sphereon/oid/fed/services/KeyServiceTest.jvm.kt new file mode 100644 index 00000000..dac55489 --- /dev/null +++ b/modules/services/src/jvmTest/kotlin/com/sphereon/oid/fed/services/KeyServiceTest.jvm.kt @@ -0,0 +1,57 @@ +package com.sphereon.oid.fed.services + +import com.sphereon.oid.fed.common.jwk.generateKeyPair +import com.sphereon.oid.fed.services.extensions.decrypt +import com.sphereon.oid.fed.services.extensions.encrypt +import org.junit.Test +import java.time.LocalDateTime +import java.util.* +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import com.sphereon.oid.fed.persistence.models.Jwk as JwkPersistence + +class KeyServiceTest { + @Test + fun testEncryption() { + val key = generateKeyPair() + val encryptedKey = key.encrypt() + + if (System.getenv("APP_KEY") == null) { + assertEquals(key.d, encryptedKey.d) + } else { + assertNotEquals(key.d, encryptedKey.d) + } + + val persistenceJwk = JwkPersistence( + id = 1, + account_id = 1, + d = encryptedKey.d, + e = encryptedKey.e, + n = encryptedKey.n, + x = encryptedKey.x, + y = encryptedKey.y, + alg = encryptedKey.alg, + crv = encryptedKey.crv, + p = encryptedKey.p, + q = encryptedKey.q, + dp = encryptedKey.dp, + qi = encryptedKey.qi, + dq = encryptedKey.dq, + x5t = encryptedKey.x5t, + x5t_s256 = encryptedKey.x5tS256, + x5u = encryptedKey.x5u, + kid = encryptedKey.kid, + kty = encryptedKey.kty, + x5c = encryptedKey.x5c?.toTypedArray(), + created_at = LocalDateTime.now(), + revoked_reason = null, + revoked_at = null, + uuid = UUID.randomUUID(), + use = encryptedKey.use + ) + + val decryptedPersistenceJwk = persistenceJwk.decrypt() + + assertEquals(key.d, decryptedPersistenceJwk.d) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index bd06755a..bff086b8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -44,4 +44,7 @@ dependencyResolutionManagement { include(":modules:openid-federation-common") include(":modules:admin-server") +include(":modules:federation-server") include(":modules:openapi") +include(":modules:persistence") +include(":modules:services")