Okta Mobile Kotlin SDK

認証ライブラリOktaKotlinAndroidOAuth2OIDCモバイルコルーチン

認証ライブラリ

Okta Mobile Kotlin SDK

概要

Okta Mobile Kotlin SDKは、Androidプラットフォーム向けの新世代認証ライブラリです。従来のmobile SDKの置き換えを目的とし、開発の効率化、保守性の向上、および従来困難だった新しいユースケースの実現を可能にします。Kotlin、Kotlinコルーチン、OkHttpをベースに構築され、複数のライブラリで構成されたモジュラー設計となっています。

詳細

Okta Mobile Kotlin SDKは、2024年現在の最新バージョン2.0.3で提供される現代的なAndroid認証ソリューションです。SDKは3つの主要コンポーネントから構成されています:AuthFoundation(認証情報管理の基盤クラス)、OktaOAuth2(OAuth2認証機能)、WebAuthenticationUI(WebベースOIDCフロー)。

このSDKの特徴として、KotlinコルーチンによるノンブロッキングAPIの提供、デバイス暗号化キーを使用したオンデバイス暗号化、包括的なOAuthフローサポート(Authorization Code、Interaction Code、Refresh Token、Resource Owner Password、Device Authorization、Token Exchange)があります。すべてのメソッドはメインスレッドを含む任意のスレッドから呼び出し可能で、内部的にネットワークI/Oや重い計算処理は自動的にバックグラウンドスレッドに切り替わります。

メリット・デメリット

メリット

  • モダンなアーキテクチャ: Kotlin、コルーチン、OkHttpベースの現代的な設計
  • ノンブロッキングAPI: メインスレッドをブロックしない非同期処理
  • 包括的なOAuthサポート: 主要なOAuthフローを網羅的にサポート
  • オンデバイス暗号化: デバイス暗号化キーによる安全なデータ保存
  • 自動トークン管理: リフレッシュ、保存、検証の自動化
  • モジュラー設計: 必要な機能のみを選択可能な柔軟な構成

デメリット

  • Android限定: Androidプラットフォーム専用でマルチプラットフォーム対応なし
  • 学習コスト: Kotlinコルーチンの理解が必要
  • 新しいSDK: 従来SDKからの移行作業が必要
  • ドキュメント: 日本語リソースが限定的

参考ページ

書き方の例

Gradle依存関係の設定

// build.gradle.kts (Module: app)
dependencies {
    // BOMを使用した統一バージョン管理
    implementation(platform("com.okta.kotlin:bom:2.0.3"))
    
    // 必要なコンポーネントを追加
    implementation("com.okta.kotlin:auth-foundation")
    implementation("com.okta.kotlin:oauth2")
    implementation("com.okta.kotlin:web-authentication-ui")
}

基本的なOAuth2クライアント設定

import com.okta.authfoundation.client.OidcClient
import com.okta.authfoundation.client.OidcClientResult
import com.okta.authfoundation.client.OidcConfiguration
import com.okta.authfoundation.credential.CredentialDataSource.Companion.createCredentialDataSource
import com.okta.oauth2.OAuth2Client
import com.okta.oauth2.OAuth2ClientResult

class AuthManager(private val context: Context) {
    
    private val oidcConfiguration = OidcConfiguration(
        clientId = "your-client-id",
        defaultScope = "openid email profile offline_access"
    )
    
    private val client = OAuth2Client.createFromDiscoveryUrl(
        configuration = oidcConfiguration,
        discoveryUrl = "https://your-org.okta.com/oauth2/default/.well-known/openid_configuration",
        credentialDataSource = context.createCredentialDataSource()
    )
    
    suspend fun isAuthenticated(): Boolean {
        return when (val result = client.credentialDataSource.default()) {
            is OidcClientResult.Error -> false
            is OidcClientResult.Success -> {
                result.result.token.isValid()
            }
        }
    }
}

Authorization Code Flowによる認証

import com.okta.webauthenticationui.WebAuthenticationClient.Companion.createWebAuthenticationClient
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope

class LoginActivity : AppCompatActivity() {
    
    private lateinit var authManager: AuthManager
    private lateinit var webAuthClient: WebAuthenticationClient
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)
        
        authManager = AuthManager(this)
        webAuthClient = createWebAuthenticationClient()
        
        findViewById<Button>(R.id.loginButton).setOnClickListener {
            performLogin()
        }
    }
    
    private fun performLogin() {
        lifecycleScope.launch {
            try {
                val result = webAuthClient.login(
                    context = this@LoginActivity,
                    redirectUrl = "com.example.app:/callback",
                    extraRequestParameters = mapOf(
                        "prompt" to "login"
                    )
                )
                
                when (result) {
                    is OAuth2ClientResult.Success -> {
                        // 認証成功
                        val token = result.result
                        handleSuccessfulLogin(token)
                    }
                    is OAuth2ClientResult.Error -> {
                        // 認証エラー
                        handleLoginError(result.exception)
                    }
                }
            } catch (e: Exception) {
                handleLoginError(e)
            }
        }
    }
    
    private fun handleSuccessfulLogin(token: Token) {
        // メイン画面に遷移
        startActivity(Intent(this, MainActivity::class.java))
        finish()
    }
    
    private fun handleLoginError(exception: Throwable) {
        Toast.makeText(this, "ログインエラー: ${exception.message}", Toast.LENGTH_LONG).show()
    }
}

ユーザー情報の取得

import com.okta.authfoundation.claims.DefaultClaimsProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class UserRepository(private val authManager: AuthManager) {
    
    suspend fun getCurrentUser(): UserInfo? = withContext(Dispatchers.IO) {
        return@withContext when (val result = authManager.client.credentialDataSource.default()) {
            is OidcClientResult.Error -> null
            is OidcClientResult.Success -> {
                val credential = result.result
                
                // トークンからユーザー情報を取得
                val claimsResult = credential.getUserInfo()
                when (claimsResult) {
                    is OidcClientResult.Success -> {
                        UserInfo(
                            id = claimsResult.result["sub"] as? String ?: "",
                            email = claimsResult.result["email"] as? String ?: "",
                            name = claimsResult.result["name"] as? String ?: "",
                            profilePicture = claimsResult.result["picture"] as? String
                        )
                    }
                    is OidcClientResult.Error -> null
                }
            }
        }
    }
    
    suspend fun refreshUserToken(): Boolean = withContext(Dispatchers.IO) {
        return@withContext when (val result = authManager.client.credentialDataSource.default()) {
            is OidcClientResult.Error -> false
            is OidcClientResult.Success -> {
                val credential = result.result
                when (val refreshResult = credential.refreshToken()) {
                    is OidcClientResult.Success -> true
                    is OidcClientResult.Error -> false
                }
            }
        }
    }
}

data class UserInfo(
    val id: String,
    val email: String,
    val name: String,
    val profilePicture: String?
)

API呼び出しでの認証ヘッダー使用

import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

class AuthInterceptor(private val authManager: AuthManager) : Interceptor {
    
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        
        // 認証が必要なリクエストかチェック
        if (!requiresAuth(originalRequest)) {
            return chain.proceed(originalRequest)
        }
        
        // 同期的にトークンを取得(注意:これはメインスレッド以外で実行すること)
        val token = runBlocking {
            when (val result = authManager.client.credentialDataSource.default()) {
                is OidcClientResult.Success -> result.result.token.accessToken
                is OidcClientResult.Error -> null
            }
        }
        
        return if (token != null) {
            val authenticatedRequest = originalRequest.newBuilder()
                .header("Authorization", "Bearer $token")
                .build()
            chain.proceed(authenticatedRequest)
        } else {
            chain.proceed(originalRequest)
        }
    }
    
    private fun requiresAuth(request: Request): Boolean {
        return request.url.host.contains("your-api.com")
    }
}

// Retrofitクライアントの設定
class ApiClientFactory(private val authManager: AuthManager) {
    
    fun createApiClient(): ApiService {
        val okHttpClient = OkHttpClient.Builder()
            .addInterceptor(AuthInterceptor(authManager))
            .build()
        
        val retrofit = Retrofit.Builder()
            .baseUrl("https://your-api.com/")
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
        
        return retrofit.create(ApiService::class.java)
    }
}

ログアウト機能の実装

class LogoutManager(
    private val authManager: AuthManager,
    private val webAuthClient: WebAuthenticationClient
) {
    
    suspend fun performLogout(context: Context): Boolean {
        return try {
            // Oktaからのログアウト(ブラウザ経由)
            val logoutResult = webAuthClient.logoutOfBrowser(
                context = context,
                redirectUrl = "com.example.app:/logout"
            )
            
            when (logoutResult) {
                is OAuth2ClientResult.Success -> {
                    // ローカルの認証情報をクリア
                    clearLocalCredentials()
                    true
                }
                is OAuth2ClientResult.Error -> {
                    // Oktaからのログアウトが失敗してもローカルはクリア
                    clearLocalCredentials()
                    false
                }
            }
        } catch (e: Exception) {
            // エラーが発生してもローカル認証情報はクリア
            clearLocalCredentials()
            false
        }
    }
    
    private suspend fun clearLocalCredentials() {
        authManager.client.credentialDataSource.removeAll()
    }
    
    suspend fun performSilentLogout(): Boolean {
        return try {
            clearLocalCredentials()
            true
        } catch (e: Exception) {
            false
        }
    }
}

認証状態の監視

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.delay

class AuthStateManager(private val authManager: AuthManager) {
    
    fun observeAuthState(): Flow<AuthState> = flow {
        while (true) {
            val isAuthenticated = authManager.isAuthenticated()
            emit(if (isAuthenticated) AuthState.Authenticated else AuthState.Unauthenticated)
            delay(5000) // 5秒ごとにチェック
        }
    }
    
    suspend fun checkTokenExpiry(): TokenStatus {
        return when (val result = authManager.client.credentialDataSource.default()) {
            is OidcClientResult.Error -> TokenStatus.NoToken
            is OidcClientResult.Success -> {
                val credential = result.result
                when {
                    credential.token.isValid() -> TokenStatus.Valid
                    credential.token.refreshToken != null -> TokenStatus.NeedsRefresh
                    else -> TokenStatus.Expired
                }
            }
        }
    }
}

sealed class AuthState {
    object Authenticated : AuthState()
    object Unauthenticated : AuthState()
}

sealed class TokenStatus {
    object Valid : TokenStatus()
    object NeedsRefresh : TokenStatus()
    object Expired : TokenStatus()
    object NoToken : TokenStatus()
}

エラーハンドリングとリトライ機能

class AuthErrorHandler {
    
    suspend fun handleAuthError(
        error: OAuth2ClientResult.Error<*>,
        retry: suspend () -> OAuth2ClientResult<Token>
    ): AuthErrorResult {
        return when (error.exception) {
            is NetworkException -> {
                // ネットワークエラーの場合はリトライ
                delay(2000)
                when (val retryResult = retry()) {
                    is OAuth2ClientResult.Success -> AuthErrorResult.Recovered(retryResult.result)
                    is OAuth2ClientResult.Error -> AuthErrorResult.Failed(retryResult.exception)
                }
            }
            is AuthenticationException -> {
                // 認証エラーの場合は再ログインが必要
                AuthErrorResult.RequiresReauth
            }
            else -> {
                // その他のエラー
                AuthErrorResult.Failed(error.exception)
            }
        }
    }
}

sealed class AuthErrorResult {
    data class Recovered(val token: Token) : AuthErrorResult()
    data class Failed(val exception: Throwable) : AuthErrorResult()
    object RequiresReauth : AuthErrorResult()
}

カスタムスコープとクレーム

class CustomAuthManager(context: Context) {
    
    private val customOidcConfiguration = OidcConfiguration(
        clientId = "your-client-id",
        defaultScope = "openid email profile custom:read custom:write"
    )
    
    private val client = OAuth2Client.createFromDiscoveryUrl(
        configuration = customOidcConfiguration,
        discoveryUrl = "https://your-org.okta.com/oauth2/default/.well-known/openid_configuration",
        credentialDataSource = context.createCredentialDataSource()
    )
    
    suspend fun getCustomClaims(): Map<String, Any>? {
        return when (val result = client.credentialDataSource.default()) {
            is OidcClientResult.Error -> null
            is OidcClientResult.Success -> {
                val credential = result.result
                when (val claimsResult = credential.getUserInfo()) {
                    is OidcClientResult.Success -> {
                        claimsResult.result.filterKeys { key ->
                            key.startsWith("custom:")
                        }
                    }
                    is OidcClientResult.Error -> null
                }
            }
        }
    }
}