diff --git a/app-android/build.gradle.kts b/app-android/build.gradle.kts index 374f254..b401717 100644 --- a/app-android/build.gradle.kts +++ b/app-android/build.gradle.kts @@ -8,11 +8,11 @@ plugins { } android { - namespace = "dev.sdkforge.template.android" + namespace = "dev.sdkforge.jwt.decode.android" compileSdk = 36 defaultConfig { - applicationId = "dev.sdkforge.template.android" - minSdk = 21 + applicationId = "dev.sdkforge.jwt.decode.android" + minSdk = 23 targetSdk = 36 versionCode = 1 versionName = "1.0" diff --git a/app-android/src/main/java/dev/sdkforge/template/android/MainActivity.kt b/app-android/src/main/java/dev/sdkforge/jwt/decode/android/MainActivity.kt similarity index 87% rename from app-android/src/main/java/dev/sdkforge/template/android/MainActivity.kt rename to app-android/src/main/java/dev/sdkforge/jwt/decode/android/MainActivity.kt index 1c9f5d2..0c12152 100644 --- a/app-android/src/main/java/dev/sdkforge/template/android/MainActivity.kt +++ b/app-android/src/main/java/dev/sdkforge/jwt/decode/android/MainActivity.kt @@ -1,4 +1,4 @@ -package dev.sdkforge.template.android +package dev.sdkforge.jwt.decode.android import android.os.Bundle import androidx.activity.ComponentActivity @@ -6,7 +6,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier -import dev.sdkforge.template.app.App +import dev.sdkforge.jwt.decode.app.App class MainActivity : ComponentActivity() { override fun onCreate( diff --git a/app-shared/build.gradle.kts b/app-shared/build.gradle.kts index 506ff0b..2bc0917 100644 --- a/app-shared/build.gradle.kts +++ b/app-shared/build.gradle.kts @@ -21,5 +21,5 @@ kotlin { } android { - namespace = "dev.sdkforge.template.app" + namespace = "dev.sdkforge.jwt.decode.app" } diff --git a/app-shared/src/commonMain/kotlin/dev/sdkforge/template/app/App.kt b/app-shared/src/commonMain/kotlin/dev/sdkforge/jwt/decode/app/App.kt similarity index 92% rename from app-shared/src/commonMain/kotlin/dev/sdkforge/template/app/App.kt rename to app-shared/src/commonMain/kotlin/dev/sdkforge/jwt/decode/app/App.kt index 0811cf7..5e94485 100644 --- a/app-shared/src/commonMain/kotlin/dev/sdkforge/template/app/App.kt +++ b/app-shared/src/commonMain/kotlin/dev/sdkforge/jwt/decode/app/App.kt @@ -1,4 +1,4 @@ -package dev.sdkforge.template.app +package dev.sdkforge.jwt.decode.app import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -9,7 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.sdkforge.template.core.currentPlatform +import dev.sdkforge.jwt.decode.core.currentPlatform @Composable fun App( diff --git a/app-shared/src/commonMain/kotlin/dev/sdkforge/template/app/ApplicationTheme.kt b/app-shared/src/commonMain/kotlin/dev/sdkforge/jwt/decode/app/ApplicationTheme.kt similarity index 97% rename from app-shared/src/commonMain/kotlin/dev/sdkforge/template/app/ApplicationTheme.kt rename to app-shared/src/commonMain/kotlin/dev/sdkforge/jwt/decode/app/ApplicationTheme.kt index 2ab7425..4661296 100644 --- a/app-shared/src/commonMain/kotlin/dev/sdkforge/template/app/ApplicationTheme.kt +++ b/app-shared/src/commonMain/kotlin/dev/sdkforge/jwt/decode/app/ApplicationTheme.kt @@ -1,4 +1,4 @@ -package dev.sdkforge.template.app +package dev.sdkforge.jwt.decode.app import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.shape.RoundedCornerShape diff --git a/app-shared/src/iosMain/kotlin/dev/sdkforge/template/app/ComposeAppViewController.kt b/app-shared/src/iosMain/kotlin/dev/sdkforge/jwt/decode/app/ComposeAppViewController.kt similarity index 93% rename from app-shared/src/iosMain/kotlin/dev/sdkforge/template/app/ComposeAppViewController.kt rename to app-shared/src/iosMain/kotlin/dev/sdkforge/jwt/decode/app/ComposeAppViewController.kt index 437287b..fd45fd1 100644 --- a/app-shared/src/iosMain/kotlin/dev/sdkforge/template/app/ComposeAppViewController.kt +++ b/app-shared/src/iosMain/kotlin/dev/sdkforge/jwt/decode/app/ComposeAppViewController.kt @@ -1,4 +1,4 @@ -package dev.sdkforge.template.app +package dev.sdkforge.jwt.decode.app import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier diff --git a/gradle.properties b/gradle.properties index b99270f..4115bb0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,8 +12,8 @@ android.nonTransitiveRClass=true org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true #Publishing -publishing.group=dev.sdkforge.template +publishing.group=dev.sdkforge.jwt.decode publishing.version=0.0.1 publishing.owner=SDKForge -publishing.repository=template-sdk -publishing.description=A modern Kotlin Multiplatform SDK template for building cross-platform libraries and applications. \ No newline at end of file +publishing.repository=JWTDecode +publishing.description=A Kotlin Multiplatform library for decoding JSON Web Tokens (JWT) across platforms. \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 85c0728..9c98e41 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,8 @@ androidx-activityCompose = "1.11.0" binary-compatibility-validator = "0.18.1" dokka = "2.0.0" dependency-guard = "0.5.0" +kotlinxDatetime = "0.7.1" +kotlinxSerializationJson = "1.9.0" kover = "0.9.2" versions = "0.52.0" benchmark = "1.4.1" @@ -28,6 +30,8 @@ compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junitVersion" } @@ -38,6 +42,7 @@ androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 0662228..a14efd8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,12 +43,14 @@ dependencyResolutionManagement { } } -rootProject.name = "SDKForgeTemplate" +rootProject.name = "SDKForge-JWTDecode" include(":app-android") include(":app-shared") include(":shared") include(":shared-core") +include(":shared-domain") +include(":shared-data") include(":internal-ktlint") // uncomment if it's needed for development diff --git a/shared-core/api/shared-core.api b/shared-core/api/shared-core.api index 9193b11..f1592e5 100644 --- a/shared-core/api/shared-core.api +++ b/shared-core/api/shared-core.api @@ -1,9 +1,9 @@ -public abstract interface class dev/sdkforge/template/core/Platform { +public abstract interface class dev/sdkforge/jwt/decode/core/Platform { public abstract fun getName ()Ljava/lang/String; public abstract fun getVersion ()Ljava/lang/String; } -public final class dev/sdkforge/template/core/Platform_androidKt { - public static final fun getCurrentPlatform ()Ldev/sdkforge/template/core/Platform; +public final class dev/sdkforge/jwt/decode/core/Platform_androidKt { + public static final fun getCurrentPlatform ()Ldev/sdkforge/jwt/decode/core/Platform; } diff --git a/shared-core/build.gradle.kts b/shared-core/build.gradle.kts index 745b438..ccad629 100644 --- a/shared-core/build.gradle.kts +++ b/shared-core/build.gradle.kts @@ -24,5 +24,5 @@ kotlin { } android { - namespace = "dev.sdkforge.template.core" + namespace = "dev.sdkforge.jwt.decode.core" } diff --git a/shared-core/src/androidMain/kotlin/dev/sdkforge/template/core/Platform.android.kt b/shared-core/src/androidMain/kotlin/dev/sdkforge/jwt/decode/core/Platform.android.kt similarity index 96% rename from shared-core/src/androidMain/kotlin/dev/sdkforge/template/core/Platform.android.kt rename to shared-core/src/androidMain/kotlin/dev/sdkforge/jwt/decode/core/Platform.android.kt index a12463d..af20374 100644 --- a/shared-core/src/androidMain/kotlin/dev/sdkforge/template/core/Platform.android.kt +++ b/shared-core/src/androidMain/kotlin/dev/sdkforge/jwt/decode/core/Platform.android.kt @@ -1,4 +1,4 @@ -package dev.sdkforge.template.core +package dev.sdkforge.jwt.decode.core /** * Android platform implementation. diff --git a/shared-core/src/androidUnitTest/kotlin/dev/sdkforge/template/core/PlatformTest.android.kt b/shared-core/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/core/PlatformTest.android.kt similarity index 88% rename from shared-core/src/androidUnitTest/kotlin/dev/sdkforge/template/core/PlatformTest.android.kt rename to shared-core/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/core/PlatformTest.android.kt index fe15c20..f6cf2bf 100644 --- a/shared-core/src/androidUnitTest/kotlin/dev/sdkforge/template/core/PlatformTest.android.kt +++ b/shared-core/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/core/PlatformTest.android.kt @@ -1,6 +1,6 @@ @file:Suppress("ktlint:standard:filename") -package dev.sdkforge.template.core +package dev.sdkforge.jwt.decode.core import org.junit.Assert.assertTrue import org.junit.Test diff --git a/shared-core/src/commonMain/kotlin/dev/sdkforge/template/core/Platform.kt b/shared-core/src/commonMain/kotlin/dev/sdkforge/jwt/decode/core/Platform.kt similarity index 97% rename from shared-core/src/commonMain/kotlin/dev/sdkforge/template/core/Platform.kt rename to shared-core/src/commonMain/kotlin/dev/sdkforge/jwt/decode/core/Platform.kt index ebd92f5..d97d20b 100644 --- a/shared-core/src/commonMain/kotlin/dev/sdkforge/template/core/Platform.kt +++ b/shared-core/src/commonMain/kotlin/dev/sdkforge/jwt/decode/core/Platform.kt @@ -1,4 +1,4 @@ -package dev.sdkforge.template.core +package dev.sdkforge.jwt.decode.core /** * Platform information interface. diff --git a/shared-core/src/commonTest/kotlin/dev/sdkforge/template/core/PlatformTest.kt b/shared-core/src/commonTest/kotlin/dev/sdkforge/jwt/decode/core/PlatformTest.kt similarity index 90% rename from shared-core/src/commonTest/kotlin/dev/sdkforge/template/core/PlatformTest.kt rename to shared-core/src/commonTest/kotlin/dev/sdkforge/jwt/decode/core/PlatformTest.kt index 3a9bfee..68231a3 100644 --- a/shared-core/src/commonTest/kotlin/dev/sdkforge/template/core/PlatformTest.kt +++ b/shared-core/src/commonTest/kotlin/dev/sdkforge/jwt/decode/core/PlatformTest.kt @@ -1,6 +1,6 @@ @file:Suppress("ktlint:standard:filename") -package dev.sdkforge.template.core +package dev.sdkforge.jwt.decode.core import kotlin.test.Test import kotlin.test.assertTrue diff --git a/shared-core/src/iosMain/kotlin/dev/sdkforge/template/core/Platform.ios.kt b/shared-core/src/iosMain/kotlin/dev/sdkforge/jwt/decode/core/Platform.ios.kt similarity index 96% rename from shared-core/src/iosMain/kotlin/dev/sdkforge/template/core/Platform.ios.kt rename to shared-core/src/iosMain/kotlin/dev/sdkforge/jwt/decode/core/Platform.ios.kt index c431488..91aad5d 100644 --- a/shared-core/src/iosMain/kotlin/dev/sdkforge/template/core/Platform.ios.kt +++ b/shared-core/src/iosMain/kotlin/dev/sdkforge/jwt/decode/core/Platform.ios.kt @@ -1,4 +1,4 @@ -package dev.sdkforge.template.core +package dev.sdkforge.jwt.decode.core import platform.UIKit.UIDevice diff --git a/shared-core/src/iosTest/kotlin/dev/sdkforge/template/core/PlatformTest.ios.kt b/shared-core/src/iosTest/kotlin/dev/sdkforge/jwt/decode/core/PlatformTest.ios.kt similarity index 88% rename from shared-core/src/iosTest/kotlin/dev/sdkforge/template/core/PlatformTest.ios.kt rename to shared-core/src/iosTest/kotlin/dev/sdkforge/jwt/decode/core/PlatformTest.ios.kt index 0053846..7323517 100644 --- a/shared-core/src/iosTest/kotlin/dev/sdkforge/template/core/PlatformTest.ios.kt +++ b/shared-core/src/iosTest/kotlin/dev/sdkforge/jwt/decode/core/PlatformTest.ios.kt @@ -1,6 +1,6 @@ @file:Suppress("ktlint:standard:filename") -package dev.sdkforge.template.core +package dev.sdkforge.jwt.decode.core import kotlin.test.Test import kotlin.test.assertTrue diff --git a/shared-data/api/shared-data.api b/shared-data/api/shared-data.api new file mode 100644 index 0000000..7c9cd60 --- /dev/null +++ b/shared-data/api/shared-data.api @@ -0,0 +1,53 @@ +public final class dev/sdkforge/jwt/decode/data/JWT { + public static final field INSTANCE Ldev/sdkforge/jwt/decode/data/JWT; + public final fun decode (Ljava/lang/String;Ldev/sdkforge/jwt/decode/domain/JWTParser;)Ldev/sdkforge/jwt/decode/domain/DecodedJWT; + public static synthetic fun decode$default (Ldev/sdkforge/jwt/decode/data/JWT;Ljava/lang/String;Ldev/sdkforge/jwt/decode/domain/JWTParser;ILjava/lang/Object;)Ldev/sdkforge/jwt/decode/domain/DecodedJWT; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public final fun require (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm;)Ldev/sdkforge/jwt/decode/domain/Verification; + public fun toString ()Ljava/lang/String; +} + +public final class dev/sdkforge/jwt/decode/data/algorithm/AlgorithmKt { + public static final fun ECDSA256 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/crypto/domain/ec/ECKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA256 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/crypto/domain/ec/ECPublicKey;Ldev/sdkforge/crypto/domain/ec/ECPrivateKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA256 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/jwt/decode/domain/provider/ECDSAKeyProvider;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA384 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/crypto/domain/ec/ECKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA384 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/crypto/domain/ec/ECPublicKey;Ldev/sdkforge/crypto/domain/ec/ECPrivateKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA384 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/jwt/decode/domain/provider/ECDSAKeyProvider;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA512 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/crypto/domain/ec/ECKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA512 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/crypto/domain/ec/ECPublicKey;Ldev/sdkforge/crypto/domain/ec/ECPrivateKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA512 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/jwt/decode/domain/provider/ECDSAKeyProvider;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun HMAC256 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun HMAC256 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;[B)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun HMAC384 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun HMAC384 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;[B)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun HMAC512 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun HMAC512 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;[B)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA256 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/crypto/domain/rsa/RSAKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA256 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/crypto/domain/rsa/RSAPublicKey;Ldev/sdkforge/crypto/domain/rsa/RSAPrivateKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA256 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/jwt/decode/domain/provider/RSAKeyProvider;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA384 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/crypto/domain/rsa/RSAKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA384 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/crypto/domain/rsa/RSAPublicKey;Ldev/sdkforge/crypto/domain/rsa/RSAPrivateKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA384 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/jwt/decode/domain/provider/RSAKeyProvider;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA512 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/crypto/domain/rsa/RSAKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA512 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/crypto/domain/rsa/RSAPublicKey;Ldev/sdkforge/crypto/domain/rsa/RSAPrivateKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA512 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ldev/sdkforge/jwt/decode/domain/provider/RSAKeyProvider;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun getNONE (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; +} + +public final class dev/sdkforge/jwt/decode/data/algorithm/Algorithm_androidKt { + public static final fun ECDSA256 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/security/interfaces/ECKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA256 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/security/interfaces/ECPublicKey;Ljava/security/interfaces/ECPrivateKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA384 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/security/interfaces/ECKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA384 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/security/interfaces/ECPublicKey;Ljava/security/interfaces/ECPrivateKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA512 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/security/interfaces/ECKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun ECDSA512 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/security/interfaces/ECPublicKey;Ljava/security/interfaces/ECPrivateKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA256 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/security/interfaces/RSAKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA256 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/security/interfaces/RSAPublicKey;Ljava/security/interfaces/RSAPrivateKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA384 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/security/interfaces/RSAKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA384 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/security/interfaces/RSAPublicKey;Ljava/security/interfaces/RSAPrivateKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA512 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/security/interfaces/RSAKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; + public static final fun RSA512 (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion;Ljava/security/interfaces/RSAPublicKey;Ljava/security/interfaces/RSAPrivateKey;)Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm; +} + diff --git a/shared-data/build.gradle.kts b/shared-data/build.gradle.kts new file mode 100644 index 0000000..2d31fb8 --- /dev/null +++ b/shared-data/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.binaryCompatibilityValidator) + alias(libs.plugins.dokka) + alias(libs.plugins.build.logic.library.kmp) + alias(libs.plugins.build.logic.library.android) + alias(libs.plugins.build.logic.library.publishing) +} + +kotlin { + sourceSets { + commonMain { + dependencies { + implementation(project(":shared-domain")) + + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) + } + } + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + androidMain { + dependencies { + implementation("dev.sdkforge.crypto:crypto-domain-android:0.0.2-SNAPSHOT") + } + } + androidUnitTest { + dependencies { + implementation("org.bouncycastle:bcprov-jdk18on:1.82") + implementation("io.mockk:mockk:1.14.5") + implementation("net.jodah:concurrentunit:0.4.6") + implementation("org.hamcrest:hamcrest:3.0") + } + } + } +} + +android { + namespace = "dev.sdkforge.jwt.decode.data" +} diff --git a/shared-data/dependencies/releaseRuntimeClasspath.txt b/shared-data/dependencies/releaseRuntimeClasspath.txt new file mode 100644 index 0000000..263c0ba --- /dev/null +++ b/shared-data/dependencies/releaseRuntimeClasspath.txt @@ -0,0 +1,11 @@ +dev.sdkforge.crypto:crypto-domain-android:0.0.2-SNAPSHOT +dev.sdkforge.crypto:crypto-domain:0.0.2-SNAPSHOT +org.jetbrains.kotlin:kotlin-stdlib:2.2.20 +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1 +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0 +org.jetbrains:annotations:13.0 diff --git a/shared-data/src/androidMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Algorithm.android.kt b/shared-data/src/androidMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Algorithm.android.kt new file mode 100644 index 0000000..492e84a --- /dev/null +++ b/shared-data/src/androidMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Algorithm.android.kt @@ -0,0 +1,219 @@ +@file:Suppress("FunctionName") + +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.ec.asNativeECPrivateKey +import dev.sdkforge.crypto.domain.ec.asNativeECPublicKey +import dev.sdkforge.crypto.domain.rsa.asNativeRSAPrivateKey +import dev.sdkforge.crypto.domain.rsa.asNativeRSAPublicKey +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import java.security.interfaces.ECKey +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAKey +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey + +/** + * Creates a new Algorithm instance using SHA256withRSA. Tokens specify this as "RS256". + * + * @param publicKey the key to use in the verify instance. + * @param privateKey the key to use in the signing instance. + * @return a valid RSA256 Algorithm. + * @throws IllegalArgumentException if both provided Keys are null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA256( + publicKey: RSAPublicKey?, + privateKey: RSAPrivateKey?, +): Algorithm = RSA256( + keyProvider = RSAAlgorithm.providerForKeys( + publicKey = publicKey?.asNativeRSAPublicKey, + privateKey = privateKey?.asNativeRSAPrivateKey, + ), +) + +/** + * Creates a new Algorithm instance using SHA256withRSA. Tokens specify this as "RS256". + * + * @param key the key to use in the verify or signing instance. + * @return a valid RSA256 Algorithm. + * @throws IllegalArgumentException if the Key Provider is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA256( + key: RSAKey, +): Algorithm = RSA256( + publicKey = key as? RSAPublicKey, + privateKey = key as? RSAPrivateKey, +) + +/** + * Creates a new Algorithm instance using SHA384withRSA. Tokens specify this as "RS384". + * + * @param publicKey the key to use in the verify instance. + * @param privateKey the key to use in the signing instance. + * @return a valid RSA384 Algorithm. + * @throws IllegalArgumentException if both provided Keys are null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA384( + publicKey: RSAPublicKey?, + privateKey: RSAPrivateKey?, +): Algorithm = RSA384( + keyProvider = RSAAlgorithm.providerForKeys( + publicKey = publicKey?.asNativeRSAPublicKey, + privateKey = privateKey?.asNativeRSAPrivateKey, + ), +) + +/** + * Creates a new Algorithm instance using SHA384withRSA. Tokens specify this as "RS384". + * + * @param key the key to use in the verify or signing instance. + * @return a valid RSA384 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA384( + key: RSAKey, +): Algorithm = RSA384( + publicKey = key as? RSAPublicKey, + privateKey = key as? RSAPrivateKey, +) + +/** + * Creates a new Algorithm instance using SHA512withRSA. Tokens specify this as "RS512". + * + * @param publicKey the key to use in the verify instance. + * @param privateKey the key to use in the signing instance. + * @return a valid RSA512 Algorithm. + * @throws IllegalArgumentException if both provided Keys are null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA512( + publicKey: RSAPublicKey?, + privateKey: RSAPrivateKey?, +): Algorithm = RSA512( + keyProvider = RSAAlgorithm.providerForKeys( + publicKey = publicKey?.asNativeRSAPublicKey, + privateKey = privateKey?.asNativeRSAPrivateKey, + ), +) + +/** + * Creates a new Algorithm instance using SHA512withRSA. Tokens specify this as "RS512". + * + * @param key the key to use in the verify or signing instance. + * @return a valid RSA512 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA512( + key: RSAKey?, +): Algorithm = RSA512( + publicKey = key as? RSAPublicKey, + privateKey = key as? RSAPrivateKey, +) + +/** + * Creates a new Algorithm instance using SHA256withECDSA. Tokens specify this as "ES256". + * + * @param publicKey the key to use in the verify instance. + * @param privateKey the key to use in the signing instance. + * @return a valid ECDSA256 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA256( + publicKey: ECPublicKey?, + privateKey: ECPrivateKey?, +): Algorithm = ECDSA256( + keyProvider = ECDSAAlgorithm.providerForKeys( + publicKey = publicKey?.asNativeECPublicKey, + privateKey = privateKey?.asNativeECPrivateKey, + ), +) + +/** + * Creates a new Algorithm instance using SHA256withECDSA. Tokens specify this as "ES256". + * + * @param key the key to use in the verify or signing instance. + * @return a valid ECDSA256 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA256( + key: ECKey, +): Algorithm = ECDSA256( + publicKey = key as? ECPublicKey, + privateKey = key as? ECPrivateKey, +) + +/** + * Creates a new Algorithm instance using SHA384withECDSA. Tokens specify this as "ES384". + * + * @param publicKey the key to use in the verify instance. + * @param privateKey the key to use in the signing instance. + * @return a valid ECDSA384 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA384( + publicKey: ECPublicKey?, + privateKey: ECPrivateKey?, +): Algorithm = ECDSA384( + keyProvider = ECDSAAlgorithm.providerForKeys( + publicKey = publicKey?.asNativeECPublicKey, + privateKey = privateKey?.asNativeECPrivateKey, + ), +) + +/** + * Creates a new Algorithm instance using SHA384withECDSA. Tokens specify this as "ES384". + * + * @param key the key to use in the verify or signing instance. + * @return a valid ECDSA384 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA384( + key: ECKey?, +): Algorithm = ECDSA384( + publicKey = key as? ECPublicKey, + privateKey = key as? ECPrivateKey, +) + +/** + * Creates a new Algorithm instance using SHA512withECDSA. Tokens specify this as "ES512". + * + * @param publicKey the key to use in the verify instance. + * @param privateKey the key to use in the signing instance. + * @return a valid ECDSA512 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA512( + publicKey: ECPublicKey?, + privateKey: ECPrivateKey?, +): Algorithm = ECDSA512( + keyProvider = ECDSAAlgorithm.providerForKeys( + publicKey = publicKey?.asNativeECPublicKey, + privateKey = privateKey?.asNativeECPrivateKey, + ), +) + +/** + * Creates a new Algorithm instance using SHA512withECDSA. Tokens specify this as "ES512". + * + * @param key the key to use in the verify or signing instance. + * @return a valid ECDSA512 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA512( + key: ECKey, +): Algorithm = ECDSA512( + publicKey = key as? ECPublicKey, + privateKey = key as? ECPrivateKey, +) diff --git a/shared-data/src/androidMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Crypto.android.kt b/shared-data/src/androidMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Crypto.android.kt new file mode 100644 index 0000000..0643ba8 --- /dev/null +++ b/shared-data/src/androidMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Crypto.android.kt @@ -0,0 +1,93 @@ +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.PrivateKey +import dev.sdkforge.crypto.domain.PublicKey +import dev.sdkforge.crypto.domain.asNativePrivateKey +import dev.sdkforge.crypto.domain.asNativePublicKey +import java.security.MessageDigest +import java.security.Signature +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +internal actual fun verifySignature( + algorithm: String, + secretBytes: ByteArray, + headerBytes: ByteArray, + payloadBytes: ByteArray, + signatureBytes: ByteArray, +): Boolean = MessageDigest.isEqual( + createSignatureFor( + algorithm = algorithm, + secretBytes = secretBytes, + headerBytes = headerBytes, + payloadBytes = payloadBytes, + ), + signatureBytes, +) + +internal actual fun verifySignature( + algorithm: String, + publicKey: PublicKey, + headerBytes: ByteArray, + payloadBytes: ByteArray, + signatureBytes: ByteArray, +): Boolean = Signature.getInstance(algorithm).run { + initVerify(publicKey.asNativePublicKey) + + update(headerBytes) + update(JWT_PART_SEPARATOR) + update(payloadBytes) + + verify(signatureBytes) +} + +internal actual fun createSignatureFor( + algorithm: String, + privateKey: PrivateKey, + headerBytes: ByteArray, + payloadBytes: ByteArray, +): ByteArray = Signature.getInstance(algorithm).run { + initSign(privateKey.asNativePrivateKey) + + update(headerBytes) + update(JWT_PART_SEPARATOR) + update(payloadBytes) + + sign() +} + +internal actual fun createSignatureFor( + algorithm: String, + secretBytes: ByteArray, + headerBytes: ByteArray, + payloadBytes: ByteArray, +): ByteArray = Mac.getInstance(algorithm).run { + init(SecretKeySpec(secretBytes, algorithm)) + + update(headerBytes) + update(JWT_PART_SEPARATOR) + + doFinal(payloadBytes) +} + +internal actual fun createSignatureFor( + algorithm: String, + secretBytes: ByteArray, + contentBytes: ByteArray, +): ByteArray = Mac.getInstance(algorithm).run { + init(SecretKeySpec(secretBytes, algorithm)) + + doFinal(contentBytes) +} + +internal actual fun createSignatureFor( + algorithm: String, + privateKey: PrivateKey, + contentBytes: ByteArray, +): ByteArray = Signature.getInstance(algorithm).run { + initSign(privateKey.asNativePrivateKey) + + update(contentBytes) + + sign() +} diff --git a/shared-data/src/androidMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSAAlgorithm.android.kt b/shared-data/src/androidMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSAAlgorithm.android.kt new file mode 100644 index 0000000..713bbb4 --- /dev/null +++ b/shared-data/src/androidMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSAAlgorithm.android.kt @@ -0,0 +1,26 @@ +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.asNativePublicKey +import dev.sdkforge.crypto.domain.ec.ECPublicKey +import dev.sdkforge.jwt.decode.domain.exception.SignatureException +import java.math.BigInteger +import java.security.interfaces.ECKey + +internal actual fun verifySignature( + publicKey: ECPublicKey, + rBytes: ByteArray, + sBytes: ByteArray, +) { + val order: BigInteger = (publicKey.asNativePublicKey as ECKey).params.order + val r = BigInteger(1, rBytes) + val s = BigInteger(1, sBytes) + + // R and S must be less than N + if (order.compareTo(r) < 1) { + throw SignatureException("Invalid signature format.") + } + + if (order.compareTo(s) < 1) { + throw SignatureException("Invalid signature format.") + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/BasicHeaderTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/BasicHeaderTest.kt new file mode 100644 index 0000000..aac6244 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/BasicHeaderTest.kt @@ -0,0 +1,147 @@ +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Header +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertIsNot +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive + +private const val ALGORITHM = "test" + +class BasicHeaderTest { + + @Test + fun shouldHaveUnmodifiableTreeWhenInstantiatedWithNonNullTree() { + val header: Header = JWTHeader( + algorithm = ALGORITHM, + tree = mutableMapOf(), + ) + + assertIs>((header as JWTHeader).tree) + } + + @Test + fun shouldHaveUnmodifiableTreeWhenInstantiatedWithNullTree() { + val header: Header = JWTHeader( + algorithm = ALGORITHM, + ) + + assertIsNot>((header as JWTHeader).tree) + } + + @Test + fun shouldHaveTree() { + val tree: Map = mapOf( + "key" to JsonNull, + ) + val header: Header = JWTHeader( + algorithm = ALGORITHM, + tree = tree, + ) + + assertIsNot(header.getHeaderClaim("key")) + } + + @Test + fun shouldGetAlgorithm() { + val header: Header = JWTHeader( + algorithm = "HS256", + ) + + assertNotNull(header.algorithm) + assertEquals("HS256", header.algorithm) + } + + @Test + fun shouldGetType() { + val header: Header = JWTHeader( + algorithm = ALGORITHM, + type = "jwt", + ) + + assertNotNull(header.type) + assertEquals("jwt", header.type) + } + + @Test + fun shouldGetNullTypeIfMissing() { + val header: Header = JWTHeader( + algorithm = ALGORITHM, + ) + + assertNull(header.type) + } + + @Test + fun shouldGetContentType() { + val header: Header = JWTHeader( + algorithm = ALGORITHM, + contentType = "content", + ) + + assertNotNull(header.contentType) + assertEquals("content", header.contentType) + } + + @Test + fun shouldGetNullContentTypeIfMissing() { + val header: Header = JWTHeader( + algorithm = ALGORITHM, + ) + + assertNull(header.contentType) + } + + @Test + fun shouldGetKeyId() { + val header: Header = JWTHeader( + algorithm = ALGORITHM, + keyId = "key", + ) + + assertNotNull(header.keyId) + assertEquals("key", header.keyId) + } + + @Test + fun shouldGetNullKeyIdIfMissing() { + val header: Header = JWTHeader( + algorithm = ALGORITHM, + ) + + assertNull(header.keyId) + } + + @Test + fun shouldGetExtraClaim() { + val tree = mapOf( + "extraClaim" to JsonPrimitive("extraValue"), + ) + val header: Header = JWTHeader( + algorithm = ALGORITHM, + tree = tree, + ) + + assertIs(header.getHeaderClaim("extraClaim")) + assertEquals("extraValue", header.getHeaderClaim("extraClaim").asString()) + } + + @Test + fun shouldGetNotNullExtraClaimIfMissing() { + val tree = mutableMapOf() + val header: Header = JWTHeader( + algorithm = ALGORITHM, + tree = tree, + ) + + assertNotNull(header.getHeaderClaim("missing")) + assertTrue(header.getHeaderClaim("missing").isMissing) + assertTrue(!header.getHeaderClaim("missing").isNull) + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/ConcurrentVerifyTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/ConcurrentVerifyTest.kt new file mode 100644 index 0000000..db96669 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/ConcurrentVerifyTest.kt @@ -0,0 +1,198 @@ +@file:Suppress("ktlint:standard:function-signature", "ktlint:standard:class-signature") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.crypto.domain.ec.asNativeECPublicKey +import dev.sdkforge.crypto.domain.rsa.asNativeRSAPublicKey +import dev.sdkforge.jwt.decode.data.algorithm.ECDSA256 +import dev.sdkforge.jwt.decode.data.algorithm.ECDSA384 +import dev.sdkforge.jwt.decode.data.algorithm.ECDSA512 +import dev.sdkforge.jwt.decode.data.algorithm.HMAC256 +import dev.sdkforge.jwt.decode.data.algorithm.HMAC384 +import dev.sdkforge.jwt.decode.data.algorithm.HMAC512 +import dev.sdkforge.jwt.decode.data.algorithm.RSA256 +import dev.sdkforge.jwt.decode.data.algorithm.RSA384 +import dev.sdkforge.jwt.decode.data.algorithm.RSA512 +import dev.sdkforge.jwt.decode.domain.DecodedJWT +import dev.sdkforge.jwt.decode.domain.JWTVerifier +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import io.mockk.junit4.MockKRule +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAPublicKey +import java.util.Collections +import java.util.concurrent.Callable +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import net.jodah.concurrentunit.Waiter +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ConcurrentVerifyTest { + + @get:Rule + val mockkRule = MockKRule(this) + + private var executor: ExecutorService? = null + + @Before + fun setUp() { + executor = Executors.newFixedThreadPool(THREAD_COUNT) + } + + @After + fun shutDown() { + executor!!.shutdown() + } + + @Throws(TimeoutException::class, InterruptedException::class) + private fun concurrentVerify(verifier: JWTVerifier, token: String) { + val waiter = Waiter() + val tasks = Collections.nCopies(REPEAT_COUNT, VerifyTask(waiter, verifier, token)) + + executor!!.invokeAll(tasks, TIMEOUT, TimeUnit.MILLISECONDS) + + waiter.await(TIMEOUT, REPEAT_COUNT) + } + + private class VerifyTask( + private val waiter: Waiter, + private val verifier: JWTVerifier, + private val token: String, + ) : Callable { + + override fun call(): DecodedJWT? { + var jwt: DecodedJWT? = null + + try { + jwt = verifier.verify(token) + waiter.assertNotNull(jwt) + } catch (e: Exception) { + waiter.fail(e) + } + + waiter.resume() + + return jwt + } + } + + @Test + fun shouldPassHMAC256Verification() { + val token = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + + val algorithm = Algorithm.HMAC256("secret") + val verifier: JWTVerifier = dev.sdkforge.jwt.decode.data.JWTVerifier.init(algorithm).withIssuer("auth0").build() + + concurrentVerify(verifier, token) + } + + @Test + fun shouldPassHMAC384Verification() { + val token = + "eyJhbGciOiJIUzM4NCIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.uztpK_wUMYJhrRv8SV-1LU4aPnwl-EM1q-wJnqgyb5DHoDteP6lN_gE1xnZJH5vw" + + val algorithm = Algorithm.HMAC384("secret") + val verifier: JWTVerifier = dev.sdkforge.jwt.decode.data.JWTVerifier.init(algorithm).withIssuer("auth0").build() + + concurrentVerify(verifier, token) + } + + @Test + fun shouldPassHMAC512Verification() { + val token = + "eyJhbGciOiJIUzUxMiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.VUo2Z9SWDV-XcOc_Hr6Lff3vl7L9e5Vb8ThXpmGDFjHxe3Dr1ZBmUChYF-xVA7cAdX1P_D4ZCUcsv3IefpVaJw" + + val algorithm = Algorithm.HMAC512("secret") + val verifier: JWTVerifier = dev.sdkforge.jwt.decode.data.JWTVerifier.init(algorithm).withIssuer("auth0").build() + + concurrentVerify(verifier, token) + } + + @Test + fun shouldPassRSA256Verification() { + val token = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.dxXF3MdsyW-AuvwJpaQtrZ33fAde9xWxpLIg9cO2tMLH2GSRNuLAe61KsJusZhqZB9Iy7DvflcmRz-9OZndm6cj_ThGeJH2LLc90K83UEvvRPo8l85RrQb8PcanxCgIs2RcZOLygERizB3pr5icGkzR7R2y6zgNCjKJ5_NJ6EiZsGN6_nc2PRK_DbyY-Wn0QDxIxKoA5YgQJ9qafe7IN980pXvQv2Z62c3XR8dYuaXBqhthBj-AbaFHEpZapN-V-TmuLNzR2MCB6Xr7BYMuCaqWf_XU8og4XNe8f_8w9Wv5vvgqMM1KhqVpG5VdMJv4o_L4NoCROHhtUQSLRh2M9cA" + + val algorithm = Algorithm.RSA256( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) + val verifier: JWTVerifier = dev.sdkforge.jwt.decode.data.JWTVerifier.init(algorithm).withIssuer("auth0").build() + + concurrentVerify(verifier, token) + } + + @Test + fun shouldPassRSA384Verification() { + val token = + "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.TZlWjXObwGSQOiu2oMq8kiKz0_BR7bbBddNL6G8eZ_GoR82BXOZDqNrQr7lb_M-78XGBguWLWNIdYhzgxOUL9EoCJlrqVm9s9vo6G8T1sj1op-4TbjXZ61TwIvrJee9BvPLdKUJ9_fp1Js5kl6yXkst40Th8Auc5as4n49MLkipjpEhKDKaENKHpSubs1ripSz8SCQZSofeTM_EWVwSw7cpiM8Fy8jOPvWG8Xz4-e3ODFowvHVsDcONX_4FTMNbeRqDuHq2ZhCJnEfzcSJdrve_5VD5fM1LperBVslTrOxIgClOJ3RmM7-WnaizJrWP3D6Z9OLxPxLhM6-jx6tcxEw" + val algorithm = Algorithm.RSA384( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) + + val verifier: JWTVerifier = dev.sdkforge.jwt.decode.data.JWTVerifier.init(algorithm).withIssuer("auth0").build() + + concurrentVerify(verifier, token) + } + + @Test + fun shouldPassRSA512Verification() { + val token = + "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mvL5LoMyIrWYjk5umEXZTmbyIrkbbcVPUkvdGZbu0qFBxGOf0nXP5PZBvPcOu084lvpwVox5n3VaD4iqzW-PsJyvKFgi5TnwmsbKchAp7JexQEsQOnTSGcfRqeUUiBZqRQdYsho71oAB3T4FnalDdFEpM-fztcZY9XqKyayqZLreTeBjqJm4jfOWH7KfGBHgZExQhe96NLq1UA9eUyQwdOA1Z0SgXe4Ja5PxZ6Fm37KnVDtDlNnY4JAAGFo6y74aGNnp_BKgpaVJCGFu1f1S5xCQ1HSvs8ZSdVWs5NgawW3wRd0kRt_GJ_Y3mIwiF4qUyHWGtsSHu_qjVdCTtbFyow" + val algorithm = Algorithm.RSA512( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) + val verifier: JWTVerifier = dev.sdkforge.jwt.decode.data.JWTVerifier.init(algorithm).withIssuer("auth0").build() + + concurrentVerify(verifier, token) + } + + @Test + fun shouldPassECDSA256VerificationWithJOSESignature() { + val token = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.4iVk3-Y0v4RT4_9IaQlp-8dZ_4fsTzIylgrPTDLrEvTHBTyVS3tgPbr2_IZfLETtiKRqCg0aQ5sh9eIsTTwB1g" + + val key = readPublicKey(PUBLIC_KEY_FILE_256, "EC") + val algorithm = Algorithm.ECDSA256(key.asNativeECPublicKey) + val verifier: JWTVerifier = dev.sdkforge.jwt.decode.data.JWTVerifier.init(algorithm).withIssuer("auth0").build() + + concurrentVerify(verifier, token) + } + + @Test + fun shouldPassECDSA384VerificationWithJOSESignature() { + val token = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9.50UU5VKNdF1wfykY8jQBKpvuHZoe6IZBJm5NvoB8bR-hnRg6ti-CHbmvoRtlLfnHfwITa_8cJMy6TenMC2g63GQHytc8rYoXqbwtS4R0Ko_AXbLFUmfxnGnMC6v4MS_z" + + val key = readPublicKey(PUBLIC_KEY_FILE_384, "EC") + val algorithm = Algorithm.ECDSA384(key.asNativeECPublicKey) + val verifier = dev.sdkforge.jwt.decode.data.JWTVerifier.init(algorithm).withIssuer("auth0").build() + + concurrentVerify(verifier, token) + } + + @Test + fun shouldPassECDSA512VerificationWithJOSESignature() { + val token = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.AeCJPDIsSHhwRSGZCY6rspi8zekOw0K9qYMNridP1Fu9uhrA1QrG-EUxXlE06yvmh2R7Rz0aE7kxBwrnq8L8aOBCAYAsqhzPeUvyp8fXjjgs0Eto5I0mndE2QHlgcMSFASyjHbU8wD2Rq7ZNzGQ5b2MZfpv030WGUajT-aZYWFUJHVg2" + + val key = readPublicKey(PUBLIC_KEY_FILE_512, "EC") + val algorithm = Algorithm.ECDSA512(key.asNativeECPublicKey) + val verifier: JWTVerifier = dev.sdkforge.jwt.decode.data.JWTVerifier.init(algorithm).withIssuer("auth0").build() + + concurrentVerify(verifier, token) + } + + companion object { + private const val TIMEOUT = (10 * 1000 * 1000).toLong() // 1 min + private const val THREAD_COUNT = 100 + private const val REPEAT_COUNT = 1000 + private const val PUBLIC_KEY_FILE = "src/androidUnitTest/resources/rsa-public.pem" + private const val PUBLIC_KEY_FILE_256 = "src/androidUnitTest/resources/ec256-key-public.pem" + private const val PUBLIC_KEY_FILE_384 = "src/androidUnitTest/resources/ec384-key-public.pem" + private const val PUBLIC_KEY_FILE_512 = "src/androidUnitTest/resources/ec512-key-public.pem" + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/HeaderDeserializerTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/HeaderDeserializerTest.kt new file mode 100644 index 0000000..78ae022 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/HeaderDeserializerTest.kt @@ -0,0 +1,31 @@ +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Header +import kotlin.test.Test +import kotlin.test.assertEquals + +class HeaderDeserializerTest { + + @Test + fun shouldNotRemoveKnownPublicClaimsFromTree() { + val headerJSON = "{\n" + + " \"alg\": \"HS256\",\n" + + " \"typ\": \"jws\",\n" + + " \"cty\": \"content\",\n" + + " \"kid\": \"key\",\n" + + " \"roles\": \"admin\"\n" + + "}" + val header: Header = JWTParser.parseHeader(headerJSON) + + assertEquals("HS256", header.algorithm) + assertEquals("jws", header.type) + assertEquals("content", header.contentType) + assertEquals("key", header.keyId) + + assertEquals("admin", header.getHeaderClaim("roles").asString()) + assertEquals("HS256", header.getHeaderClaim("alg").asString()) + assertEquals("jws", header.getHeaderClaim("typ").asString()) + assertEquals("content", header.getHeaderClaim("cty").asString()) + assertEquals("key", header.getHeaderClaim("kid").asString()) + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTCreatorTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTCreatorTest.kt new file mode 100644 index 0000000..0477c05 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTCreatorTest.kt @@ -0,0 +1,991 @@ +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.crypto.domain.ec.asNativeECPrivateKey +import dev.sdkforge.crypto.domain.rsa.asNativeRSAPrivateKey +import dev.sdkforge.jwt.decode.data.JsonMatcher.Companion.hasEntry +import dev.sdkforge.jwt.decode.data.algorithm.ECDSA256 +import dev.sdkforge.jwt.decode.data.algorithm.HMAC256 +import dev.sdkforge.jwt.decode.data.algorithm.NONE +import dev.sdkforge.jwt.decode.data.algorithm.RSA256 +import dev.sdkforge.jwt.decode.domain.Claim +import dev.sdkforge.jwt.decode.domain.Header +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.provider.ECDSAKeyProvider +import dev.sdkforge.jwt.decode.domain.provider.RSAKeyProvider +import io.mockk.every +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.RSAPrivateKey +import java.util.* +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.double +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import org.junit.Rule + +@OptIn(ExperimentalTime::class) +class JWTCreatorTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @Test + fun shouldAddHeaderClaim() { + val instant = Instant.fromEpochSeconds(123000L) + + val list = listOf(instant) + val map = buildMap { + this["instant"] = instant + } + + val expectedSerializedList = listOf(instant.epochSeconds) + val expectedSerializedMap = buildMap { + this["instant"] = instant.epochSeconds + } + + val header = buildMap { + this["string"] = "string" + this["int"] = 42 + this["long"] = 4200000000L + this["double"] = 123.123 + this["bool"] = true + this["instant"] = instant + this["list"] = list + this["map"] = map + } + + val signed = JWTCreator.init() + .withHeader(header) + .sign(Algorithm.HMAC256("secret")) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(hasEntry("string", "string").matches(headerJson)) + assertTrue(hasEntry("int", 42).matches(headerJson)) + assertTrue(hasEntry("long", 4200000000L).matches(headerJson)) + assertTrue(hasEntry("double", 123.123).matches(headerJson)) + assertTrue(hasEntry("bool", true).matches(headerJson)) + assertTrue(hasEntry("instant", 123).matches(headerJson)) + assertTrue(hasEntry("list", expectedSerializedList).matches(headerJson)) + assertTrue(hasEntry("map", expectedSerializedMap).matches(headerJson)) + } + + @Test + fun shouldReturnBuilderIfNullMapIsProvided() { + val nullMap: Map? = null + val nullString: String? = null + + JWTCreator.init() + .withHeader(nullMap) + .withHeader(nullString) + .sign(Algorithm.HMAC256("secret")) + } + + @Test + fun shouldSupportJsonValueHeaderWithNestedDataStructure() { + val stringClaim = "someClaim" + val intClaim = 1 + val nestedListClaims = listOf("1", "2") + val claimsJson = "{\"stringClaim\": \"someClaim\", \"intClaim\": 1, \"nestedClaim\": { \"listClaim\": [ \"1\", \"2\" ]}}" + + val jwt = JWTCreator.init() + .withHeader(claimsJson) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + val headerJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[0]).decodeToString() + + assertTrue(hasEntry("stringClaim", stringClaim).matches(headerJson)) + assertTrue(hasEntry("intClaim", intClaim).matches(headerJson)) + assertTrue(hasEntry("listClaim", nestedListClaims).matches(headerJson)) + } + + @Test + fun shouldFailWithIllegalArgumentExceptionForInvalidJsonForHeaderClaims() { + val t = assertFailsWith { + JWTCreator.init() + .withHeader("{ invalidJson }") + .sign(Algorithm.HMAC256("secret")) + } + + assertEquals("Invalid header JSON", t.message) + } + + @Test + fun shouldOverwriteExistingHeaderIfHeaderMapContainsTheSameKey() { + val header = buildMap { + this[Header.Companion.Params.KEY_ID] = "xyz" + } + + val signed = JWTCreator.init() + .withKeyId("abc") + .withHeader(header) + .sign(Algorithm.HMAC256("secret")) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(hasEntry(Header.Companion.Params.KEY_ID, "xyz").matches(headerJson)) + } + + @Test + fun shouldOverwriteExistingHeadersWhenSettingSameHeaderKey() { + val header = buildMap { + this[Header.Companion.Params.KEY_ID] = "xyz" + } + + val signed = JWTCreator.init() + .withHeader(header) + .withKeyId("abc") + .sign(Algorithm.HMAC256("secret")) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(hasEntry(Header.Companion.Params.KEY_ID, "abc").matches(headerJson)) + } + + @Test + fun shouldRemoveHeaderIfTheValueIsNull() { + val header = buildMap { + this[Header.Companion.Params.KEY_ID] = null + this["test2"] = "isSet" + } + + val signed = JWTCreator.init() + .withKeyId("test") + .withHeader(header) + .sign(Algorithm.HMAC256("secret")) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(JsonMatcher.isNotPresent(Header.Companion.Params.KEY_ID).matches(headerJson)) + assertTrue(hasEntry("test2", "isSet").matches(headerJson)) + } + + @Test + fun shouldAddKeyId() { + val signed = JWTCreator.init() + .withKeyId("56a8bd44da435300010000015f5ed") + .sign(Algorithm.HMAC256("secret")) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[0]).decodeToString() + + assertTrue(hasEntry("kid", "56a8bd44da435300010000015f5ed").matches(headerJson)) + } + + @Test + fun shouldAddKeyIdIfAvailableFromRSAAlgorithms() { + val provider: RSAKeyProvider = mockk { + every { privateKeyId } returns "my-key-id" + every { privateKey } returns readPrivateKey(PRIVATE_KEY_RSA, "RSA").asNativeRSAPrivateKey + } + + val signed = JWTCreator.init() + .sign(Algorithm.RSA256(provider)) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(hasEntry("kid", "my-key-id").matches(headerJson)) + } + + @Test + fun shouldNotOverwriteKeyIdIfAddedFromRSAAlgorithms() { + val provider: RSAKeyProvider = mockk { + every { privateKeyId } returns "my-key-id" + every { privateKey } returns readPrivateKey(PRIVATE_KEY_RSA, "RSA").asNativeRSAPrivateKey + } + + val signed = JWTCreator.init() + .withKeyId("real-key-id") + .sign(Algorithm.RSA256(provider)) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(hasEntry("kid", "my-key-id").matches(headerJson)) + } + + @Test + fun shouldAddKeyIdIfAvailableFromECDSAAlgorithms() { + val provider: ECDSAKeyProvider = mockk { + every { privateKeyId } returns "my-key-id" + every { privateKey } returns readPrivateKey(PRIVATE_KEY_EC_256, "EC").asNativeECPrivateKey + } + + val signed = JWTCreator.init() + .sign(Algorithm.ECDSA256(provider)) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(hasEntry("kid", "my-key-id").matches(headerJson)) + } + + @Test + fun shouldNotOverwriteKeyIdIfAddedFromECDSAAlgorithms() { + val provider: ECDSAKeyProvider = mockk { + every { privateKeyId } returns "my-key-id" + every { privateKey } returns readPrivateKey(PRIVATE_KEY_EC_256, "EC").asNativeECPrivateKey + } + + val signed = JWTCreator.init() + .withKeyId("real-key-id") + .sign(Algorithm.ECDSA256(provider)) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(hasEntry("kid", "my-key-id").matches(headerJson)) + } + + @Test + fun shouldAddIssuer() { + val signed = JWTCreator.init() + .withIssuer("auth0") + .sign(Algorithm.HMAC256("secret")) + + assertEquals("eyJpc3MiOiJhdXRoMCJ9", TokenUtils.splitToken(signed)[1]) + } + + @Test + fun shouldAddSubject() { + val signed = JWTCreator.init() + .withSubject("1234567890") + .sign(Algorithm.HMAC256("secret")) + + assertEquals("eyJzdWIiOiIxMjM0NTY3ODkwIn0", TokenUtils.splitToken(signed)[1]) + } + + @Test + fun shouldAddAudience() { + val signed = JWTCreator.init() + .withAudience("Mark") + .sign(Algorithm.HMAC256("secret")) + + assertEquals("eyJhdWQiOiJNYXJrIn0", TokenUtils.splitToken(signed)[1]) + + val signedArr = JWTCreator.init() + .withAudience("Mark", "David") + .sign(Algorithm.HMAC256("secret")) + + assertEquals("eyJhdWQiOlsiTWFyayIsIkRhdmlkIl19", TokenUtils.splitToken(signedArr)[1]) + } + + @Test + fun shouldAddExpiresAt() { + val signed = JWTCreator.init() + .withExpiresAt(Instant.fromEpochMilliseconds(1477592000)) + .sign(Algorithm.HMAC256("secret")) + + assertEquals("eyJleHAiOjE0Nzc1OTJ9", TokenUtils.splitToken(signed)[1]) + } + + @Test + fun shouldAddExpiresAtInstant() { + val signed = JWTCreator.init() + .withExpiresAt(Instant.fromEpochSeconds(1477592)) + .sign(Algorithm.HMAC256("secret")) + + assertEquals("eyJleHAiOjE0Nzc1OTJ9", TokenUtils.splitToken(signed)[1]) + } + + @Test + fun shouldAddNotBefore() { + val signed = JWTCreator.init() + .withNotBefore(Instant.fromEpochMilliseconds(1477592000)) + .sign(Algorithm.HMAC256("secret")) + + assertEquals("eyJuYmYiOjE0Nzc1OTJ9", TokenUtils.splitToken(signed)[1]) + } + + @Test + fun shouldAddNotBeforeInstant() { + val signed = JWTCreator.init() + .withNotBefore(Instant.fromEpochSeconds(1477592)) + .sign(Algorithm.HMAC256("secret")) + + assertEquals("eyJuYmYiOjE0Nzc1OTJ9", TokenUtils.splitToken(signed)[1]) + } + + @Test + fun shouldAddIssuedAt() { + val signed = JWTCreator.init() + .withIssuedAt(Instant.fromEpochMilliseconds(1477592000)) + .sign(Algorithm.HMAC256("secret")) + + assertEquals("eyJpYXQiOjE0Nzc1OTJ9", TokenUtils.splitToken(signed)[1]) + } + + @Test + fun shouldAddIssuedAtInstant() { + val signed = JWTCreator.init() + .withIssuedAt(Instant.fromEpochSeconds(1477592)) + .sign(Algorithm.HMAC256("secret")) + + assertEquals("eyJpYXQiOjE0Nzc1OTJ9", TokenUtils.splitToken(signed)[1]) + } + + @Test + fun shouldAddJWTId() { + val signed = JWTCreator.init() + .withJWTId("jwt_id_123") + .sign(Algorithm.HMAC256("secret")) + + assertEquals("eyJqdGkiOiJqd3RfaWRfMTIzIn0", TokenUtils.splitToken(signed)[1]) + } + + @Test + fun shouldSetCorrectAlgorithmInTheHeader() { + val signed = JWTCreator.init() + .sign(Algorithm.HMAC256("secret")) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(hasEntry("alg", "HS256").matches(headerJson)) + } + + @Test + fun shouldSetDefaultTypeInTheHeader() { + val signed = JWTCreator.init() + .sign(Algorithm.HMAC256("secret")) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(hasEntry("typ", "JWT").matches(headerJson)) + } + + @Test + fun shouldSetCustomTypeInTheHeader() { + val header = Collections.singletonMap("typ", "passport") + val signed = JWTCreator.init() + .withHeader(header) + .sign(Algorithm.HMAC256("secret")) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[0]).decodeToString() + + assertTrue(hasEntry("typ", "passport").matches(headerJson)) + } + + @Test + fun shouldSetEmptySignatureIfAlgorithmIsNone() { + val signed = JWTCreator.init().sign(Algorithm.NONE) + + assertEquals("", TokenUtils.splitToken(signed)[2]) + } + + @Test + fun shouldAcceptCustomClaimOfTypeString() { + val jwt = JWTCreator.init() + .withClaim("name", "value") + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + assertEquals("eyJuYW1lIjoidmFsdWUifQ", parts[1]) + } + + @Test + fun shouldAcceptCustomClaimOfTypeInteger() { + val jwt = JWTCreator.init() + .withClaim("name", 123) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + assertEquals("eyJuYW1lIjoxMjN9", parts[1]) + } + + @Test + fun shouldAcceptCustomClaimOfTypeLong() { + val jwt = JWTCreator.init() + .withClaim("name", Long.MAX_VALUE) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + assertEquals("eyJuYW1lIjo5MjIzMzcyMDM2ODU0Nzc1ODA3fQ", parts[1]) + } + + @Test + fun shouldAcceptCustomClaimOfTypeDouble() { + val jwt = JWTCreator.init() + .withClaim("name", 23.45) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + assertEquals("eyJuYW1lIjoyMy40NX0", parts[1]) + } + + @Test + fun shouldAcceptCustomClaimOfTypeBoolean() { + val jwt = JWTCreator.init() + .withClaim("name", true) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + assertEquals("eyJuYW1lIjp0cnVlfQ", parts[1]) + } + + @Test + fun shouldAcceptCustomClaimOfTypeDate() { + val date = Instant.fromEpochMilliseconds(1478891521000L) + val jwt = JWTCreator.init() + .withClaim("name", date) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + assertEquals("eyJuYW1lIjoxNDc4ODkxNTIxfQ", parts[1]) + } + + @Test + fun shouldAcceptCustomClaimOfTypeDateInstant() { + val instant = Instant.fromEpochSeconds(1478891521) + val jwt = JWTCreator.init() + .withClaim("name", instant) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + assertEquals("eyJuYW1lIjoxNDc4ODkxNTIxfQ", parts[1]) + } + + @Test + fun shouldAcceptCustomArrayClaimOfTypeString() { + val jwt = JWTCreator.init() + .withArrayClaim("name", arrayOf("text", "123", "true")) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + assertEquals("eyJuYW1lIjpbInRleHQiLCIxMjMiLCJ0cnVlIl19", parts[1]) + } + + @Test + fun shouldAcceptCustomArrayClaimOfTypeInteger() { + val jwt = JWTCreator.init() + .withArrayClaim("name", arrayOf(1, 2, 3)) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + assertEquals("eyJuYW1lIjpbMSwyLDNdfQ", parts[1]) + } + + @Test + fun shouldAcceptCustomArrayClaimOfTypeLong() { + val jwt = JWTCreator.init() + .withArrayClaim("name", arrayOf(1L, 2L, 3L)) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + assertEquals("eyJuYW1lIjpbMSwyLDNdfQ", parts[1]) + } + + @Test + fun shouldAcceptCustomClaimOfTypeMap() { + val data = buildMap { + this["test1"] = "abc" + this["test2"] = "def" + } + + val jwt = JWTCreator.init() + .withClaim("data", data) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + assertEquals("eyJkYXRhIjp7InRlc3QxIjoiYWJjIiwidGVzdDIiOiJkZWYifX0", parts[1]) + } + + @Test + fun shouldRefuseCustomClaimOfTypeUserPojo() { + val data = buildMap { + this["test1"] = UserPojo("Michael", 255) + } + + assertFailsWith { + JWTCreator.init() + .withClaim("pojo", data) + .sign(Algorithm.HMAC256("secret")) + } + } + + @Test + fun shouldAcceptCustomMapClaimOfBasicObjectTypes() { + val data = buildMap { + // simple types + this["string"] = "abc" + this["integer"] = 1 + this["long"] = Long.MAX_VALUE + this["double"] = 123.456 + this["instant"] = Instant.fromEpochSeconds(123) + this["boolean"] = true + + // array types + this["intArray"] = arrayOf(3, 5) + this["longArray"] = arrayOf(Long.MAX_VALUE, Long.MIN_VALUE) + this["stringArray"] = arrayOf("string") + + this["list"] = listOf("a", "b", "c") + this["map"] = mapOf("subKey" to "subValue") + } + + val jwt = JWTCreator.init() + .withClaim("data", data) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + val body = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[1]).decodeToString() + val map = JWTParser.JSON.decodeFromString>(body)["data"] as Map + + assertEquals(map["string"]?.jsonPrimitive?.content, "abc") + assertEquals(map["integer"]?.jsonPrimitive?.int, 1) + assertEquals(map["long"]?.jsonPrimitive?.long, Long.MAX_VALUE) + assertEquals(map["double"]?.jsonPrimitive?.double, 123.456) + + assertEquals(map["instant"]?.jsonPrimitive?.int, 123) + assertEquals(map["boolean"]?.jsonPrimitive?.boolean, true) + + // array types + assertEquals(map["intArray"]?.jsonArray[0]?.jsonPrimitive?.int, 3) + assertEquals(map["intArray"]?.jsonArray[1]?.jsonPrimitive?.int, 5) + assertEquals(map["longArray"]?.jsonArray[0]?.jsonPrimitive?.long, Long.MAX_VALUE) + assertEquals(map["longArray"]?.jsonArray[1]?.jsonPrimitive?.long, Long.MIN_VALUE) + assertEquals(map["stringArray"]?.jsonArray[0]?.jsonPrimitive?.content, "string") + + // list + assertEquals(map["list"]?.jsonArray[0]?.jsonPrimitive?.content, "a") + assertEquals(map["list"]?.jsonArray[1]?.jsonPrimitive?.content, "b") + assertEquals(map["list"]?.jsonArray[2]?.jsonPrimitive?.content, "c") + + assertEquals(map["map"]?.jsonObject?.get("subKey")?.jsonPrimitive?.content, "subValue") + } + + @Test + fun shouldAcceptCustomListClaimOfBasicObjectTypes() { + val data = buildList { + // simple types + add("abc") + add(1) + add(Long.MAX_VALUE) + add(123.456) + add(Instant.fromEpochSeconds(123)) + add(true) + + // array types + add(arrayOf(3, 5)) + add(arrayOf(Long.MAX_VALUE, Long.MIN_VALUE)) + add(arrayOf("string")) + + add(listOf("a", "b", "c")) + + add(mapOf("subKey" to "subValue")) + } + + val jwt = JWTCreator.init() + .withClaim("data", data) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + val body = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[1]).decodeToString() + + val list = JWTParser.JSON.decodeFromString>(body)["data"] as List + + assertEquals(list[0].jsonPrimitive.content, "abc") + assertEquals(list[1].jsonPrimitive.int, 1) + assertEquals(list[2].jsonPrimitive.long, Long.MAX_VALUE) + assertEquals(list[3].jsonPrimitive.double, 123.456) + assertEquals(list[4].jsonPrimitive.int, 123) + assertEquals(list[5].jsonPrimitive.boolean, true) + + // array types + assertEquals(list[6].jsonArray[0].jsonPrimitive.int, 3) + assertEquals(list[6].jsonArray[1].jsonPrimitive.int, 5) + assertEquals(list[7].jsonArray[0].jsonPrimitive.long, Long.MAX_VALUE) + assertEquals(list[7].jsonArray[1].jsonPrimitive.long, Long.MIN_VALUE) + assertEquals(list[8].jsonArray[0].jsonPrimitive.content, "string") + + // list + assertEquals(list[9].jsonArray[0].jsonPrimitive.content, "a") + assertEquals(list[9].jsonArray[1].jsonPrimitive.content, "b") + assertEquals(list[9].jsonArray[2].jsonPrimitive.content, "c") + + assertEquals(list[10].jsonObject["subKey"]?.jsonPrimitive?.content, "subValue") + } + + @Test + fun shouldAcceptCustomClaimForNullListItem() { + val data = buildMap { + this["test1"] = listOf("a", null, "c") + } + + JWTCreator.init() + .withClaim("pojo", data) + .sign(Algorithm.HMAC256("secret")) + } + + @Test + fun shouldRefuseCustomListClaimForUnknownListElement() { + val list = listOf(UserPojo(name = "Michael", id = 255)) + + assertFailsWith { + JWTCreator.init() + .withClaim("list", list) + .sign(Algorithm.HMAC256("secret")) + } + } + + @Test + fun shouldRefuseCustomListClaimForUnknownListElementWrappedInAMap() { + val list = listOf(UserPojo(name = "Michael", id = 255)) + + buildMap { + this["someList"] = list + } + + assertFailsWith { + JWTCreator.init() + .withClaim("list", list) + .sign(Algorithm.HMAC256("secret")) + } + } + + @Test + fun shouldAcceptCustomListClaimForUnknownArrayType() { + val list: MutableList = ArrayList() + list.add(arrayOf("test")) + + JWTCreator.init() + .withClaim("list", list) + .sign(Algorithm.HMAC256("secret")) + } + + @Test + fun withPayloadShouldAddBasicClaim() { + val payload = buildMap { + this["asd"] = 123 + } + + val jwt = JWTCreator.init() + .withPayload(payload) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val payloadJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[1]).decodeToString() + + assertTrue(hasEntry("asd", 123).matches(payloadJson)) + } + + @Test + fun withPayloadShouldCreateJwtWithEmptyBodyIfPayloadNull() { + val nullString: String? = null + + val jwt = JWTCreator.init() + .withPayload(nullString) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val payloadJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[1]).decodeToString() + + assertEquals("{}", payloadJson) + } + + @Test + fun withPayloadShouldOverwriteExistingClaimIfPayloadMapContainsTheSameKey() { + val payload = buildMap { + this[Header.Companion.Params.KEY_ID] = "xyz" + } + + val jwt = JWTCreator.init() + .withKeyId("abc") + .withPayload(payload) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val payloadJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[1]).decodeToString() + + assertTrue(hasEntry(Header.Companion.Params.KEY_ID, "xyz").matches(payloadJson)) + } + + @Test + fun shouldOverwriteExistingPayloadWhenSettingSamePayloadKey() { + val payload = buildMap { + this[Claim.Companion.Registered.ISSUER] = "xyz" + } + + val jwt = JWTCreator.init() + .withPayload(payload) + .withIssuer("abc") + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val payloadJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[1]).decodeToString() + + assertTrue(hasEntry(Claim.Companion.Registered.ISSUER, "abc").matches(payloadJson)) + } + + @Test + fun withPayloadShouldNotAllowCustomType() { + val payload = buildMap { + this["entry"] = "value" + this["pojo"] = UserPojo("name", 42) + } + + val t = assertFailsWith { + JWTCreator.init() + .withPayload(payload) + .sign(Algorithm.HMAC256("secret")) + } + + assertEquals("Claim values must only be of types Map, List, Boolean, Int, Long, Double, String, Instant, and Null", t.message) + } + + @Test + fun withPayloadShouldAllowNullListItems() { + val payload = buildMap { + this["list"] = listOf("item1", null, "item2") + } + + val jwt = JWTCreator.init() + .withPayload(payload) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val payloadJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[1]).decodeToString() + + assertTrue(hasEntry("list", listOf("item1", null, "item2")).matches(payloadJson)) + } + + @Test + fun withPayloadShouldNotAllowListWithCustomType() { + val payload = buildMap { + this["list"] = listOf( + "item1", + UserPojo("name", 42), + ) + } + + val t = assertFailsWith { + JWTCreator.init() + .withPayload(payload) + .sign(Algorithm.HMAC256("secret")) + } + + assertEquals("Claim values must only be of types Map, List, Boolean, Int, Long, Double, String, Instant, and Null", t.message) + } + + @Test + fun withPayloadShouldNotAllowMapWithCustomType() { + val payload = buildMap { + this["entry"] = "value" + this["map"] = Collections.singletonMap( + "pojo", + UserPojo("name", 42), + ) + } + + val t = assertFailsWith { + JWTCreator.init() + .withPayload(payload) + .sign(Algorithm.HMAC256("secret")) + } + + assertEquals("Claim values must only be of types Map, List, Boolean, Int, Long, Double, String, Instant, and Null", t.message) + } + + @Test + fun withPayloadShouldAllowNestedSupportedTypes() { + /* + JWT: + { + "stringClaim": "string", + "intClaim": 41, + "listClaim": [ + 1, 2, { + "nestedObjKey": true + } + ], + "objClaim": { + "objKey": ["nestedList1", "nestedList2"] + } + } + */ + + val listClaim = listOf(1, 2, Collections.singletonMap("nestedObjKey", "nestedObjValue")) + val mapClaim = buildMap { + this["objKey"] = mutableListOf("nestedList1", true) + } + + val payload = buildMap { + this["stringClaim"] = "string" + this["intClaim"] = 41 + this["listClaim"] = listClaim + this["objClaim"] = mapClaim + } + + val jwt = JWTCreator.init() + .withPayload(payload) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val payloadJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[1]).decodeToString() + + assertTrue(hasEntry("stringClaim", "string").matches(payloadJson)) + assertTrue(hasEntry("intClaim", 41).matches(payloadJson)) + assertTrue(hasEntry("listClaim", listClaim).matches(payloadJson)) + assertTrue(hasEntry("objClaim", mapClaim).matches(payloadJson)) + } + + @Test + fun withPayloadShouldSupportNullValuesEverywhere() { + /* + JWT: + { + "listClaim": [ + "answer to ultimate question of life", + 42, + null + ], + "claim": null, + "listNestedClaim": [ + 1, + 2, + { + "nestedObjKey": null + } + ], + "objClaim": { + "nestedObjKey": null, + "objObjKey": { + "nestedObjKey": null, + "objListKey": [ + null, + "nestedList2" + ] + }, + "objListKey": [ + null, + "nestedList2" + ] + } + } + */ + + val listClaim = listOf("answer to ultimate question of life", 42, null) + val listNestedClaim = listOf(1, 2, Collections.singletonMap("nestedObjKey", null)) + val objListKey = listOf(null, "nestedList2") + val objClaim = HashMap() + + objClaim["nestedObjKey"] = null + objClaim["objListKey"] = objListKey + objClaim["objObjKey"] = HashMap(objClaim) + + val payload = buildMap { + this["claim"] = null + this["listClaim"] = listClaim + this["listNestedClaim"] = listNestedClaim + this["objClaim"] = objClaim + } + + val jwt = JWTCreator.init() + .withPayload(payload) + .withHeader(payload) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val payloadJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[1]).decodeToString() + val headerJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[0]).decodeToString() + + assertTrue(hasEntry("claim", null).matches(payloadJson)) + assertTrue(hasEntry("listClaim", listClaim).matches(payloadJson)) + assertTrue(hasEntry("listNestedClaim", listNestedClaim).matches(payloadJson)) + assertTrue(hasEntry("objClaim", objClaim).matches(payloadJson)) + + assertTrue(hasEntry("claim", null).matches(headerJson)) + assertTrue(hasEntry("listClaim", listClaim).matches(headerJson)) + assertTrue(hasEntry("listNestedClaim", listNestedClaim).matches(headerJson)) + assertTrue(hasEntry("objClaim", objClaim).matches(headerJson)) + } + + @Test + fun withPayloadShouldSupportJsonValueWithNestedDataStructure() { + val stringClaim = "someClaim" + val intClaim = 1 + val nestedListClaims = listOf("1", "2") + val claimsJson = "{\"stringClaim\": \"someClaim\", \"intClaim\": 1, \"nestedClaim\": { \"listClaim\": [ \"1\", \"2\" ]}}" + + val jwt = JWTCreator.init() + .withPayload(claimsJson) + .sign(Algorithm.HMAC256("secret")) + + val parts = jwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val payloadJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(parts[1]).decodeToString() + + assertTrue(hasEntry("stringClaim", stringClaim).matches(payloadJson)) + assertTrue(hasEntry("intClaim", intClaim).matches(payloadJson)) + assertTrue(hasEntry("listClaim", nestedListClaims).matches(payloadJson)) + } + + @Test + fun shouldFailWithIllegalArgumentExceptionForInvalidJsonForPayloadClaims() { + val t = assertFailsWith { + JWTCreator.init() + .withPayload("{ invalidJson }") + .sign(Algorithm.HMAC256("secret")) + } + + assertEquals("Invalid payload JSON", t.message) + } + + @Test + fun shouldCreatePayloadWithNullForMap() { + val jwt = JWTCreator.init() + .withClaim("name", null as MutableMap?) + .sign(Algorithm.HMAC256("secret")) + + assertTrue(JWT.decode(jwt).getClaim("name").isNull) + } + + @Test + fun shouldCreatePayloadWithNullForList() { + val jwt = JWTCreator.init() + .withClaim("name", null as MutableList<*>?) + .sign(Algorithm.HMAC256("secret")) + + assertTrue(JWT.decode(jwt).getClaim("name").isNull) + } + + companion object { + private const val PRIVATE_KEY_RSA = "src/androidUnitTest/resources/rsa-private.pem" + private const val PRIVATE_KEY_EC_256 = "src/androidUnitTest/resources/ec256-key-private.pem" + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTDecoderTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTDecoderTest.kt new file mode 100644 index 0000000..374c402 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTDecoderTest.kt @@ -0,0 +1,416 @@ +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Claim +import dev.sdkforge.jwt.decode.domain.DecodedJWT +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException +import io.mockk.junit4.MockKRule +import java.nio.charset.StandardCharsets +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull +import org.junit.Rule + +@OptIn(ExperimentalTime::class) +class JWTDecoderTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @Test + fun getSubject() { + val jwt = JWT.decode( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ", + ) + + assertNotNull(jwt.subject) + assertEquals("1234567890", jwt.subject) + } + + // Exceptions + @Test + fun shouldThrowIfTheContentIsNotProperlyEncoded() { + val t = assertFailsWith { + JWT.decode("eyJ0eXAiOiJKV1QiLCJhbGciO-corrupted.eyJ0ZXN0IjoxMjN9.sLtFC2rLAzN0-UJ13OLQX6ezNptAQzespaOGwCnpqk") + } + + assertEquals("The input is not a valid base 64 encoded string.", t.message) + } + + @Test + fun shouldThrowIfLessThan3Parts() { + val t = assertFailsWith { + JWT.decode("two.parts") + } + + assertEquals("The token was expected to have 3 parts, but got 2.", t.message) + } + + @Test + fun shouldThrowIfMoreThan3Parts() { + val t = assertFailsWith { + JWT.decode("this.has.four.parts") + } + + assertEquals("The token was expected to have 3 parts, but got 4.", t.message) + } + + @Test + fun shouldThrowIfPayloadHasInvalidJSONFormat() { + val validJson = "{}" + val invalidJson = "}{" + + val t = assertFailsWith { + customJWT(validJson, invalidJson, "signature") + } + + assertTrue { t.message?.startsWith("Unexpected JSON token") == true } + } + + @Test + fun shouldThrowIfHeaderHasInvalidJSONFormat() { + val validJson = "{}" + val invalidJson = "}{" + + val t = assertFailsWith { + customJWT(invalidJson, validJson, "signature") + } + + assertTrue { t.message?.startsWith("Unexpected JSON token") == true } + } + + @Test + fun shouldThrowWhenHeaderNotValidBase64() { + val jwt = "eyJhbGciOiJub25l+IiwiY3R5IjoiSldUIn0.eyJpc3MiOiJhdXRoMCJ9.Ox-WRXRaGAuWt2KfPvWiGcCrPqZtbp_4OnQzZXaTfss" + + val t = assertFailsWith { + JWT.decode(jwt) + } + + assertIs(t.cause) + } + + @Test + fun shouldThrowWhenPayloadNotValidBase64() { + val jwt = "eyJhbGciOiJub25lIiwiY3R5IjoiSldUIn0.eyJpc3MiOiJhdXRo+MCJ9.Ox-WRXRaGAuWt2KfPvWiGcCrPqZtbp_4OnQzZXaTfss" + + val t = assertFailsWith { + JWT.decode(jwt) + } + + assertIs(t.cause) + } + + // Parts + @Test + fun shouldGetStringToken() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ") + + assertEquals("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ", jwt.token) + } + + @Test + fun shouldGetHeader() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ") + + assertEquals("eyJhbGciOiJIUzI1NiJ9", jwt.header) + } + + @Test + fun shouldGetPayload() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ") + + assertEquals("e30", jwt.payload) + } + + @Test + fun shouldGetSignature() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ") + + assertEquals("XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ", jwt.signature) + } + + // Standard Claims + @Test + fun shouldGetIssuer() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJKb2huIERvZSJ9.SgXosfRR_IwCgHq5lF3tlM-JHtpucWCRSaVuoHTbWbQ") + + assertEquals("John Doe", jwt.issuer) + } + + @Test + fun shouldGetSubject() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJUb2szbnMifQ.RudAxkslimoOY3BLl2Ghny3BrUKu9I1ZrXzCZGDJtNs") + + assertEquals("Tok3ns", jwt.subject) + } + + @Test + fun shouldGetArrayAudience() { + val jwt = JWT.decode( + "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOlsiSG9wZSIsIlRyYXZpcyIsIlNvbG9tb24iXX0.Tm4W8WnfPjlmHSmKFakdij0on2rWPETpoM7Sh0u6-S4", + ) + + assertEquals(3, jwt.audience?.size) + assertTrue(jwt.audience!!.containsAll(listOf("Hope", "Travis", "Solomon"))) + } + + @Test + fun shouldGetStringAudience() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJKYWNrIFJleWVzIn0.a4I9BBhPt1OB1GW67g2P1bEHgi6zgOjGUL4LvhE9Dgc") + + assertEquals(1, jwt.audience?.size) + assertTrue(jwt.audience!!.contains("Jack Reyes")) + } + + @Test + fun shouldGetExpirationTime() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE0NzY3MjcwODZ9.L9dcPHEDQew2u9MkDCORFkfDGcSOsgoPqNY-LUMLEHg") + val ms = 1476727086L * 1000 + + assertEquals(Instant.fromEpochMilliseconds(ms), jwt.expiresAt) + } + + @Test + fun shouldGetNotBefore() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJuYmYiOjE0NzY3MjcwODZ9.tkpD3iCPQPVqjnjpDVp2bJMBAgpVCG9ZjlBuMitass0") + val ms = 1476727086L * 1000 + + assertEquals(Instant.fromEpochMilliseconds(ms), jwt.notBefore) + } + + @Test + fun shouldGetIssuedAt() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0NzY3MjcwODZ9.KPjGoW665E8V5_27Jugab8qSTxLk2cgquhPCBfAP0_w") + val ms = 1476727086L * 1000 + + assertEquals(Instant.fromEpochMilliseconds(ms), jwt.issuedAt) + } + + @Test + fun shouldGetId() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxMjM0NTY3ODkwIn0.m3zgEfVUFOd-CvL3xG5BuOWLzb0zMQZCqiVNQQOPOvA") + + assertEquals("1234567890", jwt.id) + } + + @Test + fun shouldGetContentType() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiIsImN0eSI6ImF3ZXNvbWUifQ.e30.AIm-pJDOaAyct9qKMlN-lQieqNDqc3d4erqUZc5SHAs") + + assertEquals("awesome", jwt.contentType) + } + + @Test + fun shouldGetType() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXUyJ9.e30.WdFmrzx8b9v_a-r6EHC2PTAaWywgm_8LiP8RBRhYwkI") + + assertEquals("JWS", jwt.type) + } + + @Test + fun shouldGetAlgorithm() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ") + + assertEquals("HS256", jwt.algorithm) + } + + // Private Claims + @Test + fun shouldGetMissingClaimIfClaimDoesNotExist() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.K17vlwhE8FCMShdl1_65jEYqsQqBOVMPUU9IgG-QlTM") + + assertNotNull(jwt.getClaim("notExisting")) + assertTrue(jwt.getClaim("notExisting").isMissing) + assertFalse(jwt.getClaim("notExisting").isNull) + } + + @Test + fun shouldGetValidClaim() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJvYmplY3QiOnsibmFtZSI6ImpvaG4ifX0.lrU1gZlOdlmTTeZwq0VI-pZx2iV46UWYd5-lCjy6-c4") + + assertNotNull(jwt.getClaim("object")) + assertIs(jwt.getClaim("object")) + } + + @Test + fun shouldNotGetNullClaimIfClaimIsEmptyObject() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJvYmplY3QiOnt9fQ.d3nUeeL_69QsrHL0ZWij612LHEQxD8EZg1rNoY3a4aI") + + assertNotNull(jwt.getClaim("object")) + assertFalse(jwt.getClaim("object").isNull) + } + + @Test + fun shouldGetCustomClaimOfTypeInteger() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoxMjN9.XZAudnA7h3_Al5kJydzLjw6RzZC3Q6OvnLEYlhNW7HA" + val jwt = JWT.decode(token) + + assertEquals(123, jwt.getClaim("name").asInt()) + } + + @Test + fun shouldGetCustomClaimOfTypeDouble() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoyMy40NX0.7pyX2OmEGaU9q15T8bGFqRm-d3RVTYnqmZNZtxMKSlA" + val jwt = JWT.decode(token) + + assertEquals(23.45, jwt.getClaim("name").asDouble()) + } + + @Test + fun shouldGetCustomClaimOfTypeBoolean() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjp0cnVlfQ.FwQ8VfsZNRqBa9PXMinSIQplfLU4-rkCLfIlTLg_MV0" + + val jwt = JWT.decode(token) + + assertTrue(jwt.getClaim("name").asBoolean() == true) + } + + @Test + fun shouldGetCustomClaimOfTypeDate() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoxNDc4ODkxNTIxfQ.mhioumeok8fghQEhTKF3QtQAksSvZ_9wIhJmgZLhJ6c" + val instant = Instant.fromEpochMilliseconds(1478891521000L) + val jwt = JWT.decode(token) + + assertEquals(instant, jwt.getClaim("name").asInstant()) + } + + @Test + fun shouldGetCustomClaimOfTypeInstant() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoxNDc4ODkxNTIxfQ.mhioumeok8fghQEhTKF3QtQAksSvZ_9wIhJmgZLhJ6c" + val instant: Instant? = Instant.fromEpochSeconds(1478891521L) + val jwt = JWT.decode(token) + + assertEquals(instant, jwt.getClaim("name").asInstant()) + } + + @Test + fun shouldGetCustomArrayClaimOfTypeString() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjpbInRleHQiLCIxMjMiLCJ0cnVlIl19.lxM8EcmK1uSZRAPd0HUhXGZJdauRmZmLjoeqz4J9yAA" + val jwt = JWT.decode(token) + + assertEquals(jwt.getClaim("name").asList(String.serializer()), listOf("text", "123", "true")) + } + + @Test + fun shouldGetCustomArrayClaimOfTypeInteger() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjpbMSwyLDNdfQ.UEuMKRQYrzKAiPpPLhIVawWkKWA1zj0_GderrWUIyFE" + val jwt = JWT.decode(token) + + assertEquals(jwt.getClaim("name").asList(Int.serializer()), listOf(1, 2, 3)) + } + + @Test + fun shouldGetCustomMapClaim() { + val token = + "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjp7InN0cmluZyI6InZhbHVlIiwibnVtYmVyIjoxLCJib29sZWFuIjp0cnVlLCJlbXB0eSI6bnVsbH19.6xkCuYZnu4RA0xZSxlYSYAqzy9JDWsDtIWqSCUZlPt8" + val jwt = JWT.decode(token) + val map = jwt.getClaim("name").asObject(MapSerializer(String.serializer(), JsonPrimitive.serializer())) + + assertNotNull(map) + assertEquals(JsonPrimitive("value"), map["string"]) + assertEquals(JsonPrimitive(1), map["number"]) + assertEquals(JsonPrimitive(true), map["boolean"]) + assertEquals(JsonNull, map["empty"]) + } + + @Test + fun shouldGetCustomNullClaim() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjpudWxsfQ.X4ALHe7uYqEcXWFBnwBUNRKwmwrtDEGZ2aynRYYUx8c" + val jwt = JWT.decode(token) + + assertTrue(jwt.getClaim("name").isNull) + } + + @Test + fun shouldGetListClaim() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjpbbnVsbCwiaGVsbG8iXX0.SpcuQRBGdTV0ofHdxBSnhWEUsQi89noZUXin2Thwb70" + val jwt = JWT.decode(token) + + val list: List = jwt.getClaim("name").asList(String.serializer().nullable) + + assertContains(list, null) + assertContains(list, "hello") + } + + @Test + fun shouldGetAvailableClaims() { + JWT.decode( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwibmJmIjoxMjM0NTY3ODkwLCJqdGkiOiJodHRwczovL2p3dC5pby8iLCJhdWQiOiJodHRwczovL2RvbWFpbi5hdXRoMC5jb20iLCJzdWIiOiJsb2dpbiIsImlzcyI6ImF1dGgwIiwiZXh0cmFDbGFpbSI6IkpvaG4gRG9lIn0.2_0nxDPJwOk64U5V5V9pt8U92jTPJbGsHYQ35HYhbdE", + ) + } + + @Test + fun shouldSerializeAndDeserialize() { + val originalJwt = JWT.decode( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwibmJmIjoxMjM0NTY3ODkwLCJqdGkiOiJodHRwczovL2p3dC5pby8iLCJhdWQiOiJodHRwczovL2RvbWFpbi5hdXRoMC5jb20iLCJzdWIiOiJsb2dpbiIsImlzcyI6ImF1dGgwIiwiZXh0cmFDbGFpbSI6IkpvaG4gRG9lIn0.2_0nxDPJwOk64U5V5V9pt8U92jTPJbGsHYQ35HYhbdE", + ) + + assertEquals(originalJwt.header, originalJwt.header) + assertEquals(originalJwt.payload, originalJwt.payload) + assertEquals(originalJwt.signature, originalJwt.signature) + assertEquals(originalJwt.token, originalJwt.token) + assertEquals(originalJwt.algorithm, originalJwt.algorithm) + assertEquals(originalJwt.audience, originalJwt.audience) + assertEquals(originalJwt.contentType, originalJwt.contentType) + assertEquals(originalJwt.expiresAt, originalJwt.expiresAt) + assertEquals(originalJwt.id, originalJwt.id) + assertEquals(originalJwt.issuedAt, originalJwt.issuedAt) + assertEquals(originalJwt.issuer, originalJwt.issuer) + assertEquals(originalJwt.keyId, originalJwt.keyId) + assertEquals(originalJwt.notBefore, originalJwt.notBefore) + assertEquals(originalJwt.subject, originalJwt.subject) + assertEquals(originalJwt.type, originalJwt.type) + assertEquals(originalJwt.getClaim("extraClaim").asString(), originalJwt.getClaim("extraClaim").asString()) + } + + @Test + fun shouldDecodeHeaderClaims() { + val jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImRhdGUiOjE2NDczNTgzMjUsInN0cmluZyI6InN0cmluZyIsImJvb2wiOnRydWUsImRvdWJsZSI6MTIzLjEyMywibGlzdCI6WzE2NDczNTgzMjVdLCJtYXAiOnsiZGF0ZSI6MTY0NzM1ODMyNSwiaW5zdGFudCI6MTY0NzM1ODMyNX0sImludCI6NDIsImxvbmciOjQyMDAwMDAwMDAsImluc3RhbnQiOjE2NDczNTgzMjV9.eyJpYXQiOjE2NDczNjA4ODF9.S2nZDM03ZDvLMeJLWOIqWZ9kmYHZUueyQiIZCCjYNL8" + val expectedInstant: Instant = Instant.fromEpochSeconds(1647358325) + val decoded = JWT.decode(jwt) + + assertEquals(expectedInstant, decoded.getHeaderClaim("instant").asInstant()) + assertEquals("string", decoded.getHeaderClaim("string").asString()) + assertTrue(decoded.getHeaderClaim("bool").asBoolean() == true) + assertEquals(123.123, decoded.getHeaderClaim("double").asDouble()) + assertEquals(42, decoded.getHeaderClaim("int").asInt()) + assertEquals(4200000000L, decoded.getHeaderClaim("long").asLong()) + + val headerMap = decoded.getHeaderClaim("map").asObject(MapSerializer(String.serializer(), JsonPrimitive.serializer())) + + assertNotNull(headerMap) + assertEquals(2, headerMap.size) + assertEquals(1647358325, headerMap["instant"]?.intOrNull) + + val headerList: List = decoded.getHeaderClaim("list").asList(JsonPrimitive.serializer()) + + assertEquals(1, headerList.size) + assertContains(headerList, JsonPrimitive(1647358325)) + } + + // Helper Methods + private fun customJWT( + jsonHeader: String, + jsonPayload: String, + signature: String?, + ): DecodedJWT { + val header: String = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(jsonHeader.toByteArray(StandardCharsets.UTF_8)) + val body: String = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(jsonPayload.toByteArray(StandardCharsets.UTF_8)) + return JWT.decode("$header.$body.$signature") + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTParserTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTParserTest.kt new file mode 100644 index 0000000..12c5cf7 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTParserTest.kt @@ -0,0 +1,64 @@ +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlinx.serialization.ExperimentalSerializationApi + +@OptIn(ExperimentalTime::class, ExperimentalSerializationApi::class) +class JWTParserTest { + private val parser: dev.sdkforge.jwt.decode.domain.JWTParser = JWTParser + + @Test + fun shouldParsePayload() { + assertEquals(JWTPayload(), parser.parsePayload("{}")) + } + + @Test + fun shouldThrowOnInvalidPayload() { + val jsonPayload = "{{" + + val t = assertFailsWith { + parser.parsePayload(jsonPayload) + } + + assertTrue { t.message?.startsWith("Unexpected JSON token") == true } + } + + @Test + fun shouldParseHeader() { + JWTParser.parseHeader("{}") + } + + @Test + fun shouldThrowOnInvalidHeader() { + val jsonHeader = "}}" + + val t = assertFailsWith { + parser.parseHeader(jsonHeader) + } + + assertTrue { t.message?.startsWith("Unexpected JSON token") == true } + } + + @Test + fun shouldThrowWhenConvertingHeaderFromInvalidJson() { + val t = assertFailsWith { + parser.parseHeader("}{") + } + + assertTrue { t.message?.startsWith("Unexpected JSON token") == true } + } + + @Test + fun shouldThrowWhenConvertingPayloadFromInvalidJson() { + val t = assertFailsWith { + parser.parsePayload("}{") + } + + assertTrue { t.message?.startsWith("Unexpected JSON token") == true } + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTTest.kt new file mode 100644 index 0000000..7e638a8 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTTest.kt @@ -0,0 +1,473 @@ +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.data.algorithm.ECDSA256 +import dev.sdkforge.jwt.decode.data.algorithm.ECDSA384 +import dev.sdkforge.jwt.decode.data.algorithm.ECDSA512 +import dev.sdkforge.jwt.decode.data.algorithm.HMAC256 +import dev.sdkforge.jwt.decode.data.algorithm.HMAC384 +import dev.sdkforge.jwt.decode.data.algorithm.HMAC512 +import dev.sdkforge.jwt.decode.data.algorithm.NONE +import dev.sdkforge.jwt.decode.data.algorithm.RSA256 +import dev.sdkforge.jwt.decode.data.algorithm.RSA384 +import dev.sdkforge.jwt.decode.data.algorithm.RSA512 +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import io.mockk.junit4.MockKRule +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import org.junit.Rule + +@OptIn(ExperimentalTime::class) +class JWTTest { + + @get:Rule + val mockkRule = MockKRule(this) + + // Decode + @Test + fun shouldDecodeAStringToken() { + val token = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + + JWT.decode(token) + } + + @Test + fun shouldDecodeAStringTokenUsingInstance() { + val token = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + + JWT.decode(token) + } + + // getToken + @Test + fun shouldGetStringToken() { + val token = "eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ" + val jwt = JWT.decode(token) + + assertEquals(token, jwt.token) + } + + // getToken + @Test + fun shouldGetStringTokenUsingInstance() { + val token = "eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ" + val decodedJWT = JWT.decode(token) + + assertEquals(token, decodedJWT.token) + } + + // Verify + @Test + fun shouldVerifyDecodedToken() { + val token = + "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mvL5LoMyIrWYjk5umEXZTmbyIrkbbcVPUkvdGZbu0qFBxGOf0nXP5PZBvPcOu084lvpwVox5n3VaD4iqzW-PsJyvKFgi5TnwmsbKchAp7JexQEsQOnTSGcfRqeUUiBZqRQdYsho71oAB3T4FnalDdFEpM-fztcZY9XqKyayqZLreTeBjqJm4jfOWH7KfGBHgZExQhe96NLq1UA9eUyQwdOA1Z0SgXe4Ja5PxZ6Fm37KnVDtDlNnY4JAAGFo6y74aGNnp_BKgpaVJCGFu1f1S5xCQ1HSvs8ZSdVWs5NgawW3wRd0kRt_GJ_Y3mIwiF4qUyHWGtsSHu_qjVdCTtbFyow" + val decodedJWT = JWT.decode(token) + val key = readPublicKey(PUBLIC_KEY_FILE_RSA, "RSA") + val algorithm = Algorithm.RSA512(key) + + JWT.require(algorithm).build().verify(decodedJWT) + } + + @Test + fun shouldAcceptNoneAlgorithm() { + val token = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJhdXRoMCJ9." + val algorithm = Algorithm.NONE + + JWT.require(algorithm).build().verify(token) + } + + @Test + fun shouldAcceptHMAC256Algorithm() { + val token = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + val algorithm = Algorithm.HMAC256("secret") + + JWT.require(algorithm).build().verify(token) + } + + @Test + fun shouldAcceptHMAC384Algorithm() { + val token = + "eyJhbGciOiJIUzM4NCIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.uztpK_wUMYJhrRv8SV-1LU4aPnwl-EM1q-wJnqgyb5DHoDteP6lN_gE1xnZJH5vw" + val algorithm = Algorithm.HMAC384("secret") + + JWT.require(algorithm).build().verify(token) + } + + @Test + fun shouldAcceptHMAC512Algorithm() { + val token = + "eyJhbGciOiJIUzUxMiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.VUo2Z9SWDV-XcOc_Hr6Lff3vl7L9e5Vb8ThXpmGDFjHxe3Dr1ZBmUChYF-xVA7cAdX1P_D4ZCUcsv3IefpVaJw" + val algorithm = Algorithm.HMAC512("secret") + + JWT.require(algorithm).build().verify(token) + } + + @Test + fun shouldAcceptRSA256Algorithm() { + val token = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.dxXF3MdsyW-AuvwJpaQtrZ33fAde9xWxpLIg9cO2tMLH2GSRNuLAe61KsJusZhqZB9Iy7DvflcmRz-9OZndm6cj_ThGeJH2LLc90K83UEvvRPo8l85RrQb8PcanxCgIs2RcZOLygERizB3pr5icGkzR7R2y6zgNCjKJ5_NJ6EiZsGN6_nc2PRK_DbyY-Wn0QDxIxKoA5YgQJ9qafe7IN980pXvQv2Z62c3XR8dYuaXBqhthBj-AbaFHEpZapN-V-TmuLNzR2MCB6Xr7BYMuCaqWf_XU8og4XNe8f_8w9Wv5vvgqMM1KhqVpG5VdMJv4o_L4NoCROHhtUQSLRh2M9cA" + val key = readPublicKey(PUBLIC_KEY_FILE_RSA, "RSA") + val algorithm = Algorithm.RSA256(key) + + JWT.require(algorithm).build().verify(token) + } + + @Test + fun shouldAcceptRSA384Algorithm() { + val token = + "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.TZlWjXObwGSQOiu2oMq8kiKz0_BR7bbBddNL6G8eZ_GoR82BXOZDqNrQr7lb_M-78XGBguWLWNIdYhzgxOUL9EoCJlrqVm9s9vo6G8T1sj1op-4TbjXZ61TwIvrJee9BvPLdKUJ9_fp1Js5kl6yXkst40Th8Auc5as4n49MLkipjpEhKDKaENKHpSubs1ripSz8SCQZSofeTM_EWVwSw7cpiM8Fy8jOPvWG8Xz4-e3ODFowvHVsDcONX_4FTMNbeRqDuHq2ZhCJnEfzcSJdrve_5VD5fM1LperBVslTrOxIgClOJ3RmM7-WnaizJrWP3D6Z9OLxPxLhM6-jx6tcxEw" + val key = readPublicKey(PUBLIC_KEY_FILE_RSA, "RSA") + val algorithm = Algorithm.RSA384(key) + + JWT.require(algorithm).build().verify(token) + } + + @Test + fun shouldAcceptRSA512Algorithm() { + val token = + "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mvL5LoMyIrWYjk5umEXZTmbyIrkbbcVPUkvdGZbu0qFBxGOf0nXP5PZBvPcOu084lvpwVox5n3VaD4iqzW-PsJyvKFgi5TnwmsbKchAp7JexQEsQOnTSGcfRqeUUiBZqRQdYsho71oAB3T4FnalDdFEpM-fztcZY9XqKyayqZLreTeBjqJm4jfOWH7KfGBHgZExQhe96NLq1UA9eUyQwdOA1Z0SgXe4Ja5PxZ6Fm37KnVDtDlNnY4JAAGFo6y74aGNnp_BKgpaVJCGFu1f1S5xCQ1HSvs8ZSdVWs5NgawW3wRd0kRt_GJ_Y3mIwiF4qUyHWGtsSHu_qjVdCTtbFyow" + val key = readPublicKey(PUBLIC_KEY_FILE_RSA, "RSA") + val algorithm = Algorithm.RSA512(key) + + JWT.require(algorithm).build().verify(token) + } + + @Test + fun shouldAcceptECDSA256Algorithm() { + val token = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.4iVk3-Y0v4RT4_9IaQlp-8dZ_4fsTzIylgrPTDLrEvTHBTyVS3tgPbr2_IZfLETtiKRqCg0aQ5sh9eIsTTwB1g" + val key = readPublicKey(PUBLIC_KEY_FILE_EC_256, "EC") + val algorithm = Algorithm.ECDSA256(key) + + JWT.require(algorithm).build().verify(token) + } + + @Test + fun shouldAcceptECDSA384Algorithm() { + val token = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9.50UU5VKNdF1wfykY8jQBKpvuHZoe6IZBJm5NvoB8bR-hnRg6ti-CHbmvoRtlLfnHfwITa_8cJMy6TenMC2g63GQHytc8rYoXqbwtS4R0Ko_AXbLFUmfxnGnMC6v4MS_z" + val key = readPublicKey(PUBLIC_KEY_FILE_EC_384, "EC") + val algorithm = Algorithm.ECDSA384(key) + + JWT.require(algorithm).build().verify(token) + } + + @Test + fun shouldAcceptECDSA512Algorithm() { + val token = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.AeCJPDIsSHhwRSGZCY6rspi8zekOw0K9qYMNridP1Fu9uhrA1QrG-EUxXlE06yvmh2R7Rz0aE7kxBwrnq8L8aOBCAYAsqhzPeUvyp8fXjjgs0Eto5I0mndE2QHlgcMSFASyjHbU8wD2Rq7ZNzGQ5b2MZfpv030WGUajT-aZYWFUJHVg2" + val key = readPublicKey(PUBLIC_KEY_FILE_EC_512, "EC") + val algorithm = Algorithm.ECDSA512(key) + + JWT.require(algorithm).build().verify(token) + } + + // Standard Claims + @Test + fun shouldGetAlgorithm() { + val token = "eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ" + val algorithm = Algorithm.HMAC256("secret") + val jwt = JWT.require(algorithm).build().verify(token) + + assertEquals("HS256", jwt.algorithm) + } + + @Test + fun shouldGetSignature() { + val token = "eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ" + val algorithm = Algorithm.HMAC256("secret") + val jwt = JWT.require(algorithm).build().verify(token) + + assertEquals("XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ", jwt.signature) + } + + @Test + fun shouldGetIssuer() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJKb2huIERvZSJ9.SgXosfRR_IwCgHq5lF3tlM-JHtpucWCRSaVuoHTbWbQ" + val algorithm = Algorithm.HMAC256("secret") + val jwt = JWT.require(algorithm).build().verify(token) + + assertEquals("John Doe", jwt.issuer) + } + + @Test + fun shouldGetSubject() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJUb2szbnMifQ.RudAxkslimoOY3BLl2Ghny3BrUKu9I1ZrXzCZGDJtNs" + val algorithm = Algorithm.HMAC256("secret") + val jwt = JWT.require(algorithm).build().verify(token) + + assertEquals("Tok3ns", jwt.subject) + } + + @Test + fun shouldGetArrayAudience() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOlsiSG9wZSIsIlRyYXZpcyIsIlNvbG9tb24iXX0.Tm4W8WnfPjlmHSmKFakdij0on2rWPETpoM7Sh0u6-S4" + val algorithm = Algorithm.HMAC256("secret") + val jwt = JWT.require(algorithm).build().verify(token) + + assertEquals(3, jwt.audience?.size) + assertTrue(jwt.audience?.contains("Hope") == true) + assertTrue(jwt.audience?.contains("Travis") == true) + assertTrue(jwt.audience?.contains("Solomon") == true) + } + + @Test + fun shouldGetStringAudience() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJKYWNrIFJleWVzIn0.a4I9BBhPt1OB1GW67g2P1bEHgi6zgOjGUL4LvhE9Dgc" + val algorithm = Algorithm.HMAC256("secret") + val jwt = JWT.require(algorithm).build().verify(token) + + assertEquals(1, jwt.audience?.size) + assertTrue(jwt.audience?.contains("Jack Reyes") == true) + } + + @Test + fun shouldGetExpirationTime() { + val seconds = 1477592L + val mockNow = Instant.fromEpochSeconds(seconds - 1) + val token = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE0Nzc1OTJ9.x_ZjkPkKYUV5tdvc0l8go6D_z2kez1MQcOxokXrDc3k" + val algorithm = Algorithm.HMAC256("secret") + val verification = JWT.require(algorithm) as JWTVerifier.BaseVerification + + val jwt = verification.build(mockNow).verify(token) + + assertEquals(Instant.fromEpochSeconds(seconds), jwt.expiresAt) + } + + @Test + fun shouldGetNotBefore() { + val seconds: Long = 1477592 + val clock = Instant.fromEpochSeconds(seconds) + val token = "eyJhbGciOiJIUzI1NiJ9.eyJuYmYiOjE0Nzc1OTJ9.mWYSOPoNXstjKbZkKrqgkwPOQWEx3F3gMm6PMcfuJd8" + val algorithm = Algorithm.HMAC256("secret") + val verification = JWT.require(algorithm) as JWTVerifier.BaseVerification + + val jwt = verification.build(clock).verify(token) + + assertEquals(Instant.fromEpochSeconds(seconds), jwt.notBefore) + } + + @Test + fun shouldGetIssuedAt() { + val seconds: Long = 1477592 + val clock = Instant.fromEpochSeconds(seconds) + val token = "eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0Nzc1OTJ9.5o1CKlLFjKKcddZzoarQ37pq7qZqNPav3sdZ_bsZaD4" + val algorithm = Algorithm.HMAC256("secret") + val verification = JWT.require(algorithm) as JWTVerifier.BaseVerification + + val jwt = verification.build(clock).verify(token) + + assertEquals(Instant.fromEpochSeconds(seconds), jwt.issuedAt) + } + + @Test + fun shouldGetId() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxMjM0NTY3ODkwIn0.m3zgEfVUFOd-CvL3xG5BuOWLzb0zMQZCqiVNQQOPOvA" + val algorithm = Algorithm.HMAC256("secret") + val verification = JWT.require(algorithm) as JWTVerifier.BaseVerification + + val jwt = verification.build().verify(token) + + assertEquals("1234567890", jwt.id) + } + + @Test + fun shouldGetContentType() { + val token = "eyJhbGciOiJIUzI1NiIsImN0eSI6ImF3ZXNvbWUifQ.e30.AIm-pJDOaAyct9qKMlN-lQieqNDqc3d4erqUZc5SHAs" + val algorithm = Algorithm.HMAC256("secret") + val jwt = JWT.require(algorithm).build().verify(token) + + assertEquals("awesome", jwt.contentType) + } + + @Test + fun shouldGetType() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXUyJ9.e30.WdFmrzx8b9v_a-r6EHC2PTAaWywgm_8LiP8RBRhYwkI" + val algorithm = Algorithm.HMAC256("secret") + val jwt = JWT.require(algorithm).build().verify(token) + + assertEquals("JWS", jwt.type) + } + + @Test + fun shouldGetKeyId() { + val token = "eyJhbGciOiJIUzI1NiIsImtpZCI6ImtleSJ9.e30.von1Vt9tq9cn5ZYdX1f4cf2EE7fUvb5BCBlKOTm9YWs" + val algorithm = Algorithm.HMAC256("secret") + val jwt = JWT.require(algorithm).build().verify(token) + + assertEquals("key", jwt.keyId) + } + + @Test + fun shouldGetCustomClaims() { + val token = "eyJhbGciOiJIUzI1NiIsImlzQWRtaW4iOnRydWV9.eyJpc0FkbWluIjoibm9wZSJ9.YDKBAgUDbh0PkhioDcLNzdQ8c2Gdf_yS6zdEtJQS3F0" + val algorithm = Algorithm.HMAC256("secret") + val jwt = JWT.require(algorithm).build().verify(token) + + assertIs(jwt.getHeaderClaim("isAdmin")) + assertEquals(true, jwt.getHeaderClaim("isAdmin").asBoolean()) + assertIs(jwt.getClaim("isAdmin")) + assertEquals("nope", jwt.getClaim("isAdmin").asString()) + } + + // Sign + @Test + fun shouldCreateAnEmptyHMAC256SignedToken() { + val algorithm = Algorithm.HMAC256("secret") + val signed = JWT.create().sign(algorithm) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(JsonMatcher.hasEntry("alg", "HS256").matches(headerJson)) + assertTrue(JsonMatcher.hasEntry("typ", "JWT").matches(headerJson)) + assertEquals("e30", parts[1]) + + JWT.require(algorithm).build() + } + + @Test + fun shouldCreateAnEmptyHMAC384SignedToken() { + val algorithm = Algorithm.HMAC384("secret") + val signed = JWT.create().sign(algorithm) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(JsonMatcher.hasEntry("alg", "HS384").matches(headerJson)) + assertTrue(JsonMatcher.hasEntry("typ", "JWT").matches(headerJson)) + assertEquals("e30", parts[1]) + + JWT.require(algorithm).build() + } + + @Test + fun shouldCreateAnEmptyHMAC512SignedToken() { + val algorithm = Algorithm.HMAC512("secret") + val signed = JWT.create().sign(algorithm) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(JsonMatcher.hasEntry("alg", "HS512").matches(headerJson)) + assertTrue(JsonMatcher.hasEntry("typ", "JWT").matches(headerJson)) + assertEquals("e30", parts[1]) + + JWT.require(algorithm).build() + } + + @Test + fun shouldCreateAnEmptyRSA256SignedToken() { + val privateKey = readPrivateKey(PRIVATE_KEY_FILE_RSA, "RSA") + val algorithm = Algorithm.RSA256(privateKey) + val signed = JWT.create().sign(algorithm) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(JsonMatcher.hasEntry("alg", "RS256").matches(headerJson)) + assertTrue(JsonMatcher.hasEntry("typ", "JWT").matches(headerJson)) + assertEquals("e30", parts[1]) + + JWT.require(Algorithm.RSA256(readPublicKey(PUBLIC_KEY_FILE_RSA, "RSA"))).build() + } + + @Test + fun shouldCreateAnEmptyRSA384SignedToken() { + val privateKey = readPrivateKey(PRIVATE_KEY_FILE_RSA, "RSA") + val algorithm = Algorithm.RSA384(privateKey) + val signed = JWT.create().sign(algorithm) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(JsonMatcher.hasEntry("alg", "RS384").matches(headerJson)) + assertTrue(JsonMatcher.hasEntry("typ", "JWT").matches(headerJson)) + assertEquals("e30", parts[1]) + + JWT.require(Algorithm.RSA384(readPublicKey(PUBLIC_KEY_FILE_RSA, "RSA"))).build() + } + + @Test + fun shouldCreateAnEmptyRSA512SignedToken() { + val privateKey = readPrivateKey(PRIVATE_KEY_FILE_RSA, "RSA") + val algorithm = Algorithm.RSA512(privateKey) + val signed = JWT.create().sign(algorithm) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(JsonMatcher.hasEntry("alg", "RS512").matches(headerJson)) + assertTrue(JsonMatcher.hasEntry("typ", "JWT").matches(headerJson)) + assertEquals("e30", parts[1]) + + JWT.require(Algorithm.RSA512(readPublicKey(PUBLIC_KEY_FILE_RSA, "RSA"))).build() + } + + @Test + fun shouldCreateAnEmptyECDSA256SignedToken() { + val privateKey = readPrivateKey(PRIVATE_KEY_FILE_EC_256, "EC") + val algorithm = Algorithm.ECDSA256(privateKey) + val signed = JWT.create().sign(algorithm) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(JsonMatcher.hasEntry("alg", "ES256").matches(headerJson)) + assertTrue(JsonMatcher.hasEntry("typ", "JWT").matches(headerJson)) + assertEquals("e30", parts[1]) + + JWT.require(Algorithm.ECDSA256(readPublicKey(PUBLIC_KEY_FILE_EC_256, "EC"))).build() + } + + @Test + fun shouldCreateAnEmptyECDSA384SignedToken() { + val algorithm = Algorithm.ECDSA384(readPrivateKey(PRIVATE_KEY_FILE_EC_384, "EC")) + val signed = JWT.create().sign(algorithm) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(JsonMatcher.hasEntry("alg", "ES384").matches(headerJson)) + assertTrue(JsonMatcher.hasEntry("typ", "JWT").matches(headerJson)) + assertEquals("e30", parts[1]) + + JWT.require(Algorithm.ECDSA384(readPublicKey(PUBLIC_KEY_FILE_EC_384, "EC"))).build() + } + + @Test + fun shouldCreateAnEmptyECDSA512SignedToken() { + val privateKey = readPrivateKey(PRIVATE_KEY_FILE_EC_512, "EC") + val algorithm = Algorithm.ECDSA512(privateKey) + val signed = JWT.create().sign(algorithm) + + val parts = signed.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val headerJson = Base64.decode(parts[0]).decodeToString() + + assertTrue(JsonMatcher.hasEntry("alg", "ES512").matches(headerJson)) + assertTrue(JsonMatcher.hasEntry("typ", "JWT").matches(headerJson)) + assertEquals("e30", parts[1]) + + JWT.require(Algorithm.ECDSA512(readPublicKey(PUBLIC_KEY_FILE_EC_512, "EC"))).build() + } + + companion object { + private const val PUBLIC_KEY_FILE_RSA = "src/androidUnitTest/resources/rsa-public.pem" + private const val PRIVATE_KEY_FILE_RSA = "src/androidUnitTest/resources/rsa-private.pem" + + private const val PUBLIC_KEY_FILE_EC_256 = "src/androidUnitTest/resources/ec256-key-public.pem" + private const val PUBLIC_KEY_FILE_EC_384 = "src/androidUnitTest/resources/ec384-key-public.pem" + private const val PUBLIC_KEY_FILE_EC_512 = "src/androidUnitTest/resources/ec512-key-public.pem" + private const val PRIVATE_KEY_FILE_EC_256 = "src/androidUnitTest/resources/ec256-key-private.pem" + private const val PRIVATE_KEY_FILE_EC_384 = "src/androidUnitTest/resources/ec384-key-private.pem" + private const val PRIVATE_KEY_FILE_EC_512 = "src/androidUnitTest/resources/ec512-key-private.pem" + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTVerifierTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTVerifierTest.kt new file mode 100644 index 0000000..0fe9265 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JWTVerifierTest.kt @@ -0,0 +1,1287 @@ +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.data.algorithm.HMAC256 +import dev.sdkforge.jwt.decode.data.algorithm.HMAC512 +import dev.sdkforge.jwt.decode.domain.Claim +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.AlgorithmMismatchException +import dev.sdkforge.jwt.decode.domain.exception.IncorrectClaimException +import dev.sdkforge.jwt.decode.domain.exception.MissingClaimException +import dev.sdkforge.jwt.decode.domain.exception.TokenExpiredException +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import java.util.Collections.singletonMap +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import org.junit.Rule + +@OptIn(ExperimentalTime::class) +class JWTVerifierTest { + + @get:Rule + val mockkRule = MockKRule(this) + + private val mockNow: Instant = Instant.fromEpochSeconds(1477592) + private val mockOneSecondEarlier: Instant = mockNow - 1.seconds + private val mockOneSecondLater: Instant = mockNow + 1.seconds + + @Test + fun shouldThrowWhenAlgorithmDoesntMatchTheTokensAlgorithm() { + val verifier = JWTVerifier + .init(Algorithm.HMAC512("secret")) + .build() as JWTVerifier + + val t = assertFailsWith { + verifier.verify("eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.s69x7Mmu4JqwmdxiK6sesALO7tcedbFsKEEITUxw9ho") + } + + assertEquals("The provided Algorithm doesn't match the one defined in the JWT's Header.", t.message) + } + + @Test + fun shouldValidateIssuer() { + val token = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withIssuer("auth0") + .build() + .verify(token) + } + + @Test + fun shouldValidateMultipleIssuers() { + val auth0Token = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + val otherIssuerToken = + "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJvdGhlcklzc3VlciJ9.k4BCOJJl-c0_Y-49VD_mtt-u0QABKSV5i3W-RKc74co" + + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withIssuer("otherIssuer", "auth0") + .build() as JWTVerifier + + verifier.verify(auth0Token) + verifier.verify(otherIssuerToken) + } + + @Test + fun shouldThrowOnInvalidIssuer() { + val token = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withIssuer("invalid") + .build() + .verify(token) + } + + assertEquals("The Claim 'iss' value doesn't match the required issuer.", t.message) + assertEquals(Claim.Companion.Registered.ISSUER, t.claimName) + assertEquals("auth0", t.claim?.asString()) + } + + @Test + fun shouldThrowOnNullIssuer() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOm51bGx9.OoiCLipSfflWxkFX2rytvtwEiJ8eAL0opkdXY_ap0qA" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withIssuer("auth0") + .build() + .verify(token) + } + + assertEquals("The Claim 'iss' value doesn't match the required issuer.", t.message) + assertEquals(Claim.Companion.Registered.ISSUER, t.claimName) + assertEquals(true, t.claim?.isNull) + } + + @Test + fun shouldThrowOnMissingIssuer() { + val jwt = JWTCreator.init() + .sign(Algorithm.HMAC256("secret")) + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withIssuer("nope") + .build() + .verify(jwt) + } + + assertEquals("The Claim 'iss' is not present in the JWT.", t.message) + assertEquals(Claim.Companion.Registered.ISSUER, t.claimName) + } + + @Test + fun shouldValidateSubject() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.Rq8IxqeX7eA6GgYxlcHdPFVRNFFZc5rEI3MQTZZbK3I" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withSubject("1234567890") + .build() + .verify(token) + } + + @Test + fun shouldThrowOnInvalidSubject() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.Rq8IxqeX7eA6GgYxlcHdPFVRNFFZc5rEI3MQTZZbK3I" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withSubject("invalid") + .build() + .verify(token) + } + + assertEquals("The Claim 'sub' value doesn't match the required one.", t.message) + assertEquals(Claim.Companion.Registered.SUBJECT, t.claimName) + assertEquals(1234567890L, t.claim?.asLong()) + } + + @Test + fun shouldAcceptAudienceWhenWithAudienceContainsAll() { + // Token 'aud': ["Mark"] + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJNYXJrIn0.xWB6czYI0XObbVhLAxe55TwChWZg7zO08RxONWU2iY4" + // Token 'aud': ["Mark", "David"] + val tokenArr = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiTWFyayIsIkRhdmlkIl19.6WfbIt8m61f9WlCYIQn5CThvw4UNyC66qrPaoinfssw" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAudience("Mark") + .build() + .verify(token) + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAudience("Mark", "David") + .build() + .verify(tokenArr) + } + + @Test + fun shouldAllowWithAnyOfAudienceVerificationToOverrideWithAudience() { + // Token 'aud' = ["Mark", "David", "John"] + val token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiTWFyayIsIkRhdmlkIiwiSm9obiJdfQ.DX5xXiCaYvr54x_iL0LZsJhK7O6HhAdHeDYkgDeb0Rw" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAudience("Mark", "Jim") + .build() + .verify(token) + } + + assertEquals("The Claim 'aud' value doesn't contain the required audience.", t.message) + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAnyOfAudience("Mark", "Jim") + .build() + .verify(token) + } + + @Test + fun shouldAllowWithAudienceVerificationToOverrideWithAnyOfAudience() { + // Token 'aud' = ["Mark", "David", "John"] + val token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiTWFyayIsIkRhdmlkIiwiSm9obiJdfQ.DX5xXiCaYvr54x_iL0LZsJhK7O6HhAdHeDYkgDeb0Rw" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAnyOfAudience("Jim") + .build() + .verify(token) + } + + assertEquals("The Claim 'aud' value doesn't contain the required audience.", t.message) + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAudience("Mark").build().verify(token) + } + + @Test + fun shouldAcceptAudienceWhenWithAudienceAndPartialExpected() { + // Token 'aud' = ["Mark", "David", "John"] + val tokenArr = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiTWFyayIsIkRhdmlkIiwiSm9obiJdfQ.DX5xXiCaYvr54x_iL0LZsJhK7O6HhAdHeDYkgDeb0Rw" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAudience("John") + .build() + .verify(tokenArr) + } + + @Test + fun shouldAcceptAudienceWhenAnyOfAudienceAndAllContained() { + // Token 'aud' = ["Mark", "David", "John"] + val tokenArr = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiTWFyayIsIkRhdmlkIiwiSm9obiJdfQ.DX5xXiCaYvr54x_iL0LZsJhK7O6HhAdHeDYkgDeb0Rw" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAnyOfAudience("Mark", "David", "John") + .build() + .verify(tokenArr) + } + + @Test + fun shouldThrowWhenAudienceHasNoneOfExpectedAnyOfAudience() { + // Token 'aud' = ["Mark", "David", "John"] + val tokenArr = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiTWFyayIsIkRhdmlkIiwiSm9obiJdfQ.DX5xXiCaYvr54x_iL0LZsJhK7O6HhAdHeDYkgDeb0Rw" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAnyOfAudience("Joe", "Jim") + .build() + .verify(tokenArr) + } + + assertEquals("The Claim 'aud' value doesn't contain the required audience.", t.message) + assertEquals(Claim.Companion.Registered.AUDIENCE, t.claimName) + assertTrue { t.claim.toString().contains("Mark") } + assertTrue { t.claim.toString().contains("David") } + assertTrue { t.claim.toString().contains("John") } + } + + @Test + fun shouldThrowWhenAudienceClaimDoesNotContainAllExpected() { + // Token 'aud' = ["Mark", "David", "John"] + val token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiTWFyayIsIkRhdmlkIiwiSm9obiJdfQ.DX5xXiCaYvr54x_iL0LZsJhK7O6HhAdHeDYkgDeb0Rw" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAudience("Mark", "Joe") + .build() + .verify(token) + } + + assertEquals("The Claim 'aud' value doesn't contain the required audience.", t.message) + assertEquals(Claim.Companion.Registered.AUDIENCE, t.claimName) + assertTrue { t.claim.toString().contains("Mark") } + assertTrue { t.claim.toString().contains("David") } + assertTrue { t.claim.toString().contains("John") } + } + + @Test + fun shouldThrowWhenAudienceClaimIsNull() { + // Token 'aud': null + val token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjpudWxsfQ.bpPyquk3b8KepErKgTidjJ1ZwiOGuoTxam2_x7cElKI" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAudience("nope") + .build() + .verify(token) + } + + assertEquals("The Claim 'aud' value doesn't contain the required audience.", t.message) + assertEquals(Claim.Companion.Registered.AUDIENCE, t.claimName) + assertTrue(t.claim?.isNull == true) + } + + @Test + fun shouldThrowWhenAudienceClaimIsMissing() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.Rq8IxqeX7eA6GgYxlcHdPFVRNFFZc5rEI3MQTZZbK3I" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAudience("nope") + .build() + .verify(token) + } + + assertEquals("The Claim 'aud' is not present in the JWT.", t.message) + assertEquals("aud", t.claimName) + } + + @Test + fun shouldThrowWhenAudienceClaimIsNullWithAnAudience() { + // Token 'aud': [null] + val token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjpbbnVsbF19.2cBf7FbkX52h8Vmjnl1DY1PYe_J_YP0KsyeoeYmuca8" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAnyOfAudience("nope") + .build() + .verify(token) + } + + assertEquals("The Claim 'aud' value doesn't contain the required audience.", t.message) + assertEquals(Claim.Companion.Registered.AUDIENCE, t.claimName) + assertTrue { t.claim?.asList(JsonElement.serializer())[0] is JsonNull } + } + + @Test + fun shouldThrowWhenExpectedEmptyList() { + // Token 'aud': 'wide audience' + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ3aWRlIGF1ZGllbmNlIn0.c9anq03XepcuEKWEVsPk9cck0sIIfrT6hHbBsCar49o" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAnyOfAudience(*emptyArray()) + .build() + .verify(token) + } + + assertEquals("The Claim 'aud' value doesn't contain the required audience.", t.message) + assertEquals(Claim.Companion.Registered.AUDIENCE, t.claimName) + assertEquals("wide audience", t.claim?.asString()) + } + + @Test + fun shouldNotReplaceWhenMultipleChecksAreAdded() { + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAudience() + .withAnyOfAudience() + .build() as JWTVerifier + + assertEquals(5, verifier.expectedChecks.size) + } + + @Test + fun shouldThrowWhenExpectedArrayClaimIsMissing() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcnJheSI6WzEsMiwzXX0.wKNFBcMdwIpdF9rXRxvexrzSM6umgSFqRO1WZj992YM" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withArrayClaim("missing", 1, 2, 3) + .build() + .verify(token) + } + + assertEquals("The Claim 'missing' is not present in the JWT.", t.message) + assertEquals("missing", t.claimName) + } + + @Test + fun shouldThrowWhenExpectedClaimIsMissing() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbSI6InRleHQifQ.aZ27Ze35VvTqxpaSIK5ZcnYHr4SrvANlUbDR8fw9qsQ" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("missing", "text") + .build() + .verify(token) + } + + assertEquals("The Claim 'missing' is not present in the JWT.", t.message) + assertEquals("missing", t.claimName) + } + + @Test + fun shouldThrowOnInvalidCustomClaimValueOfTypeString() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjpbInNvbWV0aGluZyJdfQ.3ENLez6tU_fG0SVFrGmISltZPiXLSHaz_dyn-XFTEGQ" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", "value") + .build() + .verify(token) + } + + assertEquals("The Claim 'name' value doesn't match the required one.", t.message) + assertEquals("name", t.claimName) + assertEquals(listOf("something"), t.claim?.asList(String.serializer())) + } + + @Test + fun shouldThrowOnInvalidCustomClaimValueOfTypeInteger() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjpbInNvbWV0aGluZyJdfQ.3ENLez6tU_fG0SVFrGmISltZPiXLSHaz_dyn-XFTEGQ" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", 123) + .build() + .verify(token) + } + + assertEquals("The Claim 'name' value doesn't match the required one.", t.message) + assertEquals("name", t.claimName) + assertTrue { t.claim.toString().contains("something") } + } + + @Test + fun shouldThrowOnInvalidCustomClaimValueOfTypeDouble() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjpbInNvbWV0aGluZyJdfQ.3ENLez6tU_fG0SVFrGmISltZPiXLSHaz_dyn-XFTEGQ" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", 23.45) + .build() + .verify(token) + } + + assertEquals("The Claim 'name' value doesn't match the required one.", t.message) + assertEquals("name", t.claimName) + assertTrue { t.claim.toString().contains("something") } + } + + @Test + fun shouldThrowOnInvalidCustomClaimValueOfTypeBoolean() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjpbInNvbWV0aGluZyJdfQ.3ENLez6tU_fG0SVFrGmISltZPiXLSHaz_dyn-XFTEGQ" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", true) + .build() + .verify(token) + } + + assertEquals("The Claim 'name' value doesn't match the required one.", t.message) + assertEquals("name", t.claimName) + assertTrue { t.claim.toString().contains("something") } + } + + @Test + fun shouldThrowOnInvalidCustomClaimValueOfTypeDate() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjpbInNvbWV0aGluZyJdfQ.3ENLez6tU_fG0SVFrGmISltZPiXLSHaz_dyn-XFTEGQ" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", LocalDate(year = 1999, month = Month.JANUARY, day = 1)) + .build() + .verify(token) + } + + assertEquals("The Claim 'name' value doesn't match the required one.", t.message) + assertEquals("name", t.claimName) + assertTrue { t.claim.toString().contains("something") } + } + + @Test + fun shouldThrowOnInvalidCustomClaimValue() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjpbInNvbWV0aGluZyJdfQ.3ENLez6tU_fG0SVFrGmISltZPiXLSHaz_dyn-XFTEGQ" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", "check") + .build() + .verify(token) + } + + assertEquals("The Claim 'name' value doesn't match the required one.", t.message) + assertEquals("name", t.claimName) + assertTrue { t.claim.toString().contains("something") } + } + + @Test + fun shouldValidateCustomClaimOfTypeString() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidmFsdWUifQ.Jki8pvw6KGbxpMinufrgo6RDL1cu7AtNMJYVh6t-_cE" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", "value") + .build() + .verify(token) + } + + @Test + fun shouldValidateCustomClaimOfTypeInteger() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoxMjN9.XZAudnA7h3_Al5kJydzLjw6RzZC3Q6OvnLEYlhNW7HA" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", 123) + .build() + .verify(token) + } + + @Test + fun shouldValidateCustomClaimOfTypeLong() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjo5MjIzMzcyMDM2ODU0Nzc2MDB9.km-IwQ5IDnTZFmuJzhSgvjTzGkn_Z5X29g4nAuVC56I" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", 922337203685477600L) + .build() + .verify(token) + } + + @Test + fun shouldValidateCustomClaimOfTypeDouble() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoyMy40NX0.7pyX2OmEGaU9q15T8bGFqRm-d3RVTYnqmZNZtxMKSlA" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", 23.45) + .build() + .verify(token) + } + + @Test + fun shouldValidateCustomClaimOfTypeBoolean() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjp0cnVlfQ.FwQ8VfsZNRqBa9PXMinSIQplfLU4-rkCLfIlTLg_MV0" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", true) + .build() + .verify(token) + } + + @Test + fun shouldValidateCustomClaimOfTypeDate() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoxNDc4ODkxNTIxfQ.mhioumeok8fghQEhTKF3QtQAksSvZ_9wIhJmgZLhJ6c" + val date = Instant.fromEpochMilliseconds(1478891521123L) + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", date) + .build() + .verify(token) + } + + @Test + fun shouldNotRemoveCustomClaimOfTypeDateWhenNull() { + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("name", Instant.DISTANT_FUTURE) + .build() as JWTVerifier + + assertEquals(4, verifier.expectedChecks.size) + } + + @Test + fun shouldValidateCustomArrayClaimOfTypeString() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjpbInRleHQiLCIxMjMiLCJ0cnVlIl19.lxM8EcmK1uSZRAPd0HUhXGZJdauRmZmLjoeqz4J9yAA" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withArrayClaim("name", "text", "123", "true") + .build() + .verify(token) + } + + @Test + fun shouldValidateCustomArrayClaimOfTypeInteger() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjpbMSwyLDNdfQ.UEuMKRQYrzKAiPpPLhIVawWkKWA1zj0_GderrWUIyFE" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withArrayClaim("name", 1, 2, 3) + .build() + .verify(token) + } + + @Test + fun shouldValidateCustomArrayClaimOfTypeLong() { + val token = + "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjpbNTAwMDAwMDAwMDAxLDUwMDAwMDAwMDAwMiw1MDAwMDAwMDAwMDNdfQ.vzV7S0gbV9ZAVxChuIt4XZuSVTxMH536rFmoHzxmayM" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withArrayClaim("name", 500000000001L, 500000000002L, 500000000003L) + .build() + .verify(token) + } + + @Test + fun shouldValidateCustomArrayClaimOfTypeLongWhenValueIsInteger() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjpbMSwyLDNdfQ.UEuMKRQYrzKAiPpPLhIVawWkKWA1zj0_GderrWUIyFE" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withArrayClaim("name", 1L, 2L, 3L) + .build() + .verify(token) + } + + @Test + fun shouldValidateCustomArrayClaimOfTypeLongWhenValueIsIntegerAndLong() { + val token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjpbMSw1MDAwMDAwMDAwMDIsNTAwMDAwMDAwMDAzXX0.PQjb2rPPpYjM2sItZEzZcjS2YbfPCp6xksTSPjpjTQA" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withArrayClaim("name", 1L, 500000000002L, 500000000003L) + .build() + .verify(token) + } + + // Generic Delta + @Test + fun shouldAddDefaultLeewayToDateClaims() { + val algorithm = mockk() + val verification = JWTVerifier.init(algorithm) as JWTVerifier.BaseVerification + + verification.build() + + assertEquals(0L, verification.getLeewayFor(Claim.Companion.Registered.ISSUED_AT)) + assertEquals(0L, verification.getLeewayFor(Claim.Companion.Registered.EXPIRES_AT)) + assertEquals(0L, verification.getLeewayFor(Claim.Companion.Registered.NOT_BEFORE)) + } + + @Test + fun shouldAddCustomLeewayToDateClaims() { + val algorithm = mockk() + val verification = JWTVerifier.init(algorithm) as JWTVerifier.BaseVerification + + verification + .acceptLeeway(1234L) + .build() as JWTVerifier + + assertEquals(1234L, verification.getLeewayFor(Claim.Companion.Registered.ISSUED_AT)) + assertEquals(1234L, verification.getLeewayFor(Claim.Companion.Registered.EXPIRES_AT)) + assertEquals(1234L, verification.getLeewayFor(Claim.Companion.Registered.NOT_BEFORE)) + } + + @Test + fun shouldOverrideDefaultIssuedAtLeeway() { + val algorithm = mockk() + val verification = JWTVerifier.init(algorithm) as JWTVerifier.BaseVerification + + verification + .acceptLeeway(1234L) + .acceptIssuedAt(9999L) + .build() as JWTVerifier + + assertEquals(9999L, verification.getLeewayFor(Claim.Companion.Registered.ISSUED_AT)) + assertEquals(1234L, verification.getLeewayFor(Claim.Companion.Registered.EXPIRES_AT)) + assertEquals(1234L, verification.getLeewayFor(Claim.Companion.Registered.NOT_BEFORE)) + } + + @Test + fun shouldOverrideDefaultExpiresAtLeeway() { + val algorithm = mockk() + val verification = JWTVerifier.init(algorithm) as JWTVerifier.BaseVerification + + verification + .acceptLeeway(1234L) + .acceptExpiresAt(9999L) + .build() as JWTVerifier + + assertEquals(1234L, verification.getLeewayFor(Claim.Companion.Registered.ISSUED_AT)) + assertEquals(9999L, verification.getLeewayFor(Claim.Companion.Registered.EXPIRES_AT)) + assertEquals(1234L, verification.getLeewayFor(Claim.Companion.Registered.NOT_BEFORE)) + } + + @Test + fun shouldOverrideDefaultNotBeforeLeeway() { + val algorithm = mockk() + val verification = JWTVerifier.init(algorithm) as JWTVerifier.BaseVerification + + verification + .acceptLeeway(1234L) + .acceptNotBefore(9999L) + .build() as JWTVerifier + + assertEquals(1234L, verification.getLeewayFor(Claim.Companion.Registered.ISSUED_AT)) + assertEquals(1234L, verification.getLeewayFor(Claim.Companion.Registered.EXPIRES_AT)) + assertEquals(9999L, verification.getLeewayFor(Claim.Companion.Registered.NOT_BEFORE)) + } + + @Test + fun shouldThrowOnNegativeCustomLeeway() { + val algorithm = mockk() + + val t = assertFailsWith { + JWTVerifier.init(algorithm) + .acceptLeeway(-1) + } + + assertEquals("Leeway value can't be negative.", t.message) + } + + // Expires At + @Test + fun shouldValidateExpiresAtWithLeeway() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0Nzc1OTJ9.isvT0Pqx0yjnZk53mUFSeYFJLDs-Ls9IsNAm86gIdZo" + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")).acceptExpiresAt(2) as JWTVerifier.BaseVerification + + verification + .build(mockOneSecondLater) + .verify(token) + } + + @Test + fun shouldValidateExpiresAtIfPresent() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0Nzc1OTJ9.isvT0Pqx0yjnZk53mUFSeYFJLDs-Ls9IsNAm86gIdZo" + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")) as JWTVerifier.BaseVerification + + verification + .build(mockOneSecondEarlier) + .verify(token) + } + + @Test + fun shouldThrowWhenExpiresAtIsNow() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0Nzc1OTJ9.isvT0Pqx0yjnZk53mUFSeYFJLDs-Ls9IsNAm86gIdZo" + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")) as JWTVerifier.BaseVerification + + // exp must be > now + val t = assertFailsWith { + verification + .build(mockNow) + .verify(token) + } + + assertEquals("The Token has expired on 1970-01-18T02:26:32Z.", t.message) + assertEquals(Instant.fromEpochSeconds(1477592L), t.expiredOn) + } + + @Test + fun shouldThrowOnInvalidExpiresAtIfPresent() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0Nzc1OTJ9.isvT0Pqx0yjnZk53mUFSeYFJLDs-Ls9IsNAm86gIdZo" + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")) as JWTVerifier.BaseVerification + + val t = assertFailsWith { + verification + .build(mockOneSecondLater) + .verify(token) + } + + assertEquals("The Token has expired on 1970-01-18T02:26:32Z.", t.message) + assertEquals(Instant.fromEpochSeconds(1477592L), t.expiredOn) + } + + @Test + fun shouldThrowOnNegativeExpiresAtLeeway() { + val algorithm = mockk() + + val t = assertFailsWith { + JWTVerifier.init(algorithm) + .acceptExpiresAt(-1) + } + + assertEquals("Leeway value can't be negative.", t.message) + } + + // Not before + @Test + fun shouldValidateNotBeforeWithLeeway() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0Nzc1OTJ9.wq4ZmnSF2VOxcQBxPLfeh1J2Ozy1Tj5iUaERm3FKaw8" + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")).acceptNotBefore(2) as JWTVerifier.BaseVerification + + verification + .build(mockOneSecondEarlier) + .verify(token) + } + + @Test + fun shouldThrowOnInvalidNotBeforeIfPresent() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0Nzc1OTJ9.wq4ZmnSF2VOxcQBxPLfeh1J2Ozy1Tj5iUaERm3FKaw8" + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")) as JWTVerifier.BaseVerification + + val t = assertFailsWith { + verification + .build(mockOneSecondEarlier) + .verify(token) + } + + assertEquals("The Token can't be used before 1970-01-18T02:26:32Z.", t.message) + assertEquals(Claim.Companion.Registered.NOT_BEFORE, t.claimName) + assertEquals(1477592L, t.claim?.asLong()) + } + + @Test + fun shouldValidateNotBeforeIfPresent() { + val token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYmYiOjE0Nzc1OTN9.f4zVV0TbbTG5xxDjSoGZ320JIMchGoQCWrnT5MyQdT0" + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")) as JWTVerifier.BaseVerification + + verification + .build(mockOneSecondLater) + .verify(token) + } + + @Test + fun shouldAcceptNotBeforeEqualToNow() { + val token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYmYiOjE0Nzc1OTJ9.71XBtRmkAa4iKnyhbS4NPW-Xr26eAVAdHZgmupS7a5o" + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")) as JWTVerifier.BaseVerification + + verification + .build(mockNow) + .verify(token) + } + + @Test + fun shouldThrowOnNegativeNotBeforeLeeway() { + val algorithm = mockk() + + val t = assertFailsWith { + JWTVerifier.init(algorithm) + .acceptNotBefore(-1) + } + + assertEquals("Leeway value can't be negative.", t.message) + } + + // Issued At with future date + @Test + fun shouldThrowOnFutureIssuedAt() { + val token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0Nzc1OTJ9.CWq-6pUXl1bFg81vqOUZbZrheO2kUBd2Xr3FUZmvudE" + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")) as JWTVerifier.BaseVerification + + val t = assertFailsWith { + verification.build(mockOneSecondEarlier).verify(token) + } + + assertEquals("The Token can't be used before 1970-01-18T02:26:32Z.", t.message) + assertEquals(Claim.Companion.Registered.ISSUED_AT, t.claimName) + assertEquals(1477592L, t.claim?.asLong()) + } + + // Issued At with future date and ignore flag + @Test + fun shouldSkipIssuedAtVerificationWhenFlagIsPassed() { + val token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0Nzc1OTJ9.CWq-6pUXl1bFg81vqOUZbZrheO2kUBd2Xr3FUZmvudE" + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")) as JWTVerifier.BaseVerification + + verification.ignoreIssuedAt() + + verification.build(mockOneSecondEarlier).verify(token) + } + + @Test + fun shouldThrowOnInvalidIssuedAtIfPresent() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE0Nzc1OTJ9.0WJky9eLN7kuxLyZlmbcXRL3Wy8hLoNCEk5CCl2M4lo" + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")) as JWTVerifier.BaseVerification + + val t = assertFailsWith { + verification + .build(mockOneSecondEarlier) + .verify(token) + } + + assertEquals("The Token can't be used before 1970-01-18T02:26:32Z.", t.message) + assertEquals(Claim.Companion.Registered.ISSUED_AT, t.claimName) + assertEquals(1477592L, t.claim?.asLong()) + } + + @Test + fun shouldOverrideAcceptIssuedAtWhenIgnoreIssuedAtFlagPassedAndSkipTheVerification() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE0Nzc1OTJ9.0WJky9eLN7kuxLyZlmbcXRL3Wy8hLoNCEk5CCl2M4lo" + + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")) + .acceptIssuedAt(1) + .ignoreIssuedAt() as JWTVerifier.BaseVerification + + verification + .build(mockOneSecondEarlier) + .verify(token) + } + + @Test + fun shouldValidateIssuedAtIfPresent() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE0Nzc1OTJ9.0WJky9eLN7kuxLyZlmbcXRL3Wy8hLoNCEk5CCl2M4lo" + val verification = JWTVerifier.init(Algorithm.HMAC256("secret")) as JWTVerifier.BaseVerification + + verification + .build(mockNow) + .verify(token) + } + + @Test + fun shouldThrowOnNegativeIssuedAtLeeway() { + val algorithm = mockk() + + val t = assertFailsWith { + JWTVerifier.init(algorithm) + .acceptIssuedAt(-1) + } + + assertEquals("Leeway value can't be negative.", t.message) + } + + @Test + fun shouldValidateJWTId() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJqd3RfaWRfMTIzIn0.0kegfXUvwOYioP8PDaLMY1IlV8HOAzSVz3EGL7-jWF4" + + JWTVerifier + .init(Algorithm.HMAC256("secret")) + .withJWTId("jwt_id_123") + .build() + .verify(token) + } + + @Test + fun shouldThrowOnInvalidJWTId() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJqd3RfaWRfMTIzIn0.0kegfXUvwOYioP8PDaLMY1IlV8HOAzSVz3EGL7-jWF4" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withJWTId("invalid") + .build() + .verify(token) + } + + assertEquals("The Claim 'jti' value doesn't match the required one.", t.message) + assertEquals("jti", t.claimName) + assertEquals("jwt_id_123", t.claim?.asString()) + } + + @Test + fun shouldNotRemoveClaimWhenPassingNull() { + val algorithm = mockk() + var verifier = JWTVerifier.init(algorithm) + .withIssuer("iss") + .build() as JWTVerifier + + assertEquals(4, verifier.expectedChecks.size) + + verifier = JWTVerifier.init(algorithm) + .withIssuer("iss") + .build() as JWTVerifier + + assertEquals(4, verifier.expectedChecks.size) + } + + @Test + fun shouldNotRemoveIssuerWhenPassingNullReference() { + val algorithm = mockk() + var verifier = JWTVerifier.init(algorithm).build() as JWTVerifier + + assertEquals(3, verifier.expectedChecks.size) + + verifier = JWTVerifier.init(algorithm).build() as JWTVerifier + + assertEquals(3, verifier.expectedChecks.size) + + verifier = JWTVerifier.init(algorithm).withIssuer().build() as JWTVerifier + + assertEquals(4, verifier.expectedChecks.size) + + JWTVerifier.init(algorithm) + .withIssuer(" ") + .build() as JWTVerifier + } + + @Test + fun shouldSkipClaimValidationsIfNoClaimsRequired() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .build() + .verify(token) + } + + @Test + fun shouldThrowWhenVerifyingClaimPresenceButClaimNotPresent() { + val jwt = JWTCreator.init() + .withClaim("custom", "") + .sign(Algorithm.HMAC256("secret")) + + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaimPresence("missing") + .build() as JWTVerifier + + val t = assertFailsWith { + verifier.verify(jwt) + } + + assertEquals("The Claim 'missing' is not present in the JWT.", t.message) + assertEquals("missing", t.claimName) + } + + @Test + fun shouldVerifyStringClaimPresence() { + val jwt = JWTCreator.init() + .withClaim("custom", "") + .sign(Algorithm.HMAC256("secret")) + + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaimPresence("custom") + .build() as JWTVerifier + + verifier.verify(jwt) + } + + @Test + fun shouldVerifyBooleanClaimPresence() { + val jwt = JWTCreator.init() + .withClaim("custom", true) + .sign(Algorithm.HMAC256("secret")) + + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaimPresence("custom") + .build() as JWTVerifier + + verifier.verify(jwt) + } + + @Test + fun shouldVerifyIntegerClaimPresence() { + val jwt = JWTCreator.init() + .withClaim("custom", 123) + .sign(Algorithm.HMAC256("secret")) + + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaimPresence("custom") + .build() as JWTVerifier + + verifier.verify(jwt) + } + + @Test + fun shouldVerifyLongClaimPresence() { + val jwt = JWTCreator.init() + .withClaim("custom", 922337203685477600L) + .sign(Algorithm.HMAC256("secret")) + + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaimPresence("custom") + .build() as JWTVerifier + + verifier.verify(jwt) + } + + @Test + fun shouldVerifyDoubleClaimPresence() { + val jwt = JWTCreator.init() + .withClaim("custom", 12.34) + .sign(Algorithm.HMAC256("secret")) + + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaimPresence("custom") + .build() as JWTVerifier + + verifier.verify(jwt) + } + + @Test + fun shouldVerifyListClaimPresence() { + val jwt = JWTCreator.init() + .withClaim("custom", mutableListOf("item")) + .sign(Algorithm.HMAC256("secret")) + + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaimPresence("custom") + .build() as JWTVerifier + + verifier.verify(jwt) + } + + @Test + fun shouldVerifyMapClaimPresence() { + val jwt = JWTCreator.init() + .withClaim("custom", singletonMap("key", "value")) + .sign(Algorithm.HMAC256("secret")) + + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaimPresence("custom") + .build() as JWTVerifier + + verifier.verify(jwt) + } + + @Test + fun shouldVerifyStandardClaimPresence() { + val jwt = JWTCreator.init() + .withClaim("aud", "any value") + .sign(Algorithm.HMAC256("secret")) + + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaimPresence("aud") + .build() as JWTVerifier + + verifier.verify(jwt) + } + + @Test + fun shouldSuccessfullyVerifyClaimWithPredicate() { + val jwt = JWTCreator.init() + .withClaim("claimName", "claimValue") + .sign(Algorithm.HMAC256("secret")) + + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("claimName", { claim, decodedJWT -> "claimValue" == claim.asString() }) + .build() as JWTVerifier + + verifier.verify(jwt) + } + + @Test + fun shouldThrowWhenPredicateReturnsFalse() { + val jwt = JWTCreator.init() + .withClaim("claimName", "claimValue") + .sign(Algorithm.HMAC256("secret")) + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("claimName", { claim, decodedJWT -> "nope" == claim.asString() }) + .build() + .verify(jwt) + } + + assertEquals("The Claim 'claimName' value doesn't match the required one.", t.message) + assertEquals("claimName", t.claimName) + assertEquals("claimValue", t.claim?.asString()) + } + + @Test + fun shouldNotRemovePredicateCheckForNull() { + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("claimName", { claim, decodedJWT -> "nope" == claim.asString() }) + .build() as JWTVerifier + + assertEquals(4, verifier.expectedChecks.size) + } + + @Test + fun shouldSuccessfullyVerifyClaimWithNull() { + val jwt = JWTCreator.init() + .withNullClaim("claimName") + .sign(Algorithm.HMAC256("secret")) + + val verifier = JWTVerifier.init(Algorithm.HMAC256("secret")) + .withNullClaim("claimName") + .build() as JWTVerifier + + verifier.verify(jwt) + } + + @Test + fun shouldThrowWhenNullClaimHasValue() { + val jwt = JWTCreator.init() + .withClaim("claimName", "value") + .sign(Algorithm.HMAC256("secret")) + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withNullClaim("claimName") + .build() + .verify(jwt) + } + + assertEquals("The Claim 'claimName' value doesn't match the required one.", t.message) + assertEquals("claimName", t.claimName) + assertEquals("value", t.claim?.asString()) + } + + @Test + fun shouldThrowWhenNullClaimIsMissing() { + val jwt = JWTCreator.init() + .withClaim("claimName", "value") + .sign(Algorithm.HMAC256("secret")) + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withNullClaim("anotherClaimName") + .build() + .verify(jwt) + } + + assertEquals("The Claim 'anotherClaimName' is not present in the JWT.", t.message) + assertEquals("anotherClaimName", t.claimName) + } + + @Test + fun shouldCheckForNullValuesForSubject() { + // sub = null + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOm51bGx9.y5brmQQ05OYwVvlTg83njUrz6tfpdyWNh17LHU6DxmI" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .build() + .verify(token) + } + + @Test + fun shouldCheckForNullValuesInIssuer() { + // iss = null + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOm51bGx9.OoiCLipSfflWxkFX2rytvtwEiJ8eAL0opkdXY_ap0qA" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withIssuer() + .build() + .verify(token) + } + + @Test + fun shouldCheckForNullValuesInJwtId() { + // jti = null + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOm51bGx9.z_MDyl8uPGH0q0jeB54wbYt3bwKXamU_3MO8LofGvZs" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .build() + .verify(token) + } + + @Test + fun shouldCheckForNullValuesInCustomClaims() { + // jti = null + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjdXN0b20iOm51bGx9.inAuN3Q9UZ6WgbB63O43B1ero2MTqnfzzumr_5qYIls" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .build() + .verify(token) + } + + @Test + fun shouldCheckForNullValuesForAudience() { + // aud = null + val token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjpudWxsfQ.bpPyquk3b8KepErKgTidjJ1ZwiOGuoTxam2_x7cElKI" + + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withAudience() + .withAnyOfAudience() + .build() + .verify(token) + } + + @Test + fun shouldCheckForClaimPresenceEvenForNormalClaimChecks() { + val token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjpudWxsfQ.bpPyquk3b8KepErKgTidjJ1ZwiOGuoTxam2_x7cElKI" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("custom", true) + .build() + .verify(token) + } + + assertEquals("custom", t.claimName) + } + + @Test + fun shouldCheckForWrongLongClaim() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjdXN0b20iOjF9.00btiK0sv8pQ2T-hOr9GC5x2osi7--Bsk4pS5cTikqQ" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withClaim("custom", 2L) + .build() + .verify(token) + } + + assertEquals("custom", t.claimName) + assertEquals(1L, t.claim?.asLong()) + } + + @Test + fun shouldCheckForWrongLongArrayClaim() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjdXN0b20iOlsxXX0.R9ZSmgtJng062rcEc59u4VKCq89Yk5VlkN9BuMTMvr0" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withArrayClaim("custom", 2L) + .build() + .verify(token) + } + + assertEquals("custom", t.claimName) + } + + @Test + fun shouldCheckForWrongStringArrayClaim() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjdXN0b20iOlsxXX0.R9ZSmgtJng062rcEc59u4VKCq89Yk5VlkN9BuMTMvr0" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withArrayClaim("custom", "2L") + .build() + .verify(token) + } + + assertEquals("custom", t.claimName) + } + + @Test + fun shouldCheckForWrongIntegerArrayClaim() { + val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjdXN0b20iOlsxXX0.R9ZSmgtJng062rcEc59u4VKCq89Yk5VlkN9BuMTMvr0" + + val t = assertFailsWith { + JWTVerifier.init(Algorithm.HMAC256("secret")) + .withArrayClaim("custom", 2) + .build() + .verify(token) + } + + assertEquals("custom", t.claimName) + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JsonMatcher.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JsonMatcher.kt new file mode 100644 index 0000000..b866c03 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/JsonMatcher.kt @@ -0,0 +1,109 @@ +@file:Suppress("ktlint:standard:class-signature", "ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data + +import java.lang.reflect.Array +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeDiagnosingMatcher + +class JsonMatcher private constructor( + private val key: String, + value: Any?, + private val matcher: Matcher<*>?, +) : TypeSafeDiagnosingMatcher() { + + private val entry: String? = if (value != null) getStringKey(key) + objectToString(value) else null + + override fun matchesSafely(item: String?, mismatchDescription: Description): Boolean { + if (item == null) { + mismatchDescription.appendText("JSON was null") + return false + } + if (matcher != null) { + if (!matcher.matches(item)) { + matcher.describeMismatch(item, mismatchDescription) + return false + } + if (!item.contains(getStringKey(key))) { + mismatchDescription.appendText("JSON didn't contained the key ").appendValue(key) + return false + } + } + if (entry != null && !item.contains(entry)) { + mismatchDescription.appendText("JSON was ").appendValue(item) + return false + } + + return true + } + + override fun describeTo(description: Description) { + if (matcher == null) { + description.appendText("A JSON with entry ").appendValue(entry) + } else { + matcher.describeTo(description) + } + } + + private fun getStringKey(key: String): String = "\"$key\":" + + private fun objectToString(value: Any?): String = when (value) { + null -> "null" + is String -> "\"" + value + "\"" + is MutableMap<*, *> -> mapToString(value as MutableMap) + is Array -> arrayToString(value as kotlin.Array) + is MutableList<*> -> listToString(value as MutableList) + else -> value.toString() + } + + private fun arrayToString(array: kotlin.Array): String { + val sb = StringBuilder() + sb.append("[") + for (i in array.indices) { + val o = array[i] + sb.append(objectToString(o)) + if (i + 1 < array.size) { + sb.append(",") + } + } + sb.append("]") + return sb.toString() + } + + private fun listToString(list: MutableList): String { + val sb = StringBuilder() + sb.append("[") + val it = list.iterator() + while (it.hasNext()) { + val o = it.next() + sb.append(objectToString(o)) + if (it.hasNext()) { + sb.append(",") + } + } + sb.append("]") + return sb.toString() + } + + private fun mapToString(map: MutableMap): String { + val sb = StringBuilder() + sb.append("{") + val it = map.entries.iterator() + while (it.hasNext()) { + val e = it.next() + sb.append("\"" + e.key + "\":" + objectToString(e.value)) + if (it.hasNext()) { + sb.append(",") + } + } + sb.append("}") + return sb.toString() + } + + companion object { + fun hasEntry(key: String, value: Any?): JsonMatcher = JsonMatcher(key, value, null) + fun hasEntry(key: String, valueMatcher: Matcher<*>?): JsonMatcher = JsonMatcher(key, null, valueMatcher) + fun isNotPresent(key: String): JsonMatcher = JsonMatcher(key, null, null) + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/PEM.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/PEM.kt new file mode 100644 index 0000000..98be585 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/PEM.kt @@ -0,0 +1,71 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data + +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.security.KeyFactory +import java.security.NoSuchAlgorithmException +import java.security.PrivateKey +import java.security.PublicKey +import java.security.spec.EncodedKeySpec +import java.security.spec.InvalidKeySpecException +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import org.bouncycastle.util.io.pem.PemObject +import org.bouncycastle.util.io.pem.PemReader + +@Throws(IOException::class) +private fun parsePEMFile(pemFile: File): ByteArray? { + if (!pemFile.isFile() || !pemFile.exists()) { + throw FileNotFoundException("The file '${pemFile.absolutePath}' doesn't exist.") + } + val reader = PemReader(java.io.FileReader(pemFile)) + val pemObject: PemObject = reader.readPemObject() + val content: ByteArray? = pemObject.content + reader.close() + return content +} + +private fun getPublicKey(keyBytes: ByteArray?, algorithm: String): PK { + var publicKey: PublicKey? = null + try { + val kf = KeyFactory.getInstance(algorithm) + val keySpec: EncodedKeySpec = X509EncodedKeySpec(keyBytes) + publicKey = kf.generatePublic(keySpec) + } catch (_: NoSuchAlgorithmException) { + println("Could not reconstruct the public key, the given algorithm could not be found.") + } catch (_: InvalidKeySpecException) { + println("Could not reconstruct the public key") + } + + return publicKey as PK +} + +private fun getPrivateKey(keyBytes: ByteArray?, algorithm: String): PK { + var privateKey: PrivateKey? = null + try { + val kf = KeyFactory.getInstance(algorithm) + val keySpec: EncodedKeySpec = PKCS8EncodedKeySpec(keyBytes) + privateKey = kf.generatePrivate(keySpec) + } catch (_: NoSuchAlgorithmException) { + println("Could not reconstruct the private key, the given algorithm could not be found.") + } catch (_: InvalidKeySpecException) { + println("Could not reconstruct the private key") + } + + return privateKey as PK +} + +@Throws(IOException::class) +internal fun readPublicKey(filepath: String, algorithm: String): PK { + val bytes = parsePEMFile(File(filepath)) + return getPublicKey(bytes, algorithm) +} + +@Throws(IOException::class) +internal fun readPrivateKey(filepath: String, algorithm: String): PK { + val bytes = parsePEMFile(File(filepath)) + return getPrivateKey(bytes, algorithm) +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/PayloadDeserializerTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/PayloadDeserializerTest.kt new file mode 100644 index 0000000..7b10f75 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/PayloadDeserializerTest.kt @@ -0,0 +1,47 @@ +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Payload +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +class PayloadDeserializerTest { + + @Test + fun shouldNotRemoveKnownPublicClaimsFromTree() { + val payloadJSON = "{\n" + + " \"iss\": \"auth0\",\n" + + " \"sub\": \"emails\",\n" + + " \"aud\": \"users\",\n" + + " \"iat\": 10101010,\n" + + " \"exp\": 11111111,\n" + + " \"nbf\": 10101011,\n" + + " \"jti\": \"idid\",\n" + + " \"roles\":\"admin\" \n" + + "}" + + val payload: Payload = JWTParser.parsePayload(payloadJSON) + + assertEquals("auth0", payload.issuer) + assertEquals("emails", payload.subject) + assertTrue(payload.audience?.contains("users") == true) + assertEquals(10101010L * 1000, payload.issuedAt?.toEpochMilliseconds()) + assertEquals(11111111L * 1000, payload.expiresAt?.toEpochMilliseconds()) + assertEquals(10101011L * 1000, payload.notBefore?.toEpochMilliseconds()) + assertEquals(10101010L, payload.issuedAt?.epochSeconds) + assertEquals(11111111L, payload.expiresAt?.epochSeconds) + assertEquals(10101011L, payload.notBefore?.epochSeconds) + assertEquals("idid", payload.id) + + assertEquals("admin", payload.getClaim("roles").asString()) + assertEquals("auth0", payload.getClaim("iss").asString()) + assertEquals("emails", payload.getClaim("sub").asString()) + assertEquals("users", payload.getClaim("aud").asString()) + assertEquals(10101010.0, payload.getClaim("iat").asDouble()) + assertEquals(11111111.0, payload.getClaim("exp").asDouble()) + assertEquals(10101011.0, payload.getClaim("nbf").asDouble()) + assertEquals("idid", payload.getClaim("jti").asString()) + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/PayloadTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/PayloadTest.kt new file mode 100644 index 0000000..1571a63 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/PayloadTest.kt @@ -0,0 +1,275 @@ +package dev.sdkforge.jwt.decode.data.impl + +import dev.sdkforge.jwt.decode.data.JWTPayload +import dev.sdkforge.jwt.decode.data.JsonClaim +import dev.sdkforge.jwt.decode.domain.Payload +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Before + +@OptIn(ExperimentalTime::class) +class PayloadTest { + + private var payload: Payload? = null + private val expiresAt: Instant = Clock.System.now().plus(10.seconds) + private val notBefore: Instant = Clock.System.now() + private val issuedAt: Instant = Clock.System.now() + + @Before + fun setUp() { + val tree = mapOf( + "extraClaim" to JsonPrimitive("extraValue"), + ) + + payload = JWTPayload( + issuer = "issuer", + subject = "subject", + expiresAt = expiresAt, + notBefore = notBefore, + issuedAt = issuedAt, + id = "jwtId", + audience = listOf("audience"), + tree = tree, + ) + } + + @Test + fun shouldHaveUnmodifiableTree() { + val payload: Payload = JWTPayload( + issuer = null, + subject = null, + expiresAt = null, + notBefore = null, + issuedAt = null, + id = null, + audience = null, + tree = emptyMap(), + ) + + assertTrue { (payload as JWTPayload).tree !is MutableMap } + } + + @Test + fun shouldHaveUnmodifiableAudience() { + val payload: Payload = JWTPayload( + issuer = null, + subject = null, + expiresAt = null, + notBefore = null, + issuedAt = null, + id = null, + audience = emptyList(), + ) + + assertTrue { (payload as JWTPayload).audience !is MutableList } + } + + @Test + fun shouldGetIssuer() { + assertEquals("issuer", payload?.issuer) + } + + @Test + fun shouldGetNullIssuerIfMissing() { + val payload: Payload = JWTPayload( + issuer = null, + subject = null, + expiresAt = null, + notBefore = null, + issuedAt = null, + id = null, + audience = null, + tree = emptyMap(), + ) + + assertNull(payload.issuer) + } + + @Test + fun shouldGetSubject() { + assertEquals("subject", payload?.subject) + } + + @Test + fun shouldGetNullSubjectIfMissing() { + val payload: Payload = JWTPayload( + issuer = null, + subject = null, + expiresAt = null, + notBefore = null, + issuedAt = null, + id = null, + audience = null, + tree = emptyMap(), + ) + + assertNull(payload.subject) + } + + @Test + fun shouldGetAudience() { + assertEquals(1, payload?.audience?.size) + assertTrue(payload?.audience?.contains("audience") == true) + } + + @Test + fun shouldGetNullAudienceIfMissing() { + val payload: Payload = JWTPayload( + issuer = null, + subject = null, + expiresAt = null, + notBefore = null, + issuedAt = null, + id = null, + audience = null, + tree = emptyMap(), + ) + + assertNull(payload.audience) + } + + @Test + fun shouldGetExpiresAt() { + assertEquals(expiresAt, payload?.expiresAt) + } + + @Test + fun shouldGetNullExpiresAtIfMissing() { + val payload: Payload = JWTPayload( + issuer = null, + subject = null, + expiresAt = null, + notBefore = null, + issuedAt = null, + id = null, + audience = null, + tree = emptyMap(), + ) + + assertNull(payload.expiresAt) + } + + @Test + fun shouldGetNotBefore() { + assertEquals(notBefore, payload?.notBefore) + } + + @Test + fun shouldGetNullNotBeforeIfMissing() { + val payload: Payload = JWTPayload( + issuer = null, + subject = null, + expiresAt = null, + notBefore = null, + issuedAt = null, + id = null, + audience = null, + tree = emptyMap(), + ) + + assertNull(payload.notBefore) + } + + @Test + fun shouldGetIssuedAt() { + assertEquals(issuedAt, payload?.issuedAt) + } + + @Test + fun shouldGetNullIssuedAtIfMissing() { + val payload: Payload = JWTPayload( + issuer = null, + subject = null, + expiresAt = null, + notBefore = null, + issuedAt = null, + id = null, + audience = null, + tree = emptyMap(), + ) + + assertNull(payload.issuedAt) + } + + @Test + fun shouldGetJWTId() { + assertEquals("jwtId", payload?.id) + } + + @Test + fun shouldGetNullJWTIdIfMissing() { + val payload: Payload = JWTPayload( + issuer = null, + subject = null, + expiresAt = null, + notBefore = null, + issuedAt = null, + id = null, + audience = null, + tree = emptyMap(), + ) + + assertNull(payload.id) + } + + @Test + fun shouldGetExtraClaim() { + val claim = payload?.getClaim("extraClaim") + + assertIs(claim) + assertEquals("extraValue", claim.asString()) + } + + @Test + fun shouldGetNotNullExtraClaimIfMissing() { + val payload: Payload = JWTPayload( + issuer = null, + subject = null, + expiresAt = null, + notBefore = null, + issuedAt = null, + id = null, + audience = null, + tree = emptyMap(), + ) + + assertNotNull(payload.getClaim("missing")) + assertTrue(payload.getClaim("missing").isMissing) + assertFalse(payload.getClaim("missing").isNull) + } + + @Test + fun shouldGetClaims() { + val tree: Map = mutableMapOf( + "extraClaim" to JsonPrimitive("extraValue"), + "sub" to JsonPrimitive("auth0"), + ) + val payload: Payload = JWTPayload( + issuer = null, + subject = null, + expiresAt = null, + notBefore = null, + issuedAt = null, + id = null, + audience = null, + tree = tree, + ) + + val claims: Map = (payload as JWTPayload).tree + + assertNotNull(claims) + assertNotNull(claims["extraClaim"]) + assertNotNull(claims["sub"]) + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/TokenUtilsTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/TokenUtilsTest.kt new file mode 100644 index 0000000..7d1e0cd --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/TokenUtilsTest.kt @@ -0,0 +1,89 @@ +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class TokenUtilsTest { + + @Test + fun toleratesEmptyFirstPart() { + val token = ".eyJpc3MiOiJhdXRoMCJ9.W1mx_Y0hbAMbPmfW9whT605AAcxB7REFuJiDAHk2Sdc" + val parts = TokenUtils.splitToken(token) + + assertEquals(3, parts.size) + assertEquals("", parts[0]) + assertEquals("eyJpc3MiOiJhdXRoMCJ9", parts[1]) + assertEquals("W1mx_Y0hbAMbPmfW9whT605AAcxB7REFuJiDAHk2Sdc", parts[2]) + } + + @Test + fun toleratesEmptySecondPart() { + val token = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0..W1mx_Y0hbAMbPmfW9whT605AAcxB7REFuJiDAHk2Sdc" + val parts = TokenUtils.splitToken(token) + + assertEquals(3, parts.size) + assertEquals("eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0", parts[0]) + assertEquals("", parts[1]) + assertEquals("W1mx_Y0hbAMbPmfW9whT605AAcxB7REFuJiDAHk2Sdc", parts[2]) + } + + @Test + fun shouldSplitToken() { + val token = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJhdXRoMCJ9.W1mx_Y0hbAMbPmfW9whT605AAcxB7REFuJiDAHk2Sdc" + val parts = TokenUtils.splitToken(token) + + assertEquals(3, parts.size) + assertEquals("eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0", parts[0]) + assertEquals("eyJpc3MiOiJhdXRoMCJ9", parts[1]) + assertEquals("W1mx_Y0hbAMbPmfW9whT605AAcxB7REFuJiDAHk2Sdc", parts[2]) + } + + @Test + fun shouldSplitTokenWithEmptySignature() { + val token = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJhdXRoMCJ9." + val parts = TokenUtils.splitToken(token) + + assertEquals(3, parts.size) + assertEquals("eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0", parts[0]) + assertEquals("eyJpc3MiOiJhdXRoMCJ9", parts[1]) + assertEquals(0, parts[2].length) + } + + @Test + fun shouldThrowOnSplitTokenWithMoreThan3Parts() { + val t = assertFailsWith { + TokenUtils.splitToken("this.has.four.parts") + } + + assertEquals("The token was expected to have 3 parts, but got 4.", t.message) + } + + @Test + fun shouldThrowOnSplitTokenWithNoParts() { + val t = assertFailsWith { + TokenUtils.splitToken("notajwt") + } + + assertEquals("The token was expected to have 3 parts, but got 1.", t.message) + } + + @Test + fun shouldThrowOnSplitTokenWith2Parts() { + val t = assertFailsWith { + TokenUtils.splitToken("two.parts") + } + + assertEquals("The token was expected to have 3 parts, but got 2.", t.message) + } + + @Test + fun shouldThrowOnSplitTokenWithNullValue() { + val t = assertFailsWith { + TokenUtils.splitToken(null) + } + + assertEquals("The token is null.", t.message) + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/AlgorithmTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/AlgorithmTest.kt new file mode 100644 index 0000000..301f569 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/AlgorithmTest.kt @@ -0,0 +1,387 @@ +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.ec.ECKey +import dev.sdkforge.crypto.domain.ec.ECPrivateKey +import dev.sdkforge.crypto.domain.ec.ECPublicKey +import dev.sdkforge.crypto.domain.rsa.RSAKey +import dev.sdkforge.crypto.domain.rsa.RSAPrivateKey +import dev.sdkforge.crypto.domain.rsa.RSAPublicKey +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.provider.ECDSAKeyProvider +import dev.sdkforge.jwt.decode.domain.provider.RSAKeyProvider +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import org.junit.Rule +import org.junit.Test + +class AlgorithmTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @Test + fun shouldThrowRSA256InstanceWithNullKey() { + val t = assertFailsWith { + Algorithm.RSA256(key = mockk()) + } + + assertEquals("Both provided Keys cannot be null.", t.message) + } + + @Test + fun shouldThrowRSA384InstanceWithNullKey() { + val t = assertFailsWith { + Algorithm.RSA384(key = mockk()) + } + + assertEquals("Both provided Keys cannot be null.", t.message) + } + + @Test + fun shouldThrowRSA512InstanceWithNullKey() { + val t = assertFailsWith { + Algorithm.RSA512(key = mockk()) + } + + assertEquals("Both provided Keys cannot be null.", t.message) + } + + @Test + fun shouldThrowECDSA256InstanceWithNullKey() { + val t = assertFailsWith { + Algorithm.ECDSA256(key = mockk()) + } + + assertEquals("Both provided Keys cannot be null.", t.message) + } + + @Test + fun shouldThrowECDSA384InstanceWithNullKey() { + val t = assertFailsWith { + Algorithm.ECDSA384(key = mockk()) + } + + assertEquals("Both provided Keys cannot be null.", t.message) + } + + @Test + fun shouldThrowECDSA512InstanceWithNullKey() { + val t = assertFailsWith { + Algorithm.ECDSA512(key = mockk()) + } + + assertEquals("Both provided Keys cannot be null.", t.message) + } + + @Test + fun shouldCreateHMAC256AlgorithmWithBytes() { + val algorithm = Algorithm.HMAC256("secret".toByteArray()) + + assertIs(algorithm) + assertEquals("HmacSHA256", algorithm.description) + assertEquals("HS256", algorithm.name) + } + + @Test + fun shouldCreateHMAC384AlgorithmWithBytes() { + val algorithm = Algorithm.HMAC384("secret".toByteArray()) + + assertIs(algorithm) + assertEquals("HmacSHA384", algorithm.description) + assertEquals("HS384", algorithm.name) + } + + @Test + fun shouldCreateHMAC512AlgorithmWithBytes() { + val algorithm = Algorithm.HMAC512("secret".toByteArray()) + + assertIs(algorithm) + assertEquals("HmacSHA512", algorithm.description) + assertEquals("HS512", algorithm.name) + } + + @Test + fun shouldCreateHMAC256AlgorithmWithString() { + val algorithm = Algorithm.HMAC256("secret") + + assertIs(algorithm) + assertEquals("HmacSHA256", algorithm.description) + assertEquals("HS256", algorithm.name) + } + + @Test + fun shouldCreateHMAC384AlgorithmWithString() { + val algorithm = Algorithm.HMAC384("secret") + + assertIs(algorithm) + assertEquals("HmacSHA384", algorithm.description) + assertEquals("HS384", algorithm.name) + } + + @Test + fun shouldCreateHMAC512AlgorithmWithString() { + val algorithm = Algorithm.HMAC512("secret") + + assertIs(algorithm) + assertEquals("HmacSHA512", algorithm.description) + assertEquals("HS512", algorithm.name) + } + + @Test + fun shouldCreateRSA256AlgorithmWithPublicKey() { + val key = mockk(moreInterfaces = arrayOf(RSAPublicKey::class)) + val algorithm = Algorithm.RSA256(key) + + assertIs(algorithm) + assertEquals("SHA256withRSA", algorithm.description) + assertEquals("RS256", algorithm.name) + } + + @Test + fun shouldCreateRSA256AlgorithmWithPrivateKey() { + val key = mockk(moreInterfaces = arrayOf(RSAPrivateKey::class)) + val algorithm = Algorithm.RSA256(key) + + assertIs(algorithm) + assertEquals("SHA256withRSA", algorithm.description) + assertEquals("RS256", algorithm.name) + } + + @Test + fun shouldCreateRSA256AlgorithmWithBothKeys() { + val publicKey = mockk() + val privateKey = mockk() + val algorithm = Algorithm.RSA256(publicKey, privateKey) + + assertIs(algorithm) + assertEquals("SHA256withRSA", algorithm.description) + assertEquals("RS256", algorithm.name) + } + + @Test + fun shouldCreateRSA256AlgorithmWithProvider() { + val provider = mockk() + val algorithm = Algorithm.RSA256(provider) + + assertIs(algorithm) + assertEquals("SHA256withRSA", algorithm.description) + assertEquals("RS256", algorithm.name) + } + + @Test + fun shouldCreateRSA384AlgorithmWithPublicKey() { + val key = mockk(moreInterfaces = arrayOf(RSAPublicKey::class)) + val algorithm = Algorithm.RSA384(key) + + assertIs(algorithm) + assertEquals("SHA384withRSA", algorithm.description) + assertEquals("RS384", algorithm.name) + } + + @Test + fun shouldCreateRSA384AlgorithmWithPrivateKey() { + val key = mockk(moreInterfaces = arrayOf(RSAPrivateKey::class)) + val algorithm = Algorithm.RSA384(key) + + assertIs(algorithm) + assertEquals("SHA384withRSA", algorithm.description) + assertEquals("RS384", algorithm.name) + } + + @Test + fun shouldCreateRSA384AlgorithmWithBothKeys() { + val publicKey = mockk() + val privateKey = mockk() + val algorithm = Algorithm.RSA384(publicKey, privateKey) + + assertIs(algorithm) + assertEquals("SHA384withRSA", algorithm.description) + assertEquals("RS384", algorithm.name) + } + + @Test + fun shouldCreateRSA384AlgorithmWithProvider() { + val provider = mockk() + val algorithm = Algorithm.RSA384(provider) + + assertIs(algorithm) + assertEquals("SHA384withRSA", algorithm.description) + assertEquals("RS384", algorithm.name) + } + + @Test + fun shouldCreateRSA512AlgorithmWithPublicKey() { + val key = mockk(moreInterfaces = arrayOf(RSAPublicKey::class)) + val algorithm = Algorithm.RSA512(key) + + assertIs(algorithm) + assertEquals("SHA512withRSA", algorithm.description) + assertEquals("RS512", algorithm.name) + } + + @Test + fun shouldCreateRSA512AlgorithmWithPrivateKey() { + val key = mockk(moreInterfaces = arrayOf(RSAPrivateKey::class)) + val algorithm = Algorithm.RSA512(key) + + assertIs(algorithm) + assertEquals("SHA512withRSA", algorithm.description) + assertEquals("RS512", algorithm.name) + } + + @Test + fun shouldCreateRSA512AlgorithmWithBothKeys() { + val publicKey = mockk() + val privateKey = mockk() + val algorithm = Algorithm.RSA512(publicKey, privateKey) + + assertIs(algorithm) + assertEquals("SHA512withRSA", algorithm.description) + assertEquals("RS512", algorithm.name) + } + + @Test + fun shouldCreateRSA512AlgorithmWithProvider() { + val provider = mockk() + val algorithm = Algorithm.RSA512(provider) + + assertIs(algorithm) + assertEquals("SHA512withRSA", algorithm.description) + assertEquals("RS512", algorithm.name) + } + + @Test + fun shouldCreateECDSA256AlgorithmWithPublicKey() { + val key = mockk(moreInterfaces = arrayOf(ECPublicKey::class)) + val algorithm = Algorithm.ECDSA256(key) + + assertIs(algorithm) + assertEquals("SHA256withECDSA", algorithm.description) + assertEquals("ES256", algorithm.name) + } + + @Test + fun shouldCreateECDSA256AlgorithmWithPrivateKey() { + val key = mockk(moreInterfaces = arrayOf(ECPrivateKey::class)) + val algorithm = Algorithm.ECDSA256(key) + + assertIs(algorithm) + assertEquals("SHA256withECDSA", algorithm.description) + assertEquals("ES256", algorithm.name) + } + + @Test + fun shouldCreateECDSA256AlgorithmWithBothKeys() { + val publicKey = mockk() + val privateKey = mockk() + val algorithm = Algorithm.ECDSA256(publicKey, privateKey) + + assertIs(algorithm) + assertEquals("SHA256withECDSA", algorithm.description) + assertEquals("ES256", algorithm.name) + } + + @Test + fun shouldCreateECDSA256AlgorithmWithProvider() { + val provider = mockk() + val algorithm = Algorithm.ECDSA256(provider) + + assertIs(algorithm) + assertEquals("SHA256withECDSA", algorithm.description) + assertEquals("ES256", algorithm.name) + } + + @Test + fun shouldCreateECDSA384AlgorithmWithPublicKey() { + val key = mockk(moreInterfaces = arrayOf(ECPublicKey::class)) + val algorithm = Algorithm.ECDSA384(key) + + assertIs(algorithm) + assertEquals("SHA384withECDSA", algorithm.description) + assertEquals("ES384", algorithm.name) + } + + @Test + fun shouldCreateECDSA384AlgorithmWithPrivateKey() { + val key = mockk(moreInterfaces = arrayOf(ECPrivateKey::class)) + val algorithm = Algorithm.ECDSA384(key) + + assertIs(algorithm) + assertEquals("SHA384withECDSA", algorithm.description) + assertEquals("ES384", algorithm.name) + } + + @Test + fun shouldCreateECDSA384AlgorithmWithBothKeys() { + val publicKey = mockk() + val privateKey = mockk() + val algorithm = Algorithm.ECDSA384(publicKey, privateKey) + + assertIs(algorithm) + assertEquals("SHA384withECDSA", algorithm.description) + assertEquals("ES384", algorithm.name) + } + + @Test + fun shouldCreateECDSA384AlgorithmWithProvider() { + val provider = mockk() + val algorithm = Algorithm.ECDSA384(provider) + + assertIs(algorithm) + assertEquals("SHA384withECDSA", algorithm.description) + assertEquals("ES384", algorithm.name) + } + + @Test + fun shouldCreateECDSA512AlgorithmWithPublicKey() { + val key = mockk(moreInterfaces = arrayOf(ECPublicKey::class)) + val algorithm = Algorithm.ECDSA512(key) + + assertIs(algorithm) + assertEquals("SHA512withECDSA", algorithm.description) + assertEquals("ES512", algorithm.name) + } + + @Test + fun shouldCreateECDSA512AlgorithmWithPrivateKey() { + val key = mockk(moreInterfaces = arrayOf(ECPrivateKey::class)) + val algorithm = Algorithm.ECDSA512(key) + + assertIs(algorithm) + assertEquals("SHA512withECDSA", algorithm.description) + assertEquals("ES512", algorithm.name) + } + + @Test + fun shouldCreateECDSA512AlgorithmWithBothKeys() { + val publicKey = mockk() + val privateKey = mockk() + val algorithm = Algorithm.ECDSA512(publicKey, privateKey) + + assertIs(algorithm) + assertEquals("SHA512withECDSA", algorithm.description) + assertEquals("ES512", algorithm.name) + } + + @Test + fun shouldCreateECDSA512AlgorithmWithProvider() { + val provider = mockk() + val algorithm = Algorithm.ECDSA512(provider) + + assertIs(algorithm) + assertEquals("SHA512withECDSA", algorithm.description) + assertEquals("ES512", algorithm.name) + } + + @Test + fun shouldCreateNoneAlgorithm() { + val algorithm = Algorithm.NONE + + assertIs(algorithm) + assertEquals("none", algorithm.description) + assertEquals("none", algorithm.name) + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSAAlgorithmTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSAAlgorithmTest.kt new file mode 100644 index 0000000..9f155ac --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSAAlgorithmTest.kt @@ -0,0 +1,1800 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.PrivateKey +import dev.sdkforge.crypto.domain.PublicKey +import dev.sdkforge.crypto.domain.ec.asNativeECPrivateKey +import dev.sdkforge.crypto.domain.ec.asNativeECPublicKey +import dev.sdkforge.jwt.decode.data.JWT +import dev.sdkforge.jwt.decode.data.readPrivateKey +import dev.sdkforge.jwt.decode.data.readPublicKey +import dev.sdkforge.jwt.decode.domain.JWTVerifier +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.SignatureException +import dev.sdkforge.jwt.decode.domain.exception.SignatureGenerationException +import dev.sdkforge.jwt.decode.domain.exception.SignatureVerificationException +import dev.sdkforge.jwt.decode.domain.provider.ECDSAKeyProvider +import io.mockk.every +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import io.mockk.mockkStatic +import java.math.BigInteger +import java.security.InvalidKeyException +import java.security.NoSuchAlgorithmException +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey +import java.security.spec.ECParameterSpec +import java.util.* +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.Rule + +private fun assertThat(actual: T, expected: T) { + assertEquals(expected, actual) +} +private fun `is`(value: T): T = value + +class ECDSAAlgorithmTest { + + @get:Rule + val mockkRule = MockKRule(this) + + // JOSE Signatures obtained using Node 'jwa' lib: https://github.com/brianloveswords/node-jwa + // DER Signatures obtained from source JOSE signature using 'ecdsa-sig-formatter' lib: https://github.com/Brightspace/node-ecdsa-sig-formatter + // These tests use the default preferred SecurityProvider to handle ECDSA algorithms + + // Verify + @Test + fun shouldPassECDSA256VerificationWithJOSESignature() { + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.4iVk3-Y0v4RT4_9IaQlp-8dZ_4fsTzIylgrPTDLrEvTHBTyVS3tgPbr2_IZfLETtiKRqCg0aQ5sh9eIsTTwB1g" + val key = readPublicKey(PUBLIC_256, "EC") + val algorithm = Algorithm.ECDSA256(key) as ECDSAAlgorithm + val jwtDecoded = JWT.decode(jwt) + + algorithm.verify(jwtDecoded) + } + + @Test + fun shouldThrowOnECDSA256VerificationWithDERSignature() { + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.MEYCIQDiJWTf5jShFPj0hpCWn7x1nhxPMjKWCs9MMusS9AIhAMcFPJVLe2A9uvb8hl8sRO2IpGoKDRpDmyH14ixNPAHW" + val key = readPublicKey(PUBLIC_256, "EC") + val algorithm = Algorithm.ECDSA256(key) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassECDSA256VerificationWithJOSESignatureWithBothKeys() { + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.4iVk3-Y0v4RT4_9IaQlp-8dZ_4fsTzIylgrPTDLrEvTHBTyVS3tgPbr2_IZfLETtiKRqCg0aQ5sh9eIsTTwB1g" + val algorithm = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldThrowOnECDSA256VerificationWithDERSignatureWithBothKeys() { + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.MEYCIQDiJWTf5jShFPj0hpCWn7x1nhxPMjKWCs9MMusS9AIhAMcFPJVLe2A9uvb8hl8sRO2IpGoKDRpDmyH14ixNPAHW" + val algorithm = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassECDSA256VerificationWithProvidedPublicKey() { + val jwt = + "eyJhbGciOiJFUzI1NiIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.D_oU4CB0ZEsxHOjcWnmS3ZJvlTzm6WcGFx-HASxnvcB2Xu2WjI-axqXH9xKq45aPBDs330JpRhJmqBSc2K8MXQ" + val publicKey = readPublicKey(PUBLIC_256, "EC") + val provider: ECDSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns publicKey.asNativeECPublicKey + } + val algorithm = Algorithm.ECDSA256(provider) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailECDSA256VerificationWhenProvidedPublicKeyIsNull() { + val jwt = + "eyJhbGciOiJFUzI1NiIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.D_oU4CB0ZEsxHOjcWnmS3ZJvlTzm6WcGFx-HASxnvcB2Xu2WjI-axqXH9xKq45aPBDs330JpRhJmqBSc2K8MXQ" + val provider: ECDSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns null + } + val algorithm = Algorithm.ECDSA256(provider) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA256VerificationWithInvalidPublicKey() { + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.W9qfN1b80B9hnMo49WL8THrOsf1vEjOhapeFemPMGySzxTcgfyudS5esgeBTO908X5SLdAr5jMwPUPBs9b6nNg" + val algorithm = Algorithm.ECDSA256( + readPublicKey(INVALID_PUBLIC_256, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + } + + @Test + fun shouldFailECDSA256VerificationWhenUsingPrivateKey() { + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.W9qfN1b80B9hnMo49WL8THrOsf1vEjOhapeFemPMGySzxTcgfyudS5esgeBTO908X5SLdAr5jMwPUPBs9b6nNg" + val algorithm = Algorithm.ECDSA256( + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA256VerificationOnInvalidJOSESignatureLength() { + val bytes = ByteArray(63) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA256( + readPublicKey(INVALID_PUBLIC_256, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA256VerificationOnInvalidJOSESignature() { + val bytes = ByteArray(64) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA256( + readPublicKey(INVALID_PUBLIC_256, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + } + + @Test + fun shouldFailECDSA256VerificationOnInvalidDERSignature() { + val bytes = ByteArray(64) + bytes[0] = 0x30 + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA256( + readPublicKey(INVALID_PUBLIC_256, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + } + + @Test + fun shouldPassECDSA384VerificationWithJOSESignature() { + val jwt = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9.50UU5VKNdF1wfykY8jQBKpvuHZoe6IZBJm5NvoB8bR-hnRg6ti-CHbmvoRtlLfnHfwITa_8cJMy6TenMC2g63GQHytc8rYoXqbwtS4R0Ko_AXbLFUmfxnGnMC6v4MS_z" + val key = readPublicKey(PUBLIC_384, "EC") + val algorithm = Algorithm.ECDSA384(key) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldThrowOnECDSA384VerificationWithDERSignature() { + val jwt = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9.MGUCMQDnRRTlUo10XXBKRjyNAEqm4dmh7ohkEmbk2gHxtH6GdGDq2L4IduahG2UtccCMH8CE2vHCTMuk3pzAtoOtxkB8rXPK2KF6m8LUuEdCqPwF2yxVJn8ZxpzAurDEv8w" + val key = readPublicKey(PUBLIC_384, "EC") + val algorithm = Algorithm.ECDSA384(key) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassECDSA384VerificationWithJOSESignatureWithBothKeys() { + val jwt = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9.50UU5VKNdF1wfykY8jQBKpvuHZoe6IZBJm5NvoB8bR-hnRg6ti-CHbmvoRtlLfnHfwITa_8cJMy6TenMC2g63GQHytc8rYoXqbwtS4R0Ko_AXbLFUmfxnGnMC6v4MS_z" + val algorithm = Algorithm.ECDSA384( + readPublicKey(PUBLIC_384, "EC"), + readPrivateKey(PRIVATE_384, "EC"), + ) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldThrowOnECDSA384VerificationWithDERSignatureWithBothKeys() { + val jwt = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9.MGUCMQDnRRTlUo10XXBKRjyNAEqm4dmh7ohkEmbk2gHxtH6GdGDq2L4IduahG2UccCMH8CE2vHCTMuk3pzAtoOtxkB8rXPK2KF6m8LUuEdCqPwF2yxVJn8ZxpzAurDEv8w" + val algorithm = Algorithm.ECDSA384( + readPublicKey(PUBLIC_384, "EC"), + readPrivateKey(PRIVATE_384, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassECDSA384VerificationWithProvidedPublicKey() { + val jwt = + "eyJhbGciOiJFUzM4NCIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.9kjGuFTPx3ylfpqL0eY9H7TGmPepjQOBKI8UPoEvby6N7dDLF5HxLohosNxxFymNT7LzpeSgOPAB0wJEwG2Nl2ukgdUOpZOf492wog_i5ZcZmAykd3g1QH7onrzd69GU" + val publicKey = readPublicKey(PUBLIC_384, "EC") + val provider: ECDSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns publicKey.asNativeECPublicKey + } + val algorithm = Algorithm.ECDSA384(provider) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailECDSA384VerificationWhenProvidedPublicKeyIsNull() { + val jwt = + "eyJhbGciOiJFUzM4NCIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.9kjGuFTPx3ylfpqL0eY9H7TGmPepjQOBKI8UPoEvby6N7dDLF5HxLohosNxxFymNT7LzpeSgOPAB0wJEwG2Nl2ukgdUOpZOf492wog_i5ZcZmAykd3g1QH7onrzd69GU" + val provider: ECDSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns null + } + + val algorithm = Algorithm.ECDSA384(provider) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA384VerificationWithInvalidPublicKey() { + val jwt = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9._k5h1KyO-NE0R2_HAw0-XEc0bGT5atv29SxHhOGC9JDqUHeUdptfCK_ljQ01nLVt2OQWT2SwGs-TuyHDFmhPmPGFZ9wboxvq_ieopmYqhQilNAu-WF-frioiRz9733fU" + val algorithm = Algorithm.ECDSA384( + readPublicKey(INVALID_PUBLIC_384, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + } + + @Test + fun shouldFailECDSA384VerificationWhenUsingPrivateKey() { + val jwt = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9._k5h1KyO-NE0R2_HAw0-XEc0bGT5atv29SxHhOGC9JDqUHeUdptfCK_ljQ01nLVt2OQWT2SwGs-TuyHDFmhPmPGFZ9wboxvq_ieopmYqhQilNAu-WF-frioiRz9733fU" + val algorithm = Algorithm.ECDSA384( + readPrivateKey(PRIVATE_384, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA384VerificationOnInvalidJOSESignatureLength() { + val bytes = ByteArray(95) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA384( + readPublicKey(INVALID_PUBLIC_384, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA384VerificationOnInvalidJOSESignature() { + val bytes = ByteArray(96) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA384( + readPublicKey(INVALID_PUBLIC_384, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + } + + @Test + fun shouldFailECDSA384VerificationOnInvalidDERSignature() { + val bytes = ByteArray(96) + java.security.SecureRandom().nextBytes(bytes) + bytes[0] = 0x30 + val signature = Base64.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA384( + readPublicKey(INVALID_PUBLIC_384, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + } + + @Test + fun shouldPassECDSA512VerificationWithJOSESignature() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.AeCJPDIsSHhwRSGZCY6rspi8zekOw0K9qYMNridP1Fu9uhrA1QrG-EUxXlE06yvmh2R7Rz0aE7kxBwrnq8L8aOBCAYAsqhzPeUvyp8fXjjgs0Eto5I0mndE2QHlgcMSFASyjHbU8wD2Rq7ZNzGQ5b2MZfpv030WGUajT-aZYWFUJHVg2" + val key = readPublicKey(PUBLIC_512, "EC") + val algorithm = Algorithm.ECDSA512(key) as ECDSAAlgorithm + val decodedJWT = JWT.decode(jwt) + + algorithm.verify(decodedJWT) + } + + @Test + fun shouldThrowOnECDSA512VerificationWithDERSignature() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.MIGIAkIB4Ik8MixIeHBFIZkJjquymLzN6Q7DQr2pgw2uJ0UW726GsDVCsb4RTFeUTTrKaHZHtHPRoTuTEHCuerwvxo4EICQgGALKocz3lL8qfH1444LNBLaOSNJp3RNkB5YHDEhQEsox21PMA9kau2TcxkOW9jGX6b9N9FhlGo0mmWFhVCR1YNg" + val key = readPublicKey(PUBLIC_512, "EC") + val algorithm = Algorithm.ECDSA512(key) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassECDSA512VerificationWithJOSESignatureWithBothKeys() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.AeCJPDIsSHhwRSGZCY6rspi8zekOw0K9qYMNridP1Fu9uhrA1QrG-EUxXlE06yvmh2R7Rz0aE7kxBwrnq8L8aOBCAYAsqhzPeUvyp8fXjjgs0Eto5I0mndE2QHlgcMSFASyjHbU8wD2Rq7ZNzGQ5b2MZfpv030WGUajT-aZYWFUJHVg2" + val algorithm = Algorithm.ECDSA512( + readPublicKey(PUBLIC_512, "EC"), + readPrivateKey(PRIVATE_512, "EC"), + ) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldThrowECDSA512VerificationWithDERSignatureWithBothKeys() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.MIGIAkIB4Ik8MixIeHBFIZkJjquymLzN6Q7DQr2pgw2uJ0UW726GsDVCsb4RTFeUTTrKaHZHtHPRoTuTEHCuerwvxo4EICQgGALKocz3lL8qfH1444LNBLaOSNJp3RNkB5YHDEhQEsox21PMA9kau2TcxkOW9jGX6b9N9FhlGo0mmWFhVCR1YNg" + val algorithm = Algorithm.ECDSA512( + readPublicKey(PUBLIC_512, "EC"), + readPrivateKey(PRIVATE_512, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassECDSA512VerificationWithProvidedPublicKey() { + val jwt = + "eyJhbGciOiJFUzUxMiIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.AGxEwbsYa2bQ7Y7DAcTQnVD8PmLSlhJ20jg2OfdyPnqdXI8SgBaG6lGciq3_pofFhs1HEoFoJ33Jcluha24oMHIvAfwu8qbv_Wq3L2eI9Q0L0p6ul8Pd_BS8adRa2PgLc36xXGcRc7ID5YH-CYaQfsTp5YIaF0Po3h0QyCoQ6ZiYQkqm" + val provider: ECDSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns readPublicKey(PUBLIC_512, "EC").asNativeECPublicKey + } + val algorithm = Algorithm.ECDSA512(provider) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailECDSA512VerificationWhenProvidedPublicKeyIsNull() { + val jwt = + "eyJhbGciOiJFUzUxMiIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.AGxEwbsYa2bQ7Y7DAcTQnVD8PmLSlhJ20jg2OfdyPnqdXI8SgBaG6lGciq3_pofFhs1HEoFoJ33Jcluha24oMHIvAfwu8qbv_Wq3L2eI9Q0L0p6ul8Pd_BS8adRa2PgLc36xXGcRc7ID5YH-CYaQfsTp5YIaF0Po3h0QyCoQ6ZiYQkqm" + val provider: ECDSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns null + } + val algorithm = Algorithm.ECDSA512(provider) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA512VerificationWithInvalidPublicKey() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.AZgdopFFsN0amCSs2kOucXdpylD31DEm5ChK1PG0_gq5Mf47MrvVph8zHSVuvcrXzcE1U3VxeCg89mYW1H33Y-8iAF0QFkdfTUQIWKNObH543WNMYYssv3OtOj0znPv8atDbaF8DMYAtcT1qdmaSJRhx-egRE9HGZkinPh9CfLLLt58X" + val algorithm = Algorithm.ECDSA512( + readPublicKey(INVALID_PUBLIC_512, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + } + + @Test + fun shouldFailECDSA512VerificationWhenUsingPrivateKey() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.AZgdopFFsN0amCSs2kOucXdpylD31DEm5ChK1PG0_gq5Mf47MrvVph8zHSVuvcrXzcE1U3VxeCg89mYW1H33Y-8iAF0QFkdfTUQIWKNObH543WNMYYssv3OtOj0znPv8atDbaF8DMYAtcT1qdmaSJRhx-egRE9HGZkinPh9CfLLLt58X" + val algorithm = Algorithm.ECDSA512( + readPrivateKey(PRIVATE_512, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA512VerificationOnInvalidJOSESignatureLength() { + val bytes = ByteArray(131) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA512( + readPublicKey(INVALID_PUBLIC_512, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA512VerificationOnInvalidJOSESignature() { + val bytes = ByteArray(132) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA512( + readPublicKey(INVALID_PUBLIC_512, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + } + + @Test + fun shouldFailECDSA512VerificationOnInvalidDERSignature() { + val bytes = ByteArray(132) + java.security.SecureRandom().nextBytes(bytes) + bytes[0] = 0x30 + val signature = Base64.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA512( + readPublicKey(INVALID_PUBLIC_512, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + } + + @Test + fun shouldFailJOSEToDERConversionOnInvalidJOSESignatureLength() { + val bytes = ByteArray(256) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + + val publicKey = readPublicKey(PUBLIC_256, "EC").asNativeECPublicKey + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("ES256", "SHA256withECDSA", 128, provider) + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnVerifyWhenSignatureAlgorithmDoesNotExists() { + val publicKey: ECPublicKey = mockk() + every { publicKey.params } returns mockk() + val a = ByteArray(64) + Arrays.fill(a, Byte.MAX_VALUE) + every { publicKey.params.order } returns BigInteger(a) + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.4iVk3-Y0v4RT4_9IaQlp-8dZ_4fsTzIylgrPTDLrEvTHBTyVS3tgPbr2_IZfLETtiKRqCg0aQ5sh9eIsTTwB1g" + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnVerifyWhenThePublicKeyIsInvalid() { + val publicKey: ECPublicKey = mockk() + every { publicKey.params } returns mockk() + val a = ByteArray(64) + Arrays.fill(a, Byte.MAX_VALUE) + every { publicKey.params.order } returns BigInteger(a) + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.4iVk3-Y0v4RT4_9IaQlp-8dZ_4fsTzIylgrPTDLrEvTHBTyVS3tgPbr2_IZfLETtiKRqCg0aQ5sh9eIsTTwB1g" + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { + verifySignature( + algorithm = any(), + publicKey = any(), + headerBytes = any(), + payloadBytes = any(), + signatureBytes = any(), + ) + } throws InvalidKeyException() + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnVerifyWhenTheSignatureIsNotPrepared() { + val publicKey: ECPublicKey = mockk() + every { publicKey.params } returns mockk() + val a = ByteArray(64) + Arrays.fill(a, Byte.MAX_VALUE) + every { publicKey.params.order } returns BigInteger(a) + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.4iVk3-Y0v4RT4_9IaQlp-8dZ_4fsTzIylgrPTDLrEvTHBTyVS3tgPbr2_IZfLETtiKRqCg0aQ5sh9eIsTTwB1g" + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { + verifySignature( + algorithm = any(), + publicKey = any(), + headerBytes = any(), + payloadBytes = any(), + signatureBytes = any(), + ) + } throws SignatureException() + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowWhenSignatureNotValidBase64() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.MIGIAkIB4Ik8MixIeHBFIZkJjquymLzN6Q7DQr2pgw2uJ0UW726GsDVCsb4RTFeUTTrKaHZHtHPRoTuTEHCuerwvxo4+EICQgGALKocz3lL8qfH1444LNBLaOSNJp3RNkB5YHDEhQEsox21PMA9kau2TcxkOW9jGX6b9N9FhlGo0mmWFhVCR1YNg" + val key = readPublicKey(PUBLIC_512, "EC") + val algorithm = Algorithm.ECDSA512(key) as ECDSAAlgorithm + + assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + } + + @Test + fun shouldDoECDSA256Signing() { + val algorithm = Algorithm.ECDSA256( + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + val algorithmVerify = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + ) as ECDSAAlgorithm + + val jwt: String = asJWT( + algorithm, + ES256Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithmVerify.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoECDSA256SigningWithBothKeys() { + val algorithm = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + val signatureBytes: ByteArray = algorithm.sign( + ES256HeaderBytes, + auth0IssPayloadBytes, + ) + val jwtSignature = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).encode(signatureBytes) + val jwt = "$ES256Header.$auth0IssPayload.$jwtSignature" + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoECDSA256SigningWithProvidedPrivateKey() { + val provider: ECDSAKeyProvider = mockk() + val privateKey = readPrivateKey(PRIVATE_256, "EC") + val publicKey = readPublicKey(PUBLIC_256, "EC") + every { provider.privateKey } returns privateKey.asNativeECPrivateKey + every { provider.getPublicKeyById(null) } returns publicKey.asNativeECPublicKey + val algorithm = Algorithm.ECDSA256(provider) as ECDSAAlgorithm + + val jwt: String = asJWT( + algorithm, + ES256Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailOnECDSA256SigningWhenProvidedPrivateKeyIsNull() { + val provider: ECDSAKeyProvider = mockk() + every { provider.privateKey } returns null + val algorithm = Algorithm.ECDSA256(provider) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA256withECDSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailOnECDSA256SigningWhenUsingPublicKey() { + val algorithm = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA256withECDSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldDoECDSA384Signing() { + val algorithmSign = Algorithm.ECDSA384( + readPrivateKey(PRIVATE_384, "EC"), + ) as ECDSAAlgorithm + val algorithmVerify = Algorithm.ECDSA384( + readPublicKey(PUBLIC_384, "EC"), + ) as ECDSAAlgorithm + val jwt: String = asJWT( + algorithmSign, + ES384Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithmVerify.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoECDSA384SigningWithBothKeys() { + val algorithm = Algorithm.ECDSA384( + readPublicKey(PUBLIC_384, "EC"), + readPrivateKey(PRIVATE_384, "EC"), + ) as ECDSAAlgorithm + + val jwt: String = asJWT( + algorithm, + ES384Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoECDSA384SigningWithProvidedPrivateKey() { + val provider: ECDSAKeyProvider = mockk() + val privateKey = readPrivateKey(PRIVATE_384, "EC") + val publicKey = readPublicKey(PUBLIC_384, "EC") + every { provider.privateKey } returns privateKey.asNativeECPrivateKey + every { provider.getPublicKeyById(null) } returns publicKey.asNativeECPublicKey + val algorithm = Algorithm.ECDSA384(provider) as ECDSAAlgorithm + + val jwt: String = asJWT( + algorithm, + ES384Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailOnECDSA384SigningWhenProvidedPrivateKeyIsNull() { + val provider: ECDSAKeyProvider = mockk() + every { provider.privateKey } returns null + val algorithm = Algorithm.ECDSA384(provider) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA384withECDSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailOnECDSA384SigningWhenUsingPublicKey() { + val algorithm = Algorithm.ECDSA384( + readPublicKey(PUBLIC_384, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA384withECDSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldDoECDSA512Signing() { + val algorithmSign = Algorithm.ECDSA512( + readPrivateKey(PRIVATE_512, "EC"), + ) as ECDSAAlgorithm + val algorithmVerify = Algorithm.ECDSA512( + readPublicKey(PUBLIC_512, "EC"), + ) as ECDSAAlgorithm + + val jwt: String = asJWT( + algorithmSign, + ES512Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithmVerify.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoECDSA512SigningWithBothKeys() { + val algorithm = Algorithm.ECDSA512( + readPublicKey(PUBLIC_512, "EC"), + readPrivateKey(PRIVATE_512, "EC"), + ) as ECDSAAlgorithm + + val jwt: String = asJWT( + algorithm, + ES512Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoECDSA512SigningWithProvidedPrivateKey() { + val provider: ECDSAKeyProvider = mockk() + val privateKey = readPrivateKey(PRIVATE_512, "EC") + val publicKey = readPublicKey(PUBLIC_512, "EC") + every { provider.privateKey } returns privateKey.asNativeECPrivateKey + every { provider.getPublicKeyById(null) } returns publicKey.asNativeECPublicKey + val algorithm = Algorithm.ECDSA512(provider) as ECDSAAlgorithm + + val jwt: String = asJWT( + algorithm, + ES512Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailOnECDSA512SigningWhenProvidedPrivateKeyIsNull() { + val provider: ECDSAKeyProvider = mockk() + every { provider.privateKey } returns null + val algorithm = Algorithm.ECDSA512(provider) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA512withECDSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailOnECDSA512SigningWhenUsingPublicKey() { + val algorithm = Algorithm.ECDSA512( + readPublicKey(PUBLIC_512, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA512withECDSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnSignWhenSignatureAlgorithmDoesNotExists() { + val publicKey: ECPublicKey = mockk() + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + + val t = assertFailsWith { + algorithm.sign(ES256HeaderBytes, ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnSignWhenThePrivateKeyIsInvalid() { + val publicKey: ECPublicKey = mockk() + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { createSignatureFor(any(), any(), any(), any()) } throws InvalidKeyException() + + val t = assertFailsWith { + algorithm.sign(ES256HeaderBytes, ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnSignWhenTheSignatureIsNotPrepared() { + val publicKey: ECPublicKey = mockk() + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + + val t = assertFailsWith { + algorithm.sign(ES256HeaderBytes, ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: some-algorithm", t.message) + } + + @Test + fun shouldReturnNullSigningKeyIdIfCreatedWithDefaultProvider() { + val publicKey: ECPublicKey = mockk() + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + + assertNull(algorithm.signingKeyId) + } + + @Test + fun shouldReturnSigningKeyIdFromProvider() { + val provider: ECDSAKeyProvider = mockk { + every { privateKeyId } returns "keyId" + } + + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + + assertEquals(algorithm.signingKeyId, "keyId") + } + + @Test + fun shouldThrowOnDERSignatureConversionIfDoesNotStartWithCorrectSequenceByte() { + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + val content256 = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9" + + val signature = algorithm256.sign(content256.toByteArray(), ByteArray(0)) + + signature[0] = 0x02.toByte() + + val t = assertFailsWith { + algorithm256.DERToJOSE(signature) + } + + assertEquals("Invalid DER signature format.", t.message) + } + + @Test + fun shouldThrowOnDERSignatureConversionIfDoesNotHaveExpectedLength() { + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + val derSignature = createDERSignature(32, withRPadding = false, withSPadding = false) + var received = derSignature[1].toInt() + + received-- + + derSignature[1] = received.toByte() + + val t = assertFailsWith { + algorithm256.DERToJOSE(derSignature) + } + + assertEquals("Invalid DER signature format.", t.message) + } + + @Test + fun shouldThrowOnDERSignatureConversionIfRNumberDoesNotHaveExpectedLength() { + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + val derSignature = createDERSignature(32, withRPadding = false, withSPadding = false) + + derSignature[3] = 34.toByte() + + val t = assertFailsWith { + algorithm256.DERToJOSE(derSignature) + } + + assertEquals("Invalid DER signature format.", t.message) + } + + @Test + fun shouldThrowOnDERSignatureConversionIfSNumberDoesNotHaveExpectedLength() { + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + val derSignature = createDERSignature(32, withRPadding = false, withSPadding = false) + + derSignature[4 + 32 + 1] = 34.toByte() + + val t = assertFailsWith { + algorithm256.DERToJOSE(derSignature) + } + + assertEquals("Invalid DER signature format.", t.message) + } + + @Test + fun shouldThrowOnJOSESignatureConversionIfDoesNotHaveExpectedLength() { + val publicKey = readPublicKey(PUBLIC_256, "EC").asNativeECPublicKey + val algorithm256 = Algorithm.ECDSA256( + publicKey, + readPrivateKey(PRIVATE_256, "EC").asNativeECPrivateKey, + ) as ECDSAAlgorithm + val joseSignature = ByteArray(32 * 2 - 1) + + val t = assertFailsWith { + algorithm256.validateSignatureStructure(joseSignature, publicKey) + } + + assertEquals("Invalid JOSE signature format.", t.message) + } + + @Test + fun shouldSignAndVerifyWithECDSA256() { + val header256 = "eyJhbGciOiJFUzI1NiJ9" + val body = "eyJpc3MiOiJhdXRoMCJ9" + + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + + for (i in 0..9) { + val jwt: String = asJWT(algorithm256, header256, body) + algorithm256.verify(JWT.decode(jwt)) + } + } + + @Test + fun shouldSignAndVerifyWithECDSA384() { + val header384 = "eyJhbGciOiJFUzM4NCJ9" + val body = "eyJpc3MiOiJhdXRoMCJ9" + + val algorithm384 = Algorithm.ECDSA384( + readPublicKey(PUBLIC_384, "EC"), + readPrivateKey(PRIVATE_384, "EC"), + ) as ECDSAAlgorithm + + for (i in 0..9) { + val jwt: String = asJWT(algorithm384, header384, body) + + algorithm384.verify(JWT.decode(jwt)) + } + } + + @Test + fun shouldSignAndVerifyWithECDSA512() { + val header512 = "eyJhbGciOiJFUzUxMiJ9" + val body = "eyJpc3MiOiJhdXRoMCJ9" + + val algorithm512 = Algorithm.ECDSA512( + readPublicKey(PUBLIC_512, "EC"), + readPrivateKey(PRIVATE_512, "EC"), + ) as ECDSAAlgorithm + + for (i in 0..9) { + val jwt: String = asJWT(algorithm512, header512, body) + + algorithm512.verify(JWT.decode(jwt)) + } + } + + @Test + fun shouldDecodeECDSA256JOSE() { + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + + // Without padding + var joseSignature = createJOSESignature(32, withRPadding = false, withSPadding = false) + var derSignature: ByteArray = algorithm256.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 32, withRPadding = false, withSPadding = false) + + // With R padding + joseSignature = createJOSESignature(32, withRPadding = true, withSPadding = false) + derSignature = algorithm256.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 32, withRPadding = true, withSPadding = false) + + // With S padding + joseSignature = createJOSESignature(32, withRPadding = false, withSPadding = true) + derSignature = algorithm256.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 32, withRPadding = false, withSPadding = true) + + // With both paddings + joseSignature = createJOSESignature(32, withRPadding = true, withSPadding = true) + derSignature = algorithm256.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 32, withRPadding = true, withSPadding = true) + } + + @Test + fun shouldDecodeECDSA256DER() { + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + + // Without padding + var derSignature = createDERSignature(32, withRPadding = false, withSPadding = false) + var joseSignature = algorithm256.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 32, withRPadding = false, withSPadding = false) + + // With R padding + derSignature = createDERSignature(32, withRPadding = true, withSPadding = false) + joseSignature = algorithm256.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 32, withRPadding = true, withSPadding = false) + + // With S padding + derSignature = createDERSignature(32, withRPadding = false, withSPadding = true) + joseSignature = algorithm256.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 32, withRPadding = false, withSPadding = true) + + // With both paddings + derSignature = createDERSignature(32, withRPadding = true, withSPadding = true) + joseSignature = algorithm256.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 32, withRPadding = true, withSPadding = true) + } + + @Test + fun shouldDecodeECDSA384JOSE() { + val algorithm384 = Algorithm.ECDSA384( + readPublicKey(PUBLIC_384, "EC"), + readPrivateKey(PRIVATE_384, "EC"), + ) as ECDSAAlgorithm + + // Without padding + var joseSignature = createJOSESignature(48, withRPadding = false, withSPadding = false) + var derSignature: ByteArray = algorithm384.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 48, withRPadding = false, withSPadding = false) + + // With R padding + joseSignature = createJOSESignature(48, withRPadding = true, withSPadding = false) + derSignature = algorithm384.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 48, withRPadding = true, withSPadding = false) + + // With S padding + joseSignature = createJOSESignature(48, withRPadding = false, withSPadding = true) + derSignature = algorithm384.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 48, withRPadding = false, withSPadding = true) + + // With both paddings + joseSignature = createJOSESignature(48, withRPadding = true, withSPadding = true) + derSignature = algorithm384.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 48, withRPadding = true, withSPadding = true) + } + + @Test + fun shouldDecodeECDSA384DER() { + val algorithm384 = Algorithm.ECDSA384( + readPublicKey(PUBLIC_384, "EC"), + readPrivateKey(PRIVATE_384, "EC"), + ) as ECDSAAlgorithm + + // Without padding + var derSignature = createDERSignature(48, withRPadding = false, withSPadding = false) + var joseSignature = algorithm384.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 48, withRPadding = false, withSPadding = false) + + // With R padding + derSignature = createDERSignature(48, withRPadding = true, withSPadding = false) + joseSignature = algorithm384.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 48, withRPadding = true, withSPadding = false) + + // With S padding + derSignature = createDERSignature(48, withRPadding = false, withSPadding = true) + joseSignature = algorithm384.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 48, withRPadding = false, withSPadding = true) + + // With both paddings + derSignature = createDERSignature(48, withRPadding = true, withSPadding = true) + joseSignature = algorithm384.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 48, withRPadding = true, withSPadding = true) + } + + @Test + fun shouldDecodeECDSA512JOSE() { + val algorithm512 = Algorithm.ECDSA512( + readPublicKey(PUBLIC_512, "EC"), + readPrivateKey(PRIVATE_512, "EC"), + ) as ECDSAAlgorithm + + // Without padding + var joseSignature = createJOSESignature(66, withRPadding = false, withSPadding = false) + var derSignature = algorithm512.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 66, withRPadding = false, withSPadding = false) + + // With R padding + joseSignature = createJOSESignature(66, withRPadding = true, withSPadding = false) + derSignature = algorithm512.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 66, withRPadding = true, withSPadding = false) + + // With S padding + joseSignature = createJOSESignature(66, withRPadding = false, withSPadding = true) + derSignature = algorithm512.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 66, withRPadding = false, withSPadding = true) + + // With both paddings + joseSignature = createJOSESignature(66, withRPadding = true, withSPadding = true) + derSignature = algorithm512.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 66, withRPadding = true, withSPadding = true) + } + + @Test + fun shouldDecodeECDSA512DER() { + val algorithm512 = Algorithm.ECDSA512( + readPublicKey(PUBLIC_512, "EC"), + readPrivateKey(PRIVATE_512, "EC"), + ) as ECDSAAlgorithm + + // Without padding + var derSignature = createDERSignature(66, withRPadding = false, withSPadding = false) + var joseSignature = algorithm512.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 66, withRPadding = false, withSPadding = false) + + // With R padding + derSignature = createDERSignature(66, withRPadding = true, withSPadding = false) + joseSignature = algorithm512.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 66, withRPadding = true, withSPadding = false) + + // With S padding + derSignature = createDERSignature(66, withRPadding = false, withSPadding = true) + joseSignature = algorithm512.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 66, withRPadding = false, withSPadding = true) + + // With both paddings + derSignature = createDERSignature(66, withRPadding = true, withSPadding = true) + joseSignature = algorithm512.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 66, withRPadding = true, withSPadding = true) + } + + @Test + fun shouldBeEqualSignatureMethodDecodeResults() { + val header = "eyJhbGciOiJFUzI1NiJ9" + val payload = "eyJpc3MiOiJhdXRoMCJ9" + + // signatures are not deterministic in value, so instead of directly comparing the signatures, + // check that both sign(..) methods can be used to create a jwt which can be + // verified + val algorithm = Algorithm.ECDSA256( + readPublicKey(PUBLIC_256, "EC"), + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + + val headerBytes: ByteArray = header.toByteArray() + val payloadBytes: ByteArray = payload.toByteArray() + + val bout = java.io.ByteArrayOutputStream() + bout.write(headerBytes) + bout.write('.'.code) + bout.write(payloadBytes) + + val jwtSignature1 = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).encode(algorithm.sign(bout.toByteArray())) + val jwt1 = "$header.$payload.$jwtSignature1" + + algorithm.verify(JWT.decode(jwt1)) + + val jwtSignature2 = Base64.UrlSafe.withPadding( + Base64.PaddingOption.PRESENT_OPTIONAL, + ).encode(algorithm.sign(headerBytes, payloadBytes)) + val jwt2 = "$header.$payload.$jwtSignature2" + + algorithm.verify(JWT.decode(jwt2)) + } + + /** + * Test deprecated signing method error handling. + * + * @see {@linkplain .shouldFailOnECDSA256SigningWhenProvidedPrivateKeyIsNull} + * + * @throws Exception expected exception + */ + @Test + fun shouldFailOnECDSA256SigningWithDeprecatedMethodWhenProvidedPrivateKeyIsNull() { + val provider: ECDSAKeyProvider = mockk { + every { privateKey } returns null + } + + val algorithm = Algorithm.ECDSA256(provider) as ECDSAAlgorithm + + val exception = assertFailsWith { + algorithm.sign(ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA256withECDSA", exception.message) + assertEquals("The given Private Key is null.", exception.cause?.message) + + assertIs(exception.cause) + } + + @Test + fun invalidECDSA256SignatureShouldFailTokenVerification() { + val jwtWithInvalidSig = + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0._____wAAAAD__________7zm-q2nF56E87nKwvxjJVH_____AAAAAP__________vOb6racXnoTzucrC_GMlUQ" + + val key256 = readPublicKey(PUBLIC_256, "EC") + val key384 = readPublicKey(PUBLIC_384, "EC") + val key512 = readPublicKey(PUBLIC_512, "EC") + + val verifier256 = JWT.require(Algorithm.ECDSA256(key256)).build() + val verifier384 = JWT.require(Algorithm.ECDSA256(key384)).build() + val verifier512 = JWT.require(Algorithm.ECDSA256(key512)).build() + + assertFailsWith { verifier256.verify(jwtWithInvalidSig) }.also { + assertIs(it.cause) + } + assertFailsWith { verifier384.verify(jwtWithInvalidSig) }.also { + assertIs(it.cause) + } + assertFailsWith { verifier512.verify(jwtWithInvalidSig) }.also { + assertIs(it.cause) + } + } + + @Test + fun emptyECDSA256SignatureShouldFailTokenVerification() { + val jwtWithInvalidSig = + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + val key256 = readPublicKey(PUBLIC_256, "EC") + val key384 = readPublicKey(PUBLIC_384, "EC") + val key512 = readPublicKey(PUBLIC_512, "EC") + + val verifier256: JWTVerifier = JWT.require(Algorithm.ECDSA256(key256)).build() + val verifier384: JWTVerifier = JWT.require(Algorithm.ECDSA256(key384)).build() + val verifier512: JWTVerifier = JWT.require(Algorithm.ECDSA256(key512)).build() + + assertFailsWith { verifier256.verify(jwtWithInvalidSig) }.also { + assertTrue { it.cause is SignatureException } + } + assertFailsWith { verifier384.verify(jwtWithInvalidSig) }.also { + assertTrue { it.cause is SignatureException } + } + assertFailsWith { verifier512.verify(jwtWithInvalidSig) }.also { + assertTrue { it.cause is SignatureException } + } + } + + @Test + fun signatureWithAllZerosShouldFail() { + val pubKey = readPublicKey(PUBLIC_256, "EC") + + val algorithm256 = Algorithm.ECDSA256( + pubKey, + readPrivateKey(PRIVATE_256, "EC"), + ) as ECDSAAlgorithm + + val signatureBytes = ByteArray(64) + + val t = assertFailsWith { + algorithm256.validateSignatureStructure( + joseSignature = signatureBytes, + publicKey = pubKey.asNativeECPublicKey, + ) + } + + assertEquals("Invalid signature format.", t.message) + } + + @Test + fun signatureWithRZeroShouldFail() { + val publicKey = readPublicKey(PUBLIC_256, "EC").asNativeECPublicKey + val privateKey = readPrivateKey(PRIVATE_256, "EC").asNativeECPrivateKey + + val signedJwt: String = JWT.create().sign(Algorithm.ECDSA256(publicKey, privateKey)) + + val chunks = signedJwt.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val signature = Base64 + .UrlSafe + .withPadding(Base64.PaddingOption.ABSENT) + .decode(chunks[2]) + + val sigWithBlankR = ByteArray(signature.size) + for (i in signature.indices) { + if (i < signature.size / 2) { + sigWithBlankR[i] = 0 + } else { + sigWithBlankR[i] = signature[i] + } + } + + val algorithm256 = Algorithm.ECDSA256(publicKey, privateKey) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm256.validateSignatureStructure(sigWithBlankR, publicKey) + } + + assertEquals("Invalid signature format.", t.message) + } + + @Test + fun signatureWithSZeroShouldFail() { + val publicKey = readPublicKey(PUBLIC_256, "EC").asNativeECPublicKey + val privateKey = readPrivateKey(PRIVATE_256, "EC").asNativeECPrivateKey + + val signedJwt: String = JWT.create().sign(Algorithm.ECDSA256(publicKey, privateKey)) + + val chunks = signedJwt.split("\\.".toRegex()).toTypedArray() + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(chunks[2]) + + val sigWithBlankS = ByteArray(signature.size) + for (i in signature.indices) { + if (i < signature.size / 2) { + sigWithBlankS[i] = signature[i] + } else { + sigWithBlankS[i] = 0 + } + } + + val algorithm256 = Algorithm.ECDSA256(publicKey, privateKey) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm256.validateSignatureStructure(sigWithBlankS, publicKey) + } + + assertEquals("Invalid signature format.", t.message) + } + + @Test + fun signatureWithRValueNotLessThanOrderShouldFail() { + val publicKey = readPublicKey(PUBLIC_256, "EC").asNativeECPublicKey + val privateKey = readPrivateKey(PRIVATE_256, "EC").asNativeECPrivateKey + + val signedJwt: String = JWT.create().sign(Algorithm.ECDSA256(publicKey, privateKey)) + val jwtWithInvalidSig = signedJwt.substring( + 0, + signedJwt.lastIndexOf('.') + 1, + ) + "_____wAAAAD__________7zm-q2nF56E87nKwvxjJVH_____AAAAAP__________vOb6racXnoTzucrC_GMlUQ" + + val chunks = jwtWithInvalidSig.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val invalidSignature = Base64 + .UrlSafe + .withPadding(Base64.PaddingOption.PRESENT_OPTIONAL) + .decode(chunks[2]) + + val algorithm256 = Algorithm.ECDSA256(publicKey, privateKey) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm256.validateSignatureStructure(invalidSignature, publicKey) + } + + assertEquals("Invalid signature format.", t.message) + } + + @Test + fun signatureWithSValueNotLessThanOrderShouldFail() { + val publicKey = readPublicKey(PUBLIC_256, "EC").asNativeECPublicKey + val privateKey = readPrivateKey(PRIVATE_256, "EC").asNativeECPrivateKey + + val signedJwt = JWT.create().sign(Algorithm.ECDSA256(publicKey, privateKey)) + val jwtWithInvalidSig = signedJwt.substring( + 0, + signedJwt.lastIndexOf('.') + 1, + ) + "_____wAAAAD__________7zm-q2nF56E87nKwvxjJVH_____AAAAAP__________vOb6racXnoTzucrC_GMlUQ" + + val chunks = jwtWithInvalidSig.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val invalidSignature = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(chunks[2]) + + invalidSignature[0] = Byte.MAX_VALUE + + val algorithm256 = Algorithm.ECDSA256(publicKey, privateKey) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm256.validateSignatureStructure(invalidSignature, publicKey) + } + + assertEquals("Invalid signature format.", t.message) + } + + @Suppress("ktlint:standard:property-naming") + companion object { + private const val PRIVATE_256 = "src/androidUnitTest/resources/ec256-key-private.pem" + private const val PUBLIC_256 = "src/androidUnitTest/resources/ec256-key-public.pem" + private const val INVALID_PUBLIC_256 = "src/androidUnitTest/resources/ec256-key-public-invalid.pem" + + private const val PRIVATE_384 = "src/androidUnitTest/resources/ec384-key-private.pem" + private const val PUBLIC_384 = "src/androidUnitTest/resources/ec384-key-public.pem" + private const val INVALID_PUBLIC_384 = "src/androidUnitTest/resources/ec384-key-public-invalid.pem" + + private const val PRIVATE_512 = "src/androidUnitTest/resources/ec512-key-private.pem" + private const val PUBLIC_512 = "src/androidUnitTest/resources/ec512-key-public.pem" + private const val INVALID_PUBLIC_512 = "src/androidUnitTest/resources/ec512-key-public-invalid.pem" + + // Sign + private const val ES256Header = "eyJhbGciOiJFUzI1NiJ9" + private const val ES384Header = "eyJhbGciOiJFUzM4NCJ9" + + private const val ES512Header = "eyJhbGciOiJFUzUxMiJ9" + private const val auth0IssPayload = "eyJpc3MiOiJhdXRoMCJ9" + + private val ES256HeaderBytes: ByteArray = ES256Header.toByteArray() + private val ES384HeaderBytes: ByteArray = ES384Header.toByteArray() + private val ES512HeaderBytes: ByteArray = ES512Header.toByteArray() + private val auth0IssPayloadBytes: ByteArray = auth0IssPayload.toByteArray() + + // Test Helpers + fun assertValidJOSESignature( + joseSignature: ByteArray, + numberSize: Int, + withRPadding: Boolean, + withSPadding: Boolean, + ) { + assertTrue { numberSize in setOf(32, 48, 66) } + + assertEquals(numberSize * 2, joseSignature.size) + + val rCopy = joseSignature.copyOfRange(0, numberSize) + val sCopy = joseSignature.copyOfRange(numberSize, numberSize * 2) + + val rNumber = ByteArray(numberSize) + val sNumber = ByteArray(numberSize) + + Arrays.fill(rNumber, 0x11.toByte()) + Arrays.fill(sNumber, 0x22.toByte()) + + if (withRPadding) { + rNumber[0] = 0.toByte() + } + if (withSPadding) { + sNumber[0] = 0.toByte() + } + + assertContentEquals(rCopy, rNumber) + assertContentEquals(sCopy, sNumber) + } + + fun createDERSignature( + numberSize: Int, + withRPadding: Boolean, + withSPadding: Boolean, + ): ByteArray { + assertTrue { numberSize in setOf(32, 48, 66) } + + val rLength = if (withRPadding) numberSize - 1 else numberSize + val sLength = if (withSPadding) numberSize - 1 else numberSize + var totalLength = 2 + (2 + rLength) + (2 + sLength) + + val rNumber = ByteArray(rLength) + val sNumber = ByteArray(sLength) + + Arrays.fill(rNumber, 0x11.toByte()) + Arrays.fill(sNumber, 0x22.toByte()) + + val derSignature: ByteArray + var offset = 0 + + if (totalLength > 0x7f) { + totalLength++ + derSignature = ByteArray(totalLength) + // Start sequence and sign + derSignature[offset++] = 0x30.toByte() + derSignature[offset++] = 0x81.toByte() + } else { + derSignature = ByteArray(totalLength) + // Start sequence + derSignature[offset++] = 0x30.toByte() + } + + // Sequence length + derSignature[offset++] = (totalLength - offset).toByte() + + // R number + derSignature[offset++] = 0x02.toByte() + derSignature[offset++] = rLength.toByte() + System.arraycopy(rNumber, 0, derSignature, offset, rLength) + offset += rLength + + // S number + derSignature[offset++] = 0x02.toByte() + derSignature[offset++] = sLength.toByte() + System.arraycopy(sNumber, 0, derSignature, offset, sLength) + + return derSignature + } + + fun createJOSESignature( + numberSize: Int, + withRPadding: Boolean, + withSPadding: Boolean, + ): ByteArray { + assertTrue { numberSize in setOf(32, 48, 66) } + + val rNumber = ByteArray(numberSize) + val sNumber = ByteArray(numberSize) + + Arrays.fill(rNumber, 0x11.toByte()) + Arrays.fill(sNumber, 0x22.toByte()) + + if (withRPadding) { + rNumber[0] = 0.toByte() + } + if (withSPadding) { + sNumber[0] = 0.toByte() + } + val joseSignature = ByteArray(numberSize * 2) + + System.arraycopy(rNumber, 0, joseSignature, 0, numberSize) + System.arraycopy(sNumber, 0, joseSignature, numberSize, numberSize) + + return joseSignature + } + + fun assertValidDERSignature( + derSignature: ByteArray, + numberSize: Int, + withRPadding: Boolean, + withSPadding: Boolean, + ) { + assertTrue { numberSize in setOf(32, 48, 66) } + + val rLength = if (withRPadding) numberSize - 1 else numberSize + val sLength = if (withSPadding) numberSize - 1 else numberSize + var totalLength = 2 + (2 + rLength) + (2 + sLength) + var offset = 0 + + // Start sequence + assertThat(derSignature[offset++], `is`(0x30.toByte())) + + if (totalLength > 0x7f) { + // Add sign before sequence length + totalLength++ + assertThat(derSignature[offset++], `is`(0x81.toByte())) + } + // Sequence length + assertThat(derSignature[offset++], `is`((totalLength - offset).toByte())) + + // R number + assertThat(derSignature[offset++], `is`(0x02.toByte())) + assertThat(derSignature[offset++], `is`(rLength.toByte())) + + val rCopy = derSignature.copyOfRange(offset, offset + rLength) + offset += rLength + + // S number + assertThat(derSignature[offset++], `is`(0x02.toByte())) + assertThat(derSignature[offset++], `is`(sLength.toByte())) + + val sCopy = derSignature.copyOfRange(offset, offset + sLength) + + val rNumber = ByteArray(rLength) + val sNumber = ByteArray(sLength) + + Arrays.fill(rNumber, 0x11.toByte()) + Arrays.fill(sNumber, 0x22.toByte()) + + assertContentEquals(rCopy, rNumber) + assertContentEquals(sCopy, sNumber) + assertThat(totalLength, derSignature.size) + } + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSABouncyCastleProviderTests.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSABouncyCastleProviderTests.kt new file mode 100644 index 0000000..1ac2403 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSABouncyCastleProviderTests.kt @@ -0,0 +1,1415 @@ +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.PrivateKey +import dev.sdkforge.crypto.domain.PublicKey +import dev.sdkforge.crypto.domain.ec.asNativeECPrivateKey +import dev.sdkforge.crypto.domain.ec.asNativeECPublicKey +import dev.sdkforge.jwt.decode.data.JWT +import dev.sdkforge.jwt.decode.data.algorithm.ECDSAAlgorithmTest.Companion.assertValidDERSignature +import dev.sdkforge.jwt.decode.data.algorithm.ECDSAAlgorithmTest.Companion.assertValidJOSESignature +import dev.sdkforge.jwt.decode.data.algorithm.ECDSAAlgorithmTest.Companion.createDERSignature +import dev.sdkforge.jwt.decode.data.algorithm.ECDSAAlgorithmTest.Companion.createJOSESignature +import dev.sdkforge.jwt.decode.data.readPrivateKey +import dev.sdkforge.jwt.decode.data.readPublicKey +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.SignatureException +import dev.sdkforge.jwt.decode.domain.exception.SignatureGenerationException +import dev.sdkforge.jwt.decode.domain.exception.SignatureVerificationException +import dev.sdkforge.jwt.decode.domain.provider.ECDSAKeyProvider +import io.mockk.every +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import io.mockk.mockkStatic +import java.math.BigInteger +import java.security.InvalidKeyException +import java.security.NoSuchAlgorithmException +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey +import java.security.spec.ECParameterSpec +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.junit.After +import org.junit.Before +import org.junit.Rule + +class ECDSABouncyCastleProviderTests { + + @get:Rule + val mockkRule = MockKRule(this) + + @Test + fun shouldPreferBouncyCastleProvider() { + assertEquals(bcProvider, java.security.Security.getProviders()[0]) + } + + @Test + fun shouldPassECDSA256VerificationWithJOSESignature() { + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.4iVk3-Y0v4RT4_9IaQlp-8dZ_4fsTzIylgrPTDLrEvTHBTyVS3tgPbr2_IZfLETtiKRqCg0aQ5sh9eIsTTwB1g" + val key = readPublicKey( + PUBLIC_KEY_FILE_256, + "EC", + ).asNativeECPublicKey + val algorithm = Algorithm.ECDSA256(key) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldThrowOnECDSA256VerificationWithDERSignature() { + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.MEYCIQDiJWTf5jShFPj0hpCWn7x1nhxPMjKWCs9MMusS9AIhAMcFPJVLe2A9uvb8hl8sRO2IpGoKDRpDmyH14ixNPAHW" + val key = readPublicKey(PUBLIC_KEY_FILE_256, "EC").asNativeECPublicKey + val algorithm = Algorithm.ECDSA256(key) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassECDSA256VerificationWithJOSESignatureWithBothKeys() { + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.4iVk3-Y0v4RT4_9IaQlp-8dZ_4fsTzIylgrPTDLrEvTHBTyVS3tgPbr2_IZfLETtiKRqCg0aQ5sh9eIsTTwB1g" + val algorithm = Algorithm.ECDSA256( + readPublicKey(PUBLIC_KEY_FILE_256, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_256, "EC"), + ) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldThrowOnECDSA256VerificationWithDERSignatureWithBothKeys() { + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.MEYCIQDiJWTf5jShFPj0hpCWn7x1nhxPMjKWCs9MMusS9AIhAMcFPJVLe2A9uvb8hl8sRO2IpGoKDRpDmyH14ixNPAHW" + val algorithm = Algorithm.ECDSA256( + readPublicKey(PUBLIC_KEY_FILE_256, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_256, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassECDSA256VerificationWithProvidedPublicKey() { + val jwt = + "eyJhbGciOiJFUzI1NiIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.D_oU4CB0ZEsxHOjcWnmS3ZJvlTzm6WcGFx-HASxnvcB2Xu2WjI-axqXH9xKq45aPBDs330JpRhJmqBSc2K8MXQ" + val publicKey = readPublicKey(PUBLIC_KEY_FILE_256, "EC").asNativeECPublicKey + val provider: ECDSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns publicKey + } + val algorithm = Algorithm.ECDSA256(provider) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailECDSA256VerificationWhenProvidedPublicKeyIsNull() { + val jwt = + "eyJhbGciOiJFUzI1NiIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.D_oU4CB0ZEsxHOjcWnmS3ZJvlTzm6WcGFx-HASxnvcB2Xu2WjI-axqXH9xKq45aPBDs330JpRhJmqBSc2K8MXQ" + val provider: ECDSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns null + } + val algorithm = Algorithm.ECDSA256(provider) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA256VerificationWithInvalidPublicKey() { + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.W9qfN1b80B9hnMo49WL8THrOsf1vEjOhapeFemPMGySzxTcgfyudS5esgeBTO908X5SLdAr5jMwPUPBs9b6nNg" + val algorithm = Algorithm.ECDSA256( + readPublicKey(INVALID_PUBLIC_KEY_FILE_256, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + } + + @Test + fun shouldFailECDSA256VerificationWhenUsingPrivateKey() { + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.W9qfN1b80B9hnMo49WL8THrOsf1vEjOhapeFemPMGySzxTcgfyudS5esgeBTO908X5SLdAr5jMwPUPBs9b6nNg" + val algorithm = Algorithm.ECDSA256( + readPrivateKey(PRIVATE_KEY_FILE_256, "EC").asNativeECPrivateKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA256VerificationOnInvalidJOSESignatureLength() { + val bytes = ByteArray(63) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA256( + (readPublicKey(INVALID_PUBLIC_KEY_FILE_256, "EC") as ECPublicKey).asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA256VerificationOnInvalidJOSESignature() { + val bytes = ByteArray(64) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA256( + (readPublicKey(INVALID_PUBLIC_KEY_FILE_256, "EC") as ECPublicKey).asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + } + + @Test + fun shouldFailECDSA256VerificationOnInvalidDERSignature() { + val bytes = ByteArray(64) + bytes[0] = 0x30 + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA256( + (readPublicKey(INVALID_PUBLIC_KEY_FILE_256, "EC") as ECPublicKey).asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + } + + @Test + fun shouldPassECDSA384VerificationWithJOSESignature() { + val jwt = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9.50UU5VKNdF1wfykY8jQBKpvuHZoe6IZBJm5NvoB8bR-hnRg6ti-CHbmvoRtlLfnHfwITa_8cJMy6TenMC2g63GQHytc8rYoXqbwtS4R0Ko_AXbLFUmfxnGnMC6v4MS_z" + val key = readPublicKey(PUBLIC_KEY_FILE_384, "EC") + val algorithm = Algorithm.ECDSA384(key.asNativeECPublicKey) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldThrowOnECDSA384VerificationWithDERSignature() { + val jwt = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9.MGUCMQDnRRTlUo10XXBKRjyNAEqm4dmh7ohkEmbk2gHxtH6GdGDq2L4IduahG2UtccCMH8CE2vHCTMuk3pzAtoOtxkB8rXPK2KF6m8LUuEdCqPwF2yxVJn8ZxpzAurDEv8w" + val key = readPublicKey(PUBLIC_KEY_FILE_384, "EC").asNativeECPublicKey + val algorithm = Algorithm.ECDSA384(key) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassECDSA384VerificationWithJOSESignatureWithBothKeys() { + val jwt = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9.50UU5VKNdF1wfykY8jQBKpvuHZoe6IZBJm5NvoB8bR-hnRg6ti-CHbmvoRtlLfnHfwITa_8cJMy6TenMC2g63GQHytc8rYoXqbwtS4R0Ko_AXbLFUmfxnGnMC6v4MS_z" + val algorithm = Algorithm.ECDSA384( + readPublicKey(PUBLIC_KEY_FILE_384, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_384, "EC"), + ) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldThrowOnECDSA384VerificationWithDERSignatureWithBothKeys() { + val jwt = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9.MGUCMQDnRRTlUo10XXBKRjyNAEqm4dmh7ohkEmbk2gHxtH6GdGDq2L4IduahG2UtccCMH8CE2vHCTMuk3pzAtoOtxkB8rXPK2KF6m8LUuEdCqPwF2yxVJn8ZxpzAurDEv8w" + val algorithm = Algorithm.ECDSA384( + readPublicKey(PUBLIC_KEY_FILE_384, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_384, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassECDSA384VerificationWithProvidedPublicKey() { + val jwt = + "eyJhbGciOiJFUzM4NCIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.9kjGuFTPx3ylfpqL0eY9H7TGmPepjQOBKI8UPoEvby6N7dDLF5HxLohosNxxFymNT7LzpeSgOPAB0wJEwG2Nl2ukgdUOpZOf492wog_i5ZcZmAykd3g1QH7onrzd69GU" + val provider: ECDSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns readPublicKey(PUBLIC_KEY_FILE_384, "EC").asNativeECPublicKey + } + val algorithm = Algorithm.ECDSA384(provider) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailECDSA384VerificationWhenProvidedPublicKeyIsNull() { + val jwt = + "eyJhbGciOiJFUzM4NCIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.9kjGuFTPx3ylfpqL0eY9H7TGmPepjQOBKI8UPoEvby6N7dDLF5HxLohosNxxFymNT7LzpeSgOPAB0wJEwG2Nl2ukgdUOpZOf492wog_i5ZcZmAykd3g1QH7onrzd69GU" + val provider: ECDSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns null + } + val algorithm = Algorithm.ECDSA384(provider) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA384VerificationWithInvalidPublicKey() { + val jwt = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9._k5h1KyO-NE0R2_HAw0-XEc0bGT5atv29SxHhOGC9JDqUHeUdptfCK_ljQ01nLVt2OQWT2SwGs-TuyHDFmhPmPGFZ9wboxvq_ieopmYqhQilNAu-WF-frioiRz9733fU" + val algorithm = Algorithm.ECDSA384( + readPublicKey(INVALID_PUBLIC_KEY_FILE_384, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + } + + @Test + fun shouldFailECDSA384VerificationWhenUsingPrivateKey() { + val jwt = + "eyJhbGciOiJFUzM4NCJ9.eyJpc3MiOiJhdXRoMCJ9._k5h1KyO-NE0R2_HAw0-XEc0bGT5atv29SxHhOGC9JDqUHeUdptfCK_ljQ01nLVt2OQWT2SwGs-TuyHDFmhPmPGFZ9wboxvq_ieopmYqhQilNAu-WF-frioiRz9733fU" + val algorithm = Algorithm.ECDSA384( + readPrivateKey(PRIVATE_KEY_FILE_384, "EC").asNativeECPrivateKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA384VerificationOnInvalidJOSESignatureLength() { + val bytes = ByteArray(95) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA384( + readPublicKey(INVALID_PUBLIC_KEY_FILE_384, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA384VerificationOnInvalidJOSESignature() { + val bytes = ByteArray(96) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA384( + readPublicKey(INVALID_PUBLIC_KEY_FILE_384, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + } + + @Test + fun shouldFailECDSA384VerificationOnInvalidDERSignature() { + val bytes = ByteArray(96) + java.security.SecureRandom().nextBytes(bytes) + bytes[0] = 0x30 + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA384( + readPublicKey(INVALID_PUBLIC_KEY_FILE_384, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withECDSA", t.message) + } + + @Test + fun shouldPassECDSA512VerificationWithJOSESignature() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.AeCJPDIsSHhwRSGZCY6rspi8zekOw0K9qYMNridP1Fu9uhrA1QrG-EUxXlE06yvmh2R7Rz0aE7kxBwrnq8L8aOBCAYAsqhzPeUvyp8fXjjgs0Eto5I0mndE2QHlgcMSFASyjHbU8wD2Rq7ZNzGQ5b2MZfpv030WGUajT-aZYWFUJHVg2" + val key = readPublicKey(PUBLIC_KEY_FILE_512, "EC").asNativeECPublicKey + val algorithm = Algorithm.ECDSA512(key) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldThrowOnECDSA512VerificationWithDERSignature() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.MIGIAkIB4Ik8MixIeHBFIZkJjquymLzN6Q7DQr2pgw2uJ0UW726GsDVCsb4RTFeUTTrKaHZHtHPRoTuTEHCuerwvxo4EICQgGALKocz3lL8qfH1444LNBLaOSNJp3RNkB5YHDEhQEsox21PMA9kau2TcxkOW9jGX6b9N9FhlGo0mmWFhVCR1YNg" + val key = readPublicKey(PUBLIC_KEY_FILE_512, "EC") + val algorithm = Algorithm.ECDSA512(key.asNativeECPublicKey) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassECDSA512VerificationWithJOSESignatureWithBothKeys() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.AeCJPDIsSHhwRSGZCY6rspi8zekOw0K9qYMNridP1Fu9uhrA1QrG-EUxXlE06yvmh2R7Rz0aE7kxBwrnq8L8aOBCAYAsqhzPeUvyp8fXjjgs0Eto5I0mndE2QHlgcMSFASyjHbU8wD2Rq7ZNzGQ5b2MZfpv030WGUajT-aZYWFUJHVg2" + val algorithm = Algorithm.ECDSA512( + readPublicKey(PUBLIC_KEY_FILE_512, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_512, "EC"), + ) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldThrowECDSA512VerificationWithDERSignatureWithBothKeys() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.MIGIAkIB4Ik8MixIeHBFIZkJjquymLzN6Q7DQr2pgw2uJ0UW726GsDVCsb4RTFeUTTrKaHZHtHPRoTuTEHCuerwvxo4EICQgGALKocz3lL8qfH1444LNBLaOSNJp3RNkB5YHDEhQEsox21PMA9kau2TcxkOW9jGX6b9N9FhlGo0mmWFhVCR1YNg" + val algorithm = Algorithm.ECDSA512( + readPublicKey(PUBLIC_KEY_FILE_512, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_512, "EC"), + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassECDSA512VerificationWithProvidedPublicKey() { + val jwt = + "eyJhbGciOiJFUzUxMiIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.AGxEwbsYa2bQ7Y7DAcTQnVD8PmLSlhJ20jg2OfdyPnqdXI8SgBaG6lGciq3_pofFhs1HEoFoJ33Jcluha24oMHIvAfwu8qbv_Wq3L2eI9Q0L0p6ul8Pd_BS8adRa2PgLc36xXGcRc7ID5YH-CYaQfsTp5YIaF0Po3h0QyCoQ6ZiYQkqm" + val provider: ECDSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns readPublicKey(PUBLIC_KEY_FILE_512, "EC").asNativeECPublicKey + } + + val algorithm = Algorithm.ECDSA512(provider) as ECDSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailECDSA512VerificationWhenProvidedPublicKeyIsNull() { + val jwt = + "eyJhbGciOiJFUzUxMiIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.AGxEwbsYa2bQ7Y7DAcTQnVD8PmLSlhJ20jg2OfdyPnqdXI8SgBaG6lGciq3_pofFhs1HEoFoJ33Jcluha24oMHIvAfwu8qbv_Wq3L2eI9Q0L0p6ul8Pd_BS8adRa2PgLc36xXGcRc7ID5YH-CYaQfsTp5YIaF0Po3h0QyCoQ6ZiYQkqm" + val provider: ECDSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns null + } + val algorithm = Algorithm.ECDSA512(provider) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA512VerificationWithInvalidPublicKey() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.AZgdopFFsN0amCSs2kOucXdpylD31DEm5ChK1PG0_gq5Mf47MrvVph8zHSVuvcrXzcE1U3VxeCg89mYW1H33Y-8iAF0QFkdfTUQIWKNObH543WNMYYssv3OtOj0znPv8atDbaF8DMYAtcT1qdmaSJRhx-egRE9HGZkinPh9CfLLLt58X" + val algorithm = Algorithm.ECDSA512( + readPublicKey(INVALID_PUBLIC_KEY_FILE_512, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + } + + @Test + fun shouldFailECDSA512VerificationWhenUsingPrivateKey() { + val jwt = + "eyJhbGciOiJFUzUxMiJ9.eyJpc3MiOiJhdXRoMCJ9.AZgdopFFsN0amCSs2kOucXdpylD31DEm5ChK1PG0_gq5Mf47MrvVph8zHSVuvcrXzcE1U3VxeCg89mYW1H33Y-8iAF0QFkdfTUQIWKNObH543WNMYYssv3OtOj0znPv8atDbaF8DMYAtcT1qdmaSJRhx-egRE9HGZkinPh9CfLLLt58X" + val algorithm = Algorithm.ECDSA512( + readPrivateKey(PRIVATE_KEY_FILE_512, "EC").asNativeECPrivateKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA512VerificationOnInvalidJOSESignatureLength() { + val bytes = ByteArray(131) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA512( + readPublicKey(INVALID_PUBLIC_KEY_FILE_512, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailECDSA512VerificationOnInvalidJOSESignature() { + val bytes = ByteArray(132) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA512( + readPublicKey(INVALID_PUBLIC_KEY_FILE_512, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + } + + @Test + fun shouldFailECDSA512VerificationOnInvalidDERSignature() { + val bytes = ByteArray(132) + java.security.SecureRandom().nextBytes(bytes) + bytes[0] = 0x30 + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + val algorithm = Algorithm.ECDSA512( + readPublicKey(INVALID_PUBLIC_KEY_FILE_512, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withECDSA", t.message) + } + + @Test + fun shouldFailJOSEToDERConversionOnInvalidJOSESignatureLength() { + val bytes = ByteArray(256) + java.security.SecureRandom().nextBytes(bytes) + val signature = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.$signature" + + val publicKey = readPublicKey(PUBLIC_KEY_FILE_256, "EC") + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("ES256", "SHA256withECDSA", 128, provider) + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withECDSA", t.message) + assertEquals("Invalid JOSE signature format.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnVerifyWhenSignatureAlgorithmDoesNotExists() { + val a = ByteArray(64) + java.util.Arrays.fill(a, Byte.MAX_VALUE) + val publicKey: ECPublicKey = mockk { + every { params } returns mockk() + every { params.order } returns BigInteger(a) + } + + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.4iVk3-Y0v4RT4_9IaQlp-8dZ_4fsTzIylgrPTDLrEvTHBTyVS3tgPbr2_IZfLETtiKRqCg0aQ5sh9eIsTTwB1g" + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { createSignatureFor(any(), any(), any(), any()) } throws NoSuchAlgorithmException() + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnVerifyWhenThePublicKeyIsInvalid() { + val a = ByteArray(64) + java.util.Arrays.fill(a, Byte.MAX_VALUE) + val publicKey: ECPublicKey = mockk { + every { params } returns mockk() + every { params.order } returns BigInteger(a) + } + + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.4iVk3-Y0v4RT4_9IaQlp-8dZ_4fsTzIylgrPTDLrEvTHBTyVS3tgPbr2_IZfLETtiKRqCg0aQ5sh9eIsTTwB1g" + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { + verifySignature( + algorithm = any(), + publicKey = any(), + headerBytes = any(), + payloadBytes = any(), + signatureBytes = any(), + ) + } throws InvalidKeyException() + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnVerifyWhenTheSignatureIsNotPrepared() { + val a = ByteArray(64) + java.util.Arrays.fill(a, Byte.MAX_VALUE) + val publicKey: ECPublicKey = mockk { + every { params } returns mockk() + every { params.order } returns BigInteger(a) + } + + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + val jwt = + "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9.4iVk3-Y0v4RT4_9IaQlp-8dZ_4fsTzIylgrPTDLrEvTHBTyVS3tgPbr2_IZfLETtiKRqCg0aQ5sh9eIsTTwB1g" + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { + verifySignature( + algorithm = any(), + publicKey = any(), + headerBytes = any(), + payloadBytes = any(), + signatureBytes = any(), + ) + } throws SignatureException() + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldDoECDSA256Signing() { + val algorithmSign = Algorithm.ECDSA256( + readPrivateKey(PRIVATE_KEY_FILE_256, "EC").asNativeECPrivateKey, + ) as ECDSAAlgorithm + val algorithmVerify = Algorithm.ECDSA256( + readPublicKey(PUBLIC_KEY_FILE_256, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + val jwt: String = asJWT( + algorithmSign, + ES256Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithmVerify.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoECDSA256SigningWithBothKeys() { + val algorithm = Algorithm.ECDSA256( + readPublicKey(PUBLIC_KEY_FILE_256, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_256, "EC"), + ) as ECDSAAlgorithm + val jwt: String = asJWT( + algorithm, + ES256Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoECDSA256SigningWithProvidedPrivateKey() { + val provider: ECDSAKeyProvider = mockk { + every { privateKey } returns readPrivateKey(PRIVATE_KEY_FILE_256, "EC").asNativeECPrivateKey + every { getPublicKeyById(null) } returns readPublicKey(PUBLIC_KEY_FILE_256, "EC").asNativeECPublicKey + } + + val algorithm = Algorithm.ECDSA256(provider) as ECDSAAlgorithm + + val jwt: String = asJWT( + algorithm, + ES256Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailOnECDSA256SigningWhenProvidedPrivateKeyIsNull() { + val provider: ECDSAKeyProvider = mockk { + every { privateKey } returns null + } + val algorithm = Algorithm.ECDSA256(provider) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA256withECDSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailOnECDSA256SigningWhenUsingPublicKey() { + val algorithm = Algorithm.ECDSA256( + readPublicKey(PUBLIC_KEY_FILE_256, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA256withECDSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldDoECDSA384Signing() { + val algorithmSign = Algorithm.ECDSA384( + readPrivateKey(PRIVATE_KEY_FILE_384, "EC").asNativeECPrivateKey, + ) as ECDSAAlgorithm + val algorithmVerify = Algorithm.ECDSA384( + readPublicKey(PUBLIC_KEY_FILE_384, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + val jwt: String = asJWT( + algorithmSign, + ES384Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithmVerify.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoECDSA384SigningWithBothKeys() { + val algorithm = Algorithm.ECDSA384( + readPublicKey(PUBLIC_KEY_FILE_384, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_384, "EC"), + ) as ECDSAAlgorithm + + val jwt: String = asJWT( + algorithm, + ES384Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoECDSA384SigningWithProvidedPrivateKey() { + val provider: ECDSAKeyProvider = mockk { + every { privateKey } returns readPrivateKey(PRIVATE_KEY_FILE_384, "EC").asNativeECPrivateKey + every { getPublicKeyById(null) } returns readPublicKey(PUBLIC_KEY_FILE_384, "EC").asNativeECPublicKey + } + val algorithm = Algorithm.ECDSA384(provider) as ECDSAAlgorithm + + val jwt: String = asJWT( + algorithm, + ES384Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailOnECDSA384SigningWhenProvidedPrivateKeyIsNull() { + val provider: ECDSAKeyProvider = mockk { + every { privateKey } returns null + } + val algorithm = Algorithm.ECDSA384(provider) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA384withECDSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailOnECDSA384SigningWhenUsingPublicKey() { + val algorithm = Algorithm.ECDSA384( + readPublicKey(PUBLIC_KEY_FILE_384, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA384withECDSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldDoECDSA512Signing() { + val algorithmSign = Algorithm.ECDSA512( + readPrivateKey(PRIVATE_KEY_FILE_512, "EC").asNativeECPrivateKey, + ) as ECDSAAlgorithm + val algorithmVerify = Algorithm.ECDSA512( + readPublicKey(PUBLIC_KEY_FILE_512, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val jwt: String = asJWT( + algorithmSign, + ES512Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithmVerify.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoECDSA512SigningWithBothKeys() { + val algorithm = Algorithm.ECDSA512( + readPublicKey(PUBLIC_KEY_FILE_512, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_512, "EC"), + ) as ECDSAAlgorithm + + val jwt: String = asJWT( + algorithm, + ES512Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoECDSA512SigningWithProvidedPrivateKey() { + val provider: ECDSAKeyProvider = mockk { + every { privateKey } returns readPrivateKey(PRIVATE_KEY_FILE_512, "EC").asNativeECPrivateKey + every { getPublicKeyById(null) } returns readPublicKey(PUBLIC_KEY_FILE_512, "EC").asNativeECPublicKey + } + val algorithm = Algorithm.ECDSA512(provider) as ECDSAAlgorithm + val jwt: String = asJWT( + algorithm, + ES512Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailOnECDSA512SigningWhenProvidedPrivateKeyIsNull() { + val provider: ECDSAKeyProvider = mockk { + every { privateKey } returns null + } + val algorithm = Algorithm.ECDSA512(provider) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA512withECDSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailOnECDSA512SigningWhenUsingPublicKey() { + val algorithm = Algorithm.ECDSA512( + readPublicKey(PUBLIC_KEY_FILE_512, "EC").asNativeECPublicKey, + ) as ECDSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA512withECDSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnSignWhenSignatureAlgorithmDoesNotExists() { + val publicKey: ECPublicKey = mockk() + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { createSignatureFor(any(), any(), any(), any()) } throws NoSuchAlgorithmException() + + val t = assertFailsWith { + algorithm.sign( + ES256Header.toByteArray(java.nio.charset.StandardCharsets.UTF_8), + ByteArray(0), + ) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnSignWhenThePrivateKeyIsInvalid() { + val publicKey: ECPublicKey = mockk() + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { createSignatureFor(any(), any(), any(), any()) } throws InvalidKeyException() + + val t = assertFailsWith { + algorithm.sign( + ES256Header.toByteArray(java.nio.charset.StandardCharsets.UTF_8), + ByteArray(0), + ) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnSignWhenTheSignatureIsNotPrepared() { + val publicKey: ECPublicKey = mockk() + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { createSignatureFor(any(), any(), any(), any()) } throws SignatureException() + + val t = assertFailsWith { + algorithm.sign( + ES256Header.toByteArray(java.nio.charset.StandardCharsets.UTF_8), + ByteArray(0), + ) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldReturnNullSigningKeyIdIfCreatedWithDefaultProvider() { + val publicKey: ECPublicKey = mockk() + val privateKey: ECPrivateKey = mockk() + val provider: ECDSAKeyProvider = ECDSAAlgorithm.providerForKeys(publicKey.asNativeECPublicKey, privateKey.asNativeECPrivateKey) + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + + assertNull(algorithm.signingKeyId) + } + + @Test + fun shouldReturnSigningKeyIdFromProvider() { + val provider: ECDSAKeyProvider = mockk { + every { privateKeyId } returns "keyId" + } + val algorithm = ECDSAAlgorithm("some-alg", "some-algorithm", 32, provider) + + assertEquals("keyId", algorithm.signingKeyId) + } + + @Test + fun shouldThrowOnDERSignatureConversionIfDoesNotStartWithCorrectSequenceByte() { + val content256 = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJhdXRoMCJ9" + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_KEY_FILE_256, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_256, "EC"), + ) as ECDSAAlgorithm + val signature = algorithm256.sign(content256.toByteArray(), ByteArray(0)) + + signature[0] = 0x02.toByte() + + val t = assertFailsWith { + algorithm256.DERToJOSE(signature) + } + + assertEquals("Invalid DER signature format.", t.message) + } + + @Test + fun shouldThrowOnDERSignatureConversionIfDoesNotHaveExpectedLength() { + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_KEY_FILE_256, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_256, "EC"), + ) as ECDSAAlgorithm + val derSignature = createDERSignature(32, false, false) + var received = derSignature[1].toInt() + received-- + derSignature[1] = received.toByte() + + val t = assertFailsWith { + algorithm256.DERToJOSE(derSignature) + } + + assertEquals("Invalid DER signature format.", t.message) + } + + @Test + fun shouldThrowOnDERSignatureConversionIfRNumberDoesNotHaveExpectedLength() { + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_KEY_FILE_256, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_256, "EC"), + ) as ECDSAAlgorithm + val derSignature = createDERSignature(32, false, false) + + derSignature[3] = 34.toByte() + + val t = assertFailsWith { + algorithm256.DERToJOSE(derSignature) + } + + assertEquals("Invalid DER signature format.", t.message) + } + + @Test + fun shouldThrowOnDERSignatureConversionIfSNumberDoesNotHaveExpectedLength() { + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_KEY_FILE_256, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_256, "EC"), + ) as ECDSAAlgorithm + val derSignature = createDERSignature(32, false, false) + + derSignature[4 + 32 + 1] = 34.toByte() + + val t = assertFailsWith { + algorithm256.DERToJOSE(derSignature) + } + + assertEquals("Invalid DER signature format.", t.message) + } + + @Test + fun shouldThrowOnJOSESignatureConversionIfDoesNotHaveExpectedLength() { + val publicKey = readPublicKey(PUBLIC_KEY_FILE_256, "EC") + val privateKey = readPrivateKey(PRIVATE_KEY_FILE_256, "EC") + val algorithm256 = Algorithm.ECDSA256(publicKey, privateKey) as ECDSAAlgorithm + val joseSignature = ByteArray(32 * 2 - 1) + + val t = assertFailsWith { + algorithm256.validateSignatureStructure(joseSignature, publicKey.asNativeECPublicKey) + } + + assertEquals("Invalid JOSE signature format.", t.message) + } + + @Test + fun shouldSignAndVerifyWithECDSA256() { + val header256 = "eyJhbGciOiJFUzI1NiJ9" + val body = "eyJpc3MiOiJhdXRoMCJ9" + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_KEY_FILE_256, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_256, "EC"), + ) as ECDSAAlgorithm + + for (i in 0..9) { + val jwt: String = asJWT(algorithm256, header256, body) + algorithm256.verify(JWT.decode(jwt)) + } + } + + @Test + fun shouldSignAndVerifyWithECDSA384() { + val header384 = "eyJhbGciOiJFUzM4NCJ9" + val body = "eyJpc3MiOiJhdXRoMCJ9" + val algorithm384 = Algorithm.ECDSA384( + readPublicKey(PUBLIC_KEY_FILE_384, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_384, "EC"), + ) as ECDSAAlgorithm + + for (i in 0..9) { + val jwt: String = asJWT(algorithm384, header384, body) + algorithm384.verify(JWT.decode(jwt)) + } + } + + @Test + fun shouldSignAndVerifyWithECDSA512() { + val header512 = "eyJhbGciOiJFUzUxMiJ9" + val body = "eyJpc3MiOiJhdXRoMCJ9" + val algorithm512 = Algorithm.ECDSA512( + readPublicKey(PUBLIC_KEY_FILE_512, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_512, "EC"), + ) as ECDSAAlgorithm + + for (i in 0..9) { + val jwt: String = asJWT(algorithm512, header512, body) + algorithm512.verify(JWT.decode(jwt)) + } + } + + @Test + fun shouldDecodeECDSA256JOSE() { + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_KEY_FILE_256, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_256, "EC"), + ) as ECDSAAlgorithm + + // Without padding + var joseSignature = createJOSESignature(32, withRPadding = false, withSPadding = false) + var derSignature = algorithm256.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 32, withRPadding = false, withSPadding = false) + + // With R padding + joseSignature = createJOSESignature(32, withRPadding = true, withSPadding = false) + derSignature = algorithm256.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 32, withRPadding = true, withSPadding = false) + + // With S padding + joseSignature = createJOSESignature(32, withRPadding = false, withSPadding = true) + derSignature = algorithm256.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 32, withRPadding = false, withSPadding = true) + + // With both paddings + joseSignature = createJOSESignature(32, withRPadding = true, withSPadding = true) + derSignature = algorithm256.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 32, withRPadding = true, withSPadding = true) + } + + @Test + fun shouldDecodeECDSA256DER() { + val algorithm256 = Algorithm.ECDSA256( + readPublicKey(PUBLIC_KEY_FILE_256, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_256, "EC"), + ) as ECDSAAlgorithm + + // Without padding + var derSignature = createDERSignature(32, withRPadding = false, withSPadding = false) + var joseSignature = algorithm256.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 32, withRPadding = false, withSPadding = false) + + // With R padding + derSignature = createDERSignature(32, withRPadding = true, withSPadding = false) + joseSignature = algorithm256.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 32, withRPadding = true, withSPadding = false) + + // With S padding + derSignature = createDERSignature(32, withRPadding = false, withSPadding = true) + joseSignature = algorithm256.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 32, withRPadding = false, withSPadding = true) + + // With both paddings + derSignature = createDERSignature(32, withRPadding = true, withSPadding = true) + joseSignature = algorithm256.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 32, withRPadding = true, withSPadding = true) + } + + @Test + fun shouldDecodeECDSA384JOSE() { + val algorithm384 = Algorithm.ECDSA384( + readPublicKey(PUBLIC_KEY_FILE_384, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_384, "EC"), + ) as ECDSAAlgorithm + + // Without padding + var joseSignature = createJOSESignature(48, withRPadding = false, withSPadding = false) + var derSignature = algorithm384.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 48, withRPadding = false, withSPadding = false) + + // With R padding + joseSignature = createJOSESignature(48, withRPadding = true, withSPadding = false) + derSignature = algorithm384.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 48, withRPadding = true, withSPadding = false) + + // With S padding + joseSignature = createJOSESignature(48, withRPadding = false, withSPadding = true) + derSignature = algorithm384.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 48, withRPadding = false, withSPadding = true) + + // With both paddings + joseSignature = createJOSESignature(48, withRPadding = true, withSPadding = true) + derSignature = algorithm384.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 48, withRPadding = true, withSPadding = true) + } + + @Test + fun shouldDecodeECDSA384DER() { + val algorithm384 = Algorithm.ECDSA384( + readPublicKey(PUBLIC_KEY_FILE_384, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_384, "EC"), + ) as ECDSAAlgorithm + + // Without padding + var derSignature = createDERSignature(48, withRPadding = false, withSPadding = false) + var joseSignature = algorithm384.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 48, withRPadding = false, withSPadding = false) + + // With R padding + derSignature = createDERSignature(48, withRPadding = true, withSPadding = false) + joseSignature = algorithm384.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 48, withRPadding = true, withSPadding = false) + + // With S padding + derSignature = createDERSignature(48, withRPadding = false, withSPadding = true) + joseSignature = algorithm384.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 48, withRPadding = false, withSPadding = true) + + // With both paddings + derSignature = createDERSignature(48, withRPadding = true, withSPadding = true) + joseSignature = algorithm384.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 48, withRPadding = true, withSPadding = true) + } + + @Test + fun shouldDecodeECDSA512JOSE() { + val algorithm512 = Algorithm.ECDSA512( + readPublicKey(PUBLIC_KEY_FILE_512, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_512, "EC"), + ) as ECDSAAlgorithm + + // Without padding + var joseSignature = createJOSESignature(66, withRPadding = false, withSPadding = false) + var derSignature = algorithm512.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 66, withRPadding = false, withSPadding = false) + + // With R padding + joseSignature = createJOSESignature(66, withRPadding = true, withSPadding = false) + derSignature = algorithm512.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 66, withRPadding = true, withSPadding = false) + + // With S padding + joseSignature = createJOSESignature(66, withRPadding = false, withSPadding = true) + derSignature = algorithm512.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 66, withRPadding = false, withSPadding = true) + + // With both paddings + createJOSESignature(66, withRPadding = true, withSPadding = true).also { joseSignature = it } + derSignature = algorithm512.JOSEToDER(joseSignature) + + assertValidDERSignature(derSignature, 66, withRPadding = true, withSPadding = true) + } + + @Test + fun shouldDecodeECDSA512DER() { + val algorithm512 = Algorithm.ECDSA512( + readPublicKey(PUBLIC_KEY_FILE_512, "EC"), + readPrivateKey(PRIVATE_KEY_FILE_512, "EC"), + ) as ECDSAAlgorithm + + // Without padding + var derSignature = createDERSignature(66, withRPadding = false, withSPadding = false) + var joseSignature = algorithm512.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 66, withRPadding = false, withSPadding = false) + + // With R padding + derSignature = createDERSignature(66, withRPadding = true, withSPadding = false) + joseSignature = algorithm512.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 66, withRPadding = true, withSPadding = false) + + // With S padding + derSignature = createDERSignature(66, withRPadding = false, withSPadding = true) + joseSignature = algorithm512.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 66, withRPadding = false, withSPadding = true) + + // With both paddings + derSignature = createDERSignature(66, withRPadding = true, withSPadding = true) + joseSignature = algorithm512.DERToJOSE(derSignature) + + assertValidJOSESignature(joseSignature, 66, withRPadding = true, withSPadding = true) + } + + // JOSE Signatures obtained using Node 'jwa' lib: https://github.com/brianloveswords/node-jwa + // DER Signatures obtained from source JOSE signature using 'ecdsa-sig-formatter' lib: https://github.com/Brightspace/node-ecdsa-sig-formatter + // These tests add and use the BouncyCastle SecurityProvider to handle ECDSA algorithms + @Before + fun setUp() { + // Set BC as the preferred bcProvider + java.security.Security.insertProviderAt(bcProvider, 1) + } + + @After + fun tearDown() { + java.security.Security.removeProvider(bcProvider.name) + } + + @Suppress("ktlint:standard:property-naming") + companion object { + private const val PRIVATE_KEY_FILE_256 = "src/androidUnitTest/resources/ec256-key-private.pem" + private const val PUBLIC_KEY_FILE_256 = "src/androidUnitTest/resources/ec256-key-public.pem" + private const val INVALID_PUBLIC_KEY_FILE_256 = "src/androidUnitTest/resources/ec256-key-public-invalid.pem" + + private const val PRIVATE_KEY_FILE_384 = "src/androidUnitTest/resources/ec384-key-private.pem" + private const val PUBLIC_KEY_FILE_384 = "src/androidUnitTest/resources/ec384-key-public.pem" + private const val INVALID_PUBLIC_KEY_FILE_384 = "src/androidUnitTest/resources/ec384-key-public-invalid.pem" + + private const val PRIVATE_KEY_FILE_512 = "src/androidUnitTest/resources/ec512-key-private.pem" + private const val PUBLIC_KEY_FILE_512 = "src/androidUnitTest/resources/ec512-key-public.pem" + private const val INVALID_PUBLIC_KEY_FILE_512 = "src/androidUnitTest/resources/ec512-key-public-invalid.pem" + + private val bcProvider: java.security.Provider = BouncyCastleProvider() + + // Sign + private const val ES256Header = "eyJhbGciOiJFUzI1NiJ9" + private const val ES384Header = "eyJhbGciOiJFUzM4NCJ9" + private const val ES512Header = "eyJhbGciOiJFUzUxMiJ9" + private const val auth0IssPayload = "eyJpc3MiOiJhdXRoMCJ9" + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/HMACAlgorithmTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/HMACAlgorithmTest.kt new file mode 100644 index 0000000..2b7ece3 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/HMACAlgorithmTest.kt @@ -0,0 +1,440 @@ +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.PrivateKey +import dev.sdkforge.jwt.decode.data.JWT +import dev.sdkforge.jwt.decode.domain.DecodedJWT +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.SignatureGenerationException +import dev.sdkforge.jwt.decode.domain.exception.SignatureVerificationException +import io.mockk.every +import io.mockk.junit4.MockKRule +import io.mockk.mockkStatic +import java.security.InvalidKeyException +import java.security.NoSuchAlgorithmException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.Assert.assertArrayEquals +import org.junit.Rule + +class HMACAlgorithmTest { + + @get:Rule + val mockkRule = MockKRule(this) + + // Verify + @Test + fun shouldGetStringBytes() { + val text = "abcdef123456!@#$%^" + val expectedBytes = text.toByteArray() + + assertArrayEquals(expectedBytes, HMACAlgorithm.getSecretBytes(text)) + } + + @Test + fun shouldCopyTheReceivedSecretArray() { + val jwt = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + val secretArray = "secret".toByteArray() + + val algorithmString = Algorithm.HMAC256(secretArray) as HMACAlgorithm + + val decoded: DecodedJWT = JWT.decode(jwt) + + algorithmString.verify(decoded) + + secretArray[0] = secretArray[1] + + algorithmString.verify(decoded) + } + + @Test + fun shouldPassHMAC256Verification() { + val jwt = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + + val algorithmString = Algorithm.HMAC256("secret") as HMACAlgorithm + val algorithmBytes = Algorithm.HMAC256("secret".toByteArray()) as HMACAlgorithm + + val decoded: DecodedJWT = JWT.decode(jwt) + + algorithmString.verify(decoded) + algorithmBytes.verify(decoded) + } + + @Test + fun shouldFailHMAC256VerificationWithInvalidSecretString() { + val jwt = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + val algorithm = Algorithm.HMAC256("not_real_secret") as HMACAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: HmacSHA256", t.message) + } + + @Test + fun shouldFailHMAC256VerificationWithInvalidSecretBytes() { + val jwt = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + val algorithm = Algorithm.HMAC256("not_real_secret".toByteArray()) as HMACAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: HmacSHA256", t.message) + } + + @Test + fun shouldPassHMAC384Verification() { + val jwt = + "eyJhbGciOiJIUzM4NCIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.uztpK_wUMYJhrRv8SV-1LU4aPnwl-EM1q-wJnqgyb5DHoDteP6lN_gE1xnZJH5vw" + + val algorithmString = Algorithm.HMAC384("secret") as HMACAlgorithm + val algorithmBytes = Algorithm.HMAC384("secret".toByteArray()) as HMACAlgorithm + + val decoded: DecodedJWT = JWT.decode(jwt) + + algorithmString.verify(decoded) + algorithmBytes.verify(decoded) + } + + @Test + fun shouldFailHMAC384VerificationWithInvalidSecretString() { + val jwt = + "eyJhbGciOiJIUzM4NCIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.uztpK_wUMYJhrRv8SV-1LU4aPnwl-EM1q-wJnqgyb5DHoDteP6lN_gE1xnZJH5vw" + val algorithm = Algorithm.HMAC384("not_real_secret") as HMACAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: HmacSHA384", t.message) + } + + @Test + fun shouldFailHMAC384VerificationWithInvalidSecretBytes() { + val jwt = + "eyJhbGciOiJIUzM4NCIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.uztpK_wUMYJhrRv8SV-1LU4aPnwl-EM1q-wJnqgyb5DHoDteP6lN_gE1xnZJH5vw" + val algorithm = Algorithm.HMAC384("not_real_secret".toByteArray()) as HMACAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: HmacSHA384", t.message) + } + + @Test + fun shouldPassHMAC512Verification() { + val jwt = + "eyJhbGciOiJIUzUxMiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.VUo2Z9SWDV-XcOc_Hr6Lff3vl7L9e5Vb8ThXpmGDFjHxe3Dr1ZBmUChYF-xVA7cAdX1P_D4ZCUcsv3IefpVaJw" + + val algorithmString = Algorithm.HMAC512("secret") as HMACAlgorithm + val algorithmBytes = Algorithm.HMAC512("secret".toByteArray()) as HMACAlgorithm + + val decoded: DecodedJWT = JWT.decode(jwt) + + algorithmString.verify(decoded) + algorithmBytes.verify(decoded) + } + + @Test + fun shouldFailHMAC512VerificationWithInvalidSecretString() { + val jwt = + "eyJhbGciOiJIUzUxMiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.VUo2Z9SWDV-XcOc_Hr6Lff3vl7L9e5Vb8ThXpmGDFjHxe3Dr1ZBmUChYF-xVA7cAdX1P_D4ZCUcsv3IefpVaJw" + val algorithm = Algorithm.HMAC512("not_real_secret") as HMACAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: HmacSHA512", t.message) + } + + @Test + fun shouldFailHMAC512VerificationWithInvalidSecretBytes() { + val jwt = + "eyJhbGciOiJIUzUxMiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.VUo2Z9SWDV-XcOc_Hr6Lff3vl7L9e5Vb8ThXpmGDFjHxe3Dr1ZBmUChYF-xVA7cAdX1P_D4ZCUcsv3IefpVaJw" + val algorithm = Algorithm.HMAC512("not_real_secret".toByteArray()) as HMACAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: HmacSHA512", t.message) + } + + @Test + fun shouldThrowOnVerifyWhenSignatureAlgorithmDoesNotExists() { + val jwt = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + + val algorithm = HMACAlgorithm( + id = "some-alg", + algorithm = "some-algorithm", + secretBytes = "secret".toByteArray(), + ) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { + verifySignature( + algorithm = any(), + secretBytes = any(), + headerBytes = any(), + payloadBytes = any(), + signatureBytes = any(), + ) + } throws NoSuchAlgorithmException() + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: some-algorithm", t.message) + assertIs(t.cause) + } + + @Test + fun shouldThrowOnVerifyWhenTheSecretIsInvalid() { + val jwt = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWmi903JuUoDRZDBPB7HwkS4nVyWH1M" + + val algorithm = HMACAlgorithm( + id = "some-alg", + algorithm = "some-algorithm", + secretBytes = "secret".toByteArray(), + ) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { + verifySignature( + algorithm = any(), + secretBytes = any(), + headerBytes = any(), + payloadBytes = any(), + signatureBytes = any(), + ) + } throws InvalidKeyException() + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: some-algorithm", t.message) + assertIs(t.cause) + } + + @Test + fun shouldDoHMAC256SigningWithBytes() { + val expectedSignature = "s69x7Mmu4JqwmdxiK6sesALO7tcedbFsKEEITUxw9ho" + + val algorithm = Algorithm.HMAC256("secret".toByteArray()) as HMACAlgorithm + + val jwt: String = asJWT( + algorithm, + HS256Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + assertSignatureValue(jwt, expectedSignature) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoHMAC384SigningWithBytes() { + val expectedSignature = "4-y2Gxz_foN0jAOFimmBPF7DWxf4AsjM20zxNkHg8Zah5Q64G42P9GfjmUp4Hldt" + val algorithm = Algorithm.HMAC384("secret".toByteArray()) as HMACAlgorithm + + val jwt: String = asJWT( + algorithm, + HS384Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + assertSignatureValue(jwt, expectedSignature) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoHMAC512SigningWithBytes() { + val expectedSignature = "OXWyxmf-VcVo8viOiTFfLaEy6mrQqLEos5R82Xsx8mtFxQadJAQ1aVniIWN8qT2GNE_pMQPcdzk4x7Cqxsp1dw" + val algorithm = Algorithm.HMAC512("secret".toByteArray()) as HMACAlgorithm + + val jwt: String = asJWT( + algorithm, + HS512Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + assertSignatureValue(jwt, expectedSignature) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoHMAC256SigningWithString() { + val expectedSignature = "s69x7Mmu4JqwmdxiK6sesALO7tcedbFsKEEITUxw9ho" + val algorithm = Algorithm.HMAC256("secret") as HMACAlgorithm + + val jwt: String = asJWT( + algorithm, + HS256Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + assertSignatureValue(jwt, expectedSignature) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoHMAC384SigningWithString() { + val algorithm = Algorithm.HMAC384("secret") as HMACAlgorithm + + val jwt: String = asJWT( + algorithm, + HS384Header, + auth0IssPayload, + ) + val expectedSignature = "4-y2Gxz_foN0jAOFimmBPF7DWxf4AsjM20zxNkHg8Zah5Q64G42P9GfjmUp4Hldt" + + assertSignaturePresent(jwt) + assertSignatureValue(jwt, expectedSignature) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoHMAC512SigningWithString() { + val expectedSignature = "OXWyxmf-VcVo8viOiTFfLaEy6mrQqLEos5R82Xsx8mtFxQadJAQ1aVniIWN8qT2GNE_pMQPcdzk4x7Cqxsp1dw" + val algorithm = Algorithm.HMAC512("secret") as HMACAlgorithm + + val jwt: String = asJWT( + algorithm, + HS512Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + assertSignatureValue(jwt, expectedSignature) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldThrowOnSignWhenSignatureAlgorithmDoesNotExists() { + val algorithm = HMACAlgorithm( + id = "some-alg", + algorithm = "some-algorithm", + secretBytes = "secret".toByteArray(), + ) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { + createSignatureFor( + algorithm = any(), + privateKey = any(), + headerBytes = any(), + payloadBytes = any(), + ) + } throws NoSuchAlgorithmException() + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: some-algorithm", t.message) + assertIs(t.cause) + } + + @Test + fun shouldThrowOnSignWhenTheSecretIsInvalid() { + val algorithm = HMACAlgorithm( + id = "some-alg", + algorithm = "some-algorithm", + secretBytes = "secret".toByteArray(), + ) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { + createSignatureFor( + algorithm = any(), + secretBytes = any(), + headerBytes = any(), + payloadBytes = any(), + ) + } throws InvalidKeyException() + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: some-algorithm", t.message) + assertIs(t.cause) + } + + @Test + fun shouldReturnNullSigningKeyId() { + assertNull((Algorithm.HMAC256("secret") as HMACAlgorithm).signingKeyId) + } + + @Test + fun shouldBeEqualSignatureMethodResults() { + val algorithm = Algorithm.HMAC256("secret") as HMACAlgorithm + + val header = byteArrayOf(0x00, 0x01, 0x02) + val payload = byteArrayOf(0x04, 0x05, 0x06) + + val bout = java.io.ByteArrayOutputStream() + bout.write(header) + bout.write('.'.code) + bout.write(payload) + + assertTrue { algorithm.sign(bout.toByteArray()).contentEquals(algorithm.sign(header, payload)) } + } + + @Test + fun shouldThrowWhenSignatureNotValidBase64() { + val algorithm = HMACAlgorithm( + id = "some-alg", + algorithm = "some-algorithm", + secretBytes = "secret".toByteArray(), + ) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { + verifySignature( + algorithm = any(), + secretBytes = any(), + headerBytes = any(), + payloadBytes = any(), + signatureBytes = any(), + ) + } throws NoSuchAlgorithmException() + + val jwt = "eyJhbGciOiJIUzI1NiIsImN0eSI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mZ0m_N1J4PgeqWm+i903JuUoDRZDBPB7HwkS4nVyWH1M" + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: some-algorithm", t.message) + assertIs(t.cause) + } + + @Suppress("ktlint:standard:property-naming") + companion object { + // Sign + private const val HS256Header = "eyJhbGciOiJIUzI1NiJ9" + private const val HS384Header = "eyJhbGciOiJIUzM4NCJ9" + private const val HS512Header = "eyJhbGciOiJIUzUxMiJ9" + private const val auth0IssPayload = "eyJpc3MiOiJhdXRoMCJ9" + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/NoneAlgorithmTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/NoneAlgorithmTest.kt new file mode 100644 index 0000000..868d7c5 --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/NoneAlgorithmTest.kt @@ -0,0 +1,56 @@ +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.jwt.decode.data.JWT +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException +import dev.sdkforge.jwt.decode.domain.exception.SignatureVerificationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class NoneAlgorithmTest { + + @Test + fun shouldPassNoneVerification() { + val jwt = "eyJhbGciOiJub25lIiwiY3R5IjoiSldUIn0.eyJpc3MiOiJhdXRoMCJ9." + + (Algorithm.NONE as NoneAlgorithm).verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailNoneVerificationWhenTokenHasTwoParts() { + val jwt = "eyJhbGciOiJub25lIiwiY3R5IjoiSldUIn0.eyJpc3MiOiJhdXRoMCJ9" + + val t = assertFailsWith { + (Algorithm.NONE as NoneAlgorithm).verify(JWT.decode(jwt)) + } + + assertEquals("The token was expected to have 3 parts, but got 2.", t.message) + } + + @Test + fun shouldFailNoneVerificationWhenSignatureIsPresent() { + val jwt = "eyJhbGciOiJub25lIiwiY3R5IjoiSldUIn0.eyJpc3MiOiJhdXRoMCJ9.Ox-WRXRaGAuWt2KfPvWiGcCrPqZtbp_4OnQzZXaTfss" + + val t = assertFailsWith { + (Algorithm.NONE as NoneAlgorithm).verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: NoneAlgorithm", t.message) + } + + @Test + fun shouldReturnNullSigningKeyId() { + assertNull((Algorithm.NONE as NoneAlgorithm).signingKeyId) + } + + @Test + fun shouldThrowWhenSignatureNotValidBase64() { + val jwt = "eyJhbGciOiJub25lIiwiY3R5IjoiSldUIn0.eyJpc3MiOiJhdXRoMCJ9.Ox-WRXRaGAuWt2KfPvW+iGcCrPqZtbp_4OnQzZXaTfss" + + assertFailsWith { + (Algorithm.NONE as NoneAlgorithm).verify(JWT.decode(jwt)) + } + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/RSAAlgorithmTest.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/RSAAlgorithmTest.kt new file mode 100644 index 0000000..34a3acd --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/RSAAlgorithmTest.kt @@ -0,0 +1,887 @@ +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.PrivateKey +import dev.sdkforge.crypto.domain.PublicKey +import dev.sdkforge.crypto.domain.rsa.asNativeRSAPrivateKey +import dev.sdkforge.crypto.domain.rsa.asNativeRSAPublicKey +import dev.sdkforge.jwt.decode.data.JWT +import dev.sdkforge.jwt.decode.data.readPrivateKey +import dev.sdkforge.jwt.decode.data.readPublicKey +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.SignatureGenerationException +import dev.sdkforge.jwt.decode.domain.exception.SignatureVerificationException +import dev.sdkforge.jwt.decode.domain.provider.RSAKeyProvider +import io.mockk.every +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import io.mockk.mockkStatic +import java.security.InvalidKeyException +import java.security.NoSuchAlgorithmException +import java.security.SignatureException +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import org.junit.Rule + +class RSAAlgorithmTest { + + @get:Rule + val mockkRule = MockKRule(this) + + // Verify + + @Test + fun shouldPassRSA256Verification() { + val jwt = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.dxXF3MdsyW-AuvwJpaQtrZ33fAde9xWxpLIg9cO2tMLH2GSRNuLAe61KsJusZhqZB9Iy7DvflcmRz-9OZndm6cj_ThGeJH2LLc90K83UEvvRPo8l85RrQb8PcanxCgIs2RcZOLygERizB3pr5icGkzR7R2y6zgNCjKJ5_NJ6EiZsGN6_nc2PRK_DbyY-Wn0QDxIxKoA5YgQJ9qafe7IN980pXvQv2Z62c3XR8dYuaXBqhthBj-AbaFHEpZapN-V-TmuLNzR2MCB6Xr7BYMuCaqWf_XU8og4XNe8f_8w9Wv5vvgqMM1KhqVpG5VdMJv4o_L4NoCROHhtUQSLRh2M9cA" + + val algorithm = Algorithm.RSA256( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) as RSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldPassRSA256VerificationWithBothKeys() { + val jwt = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.dxXF3MdsyW-AuvwJpaQtrZ33fAde9xWxpLIg9cO2tMLH2GSRNuLAe61KsJusZhqZB9Iy7DvflcmRz-9OZndm6cj_ThGeJH2LLc90K83UEvvRPo8l85RrQb8PcanxCgIs2RcZOLygERizB3pr5icGkzR7R2y6zgNCjKJ5_NJ6EiZsGN6_nc2PRK_DbyY-Wn0QDxIxKoA5YgQJ9qafe7IN980pXvQv2Z62c3XR8dYuaXBqhthBj-AbaFHEpZapN-V-TmuLNzR2MCB6Xr7BYMuCaqWf_XU8og4XNe8f_8w9Wv5vvgqMM1KhqVpG5VdMJv4o_L4NoCROHhtUQSLRh2M9cA" + + val algorithm = Algorithm.RSA256( + readPublicKey( + PUBLIC_KEY_FILE, + "RSA", + ), + readPrivateKey( + PRIVATE_KEY_FILE, + "RSA", + ), + ) as RSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldPassRSA256VerificationWithProvidedPublicKey() { + val jwt = + "eyJhbGciOiJSUzI1NiIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.jXrbue3xJmnzWH9kU-uGeCTtgbQEKbch8uHd4Z52t86ncNyepfusl_bsyLJIcxMwK7odRzKiSE9efV9JaRSEDODDBdMeCzODFx82uBM7e46T1NLVSmjYIM7Hcfh81ZeTIk-hITvgtL6hvTdeJWOCZAB0bs18qSVW5SvursRUhY38xnhuNI6HOHCtqp7etxWAu6670L53I3GtXsmi6bXIzv_0v1xZcAFg4HTvXxfhfj3oCqkSs2nC27mHxBmQtmZKWmXk5HzVUyPRwTUWx5wHPT_hCsGer-CMCAyGsmOg466y1KDqf7ogpMYojfVZGWBsyA39LO1oWZ4Ryomkn8t5Vg" + + val publicKey = readPublicKey(PUBLIC_KEY_FILE, "RSA") + + val provider: RSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns publicKey.asNativeRSAPublicKey + } + + val algorithm = Algorithm.RSA256(provider) as RSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailRSA256VerificationWhenProvidedPublicKeyIsNull() { + val jwt = + "eyJhbGciOiJSUzI1NiIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.jXrbue3xJmnzWH9kU-uGeCTtgbQEKbch8uHd4Z52t86ncNyepfusl_bsyLJIcxMwK7odRzKiSE9efV9JaRSEDODDBdMeCzODFx82uBM7e46T1NLVSmjYIM7Hcfh81ZeTIk-hITvgtL6hvTdeJWOCZAB0bs18qSVW5SvursRUhY38xnhuNI6HOHCtqp7etxWAu6670L53I3GtXsmi6bXIzv_0v1xZcAFg4HTvXxfhfj3oCqkSs2nC27mHxBmQtmZKWmXk5HzVUyPRwTUWx5wHPT_hCsGer-CMCAyGsmOg466y1KDqf7ogpMYojfVZGWBsyA39LO1oWZ4Ryomkn8t5Vg" + + val provider: RSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns null + } + + val algorithm = Algorithm.RSA256(provider) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withRSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailRSA256VerificationWithInvalidPublicKey() { + val jwt = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.dxXF3MdsyW-AuvwJpaQtrZ33fAde9xWxpLIg9cO2tMLH2GSRNuLAe61KsJusZhqZB9Iy7DvflcmRz-9OZndm6cj_ThGeJH2LLc90K83UEvvRPo8l85RrQb8PcanxCgIs2RcZOLygERizB3pr5icGkzR7R2y6zgNCjKJ5_NJ6EiZsGN6_nc2PRK_DbyY-Wn0QDxIxKoA5YgQJ9qafe7IN980pXvQv2Z62c3XR8dYuaXBqhthBj-AbaFHEpZapN-V-TmuLNzR2MCB6Xr7BYMuCaqWf_XU8og4XNe8f_8w9Wv5vvgqMM1KhqVpG5VdMJv4o_L4NoCROHhtUQSLRh2M9cA" + + val algorithm = Algorithm.RSA256( + readPublicKey(INVALID_PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withRSA", t.message) + } + + @Test + fun shouldFailRSA256VerificationWhenUsingPrivateKey() { + val jwt = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.dxXF3MdsyW-AuvwJpaQtrZ33fAde9xWxpLIg9cO2tMLH2GSRNuLAe61KsJusZhqZB9Iy7DvflcmRz-9OZndm6cj_ThGeJH2LLc90K83UEvvRPo8l85RrQb8PcanxCgIs2RcZOLygERizB3pr5icGkzR7R2y6zgNCjKJ5_NJ6EiZsGN6_nc2PRK_DbyY-Wn0QDxIxKoA5YgQJ9qafe7IN980pXvQv2Z62c3XR8dYuaXBqhthBj-AbaFHEpZapN-V-TmuLNzR2MCB6Xr7BYMuCaqWf_XU8og4XNe8f_8w9Wv5vvgqMM1KhqVpG5VdMJv4o_L4NoCROHhtUQSLRh2M9cA" + + val algorithm = Algorithm.RSA256( + readPrivateKey(PRIVATE_KEY_FILE, "RSA").asNativeRSAPrivateKey, + ) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withRSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassRSA384Verification() { + val jwt = + "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.TZlWjXObwGSQOiu2oMq8kiKz0_BR7bbBddNL6G8eZ_GoR82BXOZDqNrQr7lb_M-78XGBguWLWNIdYhzgxOUL9EoCJlrqVm9s9vo6G8T1sj1op-4TbjXZ61TwIvrJee9BvPLdKUJ9_fp1Js5kl6yXkst40Th8Auc5as4n49MLkipjpEhKDKaENKHpSubs1ripSz8SCQZSofeTM_EWVwSw7cpiM8Fy8jOPvWG8Xz4-e3ODFowvHVsDcONX_4FTMNbeRqDuHq2ZhCJnEfzcSJdrve_5VD5fM1LperBVslTrOxIgClOJ3RmM7-WnaizJrWP3D6Z9OLxPxLhM6-jx6tcxEw" + + val algorithm = Algorithm.RSA384( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) as RSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldPassRSA384VerificationWithBothKeys() { + val jwt = + "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.TZlWjXObwGSQOiu2oMq8kiKz0_BR7bbBddNL6G8eZ_GoR82BXOZDqNrQr7lb_M-78XGBguWLWNIdYhzgxOUL9EoCJlrqVm9s9vo6G8T1sj1op-4TbjXZ61TwIvrJee9BvPLdKUJ9_fp1Js5kl6yXkst40Th8Auc5as4n49MLkipjpEhKDKaENKHpSubs1ripSz8SCQZSofeTM_EWVwSw7cpiM8Fy8jOPvWG8Xz4-e3ODFowvHVsDcONX_4FTMNbeRqDuHq2ZhCJnEfzcSJdrve_5VD5fM1LperBVslTrOxIgClOJ3RmM7-WnaizJrWP3D6Z9OLxPxLhM6-jx6tcxEw" + + val algorithm = Algorithm.RSA384( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + readPrivateKey(PRIVATE_KEY_FILE, "RSA").asNativeRSAPrivateKey, + ) as RSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldPassRSA384VerificationWithProvidedPublicKey() { + val jwt = + "eyJhbGciOiJSUzM4NCIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.ITNTVCT7ercumZKHV4-BXGkJwwa7fyF3CnSfEvm09fDFSkaseDxNo_75WLDmK9WM8RMHTPvkpHcTKm4guYEbC_la7RzFIKpU72bppzQojggSmWWXt_6zq50QP2t5HFMebote1zxhp8ccEdSCX5pyY6J2sm9kJ__HKK32KxIVCTjVCz-bFBS60oG35aYEySdKsxuUdWbD5FQ9I16Ony2x0EPvmlL3GPiAPmgjSFp3LtcBIbCDaoonM7iuDRGIQiDN_n2FKKb1Bt4_38uWPtTkwRpNalt6l53Y3JDdzGI5fMrMo3RQnQlAJxUJKD0eL6dRAA645IVIIXucHwuhgGGIVw" + + val publicKey = readPublicKey(PUBLIC_KEY_FILE, "RSA") + + val provider: RSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns publicKey.asNativeRSAPublicKey + } + + val algorithm = Algorithm.RSA384(provider) as RSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailRSA384VerificationWhenProvidedPublicKeyIsNull() { + val jwt = + "eyJhbGciOiJSUzM4NCIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.ITNTVCT7ercumZKHV4-BXGkJwwa7fyF3CnSfEvm09fDFSkaseDxNo_75WLDmK9WM8RMHTPvkpHcTKm4guYEbC_la7RzFIKpU72bppzQojggSmWWXt_6zq50QP2t5HFMebote1zxhp8ccEdSCX5pyY6J2sm9kJ__HKK32KxIVCTjVCz-bFBS60oG35aYEySdKsxuUdWbD5FQ9I16Ony2x0EPvmlL3GPiAPmgjSFp3LtcBIbCDaoonM7iuDRGIQiDN_n2FKKb1Bt4_38uWPtTkwRpNalt6l53Y3JDdzGI5fMrMo3RQnQlAJxUJKD0eL6dRAA645IVIIXucHwuhgGGIVw" + + val provider: RSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns null + } + + val algorithm = Algorithm.RSA384(provider) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withRSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailRSA384VerificationWithInvalidPublicKey() { + val jwt = + "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.TZlWjXObwGSQOiu2oMq8kiKz0_BR7bbBddNL6G8eZ_GoR82BXOZDqNrQr7lb_M-78XGBguWLWNIdYhzgxOUL9EoCJlrqVm9s9vo6G8T1sj1op-4TbjXZ61TwIvrJee9BvPLdKUJ9_fp1Js5kl6yXkst40Th8Auc5as4n49MLkipjpEhKDKaENKHpSubs1ripSz8SCQZSofeTM_EWVwSw7cpiM8Fy8jOPvWG8Xz4-e3ODFowvHVsDcONX_4FTMNbeRqDuHq2ZhCJnEfzcSJdrve_5VD5fM1LperBVslTrOxIgClOJ3RmM7-WnaizJrWP3D6Z9OLxPxLhM6-jx6tcxEw" + + val algorithm = Algorithm.RSA384( + readPublicKey(INVALID_PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withRSA", t.message) + } + + @Test + fun shouldFailRSA384VerificationWhenUsingPrivateKey() { + val jwt = + "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.TZlWjXObwGSQOiu2oMq8kiKz0_BR7bbBddNL6G8eZ_GoR82BXOZDqNrQr7lb_M-78XGBguWLWNIdYhzgxOUL9EoCJlrqVm9s9vo6G8T1sj1op-4TbjXZ61TwIvrJee9BvPLdKUJ9_fp1Js5kl6yXkst40Th8Auc5as4n49MLkipjpEhKDKaENKHpSubs1ripSz8SCQZSofeTM_EWVwSw7cpiM8Fy8jOPvWG8Xz4-e3ODFowvHVsDcONX_4FTMNbeRqDuHq2ZhCJnEfzcSJdrve_5VD5fM1LperBVslTrOxIgClOJ3RmM7-WnaizJrWP3D6Z9OLxPxLhM6-jx6tcxEw" + + val algorithm = Algorithm.RSA384( + readPrivateKey(PRIVATE_KEY_FILE, "RSA").asNativeRSAPrivateKey, + ) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA384withRSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldPassRSA512Verification() { + val jwt = + "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mvL5LoMyIrWYjk5umEXZTmbyIrkbbcVPUkvdGZbu0qFBxGOf0nXP5PZBvPcOu084lvpwVox5n3VaD4iqzW-PsJyvKFgi5TnwmsbKchAp7JexQEsQOnTSGcfRqeUUiBZqRQdYsho71oAB3T4FnalDdFEpM-fztcZY9XqKyayqZLreTeBjqJm4jfOWH7KfGBHgZExQhe96NLq1UA9eUyQwdOA1Z0SgXe4Ja5PxZ6Fm37KnVDtDlNnY4JAAGFo6y74aGNnp_BKgpaVJCGFu1f1S5xCQ1HSvs8ZSdVWs5NgawW3wRd0kRt_GJ_Y3mIwiF4qUyHWGtsSHu_qjVdCTtbFyow" + + val algorithm = Algorithm.RSA512( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) as RSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldPassRSA512VerificationWithBothKeys() { + val jwt = + "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mvL5LoMyIrWYjk5umEXZTmbyIrkbbcVPUkvdGZbu0qFBxGOf0nXP5PZBvPcOu084lvpwVox5n3VaD4iqzW-PsJyvKFgi5TnwmsbKchAp7JexQEsQOnTSGcfRqeUUiBZqRQdYsho71oAB3T4FnalDdFEpM-fztcZY9XqKyayqZLreTeBjqJm4jfOWH7KfGBHgZExQhe96NLq1UA9eUyQwdOA1Z0SgXe4Ja5PxZ6Fm37KnVDtDlNnY4JAAGFo6y74aGNnp_BKgpaVJCGFu1f1S5xCQ1HSvs8ZSdVWs5NgawW3wRd0kRt_GJ_Y3mIwiF4qUyHWGtsSHu_qjVdCTtbFyow" + + val algorithm = Algorithm.RSA512( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + readPrivateKey(PRIVATE_KEY_FILE, "RSA").asNativeRSAPrivateKey, + ) as RSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldPassRSA512VerificationWithProvidedPublicKey() { + val jwt = + "eyJhbGciOiJSUzUxMiIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.GpHv85Q8tAU_6hNWsmO0GEpO1qz9lmK3NKeAcemysz9MGo4FXWn8xbD8NjCfzZ8EWphm65M0NArKSjpKHO5-gcNsQxLBVfSED1vzcoaZH_Vy5Rp1M76dGH7JghB_66KrpfyMxer_yRJb-KXesNvIroDGilLQF2ENG-IfLF5nBKlDiVHmPaqr3pm1q20fNLhegkSRca4BJ5VdIlT6kOqE_ykVyCBqzD_oXp3LKO_ARnxoeB9SegIW1fy_3tuxSTKYsCZiOfiyVEXXblAuY3pSLZnGvgeBRnfvmWXDWhP0vVUFtYJBF09eULvvUMVqWcrjUG9gDzzzT7veiY_fHd_x8g" + + val publicKey = readPublicKey(PUBLIC_KEY_FILE, "RSA") + + val provider: RSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns publicKey.asNativeRSAPublicKey + } + + val algorithm = Algorithm.RSA512(provider) as RSAAlgorithm + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailRSA512VerificationWhenProvidedPublicKeyIsNull() { + val jwt = + "eyJhbGciOiJSUzUxMiIsImtpZCI6Im15LWtleS1pZCJ9.eyJpc3MiOiJhdXRoMCJ9.GpHv85Q8tAU_6hNWsmO0GEpO1qz9lmK3NKeAcemysz9MGo4FXWn8xbD8NjCfzZ8EWphm65M0NArKSjpKHO5-gcNsQxLBVfSED1vzcoaZH_Vy5Rp1M76dGH7JghB_66KrpfyMxer_yRJb-KXesNvIroDGilLQF2ENG-IfLF5nBKlDiVHmPaqr3pm1q20fNLhegkSRca4BJ5VdIlT6kOqE_ykVyCBqzD_oXp3LKO_ARnxoeB9SegIW1fy_3tuxSTKYsCZiOfiyVEXXblAuY3pSLZnGvgeBRnfvmWXDWhP0vVUFtYJBF09eULvvUMVqWcrjUG9gDzzzT7veiY_fHd_x8g" + + val provider: RSAKeyProvider = mockk { + every { getPublicKeyById("my-key-id") } returns null + } + + val algorithm = Algorithm.RSA512(provider) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withRSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailRSA512VerificationWithInvalidPublicKey() { + val jwt = + "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mvL5LoMyIrWYjk5umEXZTmbyIrkbbcVPUkvdGZbu0qFBxGOf0nXP5PZBvPcOu084lvpwVox5n3VaD4iqzW-PsJyvKFgi5TnwmsbKchAp7JexQEsQOnTSGcfRqeUUiBZqRQdYsho71oAB3T4FnalDdFEpM-fztcZY9XqKyayqZLreTeBjqJm4jfOWH7KfGBHgZExQhe96NLq1UA9eUyQwdOA1Z0SgXe4Ja5PxZ6Fm37KnVDtDlNnY4JAAGFo6y74aGNnp_BKgpaVJCGFu1f1S5xCQ1HSvs8ZSdVWs5NgawW3wRd0kRt_GJ_Y3mIwiF4qUyHWGtsSHu_qjVdCTtbFyow" + + val algorithm = Algorithm.RSA512( + readPublicKey(INVALID_PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withRSA", t.message) + } + + @Test + fun shouldFailRSA512VerificationWhenUsingPrivateKey() { + val jwt = + "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.mvL5LoMyIrWYjk5umEXZTmbyIrkbbcVPUkvdGZbu0qFBxGOf0nXP5PZBvPcOu084lvpwVox5n3VaD4iqzW-PsJyvKFgi5TnwmsbKchAp7JexQEsQOnTSGcfRqeUUiBZqRQdYsho71oAB3T4FnalDdFEpM-fztcZY9XqKyayqZLreTeBjqJm4jfOWH7KfGBHgZExQhe96NLq1UA9eUyQwdOA1Z0SgXe4Ja5PxZ6Fm37KnVDtDlNnY4JAAGFo6y74aGNnp_BKgpaVJCGFu1f1S5xCQ1HSvs8ZSdVWs5NgawW3wRd0kRt_GJ_Y3mIwiF4qUyHWGtsSHu_qjVdCTtbFyow" + + val algorithm = Algorithm.RSA512( + readPrivateKey(PRIVATE_KEY_FILE, "RSA").asNativeRSAPrivateKey, + ) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA512withRSA", t.message) + assertEquals("The given Public Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowWhenMacAlgorithmDoesNotExists() { + val jwt = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.dxXF3MdsyW-AuvwJpaQtrZ33fAde9xWxpLIg9cO2tMLH2GSRNuLAe61KsJusZhqZB9Iy7DvflcmRz-9OZndm6cj_ThGeJH2LLc90K83UEvvRPo8l85RrQb8PcanxCgIs2RcZOLygERizB3pr5icGkzR7R2y6zgNCjKJ5_NJ6EiZsGN6_nc2PRK_DbyY-Wn0QDxIxKoA5YgQJ9qafe7IN980pXvQv2Z62c3XR8dYuaXBqhthBj-AbaFHEpZapN-V-TmuLNzR2MCB6Xr7BYMuCaqWf_XU8og4XNe8f_8w9Wv5vvgqMM1KhqVpG5VdMJv4o_L4NoCROHhtUQSLRh2M9cA" + + val publicKey: RSAPublicKey = mockk() + val privateKey: RSAPrivateKey = mockk() + val provider: RSAKeyProvider = RSAAlgorithm.providerForKeys(publicKey.asNativeRSAPublicKey, privateKey.asNativeRSAPrivateKey) + val algorithm = RSAAlgorithm("some-alg", "some-algorithm", provider) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { + verifySignature( + algorithm = any(), + publicKey = any(), + headerBytes = any(), + payloadBytes = any(), + signatureBytes = any(), + ) + } throws NoSuchAlgorithmException() + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowWhenThePublicKeyIsInvalid() { + val jwt = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.dxXF3MdsyW-AuvwJpaQtrZ33fAde9xWxpLIg9cO2tMLH2GSRNuLAe61KsJusZhqZB9Iy7DvflcmRz-9OZndm6cj_ThGeJH2LLc90K83UEvvRPo8l85RrQb8PcanxCgIs2RcZOLygERizB3pr5icGkzR7R2y6zgNCjKJ5_NJ6EiZsGN6_nc2PRK_DbyY-Wn0QDxIxKoA5YgQJ9qafe7IN980pXvQv2Z62c3XR8dYuaXBqhthBj-AbaFHEpZapN-V-TmuLNzR2MCB6Xr7BYMuCaqWf_XU8og4XNe8f_8w9Wv5vvgqMM1KhqVpG5VdMJv4o_L4NoCROHhtUQSLRh2M9cA" + + val publicKey: RSAPublicKey? = mockk() + val privateKey: RSAPrivateKey? = mockk() + val provider: RSAKeyProvider = RSAAlgorithm.providerForKeys(publicKey?.asNativeRSAPublicKey, privateKey?.asNativeRSAPrivateKey) + val algorithm = RSAAlgorithm("some-alg", "some-algorithm", provider) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { + verifySignature( + algorithm = any(), + publicKey = any(), + headerBytes = any(), + payloadBytes = any(), + signatureBytes = any(), + ) + } throws InvalidKeyException() + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowWhenTheSignatureIsNotPrepared() { + val jwt = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.dxXF3MdsyW-AuvwJpaQtrZ33fAde9xWxpLIg9cO2tMLH2GSRNuLAe61KsJusZhqZB9Iy7DvflcmRz-9OZndm6cj_ThGeJH2LLc90K83UEvvRPo8l85RrQb8PcanxCgIs2RcZOLygERizB3pr5icGkzR7R2y6zgNCjKJ5_NJ6EiZsGN6_nc2PRK_DbyY-Wn0QDxIxKoA5YgQJ9qafe7IN980pXvQv2Z62c3XR8dYuaXBqhthBj-AbaFHEpZapN-V-TmuLNzR2MCB6Xr7BYMuCaqWf_XU8og4XNe8f_8w9Wv5vvgqMM1KhqVpG5VdMJv4o_L4NoCROHhtUQSLRh2M9cA" + val publicKey: RSAPublicKey? = mockk() + val privateKey: RSAPrivateKey? = mockk() + val provider: RSAKeyProvider = RSAAlgorithm.providerForKeys(publicKey?.asNativeRSAPublicKey, privateKey?.asNativeRSAPrivateKey) + val algorithm = RSAAlgorithm("some-alg", "some-algorithm", provider) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { + verifySignature( + algorithm = any(), + publicKey = any(), + headerBytes = any(), + payloadBytes = any(), + signatureBytes = any(), + ) + } throws SignatureException() + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldDoRSA256Signing() { + val expectedSignature = + "ZB-Tr0vLtnf8I9fhSdSjU6HZei5xLYZQ6nZqM5O6Va0W9PgAqgRT7ShI9CjeYulRXPHvVmSl5EQuYuXdBzM0-H_3p_Nsl6tSMy4EyX2kkhEm6T0HhvarTh8CG0PCjn5p6FP5ZxWwhLcmRN70ItP6Z5MMO4CcJh1JrNxR4Fi4xQgt-CK2aVDMFXd-Br5yQiLVx1CX83w28OD9wssW3Rdltl5e66vCef0Ql6Q5I5e5F0nqGYT989a9fkNgLIx2F8k_az5x07BY59FV2SZg59nSiY7TZNjP8ot11Ew7HKRfPXOdh9eKRUVdhcxzqDePhyzKabU8TG5FP0SiWH5qVPfAgw" + + val algorithmSign = Algorithm.RSA256( + readPrivateKey(PRIVATE_KEY_FILE, "RSA").asNativeRSAPrivateKey, + ) as RSAAlgorithm + val algorithmVerify = Algorithm.RSA256( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) as RSAAlgorithm + + val jwt: String = asJWT( + algorithmSign, + RS256Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + assertSignatureValue(jwt, expectedSignature) + + algorithmVerify.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoRSA256SigningWithBothKeys() { + val expectedSignature = + "ZB-Tr0vLtnf8I9fhSdSjU6HZei5xLYZQ6nZqM5O6Va0W9PgAqgRT7ShI9CjeYulRXPHvVmSl5EQuYuXdBzM0-H_3p_Nsl6tSMy4EyX2kkhEm6T0HhvarTh8CG0PCjn5p6FP5ZxWwhLcmRN70ItP6Z5MMO4CcJh1JrNxR4Fi4xQgt-CK2aVDMFXd-Br5yQiLVx1CX83w28OD9wssW3Rdltl5e66vCef0Ql6Q5I5e5F0nqGYT989a9fkNgLIx2F8k_az5x07BY59FV2SZg59nSiY7TZNjP8ot11Ew7HKRfPXOdh9eKRUVdhcxzqDePhyzKabU8TG5FP0SiWH5qVPfAgw" + + val algorithm = Algorithm.RSA256( + readPublicKey(PUBLIC_KEY_FILE, "RSA"), + readPrivateKey(PRIVATE_KEY_FILE, "RSA"), + ) as RSAAlgorithm + + val jwt: String = asJWT( + algorithm, + RS256Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + assertSignatureValue(jwt, expectedSignature) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoRSA256SigningWithProvidedPrivateKey() { + val provider: RSAKeyProvider = mockk { + every { privateKey } returns readPrivateKey(PRIVATE_KEY_FILE, "RSA").asNativeRSAPrivateKey + every { getPublicKeyById(null) } returns readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey + } + + val algorithm = Algorithm.RSA256(provider) as RSAAlgorithm + + val jwt: String = asJWT( + algorithm, + RS256Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailOnRSA256SigningWhenProvidedPrivateKeyIsNull() { + val provider: RSAKeyProvider = mockk { + every { privateKey } returns null + } + + val algorithm = Algorithm.RSA256(provider) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA256withRSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailOnRSA256SigningWhenUsingPublicKey() { + val algorithm = Algorithm.RSA256( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA256withRSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldDoRSA384Signing() { + val expectedSignature = + "Jx1PaTBnjd_U56MNjifFcY7w9ImDbseg0y8Ijr2pSiA1_wzQb_wy9undaWfzR5YqdIAXvjS8AGuZUAzIoTG4KMgOgdVyYDz3l2jzj6wI-lgqfR5hTy1w1ruMUQ4_wobpdxAiJ4fEbg8Mi_GljOiCO-P1HilxKnpiOJZidR8MQGwTInsf71tOUkK4x5UsdmUueuZbaU-CL5kPnRfXmJj9CcdxZbD9oMlbo23dwkP5BNMrS2LwGGzc9C_-ypxrBIOVilG3WZxcSmuG86LjcZbnL6LBEfph5NmKBgQav147uipb_7umBEr1m2dYiB_9u606n3bcoo3rnsYYK_Xfi1GAEQ" + + val algorithmSign = Algorithm.RSA384( + readPrivateKey(PRIVATE_KEY_FILE, "RSA").asNativeRSAPrivateKey, + ) as RSAAlgorithm + + val algorithmVerify = Algorithm.RSA384( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) as RSAAlgorithm + + val jwt: String = asJWT( + algorithmSign, + RS384Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + assertSignatureValue(jwt, expectedSignature) + + algorithmVerify.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoRSA384SigningWithBothKeys() { + val expectedSignature = + "Jx1PaTBnjd_U56MNjifFcY7w9ImDbseg0y8Ijr2pSiA1_wzQb_wy9undaWfzR5YqdIAXvjS8AGuZUAzIoTG4KMgOgdVyYDz3l2jzj6wI-lgqfR5hTy1w1ruMUQ4_wobpdxAiJ4fEbg8Mi_GljOiCO-P1HilxKnpiOJZidR8MQGwTInsf71tOUkK4x5UsdmUueuZbaU-CL5kPnRfXmJj9CcdxZbD9oMlbo23dwkP5BNMrS2LwGGzc9C_-ypxrBIOVilG3WZxcSmuG86LjcZbnL6LBEfph5NmKBgQav147uipb_7umBEr1m2dYiB_9u606n3bcoo3rnsYYK_Xfi1GAEQ" + + val algorithm = Algorithm.RSA384( + readPublicKey(PUBLIC_KEY_FILE, "RSA"), + readPrivateKey(PRIVATE_KEY_FILE, "RSA"), + ) as RSAAlgorithm + + val jwt: String = asJWT( + algorithm, + RS384Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + assertSignatureValue(jwt, expectedSignature) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoRSA384SigningWithProvidedPrivateKey() { + val provider: RSAKeyProvider = mockk { + every { privateKey } returns readPrivateKey(PRIVATE_KEY_FILE, "RSA").asNativeRSAPrivateKey + every { getPublicKeyById(null) } returns readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey + } + + val algorithm = Algorithm.RSA384(provider) as RSAAlgorithm + + val jwt: String = asJWT( + algorithm, + RS384Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailOnRSA384SigningWhenProvidedPrivateKeyIsNull() { + val provider: RSAKeyProvider = mockk { + every { privateKey } returns null + } + + val algorithm = Algorithm.RSA384(provider) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA384withRSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailOnRSA384SigningWhenUsingPublicKey() { + val algorithm = Algorithm.RSA384( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA384withRSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldDoRSA512Signing() { + val expectedSignature = + "THIPVYzNZ1Yo_dm0k1UELqV0txs3SzyMopCyHcLXOOdgYXF4MlGvBqu0CFvgSga72Sp5LpuC1Oesj40v_QDsp2GTGDeWnvvcv_eo-b0LPSpmT2h1Ibrmu-z70u2rKf28pkN-AJiMFqi8sit2kMIp1bwIVOovPvMTQKGFmova4Xwb3G526y_PeLlflW1h69hQTIVcI67ACEkAC-byjDnnYIklA-B4GWcggEoFwQRTdRjAUpifA6HOlvnBbZZlUd6KXwEydxVS-eh1odwPjB2_sfbyy5HnLsvNdaniiZQwX7QbwLNT4F72LctYdHHM1QCrID6bgfgYp9Ij9CRX__XDEA" + + val algorithmSign = Algorithm.RSA512( + readPrivateKey(PRIVATE_KEY_FILE, "RSA").asNativeRSAPrivateKey, + ) as RSAAlgorithm + + val algorithmVerify = Algorithm.RSA512( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) as RSAAlgorithm + + val jwt: String = asJWT( + algorithmSign, + RS512Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + assertSignatureValue(jwt, expectedSignature) + + algorithmVerify.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoRSA512SigningWithBothKeys() { + val expectedSignature = + "THIPVYzNZ1Yo_dm0k1UELqV0txs3SzyMopCyHcLXOOdgYXF4MlGvBqu0CFvgSga72Sp5LpuC1Oesj40v_QDsp2GTGDeWnvvcv_eo-b0LPSpmT2h1Ibrmu-z70u2rKf28pkN-AJiMFqi8sit2kMIp1bwIVOovPvMTQKGFmova4Xwb3G526y_PeLlflW1h69hQTIVcI67ACEkAC-byjDnnYIklA-B4GWcggEoFwQRTdRjAUpifA6HOlvnBbZZlUd6KXwEydxVS-eh1odwPjB2_sfbyy5HnLsvNdaniiZQwX7QbwLNT4F72LctYdHHM1QCrID6bgfgYp9Ij9CRX__XDEA" + + val algorithm = Algorithm.RSA512( + readPublicKey( + PUBLIC_KEY_FILE, + "RSA", + ), + readPrivateKey( + PRIVATE_KEY_FILE, + "RSA", + ), + ) as RSAAlgorithm + + val jwt: String = asJWT( + algorithm, + RS512Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + assertSignatureValue(jwt, expectedSignature) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldDoRSA512SigningWithProvidedPrivateKey() { + val rsaPrivateKey = readPrivateKey(PRIVATE_KEY_FILE, "RSA") + val rsaPublicKey = readPublicKey(PUBLIC_KEY_FILE, "RSA") + val provider: RSAKeyProvider = mockk { + every { privateKey } returns rsaPrivateKey.asNativeRSAPrivateKey + every { getPublicKeyById(null) } returns rsaPublicKey.asNativeRSAPublicKey + } + val algorithm = Algorithm.RSA512(provider) as RSAAlgorithm + + val jwt: String = asJWT( + algorithm, + RS512Header, + auth0IssPayload, + ) + + assertSignaturePresent(jwt) + + algorithm.verify(JWT.decode(jwt)) + } + + @Test + fun shouldFailOnRSA512SigningWhenProvidedPrivateKeyIsNull() { + val provider: RSAKeyProvider = mockk { + every { privateKey } returns null + } + val algorithm = Algorithm.RSA512(provider) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA512withRSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldFailOnRSA512SigningWhenUsingPublicKey() { + val algorithm = Algorithm.RSA512( + readPublicKey(PUBLIC_KEY_FILE, "RSA").asNativeRSAPublicKey, + ) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA512withRSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnSignWhenSignatureAlgorithmDoesNotExists() { + val publicKey: RSAPublicKey? = mockk() + val privateKey: RSAPrivateKey? = mockk() + val provider: RSAKeyProvider = RSAAlgorithm.providerForKeys(publicKey?.asNativeRSAPublicKey, privateKey?.asNativeRSAPrivateKey) + val algorithm = RSAAlgorithm("some-alg", "some-algorithm", provider) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { createSignatureFor(any(), any(), any(), any()) } throws NoSuchAlgorithmException() + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnSignWhenThePrivateKeyIsInvalid() { + val publicKey: RSAPublicKey? = mockk() + val privateKey: RSAPrivateKey? = mockk() + val provider: RSAKeyProvider = RSAAlgorithm.providerForKeys(publicKey?.asNativeRSAPublicKey, privateKey?.asNativeRSAPrivateKey) + val algorithm = RSAAlgorithm("some-alg", "some-algorithm", provider) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { createSignatureFor(any(), any(), any(), any()) } throws InvalidKeyException() + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowOnSignWhenTheSignatureIsNotPrepared() { + val publicKey: RSAPublicKey? = mockk() + val privateKey: RSAPrivateKey? = mockk() + val provider: RSAKeyProvider = RSAAlgorithm.providerForKeys(publicKey?.asNativeRSAPublicKey, privateKey?.asNativeRSAPrivateKey) + val algorithm = RSAAlgorithm("some-alg", "some-algorithm", provider) + + mockkStatic("dev.sdkforge.jwt.decode.data.algorithm.Crypto_androidKt") + every { createSignatureFor(any(), any(), any(), any()) } throws SignatureException() + + val t = assertFailsWith { + algorithm.sign(ByteArray(0), ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: some-algorithm", t.message) + + assertIs(t.cause) + } + + @Test + fun shouldReturnNullSigningKeyIdIfCreatedWithDefaultProvider() { + val publicKey: RSAPublicKey = mockk() + val privateKey: RSAPrivateKey = mockk() + val provider: RSAKeyProvider = RSAAlgorithm.providerForKeys(publicKey.asNativeRSAPublicKey, privateKey.asNativeRSAPrivateKey) + val algorithm = RSAAlgorithm("some-alg", "some-algorithm", provider) as RSAAlgorithm + + assertNull(algorithm.signingKeyId) + } + + @Test + fun shouldReturnSigningKeyIdFromProvider() { + val provider: RSAKeyProvider = mockk { + every { privateKeyId } returns "keyId" + } + val algorithm = RSAAlgorithm("some-alg", "some-algorithm", provider) + + assertEquals("keyId", algorithm.signingKeyId) + } + + @Test + fun shouldBeEqualSignatureMethodResults() { + val privateKey = readPrivateKey( + PRIVATE_KEY_FILE, + "RSA", + ) + val publicKey = readPublicKey( + PUBLIC_KEY_FILE, + "RSA", + ) + + val algorithm = Algorithm.RSA256(publicKey, privateKey) as RSAAlgorithm + + val header = byteArrayOf(0x00, 0x01, 0x02) + val payload = byteArrayOf(0x04, 0x05, 0x06) + + val bout = java.io.ByteArrayOutputStream() + bout.write(header) + bout.write('.'.code) + bout.write(payload) + + assertContentEquals(algorithm.sign(header, payload), algorithm.sign(bout.toByteArray())) + } + + /** + * Test deprecated signing method error handling. + * + * @see {@linkplain .shouldFailOnRSA256SigningWhenProvidedPrivateKeyIsNull} + */ + @Test + fun shouldFailOnRSA256SigningWithDeprecatedMethodWhenProvidedPrivateKeyIsNull() { + val provider: RSAKeyProvider = mockk { + every { privateKey } returns null + } + val algorithm = Algorithm.RSA256(provider) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.sign(ByteArray(0)) + } + + assertEquals("The Token's Signature couldn't be generated when signing using the Algorithm: SHA256withRSA", t.message) + assertEquals("The given Private Key is null.", t.cause?.message) + + assertIs(t.cause) + } + + @Test + fun shouldThrowWhenSignatureNotValidBase64() { + val jwt = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCJ9.dxXF3MdsyW-AuvwJpaQtrZ33fAde9xWxpLIg9cO2tMLH2GSRNu+LAe61KsJusZhqZB9Iy7DvflcmRz-9OZndm6cj_ThGeJH2LLc90K83UEvvRPo8l85RrQb8PcanxCgIs2RcZOLygERizB3pr5icGkzR7R2y6zgNCjKJ5_NJ6EiZsGN6_nc2PRK_DbyY-Wn0QDxIxKoA5YgQJ9qafe7IN980pXvQv2Z62c3XR8dYuaXBqhthBj-AbaFHEpZapN-V-TmuLNzR2MCB6Xr7BYMuCaqWf_XU8og4XNe8f_8w9Wv5vvgqMM1KhqVpG5VdMJv4o_L4NoCROHhtUQSLRh2M9cA" + val algorithm = Algorithm.RSA256( + readPrivateKey( + PRIVATE_KEY_FILE, + "RSA", + ).asNativeRSAPrivateKey, + ) as RSAAlgorithm + + val t = assertFailsWith { + algorithm.verify(JWT.decode(jwt)) + } + + assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withRSA", t.message) + + assertIs(t.cause) + } + + @Suppress("ktlint:standard:property-naming") + companion object { + private const val PRIVATE_KEY_FILE = "src/androidUnitTest/resources/rsa-private.pem" + private const val PUBLIC_KEY_FILE = "src/androidUnitTest/resources/rsa-public.pem" + private const val INVALID_PUBLIC_KEY_FILE = "src/androidUnitTest/resources/rsa-public_invalid.pem" + + // Sign + private const val RS256Header = "eyJhbGciOiJSUzI1NiJ9" + private const val RS384Header = "eyJhbGciOiJSUzM4NCJ9" + private const val RS512Header = "eyJhbGciOiJSUzUxMiJ9" + private const val auth0IssPayload = "eyJpc3MiOiJhdXRoMCJ9" + } +} diff --git a/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/helpers.kt b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/helpers.kt new file mode 100644 index 0000000..a1170ee --- /dev/null +++ b/shared-data/src/androidUnitTest/kotlin/dev/sdkforge/jwt/decode/data/algorithm/helpers.kt @@ -0,0 +1,38 @@ +@file:Suppress("ktlint:standard:filename", "ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import java.util.regex.Pattern +import kotlin.io.encoding.Base64 +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.fail + +private val authHeaderPattern: Pattern = Pattern.compile("^([\\w-]+)\\.([\\w-]+)\\.([\\w-]+)") + +fun asJWT(algorithm: Algorithm, header: String, payload: String): String { + val signatureBytes = (algorithm as SigningAlgorithm).sign(header.toByteArray(), payload.toByteArray()) + val jwtSignature = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(signatureBytes) + + return "$header.$payload.$jwtSignature" +} + +fun assertSignatureValue( + jwt: String, + expectedSignature: String?, +) { + val jwtSignature = jwt.split('.').last() + + assertEquals(expectedSignature, jwtSignature) +} + +fun assertSignaturePresent(jwt: String) { + val matcher = authHeaderPattern.matcher(jwt) + + if (!matcher.find() || matcher.groupCount() < 3) { + fail("No signature present in $jwt") + } + + assertFalse { matcher.group(3).isNullOrBlank() } +} diff --git a/shared-data/src/androidUnitTest/resources/ec256-key-pair.pem b/shared-data/src/androidUnitTest/resources/ec256-key-pair.pem new file mode 100644 index 0000000..63e8673 --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/ec256-key-pair.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDxiRgJuF9X7wbgtc0qTv+CM8ej13zTGoimkuUVJBahBoAoGCCqGSM49 +AwEHoUQDQgAEQgb5npLHd0Bk61bNnjK632uwmBfrF7I8hoPgaOZjyhh+BrPDO6CL +6D/aW/yPObXXm7SpZogmRwGROcOA3yUleg== +-----END EC PRIVATE KEY----- diff --git a/shared-data/src/androidUnitTest/resources/ec256-key-private.pem b/shared-data/src/androidUnitTest/resources/ec256-key-private.pem new file mode 100644 index 0000000..756361a --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/ec256-key-private.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPGJGAm4X1fvBuC1z +SpO/4Izx6PXfNMaiKaS5RUkFqEGhRANCAARCBvmeksd3QGTrVs2eMrrfa7CYF+sX +sjyGg+Bo5mPKGH4Gs8M7oIvoP9pb/I85tdebtKlmiCZHAZE5w4DfJSV6 +-----END PRIVATE KEY----- diff --git a/shared-data/src/androidUnitTest/resources/ec256-key-public-invalid.pem b/shared-data/src/androidUnitTest/resources/ec256-key-public-invalid.pem new file mode 100644 index 0000000..8e4f6a9 --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/ec256-key-public-invalid.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEoBUyo8CQAFPeYPvv78ylh5MwFZjT +CLQeb042TjiMJxG+9DLFmRSMlBQ9T/RsLLc+PmpB1+7yPAR+oR5gZn3kJQ== +-----END PUBLIC KEY----- diff --git a/shared-data/src/androidUnitTest/resources/ec256-key-public.pem b/shared-data/src/androidUnitTest/resources/ec256-key-public.pem new file mode 100644 index 0000000..34401f7 --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/ec256-key-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEQgb5npLHd0Bk61bNnjK632uwmBfr +F7I8hoPgaOZjyhh+BrPDO6CL6D/aW/yPObXXm7SpZogmRwGROcOA3yUleg== +-----END PUBLIC KEY----- diff --git a/shared-data/src/androidUnitTest/resources/ec384-key-pair.pem b/shared-data/src/androidUnitTest/resources/ec384-key-pair.pem new file mode 100644 index 0000000..8c12408 --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/ec384-key-pair.pem @@ -0,0 +1,6 @@ +-----BEGIN EC PRIVATE KEY----- +MIGkAgEBBDCVWQsOJHjKD0I4cXOYJm4G8i5c7IMhFbxFq57OUlrTVmND43dvvNW1 +oQ6i6NiXEQWgBwYFK4EEACKhZANiAASezSGlAu4wAaJe4676mQM0F/5slI+Ekdpt +RJdfsQP9mNxe7RdzHgcSw7j/Wxa45nlnFnFrPPL4viJKOBRxMB1jjVA9my9PixxJ +GoB22qDQwFbP8ldmEp6abwdBsXNaePM= +-----END EC PRIVATE KEY----- diff --git a/shared-data/src/androidUnitTest/resources/ec384-key-private.pem b/shared-data/src/androidUnitTest/resources/ec384-key-private.pem new file mode 100644 index 0000000..9482bfa --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/ec384-key-private.pem @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCVWQsOJHjKD0I4cXOY +Jm4G8i5c7IMhFbxFq57OUlrTVmND43dvvNW1oQ6i6NiXEQWhZANiAASezSGlAu4w +AaJe4676mQM0F/5slI+EkdptRJdfsQP9mNxe7RdzHgcSw7j/Wxa45nlnFnFrPPL4 +viJKOBRxMB1jjVA9my9PixxJGoB22qDQwFbP8ldmEp6abwdBsXNaePM= +-----END PRIVATE KEY----- diff --git a/shared-data/src/androidUnitTest/resources/ec384-key-public-invalid.pem b/shared-data/src/androidUnitTest/resources/ec384-key-public-invalid.pem new file mode 100644 index 0000000..1f4b685 --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/ec384-key-public-invalid.pem @@ -0,0 +1,5 @@ +-----BEGIN PUBLIC KEY----- +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEJ0tamPyAJVUhDO1DlceSYCdA9WKVX6nO +K4VYetXvqmMKdyVkaoA4Gl02KoVLujiSSSAE6oK/Hf7x2fagaE9LgJdJxg07Ip+T +C6cgFi2HHDeXG7djB5Zl1TKA9/w/8iW5 +-----END PUBLIC KEY----- diff --git a/shared-data/src/androidUnitTest/resources/ec384-key-public.pem b/shared-data/src/androidUnitTest/resources/ec384-key-public.pem new file mode 100644 index 0000000..511596e --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/ec384-key-public.pem @@ -0,0 +1,5 @@ +-----BEGIN PUBLIC KEY----- +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEns0hpQLuMAGiXuOu+pkDNBf+bJSPhJHa +bUSXX7ED/ZjcXu0Xcx4HEsO4/1sWuOZ5ZxZxazzy+L4iSjgUcTAdY41QPZsvT4sc +SRqAdtqg0MBWz/JXZhKemm8HQbFzWnjz +-----END PUBLIC KEY----- diff --git a/shared-data/src/androidUnitTest/resources/ec512-key-pair.pem b/shared-data/src/androidUnitTest/resources/ec512-key-pair.pem new file mode 100644 index 0000000..1428daa --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/ec512-key-pair.pem @@ -0,0 +1,7 @@ +-----BEGIN EC PRIVATE KEY----- +MIHbAgEBBEHzl1DpZSQJ8YhCbN/uvo5SOu0BjDDX9Gub6zsBW6B2TxRzb5sBeQaW +VscDUZha4Xr1HEWpVtua9+nEQU/9Aq9Pl6AHBgUrgQQAI6GBiQOBhgAEAJhvCa6S +89ePqlLO6MRV9KQqHvdAITDAf/WRDcvCmfrrNuov+j4gQXO12ohIukPCHM9rYms8 +Eqciz3gaxVTxZD4CAA8i2k9H6ew9iSh1qXa1kLxiyzMBqmAmmg4u/SroD6OleG56 +SwZVbWx+KIINB6r/PQVciGX8FjwgR/mbLHotVZYD +-----END EC PRIVATE KEY----- diff --git a/shared-data/src/androidUnitTest/resources/ec512-key-private.pem b/shared-data/src/androidUnitTest/resources/ec512-key-private.pem new file mode 100644 index 0000000..bde9098 --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/ec512-key-private.pem @@ -0,0 +1,7 @@ +-----BEGIN PRIVATE KEY----- +MIHtAgEAMBAGByqGSM49AgEGBSuBBAAjBIHVMIHSAgEBBEHzl1DpZSQJ8YhCbN/u +vo5SOu0BjDDX9Gub6zsBW6B2TxRzb5sBeQaWVscDUZha4Xr1HEWpVtua9+nEQU/9 +Aq9Pl6GBiQOBhgAEAJhvCa6S89ePqlLO6MRV9KQqHvdAITDAf/WRDcvCmfrrNuov ++j4gQXO12ohIukPCHM9rYms8Eqciz3gaxVTxZD4CAA8i2k9H6ew9iSh1qXa1kLxi +yzMBqmAmmg4u/SroD6OleG56SwZVbWx+KIINB6r/PQVciGX8FjwgR/mbLHotVZYD +-----END PRIVATE KEY----- diff --git a/shared-data/src/androidUnitTest/resources/ec512-key-public-invalid.pem b/shared-data/src/androidUnitTest/resources/ec512-key-public-invalid.pem new file mode 100644 index 0000000..f976d7f --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/ec512-key-public-invalid.pem @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBgtBWt9rVtdQPdeoChvCXnXHvkg/F +41AAubIVd+3yTvLFmxuHuaor9CHl7FlT3532mUG+GG0EEw5UuYkFrg/ABBgAfuVf +wO1u0V0wJPkmpxcnZoojFaAOKcsBvUvDulbBh0HhAyd+nlfZquvV43uwFVpn2Cjb +Lx+/AT1PE6Muqy4JkGU= +-----END PUBLIC KEY----- diff --git a/shared-data/src/androidUnitTest/resources/ec512-key-public.pem b/shared-data/src/androidUnitTest/resources/ec512-key-public.pem new file mode 100644 index 0000000..360209a --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/ec512-key-public.pem @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQAmG8JrpLz14+qUs7oxFX0pCoe90Ah +MMB/9ZENy8KZ+us26i/6PiBBc7XaiEi6Q8Icz2tiazwSpyLPeBrFVPFkPgIADyLa +T0fp7D2JKHWpdrWQvGLLMwGqYCaaDi79KugPo6V4bnpLBlVtbH4ogg0Hqv89BVyI +ZfwWPCBH+Zssei1VlgM= +-----END PUBLIC KEY----- diff --git a/shared-data/src/androidUnitTest/resources/rsa-private.pem b/shared-data/src/androidUnitTest/resources/rsa-private.pem new file mode 100644 index 0000000..e451968 --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/rsa-private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC4ZtdaIrd1BPIJ +tfnF0TjIK5inQAXZ3XlCrUlJdP+XHwIRxdv1FsN12XyMYO/6ymLmo9ryoQeIrsXB +XYqlET3zfAY+diwCb0HEsVvhisthwMU4gZQu6TYW2s9LnXZB5rVtcBK69hcSlA2k +ZudMZWxZcj0L7KMfO2rIvaHw/qaVOE9j0T257Z8Kp2CLF9MUgX0ObhIsdumFRLaL +DvDUmBPr2zuh/34j2XmWwn1yjN/WvGtdfhXW79Ki1S40HcWnygHgLV8sESFKUxxQ +mKvPUTwDOIwLFL5WtE8Mz7N++kgmDcmWMCHc8kcOIu73Ta/3D4imW7VbKgHZo9+K +3ESFE3RjAgMBAAECggEBAJTEIyjMqUT24G2FKiS1TiHvShBkTlQdoR5xvpZMlYbN +tVWxUmrAGqCQ/TIjYnfpnzCDMLhdwT48Ab6mQJw69MfiXwc1PvwX1e9hRscGul36 +ryGPKIVQEBsQG/zc4/L2tZe8ut+qeaK7XuYrPp8bk/X1e9qK5m7j+JpKosNSLgJj +NIbYsBkG2Mlq671irKYj2hVZeaBQmWmZxK4fw0Istz2WfN5nUKUeJhTwpR+JLUg4 +ELYYoB7EO0Cej9UBG30hbgu4RyXA+VbptJ+H042K5QJROUbtnLWuuWosZ5ATldwO +u03dIXL0SH0ao5NcWBzxU4F2sBXZRGP2x/jiSLHcqoECgYEA4qD7mXQpu1b8XO8U +6abpKloJCatSAHzjgdR2eRDRx5PMvloipfwqA77pnbjTUFajqWQgOXsDTCjcdQui +wf5XAaWu+TeAVTytLQbSiTsBhrnoqVrr3RoyDQmdnwHT8aCMouOgcC5thP9vQ8Us +rVdjvRRbnJpg3BeSNimH+u9AHgsCgYEA0EzcbOltCWPHRAY7B3Ge/AKBjBQr86Kv +TdpTlxePBDVIlH+BM6oct2gaSZZoHbqPjbq5v7yf0fKVcXE4bSVgqfDJ/sZQu9Lp +PTeV7wkk0OsAMKk7QukEpPno5q6tOTNnFecpUhVLLlqbfqkB2baYYwLJR3IRzboJ +FQbLY93E8gkCgYB+zlC5VlQbbNqcLXJoImqItgQkkuW5PCgYdwcrSov2ve5r/Acz +FNt1aRdSlx4176R3nXyibQA1Vw+ztiUFowiP9WLoM3PtPZwwe4bGHmwGNHPIfwVG +m+exf9XgKKespYbLhc45tuC08DATnXoYK7O1EnUINSFJRS8cezSI5eHcbQKBgQDC +PgqHXZ2aVftqCc1eAaxaIRQhRmY+CgUjumaczRFGwVFveP9I6Gdi+Kca3DE3F9Pq +PKgejo0SwP5vDT+rOGHN14bmGJUMsX9i4MTmZUZ5s8s3lXh3ysfT+GAhTd6nKrIE +kM3Nh6HWFhROptfc6BNusRh1kX/cspDplK5x8EpJ0QKBgQDWFg6S2je0KtbV5PYe +RultUEe2C0jYMDQx+JYxbPmtcopvZQrFEur3WKVuLy5UAy7EBvwMnZwIG7OOohJb +vkSpADK6VPn9lbqq7O8cTedEHttm6otmLt8ZyEl3hZMaL3hbuRj6ysjmoFKx6CrX +rK0/Ikt5ybqUzKCMJZg2VKGTxg== +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/shared-data/src/androidUnitTest/resources/rsa-public.pem b/shared-data/src/androidUnitTest/resources/rsa-public.pem new file mode 100644 index 0000000..e8d6288 --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/rsa-public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuGbXWiK3dQTyCbX5xdE4 +yCuYp0AF2d15Qq1JSXT/lx8CEcXb9RbDddl8jGDv+spi5qPa8qEHiK7FwV2KpRE9 +83wGPnYsAm9BxLFb4YrLYcDFOIGULuk2FtrPS512Qea1bXASuvYXEpQNpGbnTGVs +WXI9C+yjHztqyL2h8P6mlThPY9E9ue2fCqdgixfTFIF9Dm4SLHbphUS2iw7w1JgT +69s7of9+I9l5lsJ9cozf1rxrXX4V1u/SotUuNB3Fp8oB4C1fLBEhSlMcUJirz1E8 +AziMCxS+VrRPDM+zfvpIJg3JljAh3PJHDiLu902v9w+Iplu1WyoB2aPfitxEhRN0 +YwIDAQAB +-----END PUBLIC KEY----- diff --git a/shared-data/src/androidUnitTest/resources/rsa-public_invalid.pem b/shared-data/src/androidUnitTest/resources/rsa-public_invalid.pem new file mode 100644 index 0000000..eab17cf --- /dev/null +++ b/shared-data/src/androidUnitTest/resources/rsa-public_invalid.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxzYuc22QSst/dS7geYYK +5l5kLxU0tayNdixkEQ17ix+CUcUbKIsnyftZxaCYT46rQtXgCaYRdJcbB3hmyrOa +vkhTpX79xJZnQmfuamMbZBqitvscxW9zRR9tBUL6vdi/0rpoUwPMEh8+Bw7CgYR0 +FK0DhWYBNDfe9HKcyZEv3max8Cdq18htxjEsdYO0iwzhtKRXomBWTdhD5ykd/fAC +VTr4+KEY+IeLvubHVmLUhbE5NgWXxrRpGasDqzKhCTmsa2Ysf712rl57SlH0Wz/M +r3F7aM9YpErzeYLrl0GhQr9BVJxOvXcVd4kmY+XkiCcrkyS1cnghnllh+LCwQu1s +YwIDAQAB +-----END PUBLIC KEY----- diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/EmptyClaim.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/EmptyClaim.kt new file mode 100644 index 0000000..84efdd1 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/EmptyClaim.kt @@ -0,0 +1,30 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Claim +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.serialization.DeserializationStrategy + +/** + * The EmptyClaim class is a Claim implementation that returns null when any of it's methods it's called. + */ +@OptIn(ExperimentalTime::class) +internal data object EmptyClaim : Claim { + override val isNull: Boolean = false + override val isMissing: Boolean = true + override fun asBoolean(): Boolean? = null + override fun asInt(): Int? = null + override fun asLong(): Long? = null + override fun asDouble(): Double? = null + override fun asString(): String? = null + override fun asInstant(): Instant? = null + + @Throws(JWTDecodeException::class) + override fun asList(deserializer: DeserializationStrategy): List = emptyList() + + @Throws(JWTDecodeException::class) + override fun asObject(deserializer: DeserializationStrategy): T? = null +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/ExpectedCheckHolder.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/ExpectedCheckHolder.kt new file mode 100644 index 0000000..a090c2e --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/ExpectedCheckHolder.kt @@ -0,0 +1,27 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Claim +import dev.sdkforge.jwt.decode.domain.DecodedJWT + +/** + * This holds the checks that are run to verify a JWT. + */ +internal interface ExpectedCheckHolder { + /** + * The claim name that will be checked. + * + * @return the claim name + */ + val claimName: String + + /** + * The verification that will be run. + * + * @param claim the claim for which verification is done + * @param decodedJWT the JWT on which verification is done + * @return whether the verification passed or not + */ + fun verify(claim: Claim, decodedJWT: DecodedJWT): Boolean +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWT.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWT.kt new file mode 100644 index 0000000..3c9db73 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWT.kt @@ -0,0 +1,46 @@ +@file:Suppress("ktlint:standard:function-expression-body", "ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.DecodedJWT +import dev.sdkforge.jwt.decode.domain.Verification +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException + +data object JWT { + /** + * Decode a given Json Web Token. + * + * Note that this method **doesn't verify the token's signature!** + * Use it only if you trust the token or if you have already verified it. + * + * @param token with jwt format as string. + * @return a decoded JWT. + * @throws dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException if any part of the token contained an invalid jwt + * or JSON format of each of the jwt parts. + */ + @Throws(JWTDecodeException::class) + fun decode(token: String, parser: dev.sdkforge.jwt.decode.domain.JWTParser = JWTParser): DecodedJWT { + return JWTDecoder(parser, token) + } + + /** + * Returns a [Verification] builder with the algorithm to be used to validate token signature. + * + * @param algorithm that will be used to verify the token's signature. + * @return [Verification] builder + * @throws IllegalArgumentException if the provided algorithm is null. + */ + fun require(algorithm: Algorithm): Verification { + return JWTVerifier.init(algorithm) + } + + /** + * Returns a Json Web Token builder used to create and sign tokens. + * + * @return a token builder. + */ + internal fun create(): JWTCreator.Builder { + return JWTCreator.init() + } +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTCreator.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTCreator.kt new file mode 100644 index 0000000..5a3300d --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTCreator.kt @@ -0,0 +1,605 @@ +@file:Suppress("ktlint:standard:class-signature", "ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.data.algorithm.SigningAlgorithm +import dev.sdkforge.jwt.decode.domain.Claim +import dev.sdkforge.jwt.decode.domain.Header +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.JWTCreationException +import dev.sdkforge.jwt.decode.domain.exception.SignatureGenerationException +import kotlin.io.encoding.Base64 +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.datetime.LocalDate +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** + * The JWTCreator class holds the sign method to generate a complete JWT (with Signature) + * from a given Header and Payload content. + */ +@OptIn(ExperimentalTime::class) +internal class JWTCreator private constructor( + private val algorithm: Algorithm, + headerClaims: Map?, + payloadClaims: Map?, +) { + private val headerJson: String + private val payloadJson: String + + init { + try { + headerJson = JWTParser.JSON.encodeToString(headerClaims) + payloadJson = JWTParser.JSON.encodeToString(payloadClaims) + } catch (e: Throwable) { + throw JWTCreationException("Some of the Claims couldn't be converted to a valid JSON format.", e) + } + } + + /** + * The Builder class holds the Claims that defines the JWT to be created. + */ + class Builder internal constructor() { + private val payloadClaims = mutableMapOf() + private val headerClaims = mutableMapOf() + + /** + * Add specific Claims to set as the Header. + * If provided map is null then nothing is changed + * + * @param headerClaims the values to use as Claims in the token's Header. + * @return this same Builder instance. + */ + fun withHeader(headerClaims: Map?): Builder { + if (headerClaims == null) { + return this + } + + for (entry in headerClaims.entries) { + if (entry.value == null) { + this.headerClaims[entry.key] = JsonNull + } else { + this.headerClaims[entry.key] = entry.value.asJsonPrimitive + } + } + + return this + } + + /** + * Add specific Claims to set as the Header. + * If provided json is null then nothing is changed + * + * @param headerClaimsJson the values to use as Claims in the token's Header. + * @return this same Builder instance. + * @throws IllegalArgumentException if json value has invalid structure + */ + @Throws(IllegalArgumentException::class) + fun withHeader(headerClaimsJson: String?): Builder { + if (headerClaimsJson == null) { + return this + } + + try { + val headerClaims: Map? = JWTParser.JSON.decodeFromString(headerClaimsJson) + return withHeader(headerClaims) + } catch (e: Throwable) { + throw IllegalArgumentException("Invalid header JSON", e) + } + } + + /** + * Add a specific Key Id ("kid") claim to the Header. + * If the [Algorithm] used to sign this token was instantiated with a KeyProvider, + * the 'kid' value will be taken from that provider and this one will be ignored. + * + * @param keyId the Key Id value. + * @return this same Builder instance. + */ + fun withKeyId(keyId: String?): Builder = apply { + this.headerClaims[Header.Companion.Params.KEY_ID] = keyId.asJsonPrimitive + } + + /** + * Add a specific Issuer ("iss") claim to the Payload. + * + * @param issuer the Issuer value. + * @return this same Builder instance. + */ + fun withIssuer(issuer: String?): Builder = apply { + addClaim(Claim.Companion.Registered.ISSUER, issuer) + } + + /** + * Add a specific Subject ("sub") claim to the Payload. + * + * @param subject the Subject value. + * @return this same Builder instance. + */ + fun withSubject(subject: String?): Builder = apply { + addClaim(Claim.Companion.Registered.SUBJECT, subject) + } + + /** + * Add a specific Audience ("aud") claim to the Payload. + * + * @param audience the Audience value. + * @return this same Builder instance. + */ + fun withAudience(vararg audience: String?): Builder = apply { + if (audience.asList().size == 1) { + addClaim(Claim.Companion.Registered.AUDIENCE, audience[0]) + return this + } + + addClaim(Claim.Companion.Registered.AUDIENCE, audience) + } + + /** + * Add a specific Expires At ("exp") claim to the payload. The claim will be written as seconds since the epoch. + * Milliseconds will be truncated by rounding down to the nearest second. + * + * @param expiresAt the Expires At value. + * @return this same Builder instance. + */ + fun withExpiresAt(expiresAt: LocalDate?): Builder = apply { + addClaim(Claim.Companion.Registered.EXPIRES_AT, expiresAt) + } + + /** + * Add a specific Expires At ("exp") claim to the payload. The claim will be written as seconds since the epoch; + * Milliseconds will be truncated by rounding down to the nearest second. + * + * @param expiresAt the Expires At value. + * @return this same Builder instance. + */ + fun withExpiresAt(expiresAt: Instant?): Builder = apply { + addClaim(Claim.Companion.Registered.EXPIRES_AT, expiresAt) + } + + /** + * Add a specific Not Before ("nbf") claim to the Payload. The claim will be written as seconds since the epoch; + * Milliseconds will be truncated by rounding down to the nearest second. + * + * @param notBefore the Not Before value. + * @return this same Builder instance. + */ + fun withNotBefore(notBefore: LocalDate?): Builder = apply { + addClaim(Claim.Companion.Registered.NOT_BEFORE, notBefore) + } + + /** + * Add a specific Not Before ("nbf") claim to the Payload. The claim will be written as seconds since the epoch; + * Milliseconds will be truncated by rounding down to the nearest second. + * + * @param notBefore the Not Before value. + * @return this same Builder instance. + */ + fun withNotBefore(notBefore: Instant?): Builder = apply { + addClaim(Claim.Companion.Registered.NOT_BEFORE, notBefore) + } + + /** + * Add a specific Issued At ("iat") claim to the Payload. The claim will be written as seconds since the epoch; + * Milliseconds will be truncated by rounding down to the nearest second. + * + * @param issuedAt the Issued At value. + * @return this same Builder instance. + */ + fun withIssuedAt(issuedAt: LocalDate?): Builder = apply { + addClaim(Claim.Companion.Registered.ISSUED_AT, issuedAt) + } + + /** + * Add a specific Issued At ("iat") claim to the Payload. The claim will be written as seconds since the epoch; + * Milliseconds will be truncated by rounding down to the nearest second. + * + * @param issuedAt the Issued At value. + * @return this same Builder instance. + */ + fun withIssuedAt(issuedAt: Instant?): Builder = apply { + addClaim(Claim.Companion.Registered.ISSUED_AT, issuedAt) + } + + /** + * Add a specific JWT Id ("jti") claim to the Payload. + * + * @param jwtId the Token Id value. + * @return this same Builder instance. + */ + fun withJWTId(jwtId: String?): Builder = apply { + addClaim(Claim.Companion.Registered.JWT_ID, jwtId) + } + + /** + * Add a custom Claim value. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: Boolean?): Builder = apply { + addClaim(name, value) + } + + /** + * Add a custom Claim value. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: Int?): Builder = apply { + addClaim(name, value) + } + + /** + * Add a custom Claim value. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: Long?): Builder = apply { + addClaim(name, value) + } + + /** + * Add a custom Claim value. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: Double?): Builder = apply { + addClaim(name, value) + } + + /** + * Add a custom Claim value. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: String?): Builder = apply { + addClaim(name, value) + } + + /** + * Add a custom Claim value. The claim will be written as seconds since the epoch. + * Milliseconds will be truncated by rounding down to the nearest second. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: LocalDate?): Builder = apply { + addClaim(name, value) + } + + /** + * Add a custom Claim value. The claim will be written as seconds since the epoch. + * Milliseconds will be truncated by rounding down to the nearest second. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: Instant?): Builder = apply { + addClaim(name, value) + } + + /** + * Add a custom Map Claim with the given items. + * + * + * Accepted nested types are [Map] and [List] with basic types + * [Boolean], [Int], [Long], [Double], + * [String] and [Instant]. [Map]s cannot contain null keys or values. + * [List]s can contain null elements. + * + * @param name the Claim's name. + * @param map the Claim's key-values. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null, or if the map contents does not validate. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, map: Map?): Builder = apply { + // validate map contents + require(!(map != null && !validateClaim(map))) { + "Expected map containing Map, List, Boolean, Int, Long, Double, String and Instant" + } + addClaim(name, map) + } + + /** + * Add a custom List Claim with the given items. + * + * Accepted nested types are [Map] and [List] with basic types + * [Boolean], [Int], [Long], [Double], + * [String] and [Instant]. [Map]s cannot contain null keys or values. + * [List]s can contain null elements. + * + * @param name the Claim's name. + * @param list the Claim's list of values. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null, or if the list contents does not validate. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, list: List<*>?): Builder = apply { + // validate list contents + require(!(list != null && !validateClaim(list))) { + "Expected list containing Map, List, Boolean, Int, Long, Double, String and Date" + } + addClaim(name, list) + } + + /** + * Add a custom claim with null value. + * + * @param name the Claim's name. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null + */ + @Throws(IllegalArgumentException::class) + fun withNullClaim(name: String): Builder = apply { + addClaim(name, null) + } + + /** + * Add a custom Array Claim with the given items. + * + * @param name the Claim's name. + * @param items the Claim's value. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null. + */ + @Throws(IllegalArgumentException::class) + fun withArrayClaim(name: String, items: Array?): Builder = apply { + addClaim(name, items) + } + + /** + * Add a custom Array Claim with the given items. + * + * @param name the Claim's name. + * @param items the Claim's value. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null. + */ + @Throws(IllegalArgumentException::class) + fun withArrayClaim(name: String, items: Array?): Builder = apply { + addClaim(name, items) + } + + /** + * Add a custom Array Claim with the given items. + * + * @param name the Claim's name. + * @param items the Claim's value. + * @return this same Builder instance. + * @throws IllegalArgumentException if the name is null + */ + @Throws(IllegalArgumentException::class) + fun withArrayClaim(name: String, items: Array?): Builder = apply { + addClaim(name, items) + } + + /** + * Add specific Claims to set as the Payload. If the provided map is null then + * nothing is changed. + * + * Accepted types are [Map] and [List] with basic types + * [Boolean], [Int], [Long], [Double], + * [String] and [Instant]. + * [Map]s and [List]s can contain null elements. + * + * If any of the claims are invalid, none will be added. + * + * @param payloadClaims the values to use as Claims in the token's payload. + * @return this same Builder instance. + * @throws IllegalArgumentException if any of the claim keys or null, + * or if the values are not of a supported type. + */ + @Throws(IllegalArgumentException::class) + fun withPayload(payloadClaims: Map): Builder = apply { + require(validatePayload(payloadClaims)) { + "Claim values must only be of types Map, List, Boolean, Int, Long, Double, String, Instant, and Null" + } + + // add claims only after validating all claims so as not to corrupt the claims map of this builder + for (entry in payloadClaims.entries) { + addClaim(entry.key, entry.value) + } + } + + /** + * Add specific Claims to set as the Payload. If the provided json is null then + * nothing is changed. + * + * If any of the claims are invalid, none will be added. + * + * @param payloadClaimsJson the values to use as Claims in the token's payload. + * @return this same Builder instance. + * @throws IllegalArgumentException if any of the claim keys or null, + * or if the values are not of a supported type, + * or if json value has invalid structure. + */ + @Throws(IllegalArgumentException::class) + fun withPayload(payloadClaimsJson: String?): Builder = apply { + if (payloadClaimsJson == null) { + return@apply + } + + try { + val payloadClaims: Map = JWTParser.JSON.decodeFromString(payloadClaimsJson) + return withPayload(payloadClaims) + } catch (e: Throwable) { + throw IllegalArgumentException("Invalid payload JSON", e) + } + } + + private fun validatePayload(payload: Map): Boolean { + for (entry in payload.entries) { + val value: Any? = entry.value + if (value is List<*> && !validateClaim((value as List<*>?)!!)) { + return false + } else if (value is Map<*, *> && !validateClaim((value as Map<*, *>?)!!)) { + return false + } else if (!isSupportedType(value)) { + return false + } + } + return true + } + + /** + * Creates a new JWT and signs it with the given algorithm. + * + * @param algorithm used to sign the JWT + * @return a new JWT token + * @throws IllegalArgumentException if the provided algorithm is null. + * @throws dev.sdkforge.jwt.decode.domain.exception.JWTCreationException if the claims could not be converted to a valid JSON + * or there was a problem with the signing key. + */ + @Throws(IllegalArgumentException::class, JWTCreationException::class) + fun sign(algorithm: Algorithm): String { + headerClaims[Header.Companion.Params.ALGORITHM] = algorithm.name.asJsonPrimitive + + if (!headerClaims.containsKey(Header.Companion.Params.TYPE)) { + headerClaims[Header.Companion.Params.TYPE] = "JWT".asJsonPrimitive + } + + val signingKeyId = (algorithm as? SigningAlgorithm)?.signingKeyId + + if (signingKeyId != null) { + withKeyId(signingKeyId) + } + + return JWTCreator(algorithm, headerClaims, payloadClaims).sign() + } + + private fun addClaim(name: String, value: Any?) { + payloadClaims[name] = value.asJsonPrimitive + } + + companion object { + private fun validateClaim(map: Map<*, *>): Boolean { + // do not accept null values in maps + for (entry in map.entries) { + val value: Any? = entry.value + if (!isSupportedType(value)) { + return false + } + + if (entry.key !is String) { + return false + } + } + return true + } + + private fun validateClaim(list: List<*>): Boolean { + // accept null values in list + for (`object` in list) { + if (!isSupportedType(`object`)) { + return false + } + } + return true + } + + private fun isSupportedType(value: Any?): Boolean = when (value) { + is List<*> -> validateClaim(value) + is Map<*, *> -> validateClaim(value) + is Array<*> -> value.all(::isSupportedType) + else -> isBasicType(value) + } + + private fun isBasicType(value: Any?): Boolean = when (value) { + null -> true + is JsonElement -> true + is Instant -> true + is Boolean, + is Int, + is Long, + is Double, + is String, + -> true + + else -> false + } + } + } + + @Throws(SignatureGenerationException::class) + private fun sign(): String { + val header: String = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(headerJson.encodeToByteArray()) + val payload: String = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(payloadJson.encodeToByteArray()) + + if (algorithm !is SigningAlgorithm) { + throw SignatureGenerationException(algorithm, IllegalArgumentException("Algorithm must implement SigningAlgorithm")) + } + + val signatureBytes = algorithm.sign( + headerBytes = header.encodeToByteArray(), + payloadBytes = payload.encodeToByteArray(), + ) + val signature: String = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(signatureBytes) + + return "$header.$payload.$signature" + } + + companion object { + /** + * Initialize a JWTCreator instance. + * + * @return a JWTCreator.Builder instance to configure. + */ + fun init(): Builder = Builder() + } +} + +@OptIn(ExperimentalTime::class) +private val Any?.asJsonPrimitive: JsonElement + get() = when (this) { + null -> JsonNull + is JsonPrimitive -> this + is JsonObject -> this + is String? -> JsonPrimitive(this) + is Number? -> JsonPrimitive(this) + is Boolean? -> JsonPrimitive(this) + is Instant -> JsonPrimitive(this.epochSeconds) + is Array<*> -> JsonArray(this.map { it.asJsonPrimitive }) + is List<*> -> JsonArray(this.map { it.asJsonPrimitive }) + is Map<*, *> -> buildJsonObject { + this@asJsonPrimitive.forEach { (key, value) -> + if (key !is String) { + throw IllegalArgumentException("Map keys must be Strings") + } + put(key, value.asJsonPrimitive) + } + } + + else -> throw IllegalArgumentException("Unsupported type: ${this::class.simpleName}") + } diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTDecoder.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTDecoder.kt new file mode 100644 index 0000000..5f2c1c4 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTDecoder.kt @@ -0,0 +1,62 @@ +@file:Suppress("ktlint:standard:function-expression-body", "ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Claim +import dev.sdkforge.jwt.decode.domain.DecodedJWT +import dev.sdkforge.jwt.decode.domain.Header +import dev.sdkforge.jwt.decode.domain.Payload +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException +import kotlin.io.encoding.Base64 +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * The JWTDecoder class holds the decode method to parse a given JWT token into it's JWT representation. + */ +@OptIn(ExperimentalTime::class) +internal class JWTDecoder(parser: dev.sdkforge.jwt.decode.domain.JWTParser, jwt: String) : DecodedJWT { + private val parts: Array = TokenUtils.splitToken(jwt) + + internal val jwtHeader: Header + internal val jwtPayload: Payload + + constructor(jwt: String) : this(parser = JWTParser, jwt = jwt) + + init { + val headerJson: String? + val payloadJson: String? + try { + headerJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).decode(parts[0]).decodeToString() + payloadJson = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).decode(parts[1]).decodeToString() + } catch (e: NullPointerException) { + throw JWTDecodeException("The UTF-8 Charset isn't initialized.", e) + } catch (e: IllegalArgumentException) { + throw JWTDecodeException("The input is not a valid base 64 encoded string.", e) + } + jwtHeader = parser.parseHeader(headerJson) + jwtPayload = parser.parsePayload(payloadJson) + } + + override val algorithm: String? get() = jwtHeader.algorithm + override val type: String? get() = jwtHeader.type + override val contentType: String? get() = jwtHeader.contentType + override val keyId: String? get() = jwtHeader.keyId + override fun getHeaderClaim(name: String): Claim = jwtHeader.getHeaderClaim(name) + override val issuer: String? get() = jwtPayload.issuer + override val subject: String? get() = jwtPayload.subject + override val audience: List? get() = jwtPayload.audience + override val expiresAt: Instant? get() = jwtPayload.expiresAt + override val notBefore: Instant? get() = jwtPayload.notBefore + override val issuedAt: Instant? get() = jwtPayload.issuedAt + override val id: String? get() = jwtPayload.id + override fun getClaim(name: String): Claim = jwtPayload.getClaim(name) + + override val header: String get() = parts[0] + override val payload: String get() = parts[1] + override val signature: String get() = parts[2] + + override val token: String get() = "${parts[0]}.${parts[1]}.${parts[2]}" + + override fun toString(): String = token +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTHeader.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTHeader.kt new file mode 100644 index 0000000..7cb90b5 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTHeader.kt @@ -0,0 +1,55 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Claim +import dev.sdkforge.jwt.decode.domain.Header +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.mapSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** + * The JWTHeader class implements the Header interface. + */ +internal class JWTHeader( + override val algorithm: String? = null, + override val type: String? = null, + override val contentType: String? = null, + override val keyId: String? = null, + internal val tree: Map = emptyMap(), +) : Header { + + override fun getHeaderClaim(name: String): Claim { + // TODO: add registered claims return? + return tree[name]?.run { JsonClaim(this) } ?: EmptyClaim + } +} + +internal object JWTHeaderSerializerDeserializationStrategy : DeserializationStrategy { + @OptIn(ExperimentalSerializationApi::class) + override val descriptor: SerialDescriptor = mapSerialDescriptor() + + override fun deserialize(decoder: Decoder): JWTHeader { + val tree = decoder.decodeSerializableValue( + deserializer = MapSerializer( + keySerializer = String.serializer(), + valueSerializer = JsonElement.serializer(), + ), + ) + + return JWTHeader( + algorithm = tree[Header.Companion.Params.ALGORITHM]?.jsonPrimitive?.contentOrNull, + type = tree[Header.Companion.Params.TYPE]?.jsonPrimitive?.contentOrNull, + contentType = tree[Header.Companion.Params.CONTENT_TYPE]?.jsonPrimitive?.contentOrNull, + keyId = tree[Header.Companion.Params.KEY_ID]?.jsonPrimitive?.contentOrNull, + tree = tree, + ) + } +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTParser.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTParser.kt new file mode 100644 index 0000000..89b66d8 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTParser.kt @@ -0,0 +1,28 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Header +import dev.sdkforge.jwt.decode.domain.Payload +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException +import kotlinx.serialization.json.Json + +/** + * This class helps in decoding the Header and Payload of the JWT. + */ +internal object JWTParser : dev.sdkforge.jwt.decode.domain.JWTParser { + + internal val JSON = Json { + ignoreUnknownKeys = true + } + + @Throws(JWTDecodeException::class) + override fun parsePayload(json: String): Payload = runCatching { + JSON.decodeFromString(deserializer = JWTPayloadDeserializationStrategy, string = json) + }.getOrElse { throw JWTDecodeException(it.message, it) } + + @Throws(JWTDecodeException::class) + override fun parseHeader(json: String): Header = runCatching { + JSON.decodeFromString(deserializer = JWTHeaderSerializerDeserializationStrategy, string = json) + }.getOrElse { throw JWTDecodeException(it.message, it) } +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTPayload.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTPayload.kt new file mode 100644 index 0000000..9c82d5a --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTPayload.kt @@ -0,0 +1,79 @@ +@file:Suppress("ktlint:standard:function-signature", "ktlint:standard:function-expression-body") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Claim +import dev.sdkforge.jwt.decode.domain.Payload +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.mapSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +@OptIn(ExperimentalTime::class) +internal data class JWTPayload( + override val issuer: String? = null, + override val subject: String? = null, + override val expiresAt: Instant? = null, + override val notBefore: Instant? = null, + override val issuedAt: Instant? = null, + override val id: String? = null, + override val audience: List? = null, + internal val tree: Map = emptyMap(), +) : Payload { + + override fun getClaim(name: String): Claim { + return this.tree[name]?.run { JsonClaim(this) } ?: EmptyClaim + } +} + +internal data object JWTPayloadDeserializationStrategy : DeserializationStrategy { + override val descriptor: SerialDescriptor = mapSerialDescriptor() + + @OptIn(ExperimentalTime::class) + override fun deserialize(decoder: Decoder): JWTPayload { + val tree = decoder.decodeSerializableValue( + deserializer = MapSerializer( + keySerializer = String.serializer(), + valueSerializer = JsonElement.serializer(), + ), + ) + + return JWTPayload( + issuer = tree[Claim.Companion.Registered.ISSUER]?.jsonPrimitive?.contentOrNull, + subject = tree[Claim.Companion.Registered.SUBJECT]?.jsonPrimitive?.contentOrNull, + expiresAt = tree[Claim.Companion.Registered.EXPIRES_AT]?.asInstant(), + notBefore = tree[Claim.Companion.Registered.NOT_BEFORE]?.asInstant(), + issuedAt = tree[Claim.Companion.Registered.ISSUED_AT]?.asInstant(), + id = tree[Claim.Companion.Registered.JWT_ID]?.jsonPrimitive?.contentOrNull, + audience = tree[Claim.Companion.Registered.AUDIENCE]?.asAudience(), + tree = tree, + ) + } +} + +@OptIn(ExperimentalTime::class) +private fun JsonElement.asInstant(): Instant? { + return when (val value = this) { + is JsonPrimitive -> value.longOrNull?.run { Instant.fromEpochSeconds(this) } + else -> null + } +} + +@OptIn(ExperimentalTime::class) +private fun JsonElement.asAudience(): List? { + return when (val value = this) { + is JsonPrimitive -> listOfNotNull(value.contentOrNull) + is JsonArray -> value.mapNotNull { it.jsonPrimitive.contentOrNull } + else -> null + } +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTVerifier.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTVerifier.kt new file mode 100644 index 0000000..00f097e --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JWTVerifier.kt @@ -0,0 +1,527 @@ +@file:Suppress("ktlint:standard:class-signature", "ktlint:standard:function-signature", "ktlint:standard:function-expression-body") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.data.algorithm.VerificationAlgorithm +import dev.sdkforge.jwt.decode.domain.Claim +import dev.sdkforge.jwt.decode.domain.DecodedJWT +import dev.sdkforge.jwt.decode.domain.Verification +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.AlgorithmMismatchException +import dev.sdkforge.jwt.decode.domain.exception.IncorrectClaimException +import dev.sdkforge.jwt.decode.domain.exception.InvalidClaimException +import dev.sdkforge.jwt.decode.domain.exception.JWTVerificationException +import dev.sdkforge.jwt.decode.domain.exception.MissingClaimException +import dev.sdkforge.jwt.decode.domain.exception.TokenExpiredException +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long + +/** + * The JWTVerifier class holds the verify method to assert that a given Token has not only a proper JWT format, + * but also its signature matches. + * + * @see dev.sdkforge.jwt.decode.domain.JWTVerifier + */ +internal class JWTVerifier internal constructor( + private val algorithm: Algorithm, + internal val expectedChecks: List, +) : dev.sdkforge.jwt.decode.domain.JWTVerifier { + + private val parser: JWTParser = JWTParser + + /** + * [Verification] implementation that accepts all the expected Claim values for verification, and + * builds a [dev.sdkforge.jwt.decode.domain.JWTVerifier] used to verify a JWT's signature and expected claims. + * + * Note that this class is **not** thread-safe. Calling [.build] returns an instance of + * [dev.sdkforge.jwt.decode.domain.JWTVerifier] which can be reused. + */ + @OptIn(ExperimentalTime::class) + internal class BaseVerification internal constructor(private val algorithm: Algorithm) : Verification { + private val expectedChecks = mutableListOf() + private var defaultLeeway: Long = 0 + private val customLeewayMap = mutableMapOf() + private var ignoreIssuedAt = false + private var instant: Instant? = null + + override fun withIssuer(vararg issuer: String): Verification = apply { + val value: List = issuer.asList() + + addCheck(Claim.Companion.Registered.ISSUER) { claim, decodedJWT -> + if (verifyNull(claim, value) || value.isEmpty()) { + return@addCheck true + } else if (!value.contains(claim.asString())) { + throw IncorrectClaimException( + "The Claim 'iss' value doesn't match the required issuer.", + Claim.Companion.Registered.ISSUER, + claim, + ) + } + true + } + } + + override fun withSubject(subject: String): Verification = apply { + addCheck(Claim.Companion.Registered.SUBJECT) { claim, decodedJWT -> + verifyNull(claim, subject) || subject == claim.asString() + } + } + + override fun withAudience(vararg audience: String): Verification = apply { + val value: List? = audience.asList().ifEmpty { null } + + addCheck(Claim.Companion.Registered.AUDIENCE) { claim, decodedJWT -> + if (verifyNull(claim, value)) { + return@addCheck true + } + if (!assertValidAudienceClaim(decodedJWT.audience, value, true)) { + throw IncorrectClaimException( + message = "The Claim 'aud' value doesn't contain the required audience.", + claimName = Claim.Companion.Registered.AUDIENCE, + claim = claim, + ) + } + true + } + } + + override fun withAnyOfAudience(vararg audience: String): Verification = apply { + val value: List? = audience.asList().ifEmpty { null } + + addCheck(Claim.Companion.Registered.AUDIENCE) { claim, decodedJWT -> + if (verifyNull(claim, value)) { + return@addCheck true + } + if (!assertValidAudienceClaim(decodedJWT.audience, value, false)) { + throw IncorrectClaimException( + message = "The Claim 'aud' value doesn't contain the required audience.", + claimName = Claim.Companion.Registered.AUDIENCE, + claim = claim, + ) + } + true + } + } + + @Throws(IllegalArgumentException::class) + override fun acceptLeeway(leeway: Long): Verification = apply { + assertPositive(leeway) + this.defaultLeeway = leeway + } + + @Throws(IllegalArgumentException::class) + override fun acceptExpiresAt(leeway: Long): Verification = apply { + assertPositive(leeway) + customLeewayMap[Claim.Companion.Registered.EXPIRES_AT] = leeway + } + + @Throws(IllegalArgumentException::class) + override fun acceptNotBefore(leeway: Long): Verification = apply { + assertPositive(leeway) + customLeewayMap[Claim.Companion.Registered.NOT_BEFORE] = leeway + } + + @Throws(IllegalArgumentException::class) + override fun acceptIssuedAt(leeway: Long): Verification = apply { + assertPositive(leeway) + customLeewayMap[Claim.Companion.Registered.ISSUED_AT] = leeway + } + + override fun ignoreIssuedAt(): Verification = apply { + this.ignoreIssuedAt = true + } + + override fun withJWTId(jwtId: String): Verification = apply { + addCheck(Claim.Companion.Registered.JWT_ID) { claim, decodedJWT -> + verifyNull(claim, jwtId) || jwtId == claim.asString() + } + } + + @Throws(IllegalArgumentException::class) + override fun withClaimPresence(name: String): Verification = apply { + // since addCheck already checks presence, we just return true + withClaim(name) { claim, decodedJWT -> true } + } + + @Throws(IllegalArgumentException::class) + override fun withNullClaim(name: String): Verification = apply { + withClaim(name) { claim, decodedJWT -> claim.isNull } + } + + @Throws(IllegalArgumentException::class) + override fun withClaim(name: String, value: Boolean): Verification = apply { + addCheck(name) { claim, decodedJWT -> + verifyNull(claim, value) || value == claim.asBoolean() + } + } + + @Throws(IllegalArgumentException::class) + override fun withClaim(name: String, value: Int): Verification = apply { + addCheck(name) { claim, decodedJWT -> + verifyNull(claim, value) || value == claim.asInt() + } + } + + @Throws(IllegalArgumentException::class) + override fun withClaim(name: String, value: Long): Verification = apply { + addCheck(name) { claim, decodedJWT -> + verifyNull(claim, value) || value == claim.asLong() + } + } + + @Throws(IllegalArgumentException::class) + override fun withClaim(name: String, value: Double): Verification = apply { + addCheck(name) { claim, decodedJWT -> + verifyNull(claim, value) || value == claim.asDouble() + } + } + + @Throws(IllegalArgumentException::class) + override fun withClaim(name: String, value: String): Verification = apply { + addCheck(name) { claim, decodedJWT -> + verifyNull(claim, value) || value == claim.asString() + } + } + + @Throws(IllegalArgumentException::class) + override fun withClaim(name: String, value: LocalDate): Verification = apply { + return withClaim(name, value.atTime(hour = 0, minute = 0).toInstant(TimeZone.UTC)) + } + + @Throws(IllegalArgumentException::class) + override fun withClaim(name: String, value: Instant): Verification = apply { + // Since date-time claims are serialized as epoch seconds, + // we need to compare them with only seconds-granularity + addCheck(name) { claim, decodedJWT -> + verifyNull(claim, value) || Instant.fromEpochSeconds(value.epochSeconds) == claim.asInstant() + } + } + + @Throws(IllegalArgumentException::class) + override fun withClaim(name: String, predicate: Function2): Verification = apply { + addCheck(name) { claim, decodedJWT -> + verifyNull(claim, predicate) || predicate.invoke(claim, decodedJWT) + } + } + + @Throws(IllegalArgumentException::class) + override fun withArrayClaim(name: String, vararg items: String): Verification = apply { + addCheck(name) { claim, decodedJWT -> + verifyNull(claim, items) || assertValidCollectionClaim(claim, items) { it.jsonPrimitive.content } + } + } + + @Throws(IllegalArgumentException::class) + override fun withArrayClaim(name: String, vararg items: Int): Verification = apply { + addCheck(name) { claim, decodedJWT -> + verifyNull(claim, items) || assertValidCollectionClaim(claim, items.toTypedArray()) { it.jsonPrimitive.int } + } + } + + @Throws(IllegalArgumentException::class) + override fun withArrayClaim(name: String, vararg items: Long): Verification = apply { + addCheck(name) { claim, decodedJWT -> + verifyNull(claim, items) || assertValidCollectionClaim(claim, items.toTypedArray()) { it.jsonPrimitive.long } + } + } + + override fun build(): JWTVerifier { + return this.build(instant = Clock.System.now()) + } + + /** + * Creates a new and reusable instance of the JWTVerifier with the configuration already provided. + * ONLY FOR TEST PURPOSES. + * + * @param instant the instance that will handle the current time. + * @return a new JWTVerifier instance with a custom [Instant] + */ + fun build(instant: Instant): JWTVerifier { + this.instant = instant + addMandatoryClaimChecks() + return JWTVerifier(algorithm, expectedChecks) + } + + /** + * Fetches the Leeway set for claim or returns the [BaseVerification.defaultLeeway]. + * + * @param name Claim for which leeway is fetched + * @return Leeway value set for the claim + */ + fun getLeewayFor(name: String): Long { + return customLeewayMap.getOrElse(name) { defaultLeeway } + } + + private fun addMandatoryClaimChecks() { + val expiresAtLeeway = getLeewayFor(Claim.Companion.Registered.EXPIRES_AT) + val notBeforeLeeway = getLeewayFor(Claim.Companion.Registered.NOT_BEFORE) + val issuedAtLeeway = getLeewayFor(Claim.Companion.Registered.ISSUED_AT) + + expectedChecks.add( + constructExpectedCheck(Claim.Companion.Registered.EXPIRES_AT) { claim, decodedJWT -> + assertValidInstantClaim( + claimName = Claim.Companion.Registered.EXPIRES_AT, + claim = claim, + leeway = expiresAtLeeway, + shouldBeFuture = true, + ) + }, + ) + expectedChecks.add( + constructExpectedCheck(Claim.Companion.Registered.NOT_BEFORE) { claim, decodedJWT -> + assertValidInstantClaim( + claimName = Claim.Companion.Registered.NOT_BEFORE, + claim = claim, + leeway = notBeforeLeeway, + shouldBeFuture = false, + ) + }, + ) + if (!ignoreIssuedAt) { + expectedChecks.add( + constructExpectedCheck(Claim.Companion.Registered.ISSUED_AT) { claim, decodedJWT -> + assertValidInstantClaim( + claimName = Claim.Companion.Registered.ISSUED_AT, + claim = claim, + leeway = issuedAtLeeway, + shouldBeFuture = false, + ) + }, + ) + } + } + + private fun assertValidCollectionClaim( + claim: Claim, + expectedClaimValue: Array<*>, + mapper: (JsonElement) -> Any, + ): Boolean { + val claimArr: List = claim.asList(JsonElement.serializer()).map(mapper) + val valueArr: List = expectedClaimValue.asList() + return claimArr.containsAll(valueArr) + } + + private fun assertValidInstantClaim( + claimName: String, + claim: Claim?, + leeway: Long, + shouldBeFuture: Boolean, + ): Boolean { + val claimVal: Instant? = claim?.asInstant() + val now: Instant = Instant.fromEpochSeconds(instant!!.epochSeconds) + val isValid: Boolean + if (shouldBeFuture) { + isValid = assertInstantIsFuture(claimVal, leeway, now) + if (!isValid) { + throw TokenExpiredException( + "The Token has expired on $claimVal.", + claimVal, + ) + } + } else { + isValid = assertInstantIsLessThanOrEqualToNow(claimVal, leeway, now) + if (!isValid) { + throw IncorrectClaimException( + "The Token can't be used before $claimVal.", + claimName, + claim, + ) + } + } + return true + } + + private fun assertInstantIsFuture(claimVal: Instant?, leeway: Long, now: Instant): Boolean { + return claimVal == null || now.minus(leeway.seconds) < claimVal + } + + private fun assertInstantIsLessThanOrEqualToNow( + claimVal: Instant?, + leeway: Long, + now: Instant, + ): Boolean { + return !(claimVal != null && now.plus(leeway.seconds) < claimVal) + } + + private fun assertValidAudienceClaim( + actualAudience: List?, + expectedAudience: List?, + shouldContainAll: Boolean, + ): Boolean = when { + actualAudience == null -> false + expectedAudience == null -> false + shouldContainAll -> actualAudience.containsAll(expectedAudience) + else -> !disjoint(actualAudience, expectedAudience) + } + + private fun assertPositive(leeway: Long) { + require(leeway >= 0) { "Leeway value can't be negative." } + } + + private fun addCheck(name: String, predicate: Function2) { + expectedChecks.add( + constructExpectedCheck(name) { claim, decodedJWT -> + if (claim.isMissing) { + throw MissingClaimException(name) + } + predicate.invoke(claim, decodedJWT) + }, + ) + } + + private fun constructExpectedCheck( + claimName: String, + check: Function2, + ): ExpectedCheckHolder = object : ExpectedCheckHolder { + override val claimName: String get() = claimName + override fun verify(claim: Claim, decodedJWT: DecodedJWT): Boolean = check.invoke(claim, decodedJWT) + } + + private fun verifyNull(claim: Claim, value: Any?): Boolean { + return value == null && claim.isNull + } + } + + /** + * Perform the verification against the given Token, using any previous configured options. + * + * @param token to verify. + * @return a verified and decoded JWT. + * @throws dev.sdkforge.jwt.decode.domain.exception.AlgorithmMismatchException if the algorithm stated in the token's header is not equal to + * the one defined in the [JWTVerifier]. + * @throws dev.sdkforge.jwt.decode.domain.exception.SignatureVerificationException if the signature is invalid. + * @throws dev.sdkforge.jwt.decode.domain.exception.TokenExpiredException if the token has expired. + * @throws dev.sdkforge.jwt.decode.domain.exception.MissingClaimException if a claim to be verified is missing. + * @throws dev.sdkforge.jwt.decode.domain.exception.IncorrectClaimException if a claim contained a different value than the expected one. + */ + @Throws(JWTVerificationException::class) + override fun verify(token: String): DecodedJWT { + val jwt: DecodedJWT = JWTDecoder(parser, token) + return verify(jwt) + } + + /** + * Perform the verification against the given decoded JWT, using any previous configured options. + * + * @param jwt to verify. + * @return a verified and decoded JWT. + * @throws dev.sdkforge.jwt.decode.domain.exception.AlgorithmMismatchException if the algorithm stated in the token's header is not equal to + * the one defined in the [JWTVerifier]. + * @throws dev.sdkforge.jwt.decode.domain.exception.SignatureVerificationException if the signature is invalid. + * @throws dev.sdkforge.jwt.decode.domain.exception.TokenExpiredException if the token has expired. + * @throws dev.sdkforge.jwt.decode.domain.exception.MissingClaimException if a claim to be verified is missing. + * @throws dev.sdkforge.jwt.decode.domain.exception.IncorrectClaimException if a claim contained a different value than the expected one. + */ + @Throws(JWTVerificationException::class) + override fun verify(jwt: DecodedJWT): DecodedJWT { + verifyAlgorithm(jwt, algorithm) + (algorithm as VerificationAlgorithm).verify(jwt) + verifyClaims(jwt, expectedChecks) + return jwt + } + + @Throws(AlgorithmMismatchException::class) + private fun verifyAlgorithm(jwt: DecodedJWT, expectedAlgorithm: Algorithm) { + if (expectedAlgorithm.name != jwt.algorithm) { + throw AlgorithmMismatchException( + "The provided Algorithm doesn't match the one defined in the JWT's Header.", + ) + } + } + + @Throws(TokenExpiredException::class, InvalidClaimException::class) + private fun verifyClaims(jwt: DecodedJWT, expectedChecks: List) { + for (expectedCheck in expectedChecks) { + val isValid: Boolean + val claimName: String = expectedCheck.claimName + val claim: Claim = jwt.getClaim(claimName) + + isValid = expectedCheck.verify(claim, jwt) + + if (!isValid) { + throw IncorrectClaimException( + message = "The Claim '$claimName' value doesn't match the required one.", + claimName = claimName, + claim = claim, + ) + } + } + } + + companion object { + /** + * Initialize a [Verification] instance using the given Algorithm. + * + * @param algorithm the Algorithm to use on the JWT verification. + * @return a [Verification] instance to configure. + * @throws IllegalArgumentException if the provided algorithm is null. + */ + @Throws(IllegalArgumentException::class) + internal fun init(algorithm: Algorithm): Verification { + return BaseVerification(algorithm) + } + + // Collections.disjoint() body + fun disjoint(c1: Collection<*>, c2: Collection<*>): Boolean { + // The collection to be used for contains(). Preference is given to + // the collection who's contains() has lower O() complexity. + var contains: Collection<*> = c2 + // The collection to be iterated. If the collections' contains() impl + // are of different O() complexity, the collection with slower + // contains() will be used for iteration. For collections who's + // contains() are of the same complexity then best performance is + // achieved by iterating the smaller collection. + var iterate = c1 + + // Performance optimization cases. The heuristics: + // 1. Generally iterate over c1. + // 2. If c1 is a Set then iterate over c2. + // 3. If either collection is empty then result is always true. + // 4. Iterate over the smaller Collection. + if (c1 is Set<*>) { + // Use c1 for contains as a Set's contains() is expected to perform + // better than O(N/2) + iterate = c2 + contains = c1 + } else if (c2 !is Set<*>) { + // Both are mere Collections. Iterate over smaller collection. + // Example: If c1 contains 3 elements and c2 contains 50 elements and + // assuming contains() requires ceiling(N/2) comparisons then + // checking for all c1 elements in c2 would require 75 comparisons + // (3 * ceiling(50/2)) vs. checking all c2 elements in c1 requiring + // 100 comparisons (50 * ceiling(3/2)). + val c1size = c1.size + val c2size = c2.size + if (c1size == 0 || c2size == 0) { + // At least one collection is empty. Nothing will match. + return true + } + + if (c1size > c2size) { + iterate = c2 + contains = c1 + } + } + + for (e in iterate) { + if (contains.contains(e)) { + // Found a common element. Collections are not disjoint. + return false + } + } + + // No common elements were found. + return true + } + } +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JsonClaim.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JsonClaim.kt new file mode 100644 index 0000000..3fedece --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/JsonClaim.kt @@ -0,0 +1,98 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Claim +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +/** + * The JsonClaim class implements the Claim interface. + */ +@OptIn(ExperimentalTime::class) +internal class JsonClaim(private val value: JsonElement?) : Claim { + + override val isNull: Boolean = when (value) { + null -> false + else -> value is JsonNull + } + + override val isMissing: Boolean + get() = value == null + + override fun asBoolean(): Boolean? = when (value) { + !is JsonPrimitive -> null + else -> value.jsonPrimitive.booleanOrNull + } + + override fun asInt(): Int? = when (value) { + !is JsonPrimitive -> null + else -> value.jsonPrimitive.intOrNull + } + + override fun asLong(): Long? = when (value) { + !is JsonPrimitive -> null + else -> value.jsonPrimitive.longOrNull + } + + override fun asDouble(): Double? = when (value) { + !is JsonPrimitive -> null + else -> value.jsonPrimitive.doubleOrNull + } + + override fun asString(): String? = when (value) { + !is JsonPrimitive -> null + else -> value.jsonPrimitive.content + } + + override fun asInstant(): Instant? = when (value) { + !is JsonPrimitive -> null + else -> value.jsonPrimitive.longOrNull?.run { + Instant.fromEpochSeconds(this) + } + } + + @Throws(JWTDecodeException::class) + override fun asList(deserializer: DeserializationStrategy): List { + try { + if (value !is JsonArray) { + return emptyList() + } + + return List(value.size) { index -> Json.decodeFromJsonElement(deserializer, value[index]) } + } catch (e: IllegalArgumentException) { + throw JWTDecodeException("Failed to decode claim as list", e) + } + } + + @Throws(JWTDecodeException::class) + override fun asObject(deserializer: DeserializationStrategy): T? { + try { + if (isNull || isMissing) { + return null + } + + return Json.decodeFromJsonElement(deserializer, value!!) + } catch (e: IllegalArgumentException) { + throw JWTDecodeException("Failed to decode claim", e) + } + } + + override fun toString(): String = when { + this.isMissing -> "Missing claim" + this.isNull -> "Null claim" + else -> value.toString() + } +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/TokenUtils.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/TokenUtils.kt new file mode 100644 index 0000000..4b60190 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/TokenUtils.kt @@ -0,0 +1,31 @@ +@file:Suppress("ktlint:standard:function-expression-body", "ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException + +internal object TokenUtils { + /** + * Splits the given token on the "." chars into a String array with 3 parts. + * + * @param token the string to split. + * @return the array representing the 3 parts of the token. + * @throws JWTDecodeException if the Token doesn't have 3 parts. + */ + @Throws(JWTDecodeException::class) + fun splitToken(token: String?): Array { + if (token == null) { + throw JWTDecodeException("The token is null.") + } + + return token.split(delimiters = arrayOf(".")).toTypedArray().apply { + if (size != 3) throw wrongNumberOfParts(size) + } + } + + private fun wrongNumberOfParts(partCount: Any): JWTDecodeException { + return JWTDecodeException( + message = "The token was expected to have 3 parts, but got $partCount.", + ) + } +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Algorithm.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Algorithm.kt new file mode 100644 index 0000000..395f3a8 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Algorithm.kt @@ -0,0 +1,402 @@ +@file:Suppress("FunctionName") + +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.ec.ECKey +import dev.sdkforge.crypto.domain.ec.ECPrivateKey +import dev.sdkforge.crypto.domain.ec.ECPublicKey +import dev.sdkforge.crypto.domain.rsa.RSAKey +import dev.sdkforge.crypto.domain.rsa.RSAPrivateKey +import dev.sdkforge.crypto.domain.rsa.RSAPublicKey +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.provider.ECDSAKeyProvider +import dev.sdkforge.jwt.decode.domain.provider.RSAKeyProvider + +/** + * Creates a new Algorithm instance using SHA256withRSA. Tokens specify this as "RS256". + * + * @param keyProvider the provider of the Public Key and Private Key for the verify and signing instance. + * @return a valid RSA256 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA256( + keyProvider: RSAKeyProvider, +): Algorithm = RSAAlgorithm( + id = "RS256", + algorithm = "SHA256withRSA", + keyProvider = keyProvider, +) + +/** + * Creates a new Algorithm instance using SHA256withRSA. Tokens specify this as "RS256". + * + * @param publicKey the key to use in the verify instance. + * @param privateKey the key to use in the signing instance. + * @return a valid RSA256 Algorithm. + * @throws IllegalArgumentException if both provided Keys are null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA256( + publicKey: RSAPublicKey?, + privateKey: RSAPrivateKey?, +): Algorithm = RSA256( + keyProvider = RSAAlgorithm.providerForKeys(publicKey, privateKey), +) + +/** + * Creates a new Algorithm instance using SHA256withRSA. Tokens specify this as "RS256". + * + * @param key the key to use in the verify or signing instance. + * @return a valid RSA256 Algorithm. + * @throws IllegalArgumentException if the Key Provider is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA256( + key: RSAKey, +): Algorithm = RSA256( + publicKey = key as? RSAPublicKey, + privateKey = key as? RSAPrivateKey, +) + +/** + * Creates a new Algorithm instance using SHA384withRSA. Tokens specify this as "RS384". + * + * @param keyProvider the provider of the Public Key and Private Key for the verify and signing instance. + * @return a valid RSA384 Algorithm. + * @throws IllegalArgumentException if the Key Provider is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA384( + keyProvider: RSAKeyProvider, +): Algorithm = RSAAlgorithm( + id = "RS384", + algorithm = "SHA384withRSA", + keyProvider = keyProvider, +) + +/** + * Creates a new Algorithm instance using SHA384withRSA. Tokens specify this as "RS384". + * + * @param publicKey the key to use in the verify instance. + * @param privateKey the key to use in the signing instance. + * @return a valid RSA384 Algorithm. + * @throws IllegalArgumentException if both provided Keys are null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA384( + publicKey: RSAPublicKey?, + privateKey: RSAPrivateKey?, +): Algorithm = RSA384( + keyProvider = RSAAlgorithm.providerForKeys(publicKey, privateKey), +) + +/** + * Creates a new Algorithm instance using SHA384withRSA. Tokens specify this as "RS384". + * + * @param key the key to use in the verify or signing instance. + * @return a valid RSA384 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA384( + key: RSAKey, +): Algorithm = RSA384( + publicKey = key as? RSAPublicKey, + privateKey = key as? RSAPrivateKey, +) + +/** + * Creates a new Algorithm instance using SHA512withRSA. Tokens specify this as "RS512". + * + * @param keyProvider the provider of the Public Key and Private Key for the verify and signing instance. + * @return a valid RSA512 Algorithm. + * @throws IllegalArgumentException if the Key Provider is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA512( + keyProvider: RSAKeyProvider, +): Algorithm = RSAAlgorithm( + id = "RS512", + algorithm = "SHA512withRSA", + keyProvider = keyProvider, +) + +/** + * Creates a new Algorithm instance using SHA512withRSA. Tokens specify this as "RS512". + * + * @param publicKey the key to use in the verify instance. + * @param privateKey the key to use in the signing instance. + * @return a valid RSA512 Algorithm. + * @throws IllegalArgumentException if both provided Keys are null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA512( + publicKey: RSAPublicKey?, + privateKey: RSAPrivateKey?, +): Algorithm = RSA512( + keyProvider = RSAAlgorithm.providerForKeys(publicKey, privateKey), +) + +/** + * Creates a new Algorithm instance using SHA512withRSA. Tokens specify this as "RS512". + * + * @param key the key to use in the verify or signing instance. + * @return a valid RSA512 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.RSA512( + key: RSAKey?, +): Algorithm = RSA512( + publicKey = key as? RSAPublicKey, + privateKey = key as? RSAPrivateKey, +) + +/** + * Creates a new Algorithm instance using HmacSHA256. Tokens specify this as "HS256". + * + * @param secret the secret bytes to use in the verify or signing instance. + * Ensure the length of the secret is at least 256 bit long + * @return a valid HMAC256 Algorithm. + * @throws IllegalArgumentException if the provided Secret is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.HMAC256( + secret: String, +): Algorithm = HMACAlgorithm( + id = "HS256", + algorithm = "HmacSHA256", + secret = secret, +) + +/** + * Creates a new Algorithm instance using HmacSHA256. Tokens specify this as "HS256". + * + * @param secret the secret bytes to use in the verify or signing instance. + * Ensure the length of the secret is at least 256 bit long + * @return a valid HMAC256 Algorithm. + * @throws IllegalArgumentException if the provided Secret is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.HMAC256( + secret: ByteArray, +): Algorithm = HMACAlgorithm( + id = "HS256", + algorithm = "HmacSHA256", + secretBytes = secret, +) + +/** + * Creates a new Algorithm instance using HmacSHA384. Tokens specify this as "HS384". + * + * @param secret the secret bytes to use in the verify or signing instance. + * Ensure the length of the secret is at least 384 bit long + * @return a valid HMAC384 Algorithm. + * @throws IllegalArgumentException if the provided Secret is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.HMAC384( + secret: String, +): Algorithm = HMACAlgorithm( + id = "HS384", + algorithm = "HmacSHA384", + secret = secret, +) + +/** + * Creates a new Algorithm instance using HmacSHA384. Tokens specify this as "HS384". + * + * @param secret the secret bytes to use in the verify or signing instance. + * Ensure the length of the secret is at least 384 bit long + * @return a valid HMAC384 Algorithm. + * @throws IllegalArgumentException if the provided Secret is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.HMAC384( + secret: ByteArray, +): Algorithm = HMACAlgorithm( + id = "HS384", + algorithm = "HmacSHA384", + secretBytes = secret, +) + +/** + * Creates a new Algorithm instance using HmacSHA512. Tokens specify this as "HS512". + * + * @param secret the secret bytes to use in the verify or signing instance. + * Ensure the length of the secret is at least 512 bit long + * @return a valid HMAC512 Algorithm. + * @throws IllegalArgumentException if the provided Secret is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.HMAC512( + secret: String, +): Algorithm = HMACAlgorithm( + id = "HS512", + algorithm = "HmacSHA512", + secret = secret, +) + +/** + * Creates a new Algorithm instance using HmacSHA512. Tokens specify this as "HS512". + * + * @param secret the secret bytes to use in the verify or signing instance. + * Ensure the length of the secret is at least 512 bit long + * @return a valid HMAC512 Algorithm. + * @throws IllegalArgumentException if the provided Secret is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.HMAC512( + secret: ByteArray, +): Algorithm = HMACAlgorithm( + id = "HS512", + algorithm = "HmacSHA512", + secretBytes = secret, +) + +/** + * Creates a new Algorithm instance using SHA256withECDSA. Tokens specify this as "ES256". + * + * @param keyProvider the provider of the Public Key and Private Key for the verify and signing instance. + * @return a valid ECDSA256 Algorithm. + * @throws IllegalArgumentException if the Key Provider is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA256( + keyProvider: ECDSAKeyProvider, +): Algorithm = ECDSAAlgorithm( + id = "ES256", + algorithm = "SHA256withECDSA", + ecNumberSize = 32, + keyProvider = keyProvider, +) + +/** + * Creates a new Algorithm instance using SHA256withECDSA. Tokens specify this as "ES256". + * + * @param publicKey the key to use in the verify instance. + * @param privateKey the key to use in the signing instance. + * @return a valid ECDSA256 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA256( + publicKey: ECPublicKey?, + privateKey: ECPrivateKey?, +): Algorithm = ECDSA256( + keyProvider = ECDSAAlgorithm.providerForKeys(publicKey, privateKey), +) + +/** + * Creates a new Algorithm instance using SHA256withECDSA. Tokens specify this as "ES256". + * + * @param key the key to use in the verify or signing instance. + * @return a valid ECDSA256 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA256( + key: ECKey, +): Algorithm = ECDSA256( + publicKey = key as? ECPublicKey, + privateKey = key as? ECPrivateKey, +) + +/** + * Creates a new Algorithm instance using SHA384withECDSA. Tokens specify this as "ES384". + * + * @param keyProvider the provider of the Public Key and Private Key for the verify and signing instance. + * @return a valid ECDSA384 Algorithm. + * @throws IllegalArgumentException if the Key Provider is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA384( + keyProvider: ECDSAKeyProvider, +): Algorithm = ECDSAAlgorithm( + id = "ES384", + algorithm = "SHA384withECDSA", + ecNumberSize = 48, + keyProvider = keyProvider, +) + +/** + * Creates a new Algorithm instance using SHA384withECDSA. Tokens specify this as "ES384". + * + * @param publicKey the key to use in the verify instance. + * @param privateKey the key to use in the signing instance. + * @return a valid ECDSA384 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA384( + publicKey: ECPublicKey?, + privateKey: ECPrivateKey?, +): Algorithm = ECDSA384( + keyProvider = ECDSAAlgorithm.providerForKeys(publicKey, privateKey), +) + +/** + * Creates a new Algorithm instance using SHA384withECDSA. Tokens specify this as "ES384". + * + * @param key the key to use in the verify or signing instance. + * @return a valid ECDSA384 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA384( + key: ECKey?, +): Algorithm = ECDSA384( + publicKey = key as? ECPublicKey, + privateKey = key as? ECPrivateKey, +) + +/** + * Creates a new Algorithm instance using SHA512withECDSA. Tokens specify this as "ES512". + * + * @param keyProvider the provider of the Public Key and Private Key for the verify and signing instance. + * @return a valid ECDSA512 Algorithm. + * @throws IllegalArgumentException if the Key Provider is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA512( + keyProvider: ECDSAKeyProvider, +): Algorithm = ECDSAAlgorithm( + id = "ES512", + algorithm = "SHA512withECDSA", + ecNumberSize = 66, + keyProvider = keyProvider, +) + +/** + * Creates a new Algorithm instance using SHA512withECDSA. Tokens specify this as "ES512". + * + * @param publicKey the key to use in the verify instance. + * @param privateKey the key to use in the signing instance. + * @return a valid ECDSA512 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA512( + publicKey: ECPublicKey?, + privateKey: ECPrivateKey?, +): Algorithm = ECDSA512( + keyProvider = ECDSAAlgorithm.providerForKeys(publicKey, privateKey), +) + +/** + * Creates a new Algorithm instance using SHA512withECDSA. Tokens specify this as "ES512". + * + * @param key the key to use in the verify or signing instance. + * @return a valid ECDSA512 Algorithm. + * @throws IllegalArgumentException if the provided Key is null. + */ +@Throws(IllegalArgumentException::class) +fun Algorithm.Companion.ECDSA512( + key: ECKey, +): Algorithm = ECDSA512( + publicKey = key as? ECPublicKey, + privateKey = key as? ECPrivateKey, +) + +val Algorithm.Companion.NONE: Algorithm get() = NoneAlgorithm diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Crypto.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Crypto.kt new file mode 100644 index 0000000..0c4017f --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Crypto.kt @@ -0,0 +1,152 @@ +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.PrivateKey +import dev.sdkforge.crypto.domain.PublicKey + +/** + * Verify signature for JWT header and payload. + * + * @param algorithm algorithm name. + * @param secretBytes algorithm secret. + * @param header JWT header. + * @param payload JWT payload. + * @param signatureBytes JWT signature. + * @return true if signature is valid. + */ +internal fun verifySignature( + algorithm: String, + secretBytes: ByteArray, + header: String, + payload: String, + signatureBytes: ByteArray, +): Boolean = verifySignature( + algorithm = algorithm, + secretBytes = secretBytes, + headerBytes = header.encodeToByteArray(), + payloadBytes = payload.encodeToByteArray(), + signatureBytes = signatureBytes, +) + +/** + * Verify signature for JWT header and payload. + * + * @param algorithm algorithm name. + * @param secretBytes algorithm secret. + * @param headerBytes JWT header. + * @param payloadBytes JWT payload. + * @param signatureBytes JWT signature. + * @return true if signature is valid. + */ +internal expect fun verifySignature( + algorithm: String, + secretBytes: ByteArray, + headerBytes: ByteArray, + payloadBytes: ByteArray, + signatureBytes: ByteArray, +): Boolean + +/** + * Verify signature for JWT header and payload. + * + * @param algorithm algorithm name. + * @param publicKey algorithm public key. + * @param header JWT header. + * @param payload JWT payload. + * @param signatureBytes JWT signature. + * @return true if signature is valid. + */ +internal fun verifySignature( + algorithm: String, + publicKey: PublicKey, + header: String, + payload: String, + signatureBytes: ByteArray, +): Boolean = verifySignature( + algorithm = algorithm, + publicKey = publicKey, + headerBytes = header.encodeToByteArray(), + payloadBytes = payload.encodeToByteArray(), + signatureBytes = signatureBytes, +) + +/** + * Verify signature for JWT header and payload using a public key. + * + * @param algorithm algorithm name. + * @param publicKey the public key to use for verification. + * @param headerBytes JWT header. + * @param payloadBytes JWT payload. + * @param signatureBytes JWT signature. + * @return true if signature is valid. + */ +internal expect fun verifySignature( + algorithm: String, + publicKey: PublicKey, + headerBytes: ByteArray, + payloadBytes: ByteArray, + signatureBytes: ByteArray, +): Boolean + +/** + * Create signature for JWT header and payload using a private key. + * + * @param algorithm algorithm name. + * @param privateKey the private key to use for signing. + * @param headerBytes JWT header. + * @param payloadBytes JWT payload. + * @return the signature bytes. + */ +internal expect fun createSignatureFor( + algorithm: String, + privateKey: PrivateKey, + headerBytes: ByteArray, + payloadBytes: ByteArray, +): ByteArray + +/** + * Create signature for JWT header and payload. + * + * @param algorithm algorithm name. + * @param secretBytes algorithm secret. + * @param headerBytes JWT header. + * @param payloadBytes JWT payload. + * @return the signature bytes. + */ +internal expect fun createSignatureFor( + algorithm: String, + secretBytes: ByteArray, + headerBytes: ByteArray, + payloadBytes: ByteArray, +): ByteArray + +/** + * Create signature. + * To get the correct JWT Signature, ensure the content is in the format {HEADER}.{PAYLOAD} + * + * @param algorithm algorithm name. + * @param secretBytes algorithm secret. + * @param contentBytes the content to be signed. + * @return the signature bytes. + */ +internal expect fun createSignatureFor( + algorithm: String, + secretBytes: ByteArray, + contentBytes: ByteArray, +): ByteArray + +/** + * Create signature using a private key. + * To get the correct JWT Signature, ensure the content is in the format {HEADER}.{PAYLOAD} + * + * @param algorithm algorithm name. + * @param privateKey the private key to use for signing. + * @param contentBytes the content to be signed. + * @return the signature bytes. + */ +internal expect fun createSignatureFor( + algorithm: String, + privateKey: PrivateKey, + contentBytes: ByteArray, +): ByteArray + +internal const val JWT_PART_SEPARATOR = 46.toByte() diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSAAlgorithm.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSAAlgorithm.kt new file mode 100644 index 0000000..4c73cb8 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSAAlgorithm.kt @@ -0,0 +1,323 @@ +@file:Suppress("ktlint:standard:class-signature", "ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.ec.ECPrivateKey +import dev.sdkforge.crypto.domain.ec.ECPublicKey +import dev.sdkforge.jwt.decode.domain.DecodedJWT +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.SignatureException +import dev.sdkforge.jwt.decode.domain.exception.SignatureGenerationException +import dev.sdkforge.jwt.decode.domain.exception.SignatureVerificationException +import dev.sdkforge.jwt.decode.domain.provider.ECDSAKeyProvider +import kotlin.io.encoding.Base64 +import kotlin.math.max +import kotlin.math.min + +/** + * Subclass representing an Elliptic Curve signing algorithm + */ +internal class ECDSAAlgorithm( + id: String, + algorithm: String, + private val ecNumberSize: Int, + private val keyProvider: ECDSAKeyProvider, +) : Algorithm( + name = id, + description = algorithm, +), + VerificationAlgorithm, + SigningAlgorithm { + + @Throws(SignatureVerificationException::class) + override fun verify(jwt: DecodedJWT) { + try { + val signatureBytes: ByteArray = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(jwt.signature) + val publicKey: ECPublicKey? = keyProvider.getPublicKeyById(jwt.keyId) + + checkNotNull(publicKey) { "The given Public Key is null." } + + validateSignatureStructure(signatureBytes, publicKey) + + val valid: Boolean = verifySignature( + algorithm = description, + publicKey = publicKey, + header = jwt.header, + payload = jwt.payload, + signatureBytes = JOSEToDER( + joseSignature = signatureBytes, + ), + ) + + if (!valid) { + throw SignatureVerificationException(this) + } + } catch (e: Exception) { + throw SignatureVerificationException(this, e) + } + } + + override val signingKeyId: String? + get() = keyProvider.privateKeyId + + @Throws(SignatureGenerationException::class) + override fun sign(headerBytes: ByteArray, payloadBytes: ByteArray): ByteArray { + try { + val privateKey: ECPrivateKey? = keyProvider.privateKey + + checkNotNull(privateKey) { "The given Private Key is null." } + + val signature: ByteArray = createSignatureFor(description, privateKey, headerBytes, payloadBytes) + + return DERToJOSE(signature) + } catch (e: Exception) { + throw SignatureGenerationException(this, e) + } + } + + @Throws(SignatureGenerationException::class) + override fun sign(contentBytes: ByteArray): ByteArray { + try { + val privateKey: ECPrivateKey? = keyProvider.privateKey + + checkNotNull(privateKey) { "The given Private Key is null." } + + val signature: ByteArray = createSignatureFor(description, privateKey, contentBytes) + + return DERToJOSE(signature) + } catch (e: Exception) { + throw SignatureGenerationException(this, e) + } + } + + @Suppress("ktlint:standard:function-naming") + // Visible for testing + @Throws(SignatureException::class) + internal fun DERToJOSE(derSignature: ByteArray): ByteArray { + // DER Structure: http://crypto.stackexchange.com/a/1797 + val derEncoded = derSignature[0].toInt() == 0x30 && derSignature.size != ecNumberSize * 2 + if (!derEncoded) { + throw SignatureException("Invalid DER signature format.") + } + + val joseSignature = ByteArray(ecNumberSize * 2) + + // Skip 0x30 + var offset = 1 + if (derSignature[1] == 0x81.toByte()) { + // Skip sign + offset++ + } + + // Convert to unsigned. Should match DER length - offset + val encodedLength = derSignature[offset++].toInt() and 0xff + if (encodedLength != derSignature.size - offset) { + throw SignatureException("Invalid DER signature format.") + } + + // Skip 0x02 + offset++ + + // Obtain R number length (Includes padding) and skip it + val rlength = derSignature[offset++].toInt() + if (rlength > ecNumberSize + 1) { + throw SignatureException("Invalid DER signature format.") + } + val rpadding = ecNumberSize - rlength + // Retrieve R number + derSignature.copyInto( + joseSignature, + max(rpadding, 0), + offset + max(-rpadding, 0), + offset + max(-rpadding, 0) + rlength + min(rpadding, 0), + ) + + // Skip R number and 0x02 + offset += rlength + 1 + + // Obtain S number length. (Includes padding) + val slength = derSignature[offset++].toInt() + if (slength > ecNumberSize + 1) { + throw SignatureException("Invalid DER signature format.") + } + val spadding = ecNumberSize - slength + // Retrieve R number + + derSignature.copyInto( + joseSignature, + ecNumberSize + max(spadding, 0), + offset + max(-spadding, 0), + offset + max(-spadding, 0) + slength + min(spadding, 0), + ) + + return joseSignature + } + + /** + * Added check for extra protection against CVE-2022-21449. + * This method ensures the signature's structure is as expected. + * + * @param joseSignature is the signature from the JWT + * @param publicKey public key used to verify the JWT + * @throws SignatureException if the signature's structure is not as per expectation + */ + // Visible for testing + @Throws(SignatureException::class) + internal fun validateSignatureStructure(joseSignature: ByteArray, publicKey: ECPublicKey) { + // check signature length, moved this check from JOSEToDER method + if (joseSignature.size != ecNumberSize * 2) { + throw SignatureException("Invalid JOSE signature format.") + } + + if (joseSignature.isAllZeros) { + throw SignatureException("Invalid signature format.") + } + + // get R + val rBytes = ByteArray(ecNumberSize) + joseSignature.copyInto( + rBytes, + 0, + 0, + ecNumberSize, + ) + + if (rBytes.isAllZeros) { + throw SignatureException("Invalid signature format.") + } + + // get S + val sBytes = ByteArray(ecNumberSize) + joseSignature.copyInto( + sBytes, + 0, + ecNumberSize, + ecNumberSize * 2, + ) + + if (sBytes.isAllZeros) { + throw SignatureException("Invalid signature format.") + } + + // moved this check from JOSEToDER method + val rPadding = countPadding(joseSignature, 0, ecNumberSize) + val sPadding = countPadding(joseSignature, ecNumberSize, joseSignature.size) + val rLength = ecNumberSize - rPadding + val sLength = ecNumberSize - sPadding + + val length = 2 + rLength + 2 + sLength + if (length > 255) { + throw SignatureException("Invalid JOSE signature format.") + } + + verifySignature(publicKey, rBytes, sBytes) + } + + @Suppress("ktlint:standard:function-naming") + // Visible for testing + @Throws(SignatureException::class) + internal fun JOSEToDER(joseSignature: ByteArray): ByteArray { + // Retrieve R and S number's length and padding. + val rPadding = countPadding(joseSignature, 0, ecNumberSize) + val sPadding = countPadding(joseSignature, ecNumberSize, joseSignature.size) + val rLength = ecNumberSize - rPadding + val sLength = ecNumberSize - sPadding + + val length = 2 + rLength + 2 + sLength + + val derSignature: ByteArray + var offset: Int + if (length > 0x7f) { + derSignature = ByteArray(3 + length) + derSignature[1] = 0x81.toByte() + offset = 2 + } else { + derSignature = ByteArray(2 + length) + offset = 1 + } + + // DER Structure: http://crypto.stackexchange.com/a/1797 + // Header with signature length info + derSignature[0] = 0x30.toByte() + derSignature[offset++] = (length and 0xff).toByte() + + // Header with "min R" number length + derSignature[offset++] = 0x02.toByte() + derSignature[offset++] = rLength.toByte() + + // R number + if (rPadding < 0) { + // Sign + derSignature[offset++] = 0x00.toByte() + joseSignature.copyInto( + derSignature, + offset, + 0, + ecNumberSize, + ) + + offset += ecNumberSize + } else { + val copyLength: Int = min(ecNumberSize, rLength) + joseSignature.copyInto( + derSignature, + offset, + rPadding, + rPadding + copyLength, + ) + offset += copyLength + } + + // Header with "min S" number length + derSignature[offset++] = 0x02.toByte() + derSignature[offset++] = sLength.toByte() + + // S number + if (sPadding < 0) { + // Sign + derSignature[offset++] = 0x00.toByte() + + joseSignature.copyInto( + derSignature, + offset, + ecNumberSize, + ecNumberSize + ecNumberSize, + ) + } else { + joseSignature.copyInto( + derSignature, + offset, + ecNumberSize + sPadding, + ecNumberSize + sPadding + min(ecNumberSize, sLength), + ) + } + + return derSignature + } + + private fun countPadding(bytes: ByteArray, fromIndex: Int, toIndex: Int): Int { + var padding = 0 + while (fromIndex + padding < toIndex && bytes[fromIndex + padding].toInt() == 0) { + padding++ + } + return if ((bytes[fromIndex + padding].toInt() and 0xff) > 0x7f) padding - 1 else padding + } + + internal companion object { + // Visible for testing + internal fun providerForKeys(publicKey: ECPublicKey?, privateKey: ECPrivateKey?): ECDSAKeyProvider { + require(!(publicKey == null && privateKey == null)) { "Both provided Keys cannot be null." } + + return object : ECDSAKeyProvider { + override fun getPublicKeyById(keyId: String?): ECPublicKey? = publicKey + override val privateKey: ECPrivateKey? get() = privateKey + override val privateKeyId: String? get() = null + } + } + + private val ByteArray.isAllZeros: Boolean + get() = all { it.toInt() == 0 } + } +} + +internal expect fun verifySignature(publicKey: ECPublicKey, rBytes: ByteArray, sBytes: ByteArray) diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/HMACAlgorithm.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/HMACAlgorithm.kt new file mode 100644 index 0000000..fb15542 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/HMACAlgorithm.kt @@ -0,0 +1,71 @@ +@file:Suppress("ktlint:standard:class-signature", "ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.jwt.decode.domain.DecodedJWT +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.SignatureGenerationException +import dev.sdkforge.jwt.decode.domain.exception.SignatureVerificationException +import kotlin.io.encoding.Base64 + +/** + * Subclass representing an Hash-based MAC signing algorithm. + */ +internal class HMACAlgorithm( + id: String, + algorithm: String, + secretBytes: ByteArray, +) : Algorithm( + name = id, + description = algorithm, +), + VerificationAlgorithm, + SigningAlgorithm { + + private val secret: ByteArray = secretBytes.copyOf() + + constructor(id: String, algorithm: String, secret: String) : this(id, algorithm, getSecretBytes(secret)) + + @Throws(SignatureVerificationException::class) + override fun verify(jwt: DecodedJWT) { + try { + val signatureBytes: ByteArray = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(jwt.signature) + val valid: Boolean = verifySignature( + algorithm = description, + secretBytes = secret, + header = jwt.header, + payload = jwt.payload, + signatureBytes = signatureBytes, + ) + if (!valid) { + throw SignatureVerificationException(this) + } + } catch (e: Exception) { + throw SignatureVerificationException(this, e) + } + } + + @Throws(SignatureGenerationException::class) + override fun sign(headerBytes: ByteArray, payloadBytes: ByteArray): ByteArray { + try { + return createSignatureFor(description, secret, headerBytes, payloadBytes) + } catch (e: Exception) { + throw SignatureGenerationException(this, e) + } + } + + @Throws(SignatureGenerationException::class) + override fun sign(contentBytes: ByteArray): ByteArray { + try { + return createSignatureFor(description, secret, contentBytes) + } catch (e: Exception) { + throw SignatureGenerationException(this, e) + } + } + + companion object { + // Visible for testing + @Throws(IllegalArgumentException::class) + internal fun getSecretBytes(secret: String): ByteArray = secret.encodeToByteArray() + } +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/NoneAlgorithm.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/NoneAlgorithm.kt new file mode 100644 index 0000000..c9d5c37 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/NoneAlgorithm.kt @@ -0,0 +1,37 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.jwt.decode.domain.DecodedJWT +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.SignatureGenerationException +import dev.sdkforge.jwt.decode.domain.exception.SignatureVerificationException +import kotlin.io.encoding.Base64 + +internal data object NoneAlgorithm : + Algorithm( + name = "none", + description = "none", + ), + VerificationAlgorithm, + SigningAlgorithm { + + @Throws(SignatureVerificationException::class) + override fun verify(jwt: DecodedJWT) { + try { + val signatureBytes: ByteArray = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(jwt.signature) + + if (signatureBytes.isNotEmpty()) { + throw SignatureVerificationException(this) + } + } catch (e: IllegalArgumentException) { + throw SignatureVerificationException(this, e) + } + } + + @Throws(SignatureGenerationException::class) + override fun sign(headerBytes: ByteArray, payloadBytes: ByteArray): ByteArray = ByteArray(0) + + @Throws(SignatureGenerationException::class) + override fun sign(contentBytes: ByteArray): ByteArray = ByteArray(0) +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/RSAAlgorithm.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/RSAAlgorithm.kt new file mode 100644 index 0000000..920dc73 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/RSAAlgorithm.kt @@ -0,0 +1,93 @@ +@file:Suppress("ktlint:standard:class-signature", "ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.PrivateKey +import dev.sdkforge.crypto.domain.rsa.RSAPrivateKey +import dev.sdkforge.crypto.domain.rsa.RSAPublicKey +import dev.sdkforge.jwt.decode.domain.DecodedJWT +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm +import dev.sdkforge.jwt.decode.domain.exception.SignatureGenerationException +import dev.sdkforge.jwt.decode.domain.exception.SignatureVerificationException +import dev.sdkforge.jwt.decode.domain.provider.RSAKeyProvider +import kotlin.io.encoding.Base64 + +/** + * Subclass representing an RSA signing algorithm. + */ +internal class RSAAlgorithm( + id: String, + algorithm: String, + private val keyProvider: RSAKeyProvider, +) : Algorithm( + name = id, + description = algorithm, +), + VerificationAlgorithm, + SigningAlgorithm { + + @Throws(SignatureVerificationException::class) + override fun verify(jwt: DecodedJWT) { + try { + val signatureBytes: ByteArray = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(jwt.signature) + val publicKey: RSAPublicKey? = keyProvider.getPublicKeyById(jwt.keyId) + + checkNotNull(publicKey) { "The given Public Key is null." } + + val valid: Boolean = verifySignature( + algorithm = description, + publicKey = publicKey, + header = jwt.header, + payload = jwt.payload, + signatureBytes = signatureBytes, + ) + if (!valid) { + throw SignatureVerificationException(this) + } + } catch (e: Exception) { + throw SignatureVerificationException(this, e) + } + } + + override val signingKeyId: String? + get() = keyProvider.privateKeyId + + @Throws(SignatureGenerationException::class) + override fun sign(headerBytes: ByteArray, payloadBytes: ByteArray): ByteArray { + try { + val privateKey: PrivateKey? = keyProvider.privateKey + + checkNotNull(privateKey) { "The given Private Key is null." } + + return createSignatureFor(description, privateKey, headerBytes, payloadBytes) + } catch (e: Exception) { + throw SignatureGenerationException(this, e) + } + } + + @Throws(SignatureGenerationException::class) + override fun sign(contentBytes: ByteArray): ByteArray { + try { + val privateKey: PrivateKey? = keyProvider.privateKey + + checkNotNull(privateKey) { "The given Private Key is null." } + + return createSignatureFor(description, privateKey, contentBytes) + } catch (e: Exception) { + throw SignatureGenerationException(this, e) + } + } + + companion object { + // Visible for testing + internal fun providerForKeys(publicKey: RSAPublicKey?, privateKey: RSAPrivateKey?): RSAKeyProvider { + require(!(publicKey == null && privateKey == null)) { "Both provided Keys cannot be null." } + + return object : RSAKeyProvider { + override fun getPublicKeyById(keyId: String?): RSAPublicKey? = publicKey + override val privateKey: RSAPrivateKey? get() = privateKey + override val privateKeyId: String? get() = null + } + } + } +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/SigningAlgorithm.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/SigningAlgorithm.kt new file mode 100644 index 0000000..f64c490 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/SigningAlgorithm.kt @@ -0,0 +1,59 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.jwt.decode.domain.exception.SignatureGenerationException + +internal interface SigningAlgorithm { + + val signingKeyId: String? + /** + * Getter for the Id of the Private Key used to sign the tokens. + * This is usually specified as the `kid` claim in the Header. + * + * @return the Key Id that identifies the Signing Key or null if it's not specified. + */ + get() = null + + /** + * Sign the given content using this Algorithm instance. + * + * @param headerBytes an array of bytes representing the base64 encoded header content + * to be verified against the signature. + * @param payloadBytes an array of bytes representing the base64 encoded payload content + * to be verified against the signature. + * @return the signature in a base64 encoded array of bytes + * @throws SignatureGenerationException if the Key is invalid. + */ + @Throws(SignatureGenerationException::class) + fun sign(headerBytes: ByteArray, payloadBytes: ByteArray): ByteArray { + // default implementation; keep around until sign(byte[]) method is removed + val contentBytes = ByteArray(headerBytes.size + 1 + payloadBytes.size) + + headerBytes.copyInto( + destination = contentBytes, + destinationOffset = 0, + ) + + contentBytes[headerBytes.size] = '.'.code.toByte() + + payloadBytes.copyInto( + destination = contentBytes, + destinationOffset = headerBytes.size + 1, + ) + + return sign(contentBytes) + } + + /** + * Sign the given content using this Algorithm instance. + * To get the correct JWT Signature, ensure the content is in the format {HEADER}.{PAYLOAD} + * + * @param contentBytes an array of bytes representing the base64 encoded content + * to be verified against the signature. + * @return the signature in a base64 encoded array of bytes + * @throws SignatureGenerationException if the Key is invalid. + */ + @Throws(SignatureGenerationException::class) + fun sign(contentBytes: ByteArray): ByteArray +} diff --git a/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/VerificationAlgorithm.kt b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/VerificationAlgorithm.kt new file mode 100644 index 0000000..360df34 --- /dev/null +++ b/shared-data/src/commonMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/VerificationAlgorithm.kt @@ -0,0 +1,19 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.jwt.decode.domain.DecodedJWT +import dev.sdkforge.jwt.decode.domain.exception.SignatureVerificationException + +internal interface VerificationAlgorithm { + /** + * Verify the given token using this Algorithm instance. + * + * @param jwt the already decoded JWT that it's going to be verified. + * @throws SignatureVerificationException if the Token's Signature is invalid, + * meaning that it doesn't match the signatureBytes, + * or if the Key is invalid. + */ + @Throws(SignatureVerificationException::class) + fun verify(jwt: DecodedJWT) +} diff --git a/shared-data/src/commonTest/kotlin/dev/sdkforge/jwt/decode/data/EmptyClaimTest.kt b/shared-data/src/commonTest/kotlin/dev/sdkforge/jwt/decode/data/EmptyClaimTest.kt new file mode 100644 index 0000000..18b719e --- /dev/null +++ b/shared-data/src/commonTest/kotlin/dev/sdkforge/jwt/decode/data/EmptyClaimTest.kt @@ -0,0 +1,56 @@ +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Claim +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.ExperimentalTime +import kotlinx.serialization.builtins.serializer + +@OptIn(ExperimentalTime::class) +class EmptyClaimTest { + + private val claim: Claim = EmptyClaim + + @Test + fun shouldGetAsBoolean() { + assertNull(claim.asBoolean()) + } + + @Test + fun shouldGetAsInt() { + assertNull(claim.asInt()) + } + + @Test + fun shouldGetAsLong() { + assertNull(claim.asLong()) + } + + @Test + fun shouldGetAsDouble() { + assertNull(claim.asDouble()) + } + + @Test + fun shouldGetAsString() { + assertNull(claim.asString()) + } + + @Test + fun shouldGetAsDate() { + assertNull(claim.asInstant()) + } + + @Test + fun shouldGetAsList() { + assertNotNull(claim.asList(Unit.serializer())) + assertContentEquals(emptyList(), claim.asList(Unit.serializer())) + } + + @Test + fun shouldGetAsObject() { + assertNull(claim.asObject(Unit.serializer())) + } +} diff --git a/shared-data/src/commonTest/kotlin/dev/sdkforge/jwt/decode/data/JWTTest.kt b/shared-data/src/commonTest/kotlin/dev/sdkforge/jwt/decode/data/JWTTest.kt new file mode 100644 index 0000000..91e093c --- /dev/null +++ b/shared-data/src/commonTest/kotlin/dev/sdkforge/jwt/decode/data/JWTTest.kt @@ -0,0 +1,424 @@ +@file:Suppress("ktlint:standard:filename", "ktlint:standard:function-signature", "ktlint:standard:function-expression-body") + +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.Claim +import dev.sdkforge.jwt.decode.domain.DecodedJWT +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException +import dev.sdkforge.jwt.decode.domain.isExpired +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.serialization.json.JsonElement + +@OptIn(ExperimentalTime::class) +class CommonJWTTest { + + // Exceptions + @Test + fun shouldThrowIfLessThan3Parts() { + val t = assertFailsWith { + JWT.decode("two.parts") + } + + assertEquals("The token was expected to have 3 parts, but got 2.", t.message) + } + + @Test + fun shouldThrowIfMoreThan3Parts() { + val t = assertFailsWith { + JWT.decode("this.has.four.parts") + } + + assertEquals("The token was expected to have 3 parts, but got 4.", t.message) + } + + @Test + fun shouldThrowIfItsNotBase64Encoded() { + val t = assertFailsWith { + JWT.decode("thisIsNot.Base64_Enc.oded") + } + + assertEquals("The input is not a valid base 64 encoded string.", t.message) + } + + @Test + fun shouldThrowIfPayloadHasInvalidJSONFormat() { + val t = assertFailsWith { + JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30ijfe923.XmNK3GpH3Ys_7lyQ") + } + + assertEquals("The input is not a valid base 64 encoded string.", t.message) + } + + // toString + @Test + fun shouldGetStringToken() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ") + + assertEquals("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ", jwt.toString()) + } + + // Parts + @Test + fun shouldGetHeader() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ") + + assertEquals("HS256", jwt.getHeaderClaim("alg").asString()) + } + + @Test + fun shouldGetSignature() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ") + + assertEquals("XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ", jwt.signature) + } + + @Test + fun shouldGetEmptySignature() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.") + + assertEquals("", jwt.signature) + } + + // Public Claims + @Test + fun shouldGetIssuer() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJKb2huIERvZSJ9.SgXosfRR_IwCgHq5lF3tlM-JHtpucWCRSaVuoHTbWbQ") + + assertEquals("John Doe", jwt.issuer) + } + + @Test + fun shouldGetNullIssuerIfMissing() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.something") + + assertNull(jwt.issuer) + } + + @Test + fun shouldGetSubject() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJUb2szbnMifQ.RudAxkslimoOY3BLl2Ghny3BrUKu9I1ZrXzCZGDJtNs") + + assertEquals("Tok3ns", jwt.subject) + } + + @Test + fun shouldGetNullSubjectIfMissing() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.something") + + assertNull(jwt.subject) + } + + @Test + fun shouldGetArrayAudience() { + val jwt = JWT.decode( + "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOlsiSG9wZSIsIlRyYXZpcyIsIlNvbG9tb24iXX0.Tm4W8WnfPjlmHSmKFakdij0on2rWPETpoM7Sh0u6-S4", + ) + + val audience = jwt.audience + + assertNotNull(audience) + assertEquals(3, audience.size) + assertContains(audience, "Hope") + assertContains(audience, "Travis") + assertContains(audience, "Solomon") + } + + @Test + fun shouldGetStringAudience() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJKYWNrIFJleWVzIn0.a4I9BBhPt1OB1GW67g2P1bEHgi6zgOjGUL4LvhE9Dgc") + + val audience = jwt.audience + + assertNotNull(audience) + assertEquals(1, audience.size) + assertContains(audience, "Jack Reyes") + } + + @Test + fun shouldGetEmptyListAudienceIfMissing() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.something") + + val audience = jwt.audience + + assertEquals(null, audience) + } + + @Test + fun shouldDeserializeDatesUsingLong() { + val jwt = JWT.decode( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjIxNDc0OTM2NDcsIm5iZiI6MjE0NzQ5MzY0NywiZXhwIjoyMTQ3NDkzNjQ3LCJjdG0iOjIxNDc0OTM2NDd9.txmUJ0UCy2pqTFrEgj49eNDQCWUSW_XRMjMaRqcrgLg", + ) + + val seconds: Long = Int.MAX_VALUE + 10000L + val expectedDate = Instant.fromEpochSeconds(seconds) + + assertEquals(expectedDate, jwt.issuedAt) + assertEquals(expectedDate, jwt.notBefore) + assertEquals(expectedDate, jwt.expiresAt) + assertEquals(expectedDate, jwt.getClaim("ctm").asInstant()) + } + + @Test + fun shouldGetExpirationTime() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJleHAiOiIxNDc2NzI3MDg2In0.XwZztHlQwnAgmnQvrcWXJloLOUaLZGiY0HOXJCKRaks") + val expectedDate = Instant.fromEpochSeconds(1476727086L) + + assertNotNull(jwt.expiresAt) + assertEquals(expectedDate, jwt.expiresAt) + } + + @Test + fun shouldGetNullExpirationTimeIfMissing() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.something") + + assertNull(jwt.expiresAt) + } + + @Test + fun shouldGetNotBefore() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJuYmYiOiIxNDc2NzI3MDg2In0.pi3Fi3oFiXk5A5AetDdL0hjVx_rt6F5r_YiG6HoCYDw") + val expectedDate = Instant.fromEpochSeconds(1476727086L) + + assertNotNull(jwt.notBefore) + assertEquals(expectedDate, jwt.notBefore) + } + + @Test + fun shouldGetNullNotBeforeIfMissing() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.something") + + assertNull(jwt.notBefore) + } + + @Test + fun shouldGetIssuedAt() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOiIxNDc2NzI3MDg2In0.u6BxwrO7S0sqDY8-1cUOLzU2uejAJBzQQF8g_o5BAgo") + val expectedDate = Instant.fromEpochSeconds(1476727086L) + + assertNotNull(jwt.issuedAt) + assertEquals(expectedDate, jwt.issuedAt) + } + + @Test + fun shouldGetNullIssuedAtIfMissing() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.something") + + assertNull(jwt.issuedAt) + } + + @Test + fun shouldGetId() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxMjM0NTY3ODkwIn0.m3zgEfVUFOd-CvL3xG5BuOWLzb0zMQZCqiVNQQOPOvA") + + assertEquals("1234567890", jwt.id) + } + + @Test + fun shouldGetNullIdIfMissing() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.something") + + assertNull(jwt.id) + } + + @Test + fun shouldNotBeDeemedExpiredWithoutDateClaims() { + val jwt = customTimeJWT(null, null) + + assertFalse { jwt.isExpired(0.seconds) } + } + + @Test + fun shouldNotBeDeemedExpired() { + val jwt = customTimeJWT(null, Clock.System.now().toEpochMilliseconds() + 2000) + + assertFalse { jwt.isExpired(0.seconds) } + } + + @Test + fun shouldBeDeemedExpired() { + val jwt = customTimeJWT(null, Clock.System.now().toEpochMilliseconds() - 2000) + + assertTrue { jwt.isExpired(0.seconds) } + } + + @Test + fun shouldNotBeDeemedExpiredByLeeway() { + val jwt = customTimeJWT(null, Clock.System.now().toEpochMilliseconds() - 1000) + + assertFalse { jwt.isExpired(2.seconds) } + } + + @Test + fun shouldBeDeemedExpiredByLeeway() { + val jwt = customTimeJWT(null, Clock.System.now().toEpochMilliseconds() - 2000) + + assertTrue { jwt.isExpired(1.seconds) } + } + + @Test + fun shouldNotBeDeemedFutureIssued() { + val jwt = customTimeJWT(Clock.System.now().toEpochMilliseconds() - 2000, null) + + assertFalse { jwt.isExpired(0.seconds) } + } + + @Test + fun shouldBeDeemedFutureIssued() { + val jwt = customTimeJWT(Clock.System.now().toEpochMilliseconds() + 2000, null) + + assertTrue { jwt.isExpired(0.seconds) } + } + + @Test + fun shouldNotBeDeemedFutureIssuedByLeeway() { + val jwt = customTimeJWT(Clock.System.now().toEpochMilliseconds() + 1000, null) + + assertFalse { jwt.isExpired(2.seconds) } + } + + @Test + fun shouldBeDeemedFutureIssuedByLeeway() { + val jwt = customTimeJWT(Clock.System.now().toEpochMilliseconds() + 2000, null) + + assertTrue { jwt.isExpired(1.seconds) } + } + + @Test + fun shouldBeDeemedNotTimeValid() { + val jwt = customTimeJWT(Clock.System.now().toEpochMilliseconds() + 1000, Clock.System.now().toEpochMilliseconds() - 1000) + + assertTrue { jwt.isExpired(0.seconds) } + } + + @Test + fun shouldBeDeemedTimeValid() { + val jwt = customTimeJWT(Clock.System.now().toEpochMilliseconds() - 1000, Clock.System.now().toEpochMilliseconds() + 1000) + + assertFalse { jwt.isExpired(0.seconds) } + } + + @Test + fun shouldThrowIfLeewayIsNegative() { + val t = assertFailsWith { + customTimeJWT(null, null).isExpired(-(1.seconds)) + } + + assertEquals("The leeway must be a positive value.", t.message) + } + + @Test + fun shouldNotRemoveKnownPublicClaimsFromTree() { + val jwt = JWT.decode( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoMCIsInN1YiI6ImVtYWlscyIsImF1ZCI6InVzZXJzIiwiaWF0IjoxMDEwMTAxMCwiZXhwIjoxMTExMTExMSwibmJmIjoxMDEwMTAxMSwianRpIjoiaWRpZCIsInJvbGVzIjoiYWRtaW4ifQ.jCchxb-mdMTq5EpeVMSQyTp6zSwByKnfl9U-Zc9kg_w", + ) + + assertEquals("auth0", jwt.issuer) + assertEquals("emails", jwt.subject) + assertContains(jwt.audience.orEmpty(), "users") + + assertEquals(Instant.fromEpochSeconds(10101010L), jwt.issuedAt) + assertEquals(Instant.fromEpochSeconds(11111111L), jwt.expiresAt) + assertEquals(Instant.fromEpochSeconds(10101011L), jwt.notBefore) + assertEquals("idid", jwt.id) + + assertEquals("admin", jwt.getClaim("roles").asString()) + assertEquals("auth0", jwt.getClaim("iss").asString()) + assertEquals("emails", jwt.getClaim("sub").asString()) + assertEquals("users", jwt.getClaim("aud").asString()) + assertEquals(10101010.0, jwt.getClaim("iat").asDouble()) + assertEquals(11111111.0, jwt.getClaim("exp").asDouble()) + assertEquals(10101011.0, jwt.getClaim("nbf").asDouble()) + assertEquals("idid", jwt.getClaim("jti").asString()) + } + + // Private Claims + @Test + fun shouldGetBaseClaimIfClaimIsMissing() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.K17vlwhE8FCMShdl1_65jEYqsQqBOVMPUU9IgG-QlTM") + + assertTrue { jwt.getClaim("notExisting") !is JsonClaim } + assertTrue { jwt.getClaim("notExisting") is EmptyClaim } + } + + @Test + fun shouldGetClaim() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.eyJvYmplY3QiOnsibmFtZSI6ImpvaG4ifX0.lrU1gZlOdlmTTeZwq0VI-pZx2iV46UWYd5-lCjy6-c4") + + assertTrue { jwt.getClaim("object") is JsonClaim } + } + + @Test + fun shouldGetAllClaims() { + val jwt = JWT.decode( + "eyJhbGciOiJIUzI1NiJ9.eyJvYmplY3QiOnsibmFtZSI6ImpvaG4ifSwic3ViIjoiYXV0aDAifQ.U20MgOAV81c54mRelwYDJiLllb5OVwUAtMGn-eUOpTA", + ) + val claims: Map? = ((jwt as JWTDecoder).jwtPayload as JWTPayload).tree + + assertNotNull(claims) + + val objectClaim = jwt.getClaim("object") + + assertNotNull(objectClaim) + + assertTrue { objectClaim is JsonClaim } + + val extraClaim: Claim = jwt.getClaim("sub") + + assertTrue { extraClaim !is EmptyClaim } + + assertEquals("auth0", extraClaim.asString()) + } + + @Test + fun shouldGetEmptyAllClaims() { + val jwt = JWT.decode("eyJhbGciOiJIUzI1NiJ9.e30.ZRrHA1JJJW8opsbCGfG_HACGpVUMN_a9IV7pAx_Zmeo") + val claims: Map = assertNotNull(((jwt as JWTDecoder).jwtPayload as JWTPayload).tree) + + assertTrue { claims.isEmpty() } + } + + /** + * Creates a new JWT with custom time claims. + * + * @param iatMs iat value in MILLISECONDS + * @param expMs exp value in MILLISECONDS + * @return a JWT + */ + private fun customTimeJWT(iatMs: Long?, expMs: Long?): DecodedJWT { + val header = encodeString("{}") + val bodyBuilder = StringBuilder("{") + if (iatMs != null) { + val iatSeconds = (iatMs / 1000).toLong() + bodyBuilder.append("\"iat\":\"").append(iatSeconds).append("\"") + } + if (expMs != null) { + if (iatMs != null) { + bodyBuilder.append(",") + } + val expSeconds = (expMs / 1000).toLong() + bodyBuilder.append("\"exp\":\"").append(expSeconds).append("\"") + } + bodyBuilder.append("}") + val body = encodeString(bodyBuilder.toString()) + val signature = "sign" + return JWT.decode("$header.$body.$signature") + } + + @OptIn(ExperimentalEncodingApi::class) + private fun encodeString(source: String): String { + return Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(source.encodeToByteArray()) + } +} diff --git a/shared-data/src/commonTest/kotlin/dev/sdkforge/jwt/decode/data/JsonClaimTest.kt b/shared-data/src/commonTest/kotlin/dev/sdkforge/jwt/decode/data/JsonClaimTest.kt new file mode 100644 index 0000000..f16f6d5 --- /dev/null +++ b/shared-data/src/commonTest/kotlin/dev/sdkforge/jwt/decode/data/JsonClaimTest.kt @@ -0,0 +1,232 @@ +package dev.sdkforge.jwt.decode.data + +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.encodeToJsonElement + +@OptIn(ExperimentalTime::class) +class JsonClaimTest { + private val json = Json.Default + + @Test + fun shouldGetBooleanValue() { + val value: JsonElement = json.encodeToJsonElement(true) + val claim = JsonClaim(value) + + assertNotNull(claim.asBoolean()) + assertEquals(true, claim.asBoolean()) + } + + @Test + fun shouldGetNullBooleanIfNotPrimitiveValue() { + val value: JsonElement = json.encodeToJsonElement(Unit) + val claim = JsonClaim(value) + + assertNull(claim.asBoolean()) + } + + @Test + fun shouldGetIntValue() { + val value: JsonElement = json.encodeToJsonElement(123) + val claim = JsonClaim(value) + + assertNotNull(claim.asInt()) + assertEquals(123, claim.asInt()) + } + + @Test + fun shouldGetLongValue() { + val value: JsonElement = json.encodeToJsonElement(123L) + val claim = JsonClaim(value) + + assertNotNull(claim.asLong()) + assertEquals(123L, claim.asLong()) + } + + @Test + fun shouldGetNullIntIfNotPrimitiveValue() { + val value: JsonElement = json.encodeToJsonElement(Unit) + val claim = JsonClaim(value) + + assertNull(claim.asInt()) + } + + @Test + fun shouldGetNullLongIfNotPrimitiveValue() { + val value: JsonElement = json.encodeToJsonElement(Unit) + val claim = JsonClaim(value) + + assertNull(claim.asLong()) + } + + @Test + fun shouldGetDoubleValue() { + val value: JsonElement = json.encodeToJsonElement(1.5) + val claim = JsonClaim(value) + + assertNotNull(claim.asDouble()) + assertEquals(1.5, claim.asDouble()) + } + + @Test + fun shouldGetNullDoubleIfNotPrimitiveValue() { + val value: JsonElement = json.encodeToJsonElement(Unit) + val claim = JsonClaim(value) + + assertNull(claim.asDouble()) + } + + @Test + fun shouldGetLargeDateValue() { + val seconds: Long = Int.MAX_VALUE + 10000L + val value: JsonElement = json.encodeToJsonElement(seconds) + val claim = JsonClaim(value) + + val date: Instant? = claim.asInstant() + assertNotNull(date) + assertEquals(seconds, date.epochSeconds) + assertEquals(2147493647, date.epochSeconds) + } + + @Test + fun shouldGetDateValue() { + val value: JsonElement = json.encodeToJsonElement("1476824844") + val claim = JsonClaim(value) + + assertNotNull(claim.asInstant()) + assertEquals(Instant.fromEpochSeconds(1476824844), claim.asInstant()) + } + + @Test + fun shouldGetNullDateIfNotPrimitiveValue() { + val value: JsonElement = json.encodeToJsonElement(Unit) + val claim = JsonClaim(value) + + assertNull(claim.asInstant()) + } + + @Test + fun shouldGetStringValue() { + val value: JsonElement = json.encodeToJsonElement("string") + val claim = JsonClaim(value) + + assertNotNull(claim.asString()) + assertEquals("string", claim.asString()) + } + + @Test + fun shouldGetNullStringIfNotPrimitiveValue() { + val value: JsonElement = json.encodeToJsonElement(Unit) + val claim = JsonClaim(value) + + assertNull(claim.asString()) + } + + @Test + fun shouldGetListValueOfCustomClass() { + val value: JsonElement = json.encodeToJsonElement(listOf(UserPojo("George", 1), UserPojo("Mark", 2))) + val claim = JsonClaim(value) + + assertNotNull(claim.asList(UserPojo.serializer())) + assertContentEquals( + listOf(UserPojo("George", 1), UserPojo("Mark", 2)), + claim.asList(UserPojo.serializer()), + ) + } + + @Test + fun shouldGetListValue() { + val value: JsonElement = json.encodeToJsonElement(listOf("string1", "string2")) + val claim = JsonClaim(value) + + assertNotNull(claim.asList(String.serializer())) + assertContentEquals( + listOf("string1", "string2"), + claim.asList(String.serializer()), + ) + } + + @Test + fun shouldGetEmptyListIfNullValue() { + val value: JsonElement = json.encodeToJsonElement(null.orEmpty()) + val claim = JsonClaim(value) + + assertNotNull(claim.asList(String.serializer())) + assertContentEquals( + emptyList(), + claim.asList(String.serializer()), + ) + } + + @Test + fun shouldGetEmptyListIfNonArrayValue() { + val value: JsonElement = json.encodeToJsonElement(1) + val claim = JsonClaim(value) + + assertNotNull(claim.asList(String.serializer())) + assertContentEquals( + emptyList(), + claim.asList(String.serializer()), + ) + } + + @Test + fun shouldThrowIfListClassMismatch() { + val value: JsonElement = json.encodeToJsonElement(arrayOf("keys", "values")) + val claim = JsonClaim(value) + + assertFailsWith { + claim.asList(UserPojo.serializer()) + } + } + + @Test + fun shouldGetAsObject() { + val data = UserPojo("George", 1) + val userValue: JsonElement = json.encodeToJsonElement(data) + val userClaim = JsonClaim(userValue) + + val intValue: JsonElement = json.encodeToJsonElement(1) + val intClaim = JsonClaim(intValue) + + val booleanValue: JsonElement = json.encodeToJsonElement(true) + val booleanClaim = JsonClaim(booleanValue) + + assertNotNull(userClaim.asObject(UserPojo.serializer())) + assertEquals(UserPojo("George", 1), userClaim.asObject(UserPojo.serializer())) + + assertNotNull(intClaim.asObject(Int.serializer())) + assertEquals(1, intClaim.asObject(Int.serializer())) + + assertNotNull(booleanClaim.asObject(Boolean.serializer())) + assertEquals(true, booleanClaim.asObject(Boolean.serializer())) + } + + @Test + fun shouldGetNullObjectIfNullValue() { + val claim = JsonClaim(JsonNull) + + assertNull(claim.asObject(UserPojo.serializer())) + } + + @Test + fun shouldThrowIfObjectClassMismatch() { + val value: JsonElement = json.encodeToJsonElement(1) + val claim = JsonClaim(value) + + assertFailsWith { + claim.asObject(UserPojo.serializer()) + } + } +} diff --git a/shared-data/src/commonTest/kotlin/dev/sdkforge/jwt/decode/data/UserPojo.kt b/shared-data/src/commonTest/kotlin/dev/sdkforge/jwt/decode/data/UserPojo.kt new file mode 100644 index 0000000..6f7db59 --- /dev/null +++ b/shared-data/src/commonTest/kotlin/dev/sdkforge/jwt/decode/data/UserPojo.kt @@ -0,0 +1,11 @@ +@file:Suppress("ktlint:standard:class-signature") + +package dev.sdkforge.jwt.decode.data + +import kotlinx.serialization.Serializable + +@Serializable +internal data class UserPojo( + private val name: String?, + private val id: Int, +) diff --git a/shared-data/src/iosMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Crypto.ios.kt b/shared-data/src/iosMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Crypto.ios.kt new file mode 100644 index 0000000..c8f0264 --- /dev/null +++ b/shared-data/src/iosMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/Crypto.ios.kt @@ -0,0 +1,58 @@ +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.PrivateKey +import dev.sdkforge.crypto.domain.PublicKey + +internal actual fun verifySignature( + algorithm: String, + secretBytes: ByteArray, + headerBytes: ByteArray, + payloadBytes: ByteArray, + signatureBytes: ByteArray, +): Boolean { + TODO("Not yet implemented") +} + +internal actual fun verifySignature( + algorithm: String, + publicKey: PublicKey, + headerBytes: ByteArray, + payloadBytes: ByteArray, + signatureBytes: ByteArray, +): Boolean { + TODO("Not yet implemented") +} + +internal actual fun createSignatureFor( + algorithm: String, + privateKey: PrivateKey, + headerBytes: ByteArray, + payloadBytes: ByteArray, +): ByteArray { + TODO("Not yet implemented") +} + +internal actual fun createSignatureFor( + algorithm: String, + secretBytes: ByteArray, + headerBytes: ByteArray, + payloadBytes: ByteArray, +): ByteArray { + TODO("Not yet implemented") +} + +internal actual fun createSignatureFor( + algorithm: String, + secretBytes: ByteArray, + contentBytes: ByteArray, +): ByteArray { + TODO("Not yet implemented") +} + +internal actual fun createSignatureFor( + algorithm: String, + privateKey: PrivateKey, + contentBytes: ByteArray, +): ByteArray { + TODO("Not yet implemented") +} diff --git a/shared-data/src/iosMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSAAlgorithm.ios.kt b/shared-data/src/iosMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSAAlgorithm.ios.kt new file mode 100644 index 0000000..9b2a92c --- /dev/null +++ b/shared-data/src/iosMain/kotlin/dev/sdkforge/jwt/decode/data/algorithm/ECDSAAlgorithm.ios.kt @@ -0,0 +1,11 @@ +package dev.sdkforge.jwt.decode.data.algorithm + +import dev.sdkforge.crypto.domain.ec.ECPublicKey + +internal actual fun verifySignature( + publicKey: ECPublicKey, + rBytes: ByteArray, + sBytes: ByteArray, +) { + TODO("Not yet implemented") +} diff --git a/shared-domain/api/shared-domain.api b/shared-domain/api/shared-domain.api new file mode 100644 index 0000000..123888a --- /dev/null +++ b/shared-domain/api/shared-domain.api @@ -0,0 +1,181 @@ +public abstract interface class dev/sdkforge/jwt/decode/domain/Claim { + public static final field Companion Ldev/sdkforge/jwt/decode/domain/Claim$Companion; + public abstract fun asBoolean ()Ljava/lang/Boolean; + public abstract fun asDouble ()Ljava/lang/Double; + public abstract fun asInstant ()Lkotlin/time/Instant; + public abstract fun asInt ()Ljava/lang/Integer; + public abstract fun asList (Lkotlinx/serialization/DeserializationStrategy;)Ljava/util/List; + public abstract fun asLong ()Ljava/lang/Long; + public abstract fun asObject (Lkotlinx/serialization/DeserializationStrategy;)Ljava/lang/Object; + public abstract fun asString ()Ljava/lang/String; + public abstract fun isMissing ()Z + public abstract fun isNull ()Z +} + +public final class dev/sdkforge/jwt/decode/domain/Claim$Companion { +} + +public final class dev/sdkforge/jwt/decode/domain/Claim$Companion$Registered { + public static final field AUDIENCE Ljava/lang/String; + public static final field EXPIRES_AT Ljava/lang/String; + public static final field INSTANCE Ldev/sdkforge/jwt/decode/domain/Claim$Companion$Registered; + public static final field ISSUED_AT Ljava/lang/String; + public static final field ISSUER Ljava/lang/String; + public static final field JWT_ID Ljava/lang/String; + public static final field NOT_BEFORE Ljava/lang/String; + public static final field SUBJECT Ljava/lang/String; +} + +public abstract interface class dev/sdkforge/jwt/decode/domain/DecodedJWT : dev/sdkforge/jwt/decode/domain/Header, dev/sdkforge/jwt/decode/domain/Payload { + public abstract fun getHeader ()Ljava/lang/String; + public abstract fun getPayload ()Ljava/lang/String; + public abstract fun getSignature ()Ljava/lang/String; + public abstract fun getToken ()Ljava/lang/String; +} + +public final class dev/sdkforge/jwt/decode/domain/DecodedJWTKt { + public static final fun isExpired-HG0u8IE (Ldev/sdkforge/jwt/decode/domain/DecodedJWT;J)Z +} + +public abstract interface class dev/sdkforge/jwt/decode/domain/Header { + public static final field Companion Ldev/sdkforge/jwt/decode/domain/Header$Companion; + public abstract fun getAlgorithm ()Ljava/lang/String; + public abstract fun getContentType ()Ljava/lang/String; + public abstract fun getHeaderClaim (Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Claim; + public abstract fun getKeyId ()Ljava/lang/String; + public abstract fun getType ()Ljava/lang/String; +} + +public final class dev/sdkforge/jwt/decode/domain/Header$Companion { +} + +public final class dev/sdkforge/jwt/decode/domain/Header$Companion$Params { + public static final field ALGORITHM Ljava/lang/String; + public static final field CONTENT_TYPE Ljava/lang/String; + public static final field INSTANCE Ldev/sdkforge/jwt/decode/domain/Header$Companion$Params; + public static final field KEY_ID Ljava/lang/String; + public static final field TYPE Ljava/lang/String; +} + +public abstract interface class dev/sdkforge/jwt/decode/domain/JWTParser { + public abstract fun parseHeader (Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Header; + public abstract fun parsePayload (Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Payload; +} + +public abstract interface class dev/sdkforge/jwt/decode/domain/JWTVerifier { + public abstract fun verify (Ldev/sdkforge/jwt/decode/domain/DecodedJWT;)Ldev/sdkforge/jwt/decode/domain/DecodedJWT; + public abstract fun verify (Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/DecodedJWT; +} + +public abstract interface class dev/sdkforge/jwt/decode/domain/Payload { + public abstract fun getAudience ()Ljava/util/List; + public abstract fun getClaim (Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Claim; + public abstract fun getExpiresAt ()Lkotlin/time/Instant; + public abstract fun getId ()Ljava/lang/String; + public abstract fun getIssuedAt ()Lkotlin/time/Instant; + public abstract fun getIssuer ()Ljava/lang/String; + public abstract fun getNotBefore ()Lkotlin/time/Instant; + public abstract fun getSubject ()Ljava/lang/String; +} + +public abstract interface class dev/sdkforge/jwt/decode/domain/Verification { + public abstract fun acceptExpiresAt (J)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun acceptIssuedAt (J)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun acceptLeeway (J)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun acceptNotBefore (J)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun build ()Ldev/sdkforge/jwt/decode/domain/JWTVerifier; + public abstract fun ignoreIssuedAt ()Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withAnyOfAudience ([Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withArrayClaim (Ljava/lang/String;[I)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withArrayClaim (Ljava/lang/String;[J)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withArrayClaim (Ljava/lang/String;[Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withAudience ([Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withClaim (Ljava/lang/String;D)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withClaim (Ljava/lang/String;I)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withClaim (Ljava/lang/String;J)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withClaim (Ljava/lang/String;Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withClaim (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withClaim (Ljava/lang/String;Lkotlin/time/Instant;)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withClaim (Ljava/lang/String;Lkotlinx/datetime/LocalDate;)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withClaim (Ljava/lang/String;Z)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withClaimPresence (Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withIssuer ([Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withJWTId (Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withNullClaim (Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Verification; + public abstract fun withSubject (Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Verification; +} + +public abstract class dev/sdkforge/jwt/decode/domain/algorithm/Algorithm { + public static final field Companion Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun getDescription ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public fun toString ()Ljava/lang/String; +} + +public final class dev/sdkforge/jwt/decode/domain/algorithm/Algorithm$Companion { +} + +public final class dev/sdkforge/jwt/decode/domain/exception/AlgorithmMismatchException : dev/sdkforge/jwt/decode/domain/exception/JWTVerificationException { + public fun (Ljava/lang/String;)V +} + +public final class dev/sdkforge/jwt/decode/domain/exception/IncorrectClaimException : dev/sdkforge/jwt/decode/domain/exception/InvalidClaimException { + public fun (Ljava/lang/String;Ljava/lang/String;Ldev/sdkforge/jwt/decode/domain/Claim;)V + public final fun getClaim ()Ldev/sdkforge/jwt/decode/domain/Claim; + public final fun getClaimName ()Ljava/lang/String; +} + +public class dev/sdkforge/jwt/decode/domain/exception/InvalidClaimException : dev/sdkforge/jwt/decode/domain/exception/JWTVerificationException { +} + +public class dev/sdkforge/jwt/decode/domain/exception/JWTCreationException : java/lang/RuntimeException { + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V +} + +public final class dev/sdkforge/jwt/decode/domain/exception/JWTDecodeException : dev/sdkforge/jwt/decode/domain/exception/JWTVerificationException { + public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V +} + +public class dev/sdkforge/jwt/decode/domain/exception/JWTVerificationException : java/lang/RuntimeException { + public fun (Ljava/lang/String;)V +} + +public final class dev/sdkforge/jwt/decode/domain/exception/MissingClaimException : dev/sdkforge/jwt/decode/domain/exception/InvalidClaimException { + public fun (Ljava/lang/String;)V + public final fun getClaimName ()Ljava/lang/String; +} + +public final class dev/sdkforge/jwt/decode/domain/exception/SignatureException : dev/sdkforge/jwt/decode/domain/exception/JWTVerificationException { + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V +} + +public final class dev/sdkforge/jwt/decode/domain/exception/SignatureGenerationException : dev/sdkforge/jwt/decode/domain/exception/JWTCreationException { + public fun (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm;Ljava/lang/Throwable;)V +} + +public final class dev/sdkforge/jwt/decode/domain/exception/SignatureVerificationException : dev/sdkforge/jwt/decode/domain/exception/JWTVerificationException { + public fun (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm;)V + public fun (Ldev/sdkforge/jwt/decode/domain/algorithm/Algorithm;Ljava/lang/Throwable;)V +} + +public final class dev/sdkforge/jwt/decode/domain/exception/TokenExpiredException : dev/sdkforge/jwt/decode/domain/exception/JWTVerificationException { + public fun (Ljava/lang/String;Lkotlin/time/Instant;)V + public final fun getExpiredOn ()Lkotlin/time/Instant; +} + +public abstract interface class dev/sdkforge/jwt/decode/domain/provider/ECDSAKeyProvider : dev/sdkforge/jwt/decode/domain/provider/KeyProvider { +} + +public abstract interface class dev/sdkforge/jwt/decode/domain/provider/KeyProvider { + public abstract fun getPrivateKey ()Ldev/sdkforge/crypto/domain/PrivateKey; + public abstract fun getPrivateKeyId ()Ljava/lang/String; + public abstract fun getPublicKeyById (Ljava/lang/String;)Ldev/sdkforge/crypto/domain/PublicKey; +} + +public abstract interface class dev/sdkforge/jwt/decode/domain/provider/RSAKeyProvider : dev/sdkforge/jwt/decode/domain/provider/KeyProvider { +} + diff --git a/shared-domain/build.gradle.kts b/shared-domain/build.gradle.kts new file mode 100644 index 0000000..4211311 --- /dev/null +++ b/shared-domain/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.binaryCompatibilityValidator) + alias(libs.plugins.dokka) + alias(libs.plugins.build.logic.library.kmp) + alias(libs.plugins.build.logic.library.android) + alias(libs.plugins.build.logic.library.publishing) +} + +kotlin { + sourceSets { + commonMain { + dependencies { + implementation("dev.sdkforge.crypto:crypto-domain:0.0.2-SNAPSHOT") + + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) + } + } + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + } +} + +android { + namespace = "dev.sdkforge.jwt.decode.domain" +} diff --git a/shared-domain/dependencies/releaseRuntimeClasspath.txt b/shared-domain/dependencies/releaseRuntimeClasspath.txt new file mode 100644 index 0000000..263c0ba --- /dev/null +++ b/shared-domain/dependencies/releaseRuntimeClasspath.txt @@ -0,0 +1,11 @@ +dev.sdkforge.crypto:crypto-domain-android:0.0.2-SNAPSHOT +dev.sdkforge.crypto:crypto-domain:0.0.2-SNAPSHOT +org.jetbrains.kotlin:kotlin-stdlib:2.2.20 +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1 +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0 +org.jetbrains:annotations:13.0 diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Claim.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Claim.kt new file mode 100644 index 0000000..40989f7 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Claim.kt @@ -0,0 +1,150 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.domain + +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.serialization.DeserializationStrategy + +/** + * The Claim class holds the value in a generic way so that it can be recovered in many representations. + */ +@OptIn(ExperimentalTime::class) +interface Claim { + /** + * Whether this Claim has a null value or not. + * If the claim is not present, it will return false hence checking [Claim.isMissing] is advised as well + * + * @return whether this Claim has a null value or not. + */ + val isNull: Boolean + + /** + * Can be used to verify whether the Claim is found or not. + * This will be true even if the Claim has `null` value associated to it. + * + * @return whether this Claim is present or not + */ + val isMissing: Boolean + + /** + * Get this Claim as a Boolean. + * If the value isn't of type Boolean or it can't be converted to a Boolean, `null` will be returned. + * + * @return the value as a Boolean or null. + */ + fun asBoolean(): Boolean? + + /** + * Get this Claim as an Integer. + * If the value isn't of type Integer or it can't be converted to an Integer, `null` will be returned. + * + * @return the value as an Integer or null. + */ + fun asInt(): Int? + + /** + * Get this Claim as an Long. + * If the value isn't of type Long or it can't be converted to a Long, `null` will be returned. + * + * @return the value as an Long or null. + */ + fun asLong(): Long? + + /** + * Get this Claim as a Double. + * If the value isn't of type Double or it can't be converted to a Double, `null` will be returned. + * + * @return the value as a Double or null. + */ + fun asDouble(): Double? + + /** + * Get this Claim as a String. + * If the value isn't of type String, `null` will be returned. For a String representation of non-textual + * claim types, clients can call `toString()`. + * + * @return the value as a String or null if the underlying value is not a string. + */ + fun asString(): String? + + /** + * Get this Claim as an Instant. + * If the value can't be converted to an Instant, `null` will be returned. + * + * @return the value as an Instant or null. + */ + fun asInstant(): Instant? + + /** + * Get this Claim as a List of type T. + * If the value isn't an Array, an empty List will be returned. + * + * @return the value as a List or an empty List. + * @throws JWTDecodeException if the values inside the List can't be converted to a class T. + */ + @Throws(JWTDecodeException::class) + fun asList(deserializer: DeserializationStrategy): List + + /** + * Get this Claim as a Object of type T. + * This method will return null if [Claim.isMissing] or [Claim.isNull] is true + * + * @return the value as a Object of type T or null. + * @throws JWTDecodeException if the value can't be converted to a class T. + */ + @Throws(JWTDecodeException::class) + fun asObject(deserializer: DeserializationStrategy): T? + + companion object { + /** + * Contains constants representing the name of the Registered Claim Names as defined in Section 4.1 of + * [RFC 7529](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1) + */ + object Registered { + /** + * The "iss" (issuer) claim identifies the principal that issued the JWT. + * Refer RFC 7529 [Section 4.1.1](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1) + */ + const val ISSUER: String = "iss" + + /** + * The "sub" (subject) claim identifies the principal that is the subject of the JWT. + * Refer RFC 7529 [Section 4.1.2](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2) + */ + const val SUBJECT: String = "sub" + + /** + * The "aud" (audience) claim identifies the recipients that the JWT is intended for. + * Refer RFC 7529 [Section 4.1.3](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3) + */ + const val AUDIENCE: String = "aud" + + /** + * The "exp" (expiration time) claim identifies the expiration time on or after which the JWT MUST NOT be + * accepted for processing. + * Refer RFC 7529 [Section 4.1.4](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4) + */ + const val EXPIRES_AT: String = "exp" + + /** + * The "nbf" (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing. + * Refer RFC 7529 [Section 4.1.5](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5) + */ + const val NOT_BEFORE: String = "nbf" + + /** + * The "iat" (issued at) claim identifies the time at which the JWT was issued. + * Refer RFC 7529 [Section 4.1.6](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6) + */ + const val ISSUED_AT: String = "iat" + + /** + * The "jti" (JWT ID) claim provides a unique identifier for the JWT. + * Refer RFC 7529 [Section 4.1.7](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7) + */ + const val JWT_ID: String = "jti" + } + } +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/DecodedJWT.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/DecodedJWT.kt new file mode 100644 index 0000000..a52ce2e --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/DecodedJWT.kt @@ -0,0 +1,61 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.domain + +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * Class that represents a Json Web Token that was decoded from it's string representation. + */ +interface DecodedJWT : + Payload, + Header { + /** + * Getter for the String Token used to create this JWT instance. + * + * @return the String Token. + */ + val token: String + + /** + * Getter for the Header contained in the JWT as a Base64 encoded String. + * This represents the first part of the token. + * + * @return the Header of the JWT. + */ + val header: String + + /** + * Getter for the Payload contained in the JWT as a Base64 encoded String. + * This represents the second part of the token. + * + * @return the Payload of the JWT. + */ + val payload: String + + /** + * Getter for the Signature contained in the JWT as a Base64 encoded String. + * This represents the third part of the token. + * + * @return the Signature of the JWT. + */ + val signature: String +} + +@OptIn(ExperimentalTime::class) +fun DecodedJWT.isExpired(leeway: Duration): Boolean { + require(leeway.inWholeSeconds >= 0) { "The leeway must be a positive value." } + + val now = Instant.fromEpochSeconds(Clock.System.now().epochSeconds) + val future: Instant = now + leeway + val past: Instant = now - leeway + val expiresAt = expiresAt + val issuedAt = issuedAt + val expValid = expiresAt == null || past <= expiresAt + val iatValid = issuedAt == null || future >= issuedAt + + return !expValid || !iatValid +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Header.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Header.kt new file mode 100644 index 0000000..7c8badd --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Header.kt @@ -0,0 +1,72 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.domain + +/** + * The Header class represents the 1st part of the JWT, where the Header value is held. + */ +interface Header { + /** + * Getter for the Algorithm "alg" claim defined in the JWT's Header. If the claim is missing it will return null. + * + * @return the Algorithm defined or null. + */ + val algorithm: String? + + /** + * Getter for the Type "typ" claim defined in the JWT's Header. If the claim is missing it will return null. + * + * @return the Type defined or null. + */ + val type: String? + + /** + * Getter for the Content Type "cty" claim defined in the JWT's Header. If the claim is missing it will return null. + * + * @return the Content Type defined or null. + */ + val contentType: String? + + /** + * Get the value of the "kid" claim, or null if it's not available. + * + * @return the Key ID value or null. + */ + val keyId: String? + + /** + * Get a Private Claim given it's name. If the Claim wasn't specified in the Header, a 'null claim' will be + * returned. All the methods of that claim will return `null`. + * + * @param name the name of the Claim to retrieve. + * @return a non-null Claim. + */ + fun getHeaderClaim(name: String): Claim + + companion object { + /** + * Contains constants representing the JWT header parameter names. + */ + object Params { + /** + * The algorithm used to sign a JWT. + */ + const val ALGORITHM: String = "alg" + + /** + * The content type of the JWT. + */ + const val CONTENT_TYPE: String = "cty" + + /** + * The media type of the JWT. + */ + const val TYPE: String = "typ" + + /** + * The key ID of a JWT used to specify the key for signature validation. + */ + const val KEY_ID: String = "kid" + } + } +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/JWTParser.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/JWTParser.kt new file mode 100644 index 0000000..8f70f10 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/JWTParser.kt @@ -0,0 +1,31 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.domain + +import dev.sdkforge.jwt.decode.domain.exception.JWTDecodeException + +/** + * The JWTParser class defines which parts of the JWT should be converted + * to its specific Object representation instance. + */ +interface JWTParser { + /** + * Parses the given JSON into a [Payload] instance. + * + * @param json the content of the Payload in a JSON representation. + * @return the Payload. + * @throws JWTDecodeException if the json doesn't have a proper JSON format. + */ + @Throws(JWTDecodeException::class) + fun parsePayload(json: String): Payload + + /** + * Parses the given JSON into a [Header] instance. + * + * @param json the content of the Header in a JSON representation. + * @return the Header. + * @throws JWTDecodeException if the json doesn't have a proper JSON format. + */ + @Throws(JWTDecodeException::class) + fun parseHeader(json: String): Header +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/JWTVerifier.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/JWTVerifier.kt new file mode 100644 index 0000000..0aeac7a --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/JWTVerifier.kt @@ -0,0 +1,30 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.domain + +import dev.sdkforge.jwt.decode.domain.exception.JWTVerificationException + +/** + * Used to verify the JWT for its signature and claims. Instances are created using [Verification]. + */ +interface JWTVerifier { + /** + * Performs the verification against the given Token. + * + * @param token to verify. + * @return a verified and decoded JWT. + * @throws JWTVerificationException if any of the verification steps fail + */ + @Throws(JWTVerificationException::class) + fun verify(token: String): DecodedJWT + + /** + * Performs the verification against the given [DecodedJWT]. + * + * @param jwt to verify. + * @return a verified and decoded JWT. + * @throws JWTVerificationException if any of the verification steps fail + */ + @Throws(JWTVerificationException::class) + fun verify(jwt: DecodedJWT): DecodedJWT +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Payload.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Payload.kt new file mode 100644 index 0000000..113589a --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Payload.kt @@ -0,0 +1,70 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.domain + +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * The Payload class represents the 2nd part of the JWT, where the Payload value is held. + */ +@OptIn(ExperimentalTime::class) +interface Payload { + /** + * Get the value of the "iss" claim, or null if it's not available. + * + * @return the Issuer value or null. + */ + val issuer: String? + + /** + * Get the value of the "sub" claim, or null if it's not available. + * + * @return the Subject value or null. + */ + val subject: String? + + /** + * Get the value of the "aud" claim, or null if it's not available. + * + * @return the Audience value or null. + */ + val audience: List? + + /** + * Get the value of the "exp" claim, or null if it's not available. + * + * @return the Expiration Time value or null. + */ + val expiresAt: Instant? + + /** + * Get the value of the "nbf" claim, or null if it's not available. + * + * @return the Not Before value or null. + */ + val notBefore: Instant? + + /** + * Get the value of the "iat" claim, or null if it's not available. + * + * @return the Issued At value or null. + */ + val issuedAt: Instant? + + /** + * Get the value of the "jti" claim, or null if it's not available. + * + * @return the JWT ID value or null. + */ + val id: String? + + /** + * Get a Claim given its name. If the Claim wasn't specified in the Payload, a 'null claim' + * will be returned. All the methods of that claim will return `null`. + * + * @param name the name of the Claim to retrieve. + * @return a non-null Claim. + */ + fun getClaim(name: String): Claim +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Verification.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Verification.kt new file mode 100644 index 0000000..c9b0c10 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Verification.kt @@ -0,0 +1,269 @@ +@file:Suppress("ktlint:standard:function-signature", "ktlint:standard:function-expression-body") + +package dev.sdkforge.jwt.decode.domain + +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.datetime.LocalDate + +/** + * Constructs and holds the checks required for a JWT to be considered valid. + */ +@OptIn(ExperimentalTime::class) +interface Verification { + + /** + * Verifies whether the JWT contains an Issuer ("iss") claim that contains all the values provided. + * This check is case-sensitive. An empty array is considered as a `null`. + * + * @param issuer the required Issuer value. If multiple values are given, the claim must at least match one of them + * @return this same Verification instance. + */ + fun withIssuer(vararg issuer: String): Verification + + /** + * Verifies whether the JWT contains a Subject ("sub") claim that equals to the value provided. + * This check is case-sensitive. + * + * @param subject the required Subject value + * @return this same Verification instance. + */ + fun withSubject(subject: String): Verification + + /** + * Verifies whether the JWT contains an Audience ("aud") claim that contains all the values provided. + * This check is case-sensitive. An empty array is considered as a `null`. + * + * @param audience the required Audience value + * @return this same Verification instance. + */ + fun withAudience(vararg audience: String): Verification + + /** + * Verifies whether the JWT contains an Audience ("aud") claim contain at least one of the specified audiences. + * This check is case-sensitive. An empty array is considered as a `null`. + * + * @param audience the required Audience value for which the "aud" claim must contain at least one value. + * @return this same Verification instance. + */ + fun withAnyOfAudience(vararg audience: String): Verification + + /** + * Define the default window in seconds in which the Not Before, Issued At and Expires At Claims + * will still be valid. Setting a specific leeway value on a given Claim will override this value for that Claim. + * + * @param leeway the window in seconds in which the Not Before, Issued At and Expires At Claims will still be valid. + * @return this same Verification instance. + * @throws IllegalArgumentException if leeway is negative. + */ + @Throws(IllegalArgumentException::class) + fun acceptLeeway(leeway: Long): Verification + + /** + * Set a specific leeway window in seconds in which the Expires At ("exp") Claim will still be valid. + * Expiration Date is always verified when the value is present. + * This method overrides the value set with acceptLeeway + * + * @param leeway the window in seconds in which the Expires At Claim will still be valid. + * @return this same Verification instance. + * @throws IllegalArgumentException if leeway is negative. + */ + @Throws(IllegalArgumentException::class) + fun acceptExpiresAt(leeway: Long): Verification + + /** + * Set a specific leeway window in seconds in which the Not Before ("nbf") Claim will still be valid. + * Not Before Date is always verified when the value is present. + * This method overrides the value set with acceptLeeway + * + * @param leeway the window in seconds in which the Not Before Claim will still be valid. + * @return this same Verification instance. + * @throws IllegalArgumentException if leeway is negative. + */ + @Throws(IllegalArgumentException::class) + fun acceptNotBefore(leeway: Long): Verification + + /** + * Set a specific leeway window in seconds in which the Issued At ("iat") Claim will still be valid. + * This method overrides the value set with [.acceptLeeway]. + * By default, the Issued At claim is always verified when the value is present, + * unless disabled with [.ignoreIssuedAt]. + * If Issued At verification has been disabled, no verification of the Issued At claim will be performed, + * and this method has no effect. + * + * @param leeway the window in seconds in which the Issued At Claim will still be valid. + * @return this same Verification instance. + * @throws IllegalArgumentException if leeway is negative. + */ + @Throws(IllegalArgumentException::class) + fun acceptIssuedAt(leeway: Long): Verification + + /** + * Verifies whether the JWT contains a JWT ID ("jti") claim that equals to the value provided. + * This check is case-sensitive. + * + * @param jwtId the required ID value + * @return this same Verification instance. + */ + fun withJWTId(jwtId: String): Verification + + /** + * Verifies whether the claim is present in the JWT, with any value including `null`. + * + * @param name the Claim's name. + * @return this same Verification instance + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withClaimPresence(name: String): Verification + + /** + * Verifies whether the claim is present with a `null` value. + * + * @param name the Claim's name. + * @return this same Verification instance. + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withNullClaim(name: String): Verification + + /** + * Verifies whether the claim is equal to the given Boolean value. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Verification instance. + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: Boolean): Verification + + /** + * Verifies whether the claim is equal to the given Integer value. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Verification instance. + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: Int): Verification + + /** + * Verifies whether the claim is equal to the given Long value. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Verification instance. + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: Long): Verification + + /** + * Verifies whether the claim is equal to the given Integer value. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Verification instance. + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: Double): Verification + + /** + * Verifies whether the claim is equal to the given String value. + * This check is case-sensitive. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Verification instance. + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: String): Verification + + /** + * Verifies whether the claim is equal to the given Date value. + * Note that date-time claims are serialized as seconds since the epoch; + * when verifying date-time claim value, any time units more granular than seconds will not be considered. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Verification instance. + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: LocalDate): Verification + + /** + * Verifies whether the claim is equal to the given Instant value. + * Note that date-time claims are serialized as seconds since the epoch; + * when verifying a date-time claim value, any time units more granular than seconds will not be considered. + * + * @param name the Claim's name. + * @param value the Claim's value. + * @return this same Verification instance. + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, value: Instant): Verification + + /** + * Executes the predicate provided and the validates the JWT if the predicate returns true. + * + * @param name the Claim's name + * @param predicate the predicate check to be done. + * @return this same Verification instance. + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withClaim(name: String, predicate: Function2): Verification + + /** + * Verifies whether the claim contain at least the given String items. + * + * @param name the Claim's name. + * @param items the items the Claim must contain. + * @return this same Verification instance. + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withArrayClaim(name: String, vararg items: String): Verification + + /** + * Verifies whether the claim contain at least the given Integer items. + * + * @param name the Claim's name. + * @param items the items the Claim must contain. + * @return this same Verification instance. + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withArrayClaim(name: String, vararg items: Int): Verification + + /** + * Verifies whether the claim contain at least the given Long items. + * + * @param name the Claim's name. + * @param items the items the Claim must contain. + * @return this same Verification instance. + * @throws IllegalArgumentException if the name is `null`. + */ + @Throws(IllegalArgumentException::class) + fun withArrayClaim(name: String, vararg items: Long): Verification + + /** + * Skip the Issued At ("iat") claim verification. By default, the verification is performed. + * + * @return this same Verification instance. + */ + fun ignoreIssuedAt(): Verification + + /** + * Creates a new and reusable instance of the JWTVerifier with the configuration already provided. + * + * @return a new [JWTVerifier] instance. + */ + fun build(): JWTVerifier +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/algorithm/Algorithm.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/algorithm/Algorithm.kt new file mode 100644 index 0000000..658f787 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/algorithm/Algorithm.kt @@ -0,0 +1,24 @@ +package dev.sdkforge.jwt.decode.domain.algorithm + +/** + * The Algorithm class represents an algorithm to be used in the Signing or Verification process of a Token. + */ +abstract class Algorithm( + /** + * Getter for the name of this Algorithm, as defined in the JWT Standard. + * + * @return the algorithm name. + */ + val name: String, + /** + * Getter for the description of this Algorithm, required when instantiating a Mac or Signature object. + * + * @return the algorithm description. + */ + val description: String, +) { + + override fun toString(): String = description + + companion object +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/AlgorithmMismatchException.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/AlgorithmMismatchException.kt new file mode 100644 index 0000000..3913eba --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/AlgorithmMismatchException.kt @@ -0,0 +1,6 @@ +package dev.sdkforge.jwt.decode.domain.exception + +/** + * The exception that will be thrown if the exception doesn't match the one mentioned in the JWT Header. + */ +class AlgorithmMismatchException(message: String) : JWTVerificationException(message) diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/IncorrectClaimException.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/IncorrectClaimException.kt new file mode 100644 index 0000000..199214e --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/IncorrectClaimException.kt @@ -0,0 +1,24 @@ +package dev.sdkforge.jwt.decode.domain.exception + +import dev.sdkforge.jwt.decode.domain.Claim + +/** + * This exception is thrown when the expected value is not found while verifying the Claims. + * + * @param message The error message + * @param claimName The Claim name for which verification failed + * @param claim The Claim value for which verification failed + */ +class IncorrectClaimException( + message: String, + /** + * This method can be used to fetch the name for which the Claim verification failed. + * + * @return The claim name for which the verification failed. + */ + val claimName: String, + /** + * The value for which the verification failed. + */ + val claim: Claim?, +) : InvalidClaimException(message) diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/InvalidClaimException.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/InvalidClaimException.kt new file mode 100644 index 0000000..1115faf --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/InvalidClaimException.kt @@ -0,0 +1,6 @@ +package dev.sdkforge.jwt.decode.domain.exception + +/** + * The exception that will be thrown while verifying Claims of a JWT. + */ +open class InvalidClaimException internal constructor(message: String?) : JWTVerificationException(message) diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/JWTCreationException.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/JWTCreationException.kt new file mode 100644 index 0000000..7139df7 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/JWTCreationException.kt @@ -0,0 +1,6 @@ +package dev.sdkforge.jwt.decode.domain.exception + +/** + * The exception that is thrown when a JWT cannot be created. + */ +open class JWTCreationException(message: String, cause: Throwable) : RuntimeException(message, cause) diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/JWTDecodeException.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/JWTDecodeException.kt new file mode 100644 index 0000000..73f15b7 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/JWTDecodeException.kt @@ -0,0 +1,8 @@ +package dev.sdkforge.jwt.decode.domain.exception + +/** + * The exception that is thrown when any part of the token contained an invalid JWT or JSON format. + */ +class JWTDecodeException(message: String?, cause: Throwable?) : JWTVerificationException(message, cause) { + constructor(message: String?) : this(message, null) +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/JWTVerificationException.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/JWTVerificationException.kt new file mode 100644 index 0000000..3908d46 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/JWTVerificationException.kt @@ -0,0 +1,8 @@ +package dev.sdkforge.jwt.decode.domain.exception + +/** + * Parent to all the exception thrown while verifying a JWT. + */ +open class JWTVerificationException internal constructor(message: String?, cause: Throwable?) : RuntimeException(message, cause) { + constructor(message: String?) : this(message, null) +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/MissingClaimException.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/MissingClaimException.kt new file mode 100644 index 0000000..46d4b54 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/MissingClaimException.kt @@ -0,0 +1,15 @@ +package dev.sdkforge.jwt.decode.domain.exception + +/** + * This exception is thrown when the claim to be verified is missing. + */ +class MissingClaimException( + /** + * This method can be used to fetch the name for which the Claim is missing during the verification. + * + * @return The name of the Claim that doesn't exist. + */ + val claimName: String, +) : InvalidClaimException( + message = "The Claim '$claimName' is not present in the JWT.", +) diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/SignatureException.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/SignatureException.kt new file mode 100644 index 0000000..65def6c --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/SignatureException.kt @@ -0,0 +1,5 @@ +package dev.sdkforge.jwt.decode.domain.exception + +class SignatureException(message: String?, cause: Throwable?) : JWTVerificationException(message, cause) { + constructor(message: String? = null) : this(message, null) +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/SignatureGenerationException.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/SignatureGenerationException.kt new file mode 100644 index 0000000..ad1ab60 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/SignatureGenerationException.kt @@ -0,0 +1,13 @@ +@file:Suppress("ktlint:standard:class-signature") + +package dev.sdkforge.jwt.decode.domain.exception + +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm + +/** + * The exception that is thrown when signature is not able to be generated. + */ +class SignatureGenerationException(algorithm: Algorithm, cause: Throwable) : JWTCreationException( + message = "The Token's Signature couldn't be generated when signing using the Algorithm: $algorithm", + cause = cause, +) diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/SignatureVerificationException.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/SignatureVerificationException.kt new file mode 100644 index 0000000..ed41588 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/SignatureVerificationException.kt @@ -0,0 +1,15 @@ +@file:Suppress("ktlint:standard:class-signature") + +package dev.sdkforge.jwt.decode.domain.exception + +import dev.sdkforge.jwt.decode.domain.algorithm.Algorithm + +/** + * The exception that is thrown if the Signature verification fails. + */ +class SignatureVerificationException(algorithm: Algorithm, cause: Throwable?) : JWTVerificationException( + message = "The Token's Signature resulted invalid when verified using the Algorithm: $algorithm", + cause = cause, +) { + constructor(algorithm: Algorithm) : this(algorithm = algorithm, cause = null) +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/TokenExpiredException.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/TokenExpiredException.kt new file mode 100644 index 0000000..acbe1fc --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/exception/TokenExpiredException.kt @@ -0,0 +1,10 @@ +package dev.sdkforge.jwt.decode.domain.exception + +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * The exception that is thrown if the token is expired. + */ +@OptIn(ExperimentalTime::class) +class TokenExpiredException(message: String, val expiredOn: Instant?) : JWTVerificationException(message) diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/provider/ECDSAKeyProvider.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/provider/ECDSAKeyProvider.kt new file mode 100644 index 0000000..603bf04 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/provider/ECDSAKeyProvider.kt @@ -0,0 +1,9 @@ +package dev.sdkforge.jwt.decode.domain.provider + +import dev.sdkforge.crypto.domain.ec.ECPrivateKey +import dev.sdkforge.crypto.domain.ec.ECPublicKey + +/** + * Elliptic Curve (EC) Public/Private Key provider. + */ +interface ECDSAKeyProvider : KeyProvider diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/provider/KeyProvider.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/provider/KeyProvider.kt new file mode 100644 index 0000000..e46b090 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/provider/KeyProvider.kt @@ -0,0 +1,39 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.domain.provider + +import dev.sdkforge.crypto.domain.PrivateKey +import dev.sdkforge.crypto.domain.PublicKey + +/** + * Generic Public/Private Key provider. + * While implementing, ensure the Private Key and Private Key ID doesn't change in between signing a token. + * + * @param the class that represents the Public Key + * @param the class that represents the Private Key + */ +interface KeyProvider { + /** + * Getter for the Public Key instance with the given Id. Used to verify the signature on the JWT verification stage. + * + * @param keyId the Key Id specified in the Token's Header or null if none is available. + * Provides a hint on which Public Key to use to verify the token's signature. + * @return the Public Key instance + */ + fun getPublicKeyById(keyId: String?): U? + + /** + * Getter for the Private Key instance. Used to sign the content on the JWT signing stage. + * + * @return the Private Key instance + */ + val privateKey: R? + + /** + * Getter for the Id of the Private Key used to sign the tokens. + * This represents the `kid` claim and will be placed in the Header. + * + * @return the Key Id that identifies the Private Key or null if it's not specified. + */ + val privateKeyId: String? +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/provider/RSAKeyProvider.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/provider/RSAKeyProvider.kt new file mode 100644 index 0000000..bad1715 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/provider/RSAKeyProvider.kt @@ -0,0 +1,9 @@ +package dev.sdkforge.jwt.decode.domain.provider + +import dev.sdkforge.crypto.domain.rsa.RSAPrivateKey +import dev.sdkforge.crypto.domain.rsa.RSAPublicKey + +/** + * RSA Public/Private Key provider. + */ +interface RSAKeyProvider : KeyProvider diff --git a/shared/api/shared.api b/shared/api/shared.api index c1fd5a0..d896552 100644 --- a/shared/api/shared.api +++ b/shared/api/shared.api @@ -1,5 +1,5 @@ -public final class dev/sdkforge/template/Library { - public static final field INSTANCE Ldev/sdkforge/template/Library; +public final class dev/sdkforge/jwt/decode/Library { + public static final field INSTANCE Ldev/sdkforge/jwt/decode/Library; public static final field VERSION Ljava/lang/String; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 0ad5432..03b8c7c 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -47,5 +47,5 @@ kotlin { } android { - namespace = "dev.sdkforge.template" + namespace = "dev.sdkforge.jwt.decode" } diff --git a/shared/dependencies/releaseRuntimeClasspath.txt b/shared/dependencies/releaseRuntimeClasspath.txt index a8f1bc1..263c0ba 100644 --- a/shared/dependencies/releaseRuntimeClasspath.txt +++ b/shared/dependencies/releaseRuntimeClasspath.txt @@ -1,2 +1,11 @@ +dev.sdkforge.crypto:crypto-domain-android:0.0.2-SNAPSHOT +dev.sdkforge.crypto:crypto-domain:0.0.2-SNAPSHOT org.jetbrains.kotlin:kotlin-stdlib:2.2.20 +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1 +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0 org.jetbrains:annotations:13.0 diff --git a/shared/src/commonMain/kotlin/dev/sdkforge/template/Library.kt b/shared/src/commonMain/kotlin/dev/sdkforge/jwt/decode/Library.kt similarity index 86% rename from shared/src/commonMain/kotlin/dev/sdkforge/template/Library.kt rename to shared/src/commonMain/kotlin/dev/sdkforge/jwt/decode/Library.kt index 2d5ba95..6a1f243 100644 --- a/shared/src/commonMain/kotlin/dev/sdkforge/template/Library.kt +++ b/shared/src/commonMain/kotlin/dev/sdkforge/jwt/decode/Library.kt @@ -1,6 +1,6 @@ -package dev.sdkforge.template +package dev.sdkforge.jwt.decode -import dev.sdkforge.template.Library.VERSION +import dev.sdkforge.jwt.decode.Library.VERSION /** * Library metadata and version information.