diff --git a/app-android/build.gradle.kts b/app-android/build.gradle.kts index 374f254..3a50e3d 100644 --- a/app-android/build.gradle.kts +++ b/app-android/build.gradle.kts @@ -8,10 +8,10 @@ plugins { } android { - namespace = "dev.sdkforge.template.android" + namespace = "dev.sdkforge.jwt.decode.android" compileSdk = 36 defaultConfig { - applicationId = "dev.sdkforge.template.android" + applicationId = "dev.sdkforge.jwt.decode.android" minSdk = 21 targetSdk = 36 versionCode = 1 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 5cc4358..5eb7354 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ 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 \ No newline at end of file +publishing.repository=JWTDecode \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 13dcc8a..5504b75 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,8 @@ androidx-activityCompose = "1.10.1" 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.1" versions = "0.52.0" benchmark = "1.4.0" @@ -26,6 +28,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" } @@ -36,6 +40,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/internal-benchmark/build.gradle.kts b/internal-benchmark/build.gradle.kts index 3b524e8..824965a 100644 --- a/internal-benchmark/build.gradle.kts +++ b/internal-benchmark/build.gradle.kts @@ -31,6 +31,8 @@ android { } dependencies { + androidTestImplementation(projects.shared) + androidTestImplementation(libs.androidx.runner) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.junit) diff --git a/internal-benchmark/src/androidTest/java/dev/sdkforge/benchmark/DefaultBenchmark.kt b/internal-benchmark/src/androidTest/java/dev/sdkforge/benchmark/DefaultBenchmark.kt index 492fc20..e864559 100644 --- a/internal-benchmark/src/androidTest/java/dev/sdkforge/benchmark/DefaultBenchmark.kt +++ b/internal-benchmark/src/androidTest/java/dev/sdkforge/benchmark/DefaultBenchmark.kt @@ -4,6 +4,7 @@ import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest +import dev.sdkforge.jwt.decode.domain.JWT import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -15,10 +16,17 @@ class DefaultBenchmark { @get:Rule val benchmarkRule = BenchmarkRule() + val jwt = "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9" + + ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0" + + ".jYW04zLDHfR1v7xdrW3lCGZrMIsVe0vWCfVkN2DRns2c3MN-mcp_-RE6TN9umSBYoNV-mnb31wFf8iun3fB6aDS6m" + + "_OXAiURVEKrPFNGlR38JSHUtsFzqTOj-wFrJZN4RwvZnNGSMvK3wzzUriZqmiNLsG8lktlEn6KA4kYVaM61" + + "_NpmPHWAjGExWv7cjHYupcjMSmR8uMTwN5UuAwgW6FRstCJEfoxwb0WKiyoaSlDuIiHZJ0cyGhhEmmAPiCwtPAwGeaL1yZMcp0p82cpTQ5Qb" + + "-7CtRov3N4DcOHgWYk6LomPR5j5cCkePAz87duqyzSMpCB0mCOuE3CU2VMtGeQ" + @Test - fun measure() { + fun measureRS512JWTParsing() { benchmarkRule.measureRepeated { - // TODO: add measurables here + JWT(jwt) } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6db4227..15d493a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,14 +33,15 @@ dependencyResolutionManagement { } } -rootProject.name = "SDKForgeTemplate" +rootProject.name = "SDKForge-JWTDecode" include(":app-android") include(":app-shared") include(":shared") include(":shared-core") +include(":shared-domain") include(":internal-ktlint") // uncomment if it's needed for development // include(":shared-template") -// include(":internal-benchmark") +include(":internal-benchmark") 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 84% 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 2271a96..31a76f4 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 actual val currentPlatform: Platform = object : Platform { override val name: String get() = "Android" 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 74% 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 14f599a..26c39a3 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 interface Platform { val name: String 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 86% 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 6d8ce7c..ee141ba 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-domain/api/shared-domain.api b/shared-domain/api/shared-domain.api new file mode 100644 index 0000000..32de6fa --- /dev/null +++ b/shared-domain/api/shared-domain.api @@ -0,0 +1,76 @@ +public final class dev/sdkforge/jwt/decode/domain/AudienceAsStringSerializer : kotlinx/serialization/KSerializer { + public static final field INSTANCE Ldev/sdkforge/jwt/decode/domain/AudienceAsStringSerializer; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/util/List; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/util/List;)V +} + +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 asDate ()Lkotlin/time/Instant; + public abstract fun asDouble ()Ljava/lang/Double; + 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 final class dev/sdkforge/jwt/decode/domain/Claim$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class dev/sdkforge/jwt/decode/domain/ClaimAsMapSerializer : kotlinx/serialization/KSerializer { + public static final field INSTANCE Ldev/sdkforge/jwt/decode/domain/ClaimAsMapSerializer; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/util/Map; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/util/Map;)V +} + +public final class dev/sdkforge/jwt/decode/domain/ClaimAsStringSerializer : kotlinx/serialization/KSerializer { + public static final field INSTANCE Ldev/sdkforge/jwt/decode/domain/ClaimAsStringSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/sdkforge/jwt/decode/domain/Claim; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/sdkforge/jwt/decode/domain/Claim;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V +} + +public final class dev/sdkforge/jwt/decode/domain/DecodeException : java/lang/RuntimeException { +} + +public final class dev/sdkforge/jwt/decode/domain/InstantAsStringSerializer : kotlinx/serialization/KSerializer { + public static final field INSTANCE Ldev/sdkforge/jwt/decode/domain/InstantAsStringSerializer; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lkotlin/time/Instant; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lkotlin/time/Instant;)V +} + +public final class dev/sdkforge/jwt/decode/domain/JWT { + public static final field Companion Ldev/sdkforge/jwt/decode/domain/JWT$Companion; + public fun (Ljava/lang/String;)V + public final fun getAudience ()Ljava/util/List; + public final fun getClaim (Ljava/lang/String;)Ldev/sdkforge/jwt/decode/domain/Claim; + public final fun getClaims ()Ljava/util/Map; + public final fun getExpiresAt ()Lkotlin/time/Instant; + public final fun getHeader ()Ljava/util/Map; + public final fun getId ()Ljava/lang/String; + public final fun getIssuedAt ()Lkotlin/time/Instant; + public final fun getIssuer ()Ljava/lang/String; + public final fun getNotBefore ()Lkotlin/time/Instant; + public final fun getSignature ()Ljava/lang/String; + public final fun getSubject ()Ljava/lang/String; + public final fun isExpired-LRDsOJo (J)Z + public fun toString ()Ljava/lang/String; +} + +public final class dev/sdkforge/jwt/decode/domain/JWT$Companion { +} + diff --git a/shared-domain/build.gradle.kts b/shared-domain/build.gradle.kts new file mode 100644 index 0000000..ba0b5ef --- /dev/null +++ b/shared-domain/build.gradle.kts @@ -0,0 +1,30 @@ +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(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..7bd7eef --- /dev/null +++ b/shared-domain/dependencies/releaseRuntimeClasspath.txt @@ -0,0 +1,9 @@ +org.jetbrains.kotlin:kotlin-stdlib:2.2.20-Beta2 +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/BaseClaim.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/BaseClaim.kt new file mode 100644 index 0000000..2e6768a --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/BaseClaim.kt @@ -0,0 +1,26 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.domain + +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.serialization.DeserializationStrategy + +/** + * The BaseClaim class is a Claim implementation that returns null when any of it's methods it's called. + */ +@OptIn(ExperimentalTime::class) +internal open class BaseClaim : Claim { + 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 asDate(): Instant? = null + + @Throws(DecodeException::class) + override fun asList(deserializer: DeserializationStrategy): List = emptyList() + + @Throws(DecodeException::class) + override fun asObject(deserializer: DeserializationStrategy): T? = null +} 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..55bc5f8 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/Claim.kt @@ -0,0 +1,84 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.domain + +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.Serializable + +/** + * The Claim class holds the value in a generic way so that it can be recovered in many representations. + */ + +@OptIn(ExperimentalTime::class) +@Serializable(with = ClaimAsStringSerializer::class) +interface Claim { + /** + * 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 an 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 or it can't be converted to a String, null will be returned. + * + * @return the value as a String or null. + */ + fun asString(): String? + + /** + * Get this Claim as a Date. + * If the value can't be converted to a Date, null will be returned. + * + * @return the value as a Date or null. + */ + fun asDate(): 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 DecodeException if the values inside the List can't be converted to a class T. + */ + @Throws(DecodeException::class) + fun asList(deserializer: DeserializationStrategy): List + + /** + * Get this Claim as a Object of type T. + * If the value isn't of type Object, null will be returned. + * + * @return the value as a Object of type T or null. + * @throws DecodeException if the value can't be converted to a class T. + */ + @Throws(DecodeException::class) + fun asObject(deserializer: DeserializationStrategy): T? +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/ClaimImpl.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/ClaimImpl.kt new file mode 100644 index 0000000..fe5c7d0 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/ClaimImpl.kt @@ -0,0 +1,82 @@ +@file:Suppress("ktlint:standard:function-signature") + +package dev.sdkforge.jwt.decode.domain + +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 ClaimImpl class implements the Claim interface. + */ +@OptIn(ExperimentalTime::class) +internal class ClaimImpl(private val value: JsonElement) : BaseClaim() { + + 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 asDate(): Instant? = when (value) { + !is JsonPrimitive -> null + else -> value.jsonPrimitive.longOrNull?.run { + Instant.fromEpochSeconds(this) + } + } + + @Throws(DecodeException::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 DecodeException("Failed to decode claim as list", e) + } + } + + @Throws(DecodeException::class) + override fun asObject(deserializer: DeserializationStrategy): T? { + try { + if (value is JsonNull) { + return null + } + + return Json.decodeFromJsonElement(deserializer, value) + } catch (e: IllegalArgumentException) { + throw DecodeException("Failed to decode claim", e) + } + } +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/DecodeException.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/DecodeException.kt new file mode 100644 index 0000000..ad84733 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/DecodeException.kt @@ -0,0 +1,6 @@ +package dev.sdkforge.jwt.decode.domain + +class DecodeException : RuntimeException { + internal constructor(message: String?) : super(message) + internal constructor(message: String?, cause: Throwable?) : super(message, cause) +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/JWT.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/JWT.kt new file mode 100644 index 0000000..d6485d5 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/JWT.kt @@ -0,0 +1,189 @@ +@file:Suppress("ktlint:standard:function-signature", "ktlint:standard:function-expression-body") + +package dev.sdkforge.jwt.decode.domain + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.serialization.json.Json + +/** + * Wrapper class for values contained inside a Json Web Token (JWT). + */ +@OptIn(ExperimentalTime::class) +class JWT(private var token: String) { + + private val json: Json = Json { ignoreUnknownKeys = true } + + private var payload: JWTPayload? = null + + /** + * Decode a given string JWT token. + * + * @param token the string JWT token. + * @throws DecodeException if the token cannot be decoded + */ + init { + decode(token) + + this.token = token + } + + /** + * Get the Header values from this JWT as a Map of Strings. + * + * @return the Header values of the JWT. + */ + var header: Map? = null + private set + + /** + * Get the Signature from this JWT as a Base64 encoded String. + * + * @return the Signature of the JWT. + */ + var signature: String? = null + private set + + /** + * Get the value of the "iss" claim, or null if it's not available. + * + * @return the Issuer value or null. + */ + val issuer: String? get() = payload?.iss + + /** + * Get the value of the "sub" claim, or null if it's not available. + * + * @return the Subject value or null. + */ + val subject: String? get() = payload?.sub + + /** + * Get the value of the "aud" claim, or an empty list if it's not available. + * + * @return the Audience value or an empty list. + */ + val audience: List get() = payload?.aud.orEmpty() + + /** + * 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() = payload?.exp + + /** + * 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() = payload?.nbf + + /** + * 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() = payload?.iat + + /** + * 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() = payload?.jti + + /** + * Get a Claim given it's name. If the Claim wasn't specified in the JWT payload, a BaseClaim will be returned. + * + * @param name the name of the Claim to retrieve. + * @return a valid Claim. + */ + fun getClaim(name: String): Claim { + return payload?.claimForName(name) ?: BaseClaim() + } + + /** + * Get all the Claims. + * + * @return a valid Map of Claims. + */ + val claims: Map? get() = payload?.tree.orEmpty() + + /** + * Validates that this JWT was issued in the past and hasn't expired yet. + * + * @param leeway the time leeway in seconds in which the token should still be considered valid. + * @return if this JWT has already expired or not. + */ + fun isExpired(leeway: Duration): Boolean { + require(leeway.inWholeSeconds >= 0) { "The leeway must be a positive value." } + val todayTime = Instant.fromEpochSeconds(Clock.System.now().epochSeconds) + val futureToday = (todayTime + leeway) + val pastToday = (todayTime - leeway) + val exp = payload?.exp + val iat = payload?.iat + val expValid = exp == null || pastToday <= exp + val iatValid = iat == null || futureToday >= iat + return !expValid || !iatValid + } + + /** + * Returns the String representation of this JWT. + * + * @return the String Token. + */ + override fun toString(): String = token + + // ===================================== + // ===========Private Methods=========== + // ===================================== + private fun decode(token: String) { + val parts = splitToken(token) + + header = parseJson?>(base64Decode(parts[0])) + payload = parseJson(base64Decode(parts[1])) + signature = parts[2] + } + + private fun splitToken(token: String): List { + var parts: List = token.split('.') + if (parts.size == 2 && token.endsWith('.')) { + // Tokens with alg='none' have empty String as Signature. + parts = listOf(parts[0], parts[1], "") + } + if (parts.size != 3) { + throw DecodeException("The token was expected to have 3 parts, but got ${parts.size}.") + } + return parts + } + + @OptIn(ExperimentalEncodingApi::class) + private fun base64Decode(string: String?): String? { + string ?: return null + + try { + return Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).decode(string).decodeToString() + } catch (e: IllegalArgumentException) { + throw DecodeException("Received bytes didn't correspond to a valid Base64 encoded string.", e) + } + } + + private inline fun parseJson(json: String?): T? { + json ?: return null + + try { + return this.json.decodeFromString(json) + } catch (e: Exception) { + throw DecodeException("The token's payload had an invalid JSON format.", e) + } + } + + companion object { + private val TAG: String? = JWT::class.simpleName + } +} diff --git a/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/JWTPayload.kt b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/JWTPayload.kt new file mode 100644 index 0000000..da79d25 --- /dev/null +++ b/shared-domain/src/commonMain/kotlin/dev/sdkforge/jwt/decode/domain/JWTPayload.kt @@ -0,0 +1,100 @@ +@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.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.listSerialDescriptor +import kotlinx.serialization.descriptors.mapSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json + +@OptIn(ExperimentalTime::class) +@Serializable +internal class JWTPayload( + @SerialName("iss") val iss: String? = null, + @SerialName("sub") val sub: String? = null, + @Serializable(with = InstantAsStringSerializer::class) @SerialName("exp") val exp: Instant? = null, + @Serializable(with = InstantAsStringSerializer::class) @SerialName("nbf") val nbf: Instant? = null, + @Serializable(with = InstantAsStringSerializer::class) @SerialName("iat") val iat: Instant? = null, + @SerialName("jti") val jti: String? = null, + @Serializable(with = AudienceAsStringSerializer::class) @SerialName("aud") val aud: List? = null, + @Serializable(with = ClaimAsMapSerializer::class) @SerialName("tree") val tree: Map? = null, +) { + internal fun claimForName(name: String): Claim { + return this.tree?.get(name) ?: BaseClaim() + } +} + +@OptIn(ExperimentalTime::class) +object InstantAsStringSerializer : KSerializer { + // Serial names of descriptors should be unique, this is why we advise including app package in the name. + override val descriptor: SerialDescriptor + get() = PrimitiveSerialDescriptor("dev.sdkforge.jwt.decode.domain.Instant", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: Instant) { + encoder.encodeLong(value.epochSeconds) + } + + override fun deserialize(decoder: Decoder): Instant { + val seconds = decoder.decodeLong() + return Instant.fromEpochSeconds(seconds) + } +} + +object ClaimAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor + get() = PrimitiveSerialDescriptor("dev.sdkforge.jwt.decode.domain.Claim", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Claim) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Claim { + return ClaimImpl(Json.parseToJsonElement(decoder.decodeString())) + } +} + +object ClaimAsMapSerializer : KSerializer> { + @OptIn(ExperimentalSerializationApi::class) + override val descriptor: SerialDescriptor + get() = mapSerialDescriptor() + + override fun serialize(encoder: Encoder, value: Map) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Map { + return decoder.decodeSerializableValue>(MapSerializer(String.serializer(), Claim.serializer())) + } +} + +object AudienceAsStringSerializer : KSerializer> { + @OptIn(ExperimentalSerializationApi::class) + override val descriptor: SerialDescriptor + get() = listSerialDescriptor() + + override fun serialize(encoder: Encoder, value: List) { + encoder.encodeString(value.toString()) + } + + @OptIn(ExperimentalSerializationApi::class) + override fun deserialize(decoder: Decoder): List { + val singleAudience = decoder.decodeNullableSerializableValue(String.serializer()) + + if (singleAudience != null) return listOf(singleAudience) + + return decoder.decodeSerializableValue>(ListSerializer(String.serializer())) + } +} diff --git a/shared-domain/src/commonTest/kotlin/dev/sdkforge/jwt/decode/domain/BaseClaimTest.kt b/shared-domain/src/commonTest/kotlin/dev/sdkforge/jwt/decode/domain/BaseClaimTest.kt new file mode 100644 index 0000000..3056f16 --- /dev/null +++ b/shared-domain/src/commonTest/kotlin/dev/sdkforge/jwt/decode/domain/BaseClaimTest.kt @@ -0,0 +1,55 @@ +package dev.sdkforge.jwt.decode.domain + +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 BaseClaimTest { + + private val claim: BaseClaim = BaseClaim() + + @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.asDate()) + } + + @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-domain/src/commonTest/kotlin/dev/sdkforge/jwt/decode/domain/ClaimImplTest.kt b/shared-domain/src/commonTest/kotlin/dev/sdkforge/jwt/decode/domain/ClaimImplTest.kt new file mode 100644 index 0000000..226a208 --- /dev/null +++ b/shared-domain/src/commonTest/kotlin/dev/sdkforge/jwt/decode/domain/ClaimImplTest.kt @@ -0,0 +1,231 @@ +package dev.sdkforge.jwt.decode.domain + +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 ClaimImplTest { + private val json = Json.Default + + @Test + fun shouldGetBooleanValue() { + val value: JsonElement = json.encodeToJsonElement(true) + val claim = ClaimImpl(value) + + assertNotNull(claim.asBoolean()) + assertEquals(true, claim.asBoolean()) + } + + @Test + fun shouldGetNullBooleanIfNotPrimitiveValue() { + val value: JsonElement = json.encodeToJsonElement(Unit) + val claim = ClaimImpl(value) + + assertNull(claim.asBoolean()) + } + + @Test + fun shouldGetIntValue() { + val value: JsonElement = json.encodeToJsonElement(123) + val claim = ClaimImpl(value) + + assertNotNull(claim.asInt()) + assertEquals(123, claim.asInt()) + } + + @Test + fun shouldGetLongValue() { + val value: JsonElement = json.encodeToJsonElement(123L) + val claim = ClaimImpl(value) + + assertNotNull(claim.asLong()) + assertEquals(123L, claim.asLong()) + } + + @Test + fun shouldGetNullIntIfNotPrimitiveValue() { + val value: JsonElement = json.encodeToJsonElement(Unit) + val claim = ClaimImpl(value) + + assertNull(claim.asInt()) + } + + @Test + fun shouldGetNullLongIfNotPrimitiveValue() { + val value: JsonElement = json.encodeToJsonElement(Unit) + val claim = ClaimImpl(value) + + assertNull(claim.asLong()) + } + + @Test + fun shouldGetDoubleValue() { + val value: JsonElement = json.encodeToJsonElement(1.5) + val claim = ClaimImpl(value) + + assertNotNull(claim.asDouble()) + assertEquals(1.5, claim.asDouble()) + } + + @Test + fun shouldGetNullDoubleIfNotPrimitiveValue() { + val value: JsonElement = json.encodeToJsonElement(Unit) + val claim = ClaimImpl(value) + + assertNull(claim.asDouble()) + } + + @Test + fun shouldGetLargeDateValue() { + val seconds: Long = Int.MAX_VALUE + 10000L + val value: JsonElement = json.encodeToJsonElement(seconds) + val claim = ClaimImpl(value) + + val date: Instant? = claim.asDate() + assertNotNull(date) + assertEquals(seconds, date.epochSeconds) + assertEquals(2147493647, date.epochSeconds) + } + + @Test + fun shouldGetDateValue() { + val value: JsonElement = json.encodeToJsonElement("1476824844") + val claim = ClaimImpl(value) + + assertNotNull(claim.asDate()) + assertEquals(Instant.fromEpochSeconds(1476824844), claim.asDate()) + } + + @Test + fun shouldGetNullDateIfNotPrimitiveValue() { + val value: JsonElement = json.encodeToJsonElement(Unit) + val claim = ClaimImpl(value) + + assertNull(claim.asDate()) + } + + @Test + fun shouldGetStringValue() { + val value: JsonElement = json.encodeToJsonElement("string") + val claim = ClaimImpl(value) + + assertNotNull(claim.asString()) + assertEquals("string", claim.asString()) + } + + @Test + fun shouldGetNullStringIfNotPrimitiveValue() { + val value: JsonElement = json.encodeToJsonElement(Unit) + val claim = ClaimImpl(value) + + assertNull(claim.asString()) + } + + @Test + fun shouldGetListValueOfCustomClass() { + val value: JsonElement = json.encodeToJsonElement(listOf(UserPojo("George", 1), UserPojo("Mark", 2))) + val claim = ClaimImpl(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 = ClaimImpl(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 = ClaimImpl(value) + + assertNotNull(claim.asList(String.serializer())) + assertContentEquals( + emptyList(), + claim.asList(String.serializer()), + ) + } + + @Test + fun shouldGetEmptyListIfNonArrayValue() { + val value: JsonElement = json.encodeToJsonElement(1) + val claim = ClaimImpl(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 = ClaimImpl(value) + + assertFailsWith { + claim.asList(UserPojo.serializer()) + } + } + + @Test + fun shouldGetAsObject() { + val data = UserPojo("George", 1) + val userValue: JsonElement = json.encodeToJsonElement(data) + val userClaim = ClaimImpl(userValue) + + val intValue: JsonElement = json.encodeToJsonElement(1) + val intClaim = ClaimImpl(intValue) + + val booleanValue: JsonElement = json.encodeToJsonElement(true) + val booleanClaim = ClaimImpl(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 = ClaimImpl(JsonNull) + + assertNull(claim.asObject(UserPojo.serializer())) + } + + @Test + fun shouldThrowIfObjectClassMismatch() { + val value: JsonElement = json.encodeToJsonElement(1) + val claim = ClaimImpl(value) + + assertFailsWith { + claim.asObject(UserPojo.serializer()) + } + } +} diff --git a/shared-domain/src/commonTest/kotlin/dev/sdkforge/jwt/decode/domain/JWTTest.kt b/shared-domain/src/commonTest/kotlin/dev/sdkforge/jwt/decode/domain/JWTTest.kt new file mode 100644 index 0000000..c97075a --- /dev/null +++ b/shared-domain/src/commonTest/kotlin/dev/sdkforge/jwt/decode/domain/JWTTest.kt @@ -0,0 +1,446 @@ +@file:Suppress("ktlint:standard:function-signature", "ktlint:standard:function-expression-body") + +package dev.sdkforge.jwt.decode.domain + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Ignore +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 + +@OptIn(ExperimentalTime::class) +class JWTTest { + + // Exceptions + @Test + fun shouldThrowIfLessThan3Parts() { + assertFailsWith( + message = "The token was expected to have 3 parts, but got 2.", + ) { + JWT("two.parts") + } + } + + @Test + fun shouldThrowIfMoreThan3Parts() { + assertFailsWith( + message = "The token was expected to have 3 parts, but got 4.", + ) { + JWT("this.has.four.parts") + } + } + + @Test + fun shouldThrowIfItsNotBase64Encoded() { + assertFailsWith( + message = "Received bytes didn't correspond to a valid Base64 encoded string.", + ) { + JWT("thisIsNot.Base64_Enc.oded") + } + } + + @Test + fun shouldThrowIfPayloadHasInvalidJSONFormat() { + assertFailsWith( + message = "The token's payload had an invalid JSON format.", + ) { + JWT("eyJhbGciOiJIUzI1NiJ9.e30ijfe923.XmNK3GpH3Ys_7lyQ") + } + } + + // toString + @Test + fun shouldGetStringToken() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ") + + assertEquals("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ", jwt.toString()) + } + + // Parts + @Test + fun shouldGetHeader() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ") + val header = jwt.header + + assertNotNull(header) + assertEquals("HS256", header["alg"]) + } + + @Test + fun shouldGetSignature() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ") + assertEquals("XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ", jwt.signature) + } + + @Test + fun shouldGetEmptySignature() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.e30.") + + assertEquals("", jwt.signature) + } + + // Public Claims + @Test + fun shouldGetIssuer() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJKb2huIERvZSJ9.SgXosfRR_IwCgHq5lF3tlM-JHtpucWCRSaVuoHTbWbQ") + assertEquals("John Doe", jwt.issuer) + } + + @Test + fun shouldGetNullIssuerIfMissing() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.e30.something") + + assertNull(jwt.issuer) + } + + @Test + fun shouldGetSubject() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJUb2szbnMifQ.RudAxkslimoOY3BLl2Ghny3BrUKu9I1ZrXzCZGDJtNs") + + assertEquals("Tok3ns", jwt.subject) + } + + @Test + fun shouldGetNullSubjectIfMissing() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.e30.something") + + assertNull(jwt.subject) + } + + @Ignore + @Test + fun shouldGetArrayAudience() { + val jwt = JWT("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("eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJKYWNrIFJleWVzIn0.a4I9BBhPt1OB1GW67g2P1bEHgi6zgOjGUL4LvhE9Dgc") + + val audience = jwt.audience + + assertNotNull(audience) + assertEquals(1, audience.size) + assertContains(audience, "Jack Reyes") + } + + @Test + fun shouldGetEmptyListAudienceIfMissing() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.e30.something") + + val audience = jwt.audience + + assertNotNull(audience) + assertEquals(emptyList(), audience) + } + + @Ignore + @Test + fun shouldDeserializeDatesUsingLong() { + val jwt = JWT( + "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").asDate()) + } + + @Test + fun shouldGetExpirationTime() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.eyJleHAiOiIxNDc2NzI3MDg2In0.XwZztHlQwnAgmnQvrcWXJloLOUaLZGiY0HOXJCKRaks") + val expectedDate = Instant.fromEpochSeconds(1476727086L) + + assertNotNull(jwt.expiresAt) + assertEquals(expectedDate, jwt.expiresAt) + } + + @Test + fun shouldGetNullExpirationTimeIfMissing() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.e30.something") + + assertNull(jwt.expiresAt) + } + + @Test + fun shouldGetNotBefore() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.eyJuYmYiOiIxNDc2NzI3MDg2In0.pi3Fi3oFiXk5A5AetDdL0hjVx_rt6F5r_YiG6HoCYDw") + val expectedDate = Instant.fromEpochSeconds(1476727086L) + + assertNotNull(jwt.notBefore) + assertEquals(expectedDate, jwt.notBefore) + } + + @Test + fun shouldGetNullNotBeforeIfMissing() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.e30.something") + + assertNull(jwt.notBefore) + } + + @Test + fun shouldGetIssuedAt() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOiIxNDc2NzI3MDg2In0.u6BxwrO7S0sqDY8-1cUOLzU2uejAJBzQQF8g_o5BAgo") + + val expectedDate = Instant.fromEpochSeconds(1476727086L) + assertNotNull(jwt.issuedAt) + assertEquals(expectedDate, jwt.issuedAt) + } + + @Test + fun shouldGetNullIssuedAtIfMissing() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.e30.something") + + assertNull(jwt.issuedAt) + } + + @Test + fun shouldGetId() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxMjM0NTY3ODkwIn0.m3zgEfVUFOd-CvL3xG5BuOWLzb0zMQZCqiVNQQOPOvA") + + assertEquals("1234567890", jwt.id) + } + + @Test + fun shouldGetNullIdIfMissing() { + val jwt = JWT("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() { + assertFailsWith( + message = "The leeway must be a positive value.", + ) { + customTimeJWT(null, null).isExpired(-(1.seconds)) + } + } + + @Ignore + @Test + fun shouldNotRemoveKnownPublicClaimsFromTree() { + val jwt = JWT( + "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 + @Ignore + @Test + fun shouldGetBaseClaimIfClaimIsMissing() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.e30.K17vlwhE8FCMShdl1_65jEYqsQqBOVMPUU9IgG-QlTM") + assertNotNull(jwt.getClaim("notExisting")) + assertTrue { jwt.getClaim("notExisting") is ClaimImpl } + assertTrue { jwt.getClaim("notExisting") is BaseClaim } + } + + @Ignore + @Test + fun shouldGetClaim() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.eyJvYmplY3QiOnsibmFtZSI6ImpvaG4ifX0.lrU1gZlOdlmTTeZwq0VI-pZx2iV46UWYd5-lCjy6-c4") + + assertNotNull(jwt.getClaim("object")) + assertTrue { jwt.getClaim("object") is ClaimImpl } + } + + @Ignore + @Test + fun shouldGetAllClaims() { + val jwt = + JWT("eyJhbGciOiJIUzI1NiJ9.eyJvYmplY3QiOnsibmFtZSI6ImpvaG4ifSwic3ViIjoiYXV0aDAifQ.U20MgOAV81c54mRelwYDJiLllb5OVwUAtMGn-eUOpTA") + val claims: Map? = jwt.claims + + assertNotNull(claims) + + val objectClaim = claims["object"] + + assertNotNull(objectClaim) + + assertTrue { objectClaim is ClaimImpl } + + val extraClaim: Claim = claims["sub"]!! + + assertNotNull(extraClaim) + + assertEquals("auth0", extraClaim.asString()) + } + + @Test + fun shouldGetEmptyAllClaims() { + val jwt = JWT("eyJhbGciOiJIUzI1NiJ9.e30.ZRrHA1JJJW8opsbCGfG_HACGpVUMN_a9IV7pAx_Zmeo") + val claims: Map = assertNotNull(jwt.claims) + + assertTrue { claims.isEmpty() } + } + + // Parcelable + @Test + fun shouldBeParceled() { + val jwtOrigin = JWT("eyJhbGciOiJIUzI1NiJ9.e30.K17vlwhE8FCMShdl1_65jEYqsQqBOVMPUU9IgG-QlTM") + +// val bundleOrigin: Bundle = Bundle() +// bundleOrigin.putParcelable("jwt", jwtOrigin) +// val parcel: Parcel = Parcel.obtain() +// bundleOrigin.writeToParcel(parcel, 0) +// +// //Extract bundle from parcel +// parcel.setDataPosition(0) +// val bundleDest: Bundle = parcel.readBundle(JWT::class.java.getClassLoader()) +// val jwtDest: JWT = bundleDest.getParcelable("jwt") +// +// assertThat(jwtDest, `is`(notNullValue())) +// assertThat(bundleOrigin, `is`(not(bundleDest))) +// assertThat(jwtOrigin, `is`(not(jwtDest))) +// assertThat(jwtOrigin.toString(), `is`(jwtDest.toString())) + } + + /** + * 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?): JWT { + 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("$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-domain/src/commonTest/kotlin/dev/sdkforge/jwt/decode/domain/UserPojo.kt b/shared-domain/src/commonTest/kotlin/dev/sdkforge/jwt/decode/domain/UserPojo.kt new file mode 100644 index 0000000..b55ebfb --- /dev/null +++ b/shared-domain/src/commonTest/kotlin/dev/sdkforge/jwt/decode/domain/UserPojo.kt @@ -0,0 +1,11 @@ +@file:Suppress("ktlint:standard:class-signature") + +package dev.sdkforge.jwt.decode.domain + +import kotlinx.serialization.Serializable + +@Serializable +internal data class UserPojo( + private val name: String?, + private val id: Int, +) 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 9f2a336..7bd7eef 100644 --- a/shared/dependencies/releaseRuntimeClasspath.txt +++ b/shared/dependencies/releaseRuntimeClasspath.txt @@ -1,2 +1,9 @@ org.jetbrains.kotlin:kotlin-stdlib:2.2.20-Beta2 +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 64% rename from shared/src/commonMain/kotlin/dev/sdkforge/template/Library.kt rename to shared/src/commonMain/kotlin/dev/sdkforge/jwt/decode/Library.kt index c0641f5..5619665 100644 --- a/shared/src/commonMain/kotlin/dev/sdkforge/template/Library.kt +++ b/shared/src/commonMain/kotlin/dev/sdkforge/jwt/decode/Library.kt @@ -1,4 +1,4 @@ -package dev.sdkforge.template +package dev.sdkforge.jwt.decode data object Library { const val VERSION = "0.0.1"