diff --git a/server/src/main/kotlin/ivy/learn/di/AppModule.kt b/server/src/main/kotlin/ivy/learn/di/AppModule.kt index 4f0eb74e..6efed337 100644 --- a/server/src/main/kotlin/ivy/learn/di/AppModule.kt +++ b/server/src/main/kotlin/ivy/learn/di/AppModule.kt @@ -10,6 +10,7 @@ import ivy.learn.ServerMode import ivy.learn.config.Environment import ivy.learn.config.EnvironmentImpl import ivy.learn.config.ServerConfigurationProvider +import ivy.learn.util.Base64Util import ivy.learn.util.Crypto import ivy.learn.util.TimeProvider @@ -22,5 +23,6 @@ class AppModule(private val devMode: Boolean) : Di.Module { autoWireSingleton(::LearnServer) autoWire(::Crypto) autoWire(::TimeProvider) + autoWire(::Base64Util) } } \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/domain/auth/GoogleOAuthUseCase.kt b/server/src/main/kotlin/ivy/learn/domain/auth/GoogleOAuthUseCase.kt index 58ae31e4..a753473a 100644 --- a/server/src/main/kotlin/ivy/learn/domain/auth/GoogleOAuthUseCase.kt +++ b/server/src/main/kotlin/ivy/learn/domain/auth/GoogleOAuthUseCase.kt @@ -1,23 +1,26 @@ package ivy.learn.domain.auth import arrow.core.Either +import arrow.core.left import arrow.core.raise.catch import arrow.core.raise.either import arrow.core.raise.ensure import arrow.core.raise.ensureNotNull +import arrow.core.right import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.http.* import ivy.learn.config.ServerConfiguration +import ivy.learn.util.Base64Util import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import java.util.* class GoogleOAuthUseCase( private val config: ServerConfiguration, private val httpClient: HttpClient, + private val base64: Base64Util, ) { suspend fun verify( @@ -62,9 +65,11 @@ class GoogleOAuthUseCase( } private fun extractPublicProfile(idToken: String): Either = either { - val idTokenPayload = catch({ decodeJwt(idToken) }) { e -> - raise("Failed to decode Google JWT idToken '$idToken': $e") - } + val idTokenPayload = decodeIdTokenPayload(idToken) + .mapLeft { errMsg -> + "Google ID token decode: $errMsg; idToken = $idToken" + } + .bind() val audience = idTokenPayload["aud"] ensure(audience != config.googleOAuth.clientId) { "Google ID token is not intended for our client" @@ -85,10 +90,21 @@ class GoogleOAuthUseCase( ) } - private fun decodeJwt(jwt: String): Map { - val payload = jwt.split(".")[1] - val decodedBytes = Base64.getUrlDecoder().decode(payload) - return Json.decodeFromString(decodedBytes.decodeToString()) + private fun decodeIdTokenPayload( + idToken: String + ): Either> = either { + val payloadBase64 = catch({ + idToken.split(".")[1].right() + }) { e -> + "Split on '.' for \"$idToken\" because $e".left() + }.bind() + val payloadText = base64.decode(payloadBase64).bind() + val payload = catch({ + Json.decodeFromString>(payloadText).right() + }) { e -> + "JSON decode of '$payloadText' because $e".left() + }.bind() + payload } @@ -99,7 +115,6 @@ class GoogleOAuthUseCase( ) } - data class GooglePublicProfile( val email: String, val names: String?, diff --git a/server/src/main/kotlin/ivy/learn/util/Base64Util.kt b/server/src/main/kotlin/ivy/learn/util/Base64Util.kt new file mode 100644 index 00000000..13abd1c5 --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/util/Base64Util.kt @@ -0,0 +1,22 @@ +package ivy.learn.util + +import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.right +import kotlinx.io.bytestring.decodeToByteString +import kotlinx.io.bytestring.decodeToString +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +class Base64Util { + @OptIn(ExperimentalEncodingApi::class) + fun decode( + textBase64: String, + ): Either = catch({ + Base64.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL) + .decodeToByteString(textBase64).decodeToString() + .right() + }) { e -> + Either.Left("Base64 decode of '$textBase64' failed because $e") + } +} \ No newline at end of file diff --git a/server/src/test/kotlin/ivy/learn/util/Base64UtilTest.kt b/server/src/test/kotlin/ivy/learn/util/Base64UtilTest.kt new file mode 100644 index 00000000..77b5670d --- /dev/null +++ b/server/src/test/kotlin/ivy/learn/util/Base64UtilTest.kt @@ -0,0 +1,60 @@ +package ivy.learn.util + +import com.google.testing.junit.testparameterinjector.TestParameter +import com.google.testing.junit.testparameterinjector.TestParameterInjector +import io.kotest.assertions.arrow.core.shouldBeLeft +import io.kotest.assertions.arrow.core.shouldBeRight +import io.kotest.matchers.shouldBe +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(TestParameterInjector::class) +class Base64UtilTest { + private lateinit var base64: Base64Util + + @Before + fun setup() { + base64 = Base64Util() + } + + enum class ValidBase64TestCase( + val encoded: String, + val expectedDecoded: String, + ) { + PADDED( + encoded = "SGVsbG8sIEJhc2U2NCE=", + expectedDecoded = "Hello, Base64!", + ), + NOT_PADDED( + encoded = "SGVsbG8sIEJhc2U2NCE", + expectedDecoded = "Hello, Base64!" + ) + } + + @Test + fun `decodes valid base64`( + @TestParameter testCase: ValidBase64TestCase, + ) { + // Given + val encoded = testCase.encoded + + // When + val decoded = base64.decode(encoded) + + // Then + decoded.shouldBeRight() shouldBe testCase.expectedDecoded + } + + @Test + fun `fails to decode invalid base64`() { + // Given + val encoded = "Hello, Base64!" + + // When + val decoded = base64.decode(encoded) + + // Then + decoded.shouldBeLeft() + } +} \ No newline at end of file