Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Android アプリに Okta 認証を入れてみた

こんにちは、最近 iOS から Android エンジニアにジョブチェンジした西村です。

最近社内の Android アプリに Okta 認証を導入し、ログインをしないと使えないようにセキュリティを強化しました。 あまり実装する機会はないかもしれないですが、どのように実装したか紹介していきます!

この記事は 「ヤプリ&フラー 合同アドベントカレンダー #2」 の21日目の記事です!🎄

Oktaとは?

OktaとはどのようなサービスなのかAIに聞いてみました。

Oktaは、企業向けのログイン認証をまとめて管理するクラウドサービスです。 一度のログインで複数の業務システムを使える(SSO)ほか、多要素認証でセキュリティを強化できます。主に企業の社内システムやクラウドサービスの認証基盤として使われます。

IDやパスワードの管理を行ってくれるので、様々なサービスへのログインが簡単になります。
また社内システムの認証基盤としても使えるということで、今回は Okta を利用することにしました!

今回やりたいこと

やりたいことはとてもシンプルです。

  • アプリにログイン機能を持たせる。
  • 認証基盤は Okta を採用し、ログインしないとアプリが使えない状態を目指す。

実装の前に

以下の Okta SDK を利用して実装していきます。(バージョンは2.0.3で実装します)
GitHub - okta/okta-mobile-kotlin: Okta's Android Authentication SDK

サンプルアプリもあるようなので、こちらを見てみるのもいいかもしれません。(私の環境ではバージョンの違いかうまく動きませんでした…)
GitHub - okta-samples/okta-android-kotlin-sample: Android Kotlin + Okta

実装

1. Okta SDK の導入

まずは build.gradle に以下を追加して、 Okta SDK を導入します!

dependencies {
    implementation(platform(libs.okta.bom))
    implementation(libs.okta.auth.foundation) // 認証情報を管理するための共通クラスであり、他のライブラリの基盤として使用される。
    implementation(libs.okta.oauth2) // ユーザー認証のためのOAuth2認証機能。
    implementation(libs.okta.web.authentication.ui) // WebベースのOIDCフローを使用してユーザーを認証する。
}

GitHub - okta/okta-mobile-kotlin: Okta's Android Authentication SDK


Okta の認証はブラウザで行われるため、アプリに戻るためのリダイレクトスキームも合わせて設定しておきましょう。

{redirectUriScheme} をアプリケーションのリダイレクトスキームに置き換える必要があります。例えば、signInRedirectUri が com.okta.sample.android:/login の場合、{redirectUriScheme} を com.okta.sample.android. に置き換えることを意味します。
GitHub - okta/okta-mobile-kotlin: Okta's Android Authentication SDK

android {
  defaultConfig {
    manifestPlaceholders = [
      "webAuthenticationRedirectScheme": "{redirectUriScheme}"
    ]
  }
}

2. SDK の初期化

Okta SDK の初期化処理は、Application を継承したクラスで処理するようにしています。
OidcConfiguration に設定する clientIdissuer については、Okta Developer Console を確認してください。

defaultScope に設定する説明は以下のサイトをご覧ください。
OpenID Connect & OAuth 2.0

import android.app.Application
import com.okta.authfoundation.AuthFoundation
import com.okta.authfoundation.client.OidcConfiguration

class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        AuthFoundation.initializeAndroidContext(this)
        OidcConfiguration.default = OidcConfiguration(
            clientId = "{clientId}",
            defaultScope = "openid offline_access",
            issuer = "https://{yourOktaOrg}.okta.com/oauth2/default"
        )
    }
}

GitHub - okta/okta-mobile-kotlin: Okta's Android Authentication SDK

3. ログイン画面

アプリを開いたら、いきなり Okta のログイン画面に遷移すると利用者は混乱してしまうので、簡単なログイン画面を用意しました。

@Composable
fun LoginScreen(
    modifier: Modifier = Modifier,
    onLoginClick: (Context) -> Unit,
    errorMessage: String? = null
) {
    val context = LocalContext.current
    Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
        Column(
            verticalArrangement = Arrangement.spacedBy(64.dp, Alignment.CenterVertically),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = stringResource(R.string.app_name),
                fontSize = 40.sp,
                fontWeight = FontWeight.Bold
            )
            if (errorMessage != null) {
                Text(
                    stringResource(R.string.error_message, errorMessage),
                    color = MaterialTheme.colorScheme.error
                )
            }
            Button(onClick = { onLoginClick(context) }) {
                Text(
                    stringResource(R.string.okta_login),
                    fontSize = 16.sp
                )
            }
        }
    }
}

4. ログイン処理

実際に Okta を利用してログインできるように処理を実装していきます!(ついでにログイン状態の確認も)
※ リフレッシュトークンを有効にしないと、60分でトークンの有効期限が切れ再ログインが必要になるため注意です。

interface AuthRepository {
    suspend fun isLoggedIn(): Boolean
    suspend fun login(context: Context): Result<Unit>
    suspend fun logout(context: Context): Result<Unit>
}

class OktaAuthRepository @Inject constructor() : AuthRepository {

    // ログイン状態の確認
    override suspend fun isLoggedIn(): Boolean {
        val credential = Credential.default
        return credential?.getValidAccessToken() != null
    }

    // Oktaのログイン処理(ブラウザログイン)
    override suspend fun login(context: Context): Result<Unit> {
        return try {
            val result = WebAuthentication().login(
                context,
                BuildConfig.OKTA_SIGN_IN_REDIRECT_URI
            )
            when (result) {
                is OAuth2ClientResult.Success -> {
                    val credential = Credential.store(result.result)
                    Credential.setDefaultAsync(credential)
                    Result.success(Unit)
                }
                is OAuth2ClientResult.Error -> {
                    Result.failure(result.exception)
                }
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

5. 状態を Model で管理

ローディングやログイン状態を State として Model で管理します。

sealed class State {
    data object Loading: State()
    data object LoggedIn: State()
    data class LoggedOut(val errorMessage: String? = null): State()
}

@HiltViewModel
class AuthViewModel @Inject constructor(
    private val authRepository: AuthRepository
) : ViewModel() {
    private val _state = MutableStateFlow<State>(State.Loading)
    val state: StateFlow<State> = _state

    init {
        checkLoginStatus()
    }

    private fun checkLoginStatus() {
        // ログイン状態のチェック(トークンの有効期限で判断)
        viewModelScope.launch {
            if (authRepository.isLoggedIn()) {
                _state.value = State.LoggedIn
            } else {
                _state.value = State.LoggedOut()
            }
        }
    }

    fun login(context: Context) {
        if (_state.value is State.Loading) return

        viewModelScope.launch {
            _state.value = State.Loading

            authRepository.login(context)
                .onSuccess {
                    _state.value = State.LoggedIn
                }
                .onFailure { error ->
                    _state.value = State.LoggedOut(errorMessage = error.message)
                }
        }
    }
}

State の状態に応じて、画面を出し分けます。

private val model by viewModels<Model>()
    
val state by model.state.collectAsState()
when (val currentState = state) {
    is State.Loading -> LoadingScreen()
    is State.LoggedOut -> LoginScreen(
        onLoginClick = model::login,
        errorMessage = state.errorMessage
    )
    is State.LoggedIn -> ContentScreen()
}

6. ログアウト処理の実装

ログアウト処理も必要であれば、以下のように書けます。
ここでは、ブラウザから Okta のログアウトと同時に、端末に保存されている Token の削除も行います。

class OktaAuthRepository @Inject constructor() : AuthRepository {

    // Oktaのブラウザからログアウト
    override suspend fun logout(context: Context): Result<Unit> {
        return try {
            val credential = Credential.default
            val idToken = credential?.token?.idToken

            // idTokenがない場合はブラウザログアウトはスキップ
            if (idToken == null) {
                credential?.delete()
                return Result.success(Unit)
            }

            // ブラウザからログアウト
            val result = WebAuthentication().logoutOfBrowser(
                context,
                BuildConfig.OKTA_SIGN_OUT_REDIRECT_URI,
                idToken
            )

            // 端末に保存されている認証情報を削除
            credential.delete()

            when (result) {
                is OAuth2ClientResult.Success -> Result.success(Unit)
                is OAuth2ClientResult.Error -> Result.failure(result.exception)
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

最後に

Okta 認証を導入する機会は少ないと思いますが、簡単に社内サービスのセキュリティを向上させることができるので、ぜひセキュリティ不安だなと思った方は導入を検討してみるといいかもしれません。

自分と同じように Okta 認証を導入しなければいけなくなった方の助けになれたら幸いです 🙌