Kotlin - AppleのIDトークンを検証

2024/08/05に公開

はじめに

みなさん、こんにちは。

株式会社コズムのDUCと申します。

今回は、Kotlinを使ってAppleのIDトークンを検証する方法をご紹介します。
Kotlinのコード例や実装手順を通じて、詳しく記載していきます。この方法は、セキュアなユーザー認証を実現するために重要なステップですので、ぜひ最後までご覧ください。

記事の対象者

  • Appleログインを実装し、ユーザーの公開データをバックエンドに保存したい方
  • 安全で効率的なユーザー認証システムを構築したいと考えている開発者

使用したライブラリ

  • com.auth0:java-jwt:4.4.0
  • ktor-client

詳細

以下の流れで進めます:

  • クライアントアプリ(iOS/Android/Web)でOAuth2認証画面に遷移
  • ユーザーがログイン
  • クライアントアプリがIDトークンを取得
  • クライアントアプリがIDトークンをバックエンドへ送信し、バックエンドがIDトークンを検証してログイン権限を付与

実装概要

  • Appleの公開キーでトークンの署名を検証
  • トークンの発行者(iss)を検証
  • トークンの受信者(aud)を検証
  • トークンの有効期限(exp)を検証
  • トークンからユーザーデータを取得

実装

  1. まず、build.gradle.ktsのdependenciesに以下のライブラリを追加します。

    plugins {
        alias(libs.plugins.jvm)
        kotlin("plugin.serialization") version "1.9.0"
        application
    }
    
    dependencies {
        implementation("com.auth0:java-jwt:4.4.0")
        implementation("io.ktor:ktor-client-core:2.3.1")
        implementation("io.ktor:ktor-client-cio:2.3.1")
        implementation("io.ktor:ktor-client-content-negotiation:2.3.1")
        implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.1")
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
        implementation("org.slf4j:slf4j-api:2.0.7")
        implementation("ch.qos.logback:logback-classic:1.4.11")
    }
    
    
  2. 次に、Apple公開キーのレスポンスを格納するクラスを定義します。

    package fc.common.lib.fc.common.batch
    
    import kotlinx.serialization.Serializable
    
    @Serializable
    data class AppleAuthPublicKeyResponse(
        val keys: List<AppleAuthPublicKey>
    )
    
    @Serializable
    data class AppleAuthPublicKey(
        val kty: String,
        val kid: String,
        val use: String,
        val alg: String,
        val n: String,
        val e: String
    )
    
    
  3. Apple公開キーを取得するための関数を作成します。

    private var publicKeys = AppleAuthPublicKeyResponse(emptyList())
    private suspend fun fetchAppleAuthPublicKeys() {
        val client = HttpClient(CIO) {
            install(ContentNegotiation) {
                json()
            }
        }
        publicKeys = client.get("<https://appleid.apple.com/auth/keys>").body()
    }
    
    
  4. 次に、JWTライブラリを使用してトークンを復号し、署名を検証します。withAudienceの部分に、アプリのバンドルIDを設定します。

    private fun createRSAPublicKey(n: String, e: String): RSAPublicKey {
        val nBytes = Base64.getUrlDecoder().decode(n)
        val eBytes = Base64.getUrlDecoder().decode(e)
    
        val modulus = BigInteger(1, nBytes)
        val exponent = BigInteger(1, eBytes)
    
        val keySpec = RSAPublicKeySpec(modulus, exponent)
        val keyFactory = KeyFactory.getInstance("RSA")
        return keyFactory.generatePublic(keySpec) as RSAPublicKey
    }
    
    val rsaPublicKey = createRSAPublicKey(key.n, key.e)
    val algorithm = Algorithm.RSA256(rsaPublicKey, null)
    val verifier = JWT.require(algorithm)
        .withIssuer("<https://appleid.apple.com>")
        .withAudience("アプリのバンドルID")
        .build()
    
    val jwt = verifier.verify(idTokenString)
    
    
  5. 次に、トークンの有効期限を検証します。

    if (jwt.expiresAt.time < System.currentTimeMillis()) {
        throw Exception("IDトークンの有効期限が切れています")
    }
    
    
  6. 最後に、トークンからユーザーデータを取得し、表示します。

    println(jwt.subject)
    println(jwt.getClaim("email").asString())
    println(jwt.getClaim("is_private_email").asBoolean())
    
    

全体のコード例

AppleIdTokenVerifier.kt

import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking
import java.math.BigInteger
import java.security.KeyFactory
import java.security.interfaces.RSAPublicKey
import java.security.spec.RSAPublicKeySpec
import java.util.*

/**
 * <https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple>
 */
class AppleIdTokenVerifier {

    private var publicKeys = AppleAuthPublicKeyResponse(emptyList())

    fun verifyIdToken(idTokenString: String) {
        runBlocking {
            fetchAppleAuthPublicKeys()
        }

        val decodedToken = JWT.decode(idTokenString)

        val key = publicKeys.keys.find { it.kid == decodedToken.keyId }
            ?: throw Exception("不正なIDトークンです")

        val rsaPublicKey = createRSAPublicKey(key.n, key.e)
        val algorithm = Algorithm.RSA256(rsaPublicKey, null)
        val verifier = JWT.require(algorithm)
            .withIssuer("<https://appleid.apple.com>")
            .withAudience("アプリのバンドルID")
            .build()

        val jwt = verifier.verify(idTokenString)

        if (jwt.expiresAt.time < System.currentTimeMillis()) {
            throw Exception("IDトークンの有効期限が切れています")
        }

        println(jwt.subject)
        println(jwt.getClaim("email").asString())
        println(jwt.getClaim("is_private_email").asBoolean())
    }

    private suspend fun fetchAppleAuthPublicKeys() {
        val client = HttpClient(CIO) {
            install(ContentNegotiation) {
                json()
            }
        }
        publicKeys = client.get("<https://appleid.apple.com/auth/keys>").body()
    }

    private fun createRSAPublicKey(n: String, e: String): RSAPublicKey {
        val nBytes = Base64.getUrlDecoder().decode(n)
        val eBytes = Base64.getUrlDecoder().decode(e)

        val modulus = BigInteger(1, nBytes)
        val exponent = BigInteger(1, eBytes)

        val keySpec = RSAPublicKeySpec(modulus, exponent)
        val keyFactory = KeyFactory.getInstance("RSA")
        return keyFactory.generatePublic(keySpec) as RSAPublicKey
    }
}

AppleAuthPublicKeyResponse.kt

package org.example

import kotlinx.serialization.Serializable

@Serializable
data class AppleAuthPublicKeyResponse(
    val keys: List<AppleAuthPublicKey>
)

@Serializable
data class AppleAuthPublicKey(
    val kty: String,
    val kid: String,
    val use: String,
    val alg: String,
    val n: String,
    val e: String
)

build.gradle.kts

plugins {
    alias(libs.plugins.jvm)
    kotlin("plugin.serialization") version "1.9.0"
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.auth0:java-jwt:4.4.0")
    implementation("io.ktor:ktor-client-core:2.3.1")
    implementation("io.ktor:ktor-client-cio:2.3.1")
    implementation("io.ktor:ktor-client-content-negotiation:2.3.1")
    implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.1")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

application {
    mainClass = "org.example.AppKt"
}

tasks.named<Test>("test") {
    useJUnitPlatform()
}

終わりに

この記事を通じて、Kotlinを使ってAppleのIDトークンを検証する方法について理解を深めていただけたなら幸いです。セキュアなユーザー認証システムの構築にお役立てください。

ご質問やご意見がございましたら、ぜひご気軽にコメントください。

それでは、良い開発ライフをお過ごしください。

株式会社コズム

Discussion