Skip to content

Android (Kotlin)

Android Custom Tabs + DataStore + Android Keystore로 네이티브 OAuth + PKCE를 구현합니다.

⚠️ EncryptedSharedPreferences는 사용하지 마세요

androidx.security:security-crypto 1.1.0부터 deprecated. 신규 코드에서는 DataStore + Tink 또는 Ackee Guardian 같은 활성 라이브러리를 사용하세요.

의존성

kotlin
// app/build.gradle.kts
dependencies {
    // Custom Tabs
    implementation("androidx.browser:browser:1.8.0")

    // DataStore + Tink for encrypted storage
    implementation("androidx.datastore:datastore-preferences:1.1.1")
    implementation("com.google.crypto.tink:tink-android:1.13.0")

    // Biometric (선택, 민감 작업용)
    implementation("androidx.biometric:biometric:1.2.0-alpha05")
}

OAuth + PKCE 플로우

kotlin
import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import java.security.MessageDigest
import java.security.SecureRandom
import android.util.Base64
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.FormBody

class LogiOAuth(private val context: Context) {
    private val base = "https://logi.example.com"
    private val clientId = "logi_..."
    private val redirectUri = "com.example.myapp://callback"
    private val client = OkHttpClient()

    private fun base64UrlEncode(data: ByteArray): String =
        Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)

    fun startSignIn(): Pair<String, String> {
        // 1. PKCE
        val verifier = ByteArray(32).also { SecureRandom().nextBytes(it) }
            .let { base64UrlEncode(it) }
        val challenge = base64UrlEncode(
            MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray())
        )
        val state = base64UrlEncode(
            ByteArray(16).also { SecureRandom().nextBytes(it) }
        )

        // 2. authorize URL
        val url = Uri.parse("$base/oauth/authorize").buildUpon()
            .appendQueryParameter("client_id", clientId)
            .appendQueryParameter("redirect_uri", redirectUri)
            .appendQueryParameter("response_type", "code")
            .appendQueryParameter("scope", "profile email")
            .appendQueryParameter("state", state)
            .appendQueryParameter("code_challenge", challenge)
            .appendQueryParameter("code_challenge_method", "S256")
            .build()

        // 3. Custom Tabs로 열기
        CustomTabsIntent.Builder().build().launchUrl(context, url)
        return verifier to state  // Activity에서 보관 → onNewIntent에서 검증
    }

    suspend fun exchangeCode(code: String, verifier: String): TokenResponse {
        val body = FormBody.Builder()
            .add("grant_type", "authorization_code")
            .add("code", code)
            .add("redirect_uri", redirectUri)
            .add("code_verifier", verifier)
            .add("client_id", clientId)
            .build()

        val req = Request.Builder().url("$base/oauth/token").post(body).build()
        client.newCall(req).execute().use { resp ->
            // JSON 파싱 (kotlinx.serialization 등)
            return parseToken(resp.body!!.string())
        }
    }
}

data class TokenResponse(val accessToken: String, val refreshToken: String)

onNewIntent에서 redirect callback URI를 받아 state 검증 후 exchangeCode 호출하세요.

Refresh Token 저장 (DataStore + Tink)

Tink는 Google이 만든 암호화 라이브러리로 키 관리를 자동화합니다. Android Keystore와 연동되어 키 자체는 하드웨어 백킹(가능 시), 데이터는 AEAD로 암호화됩니다.

kotlin
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.integration.android.AndroidKeysetManager
import android.util.Base64

private val Context.tokenStore by preferencesDataStore(name = "logi_tokens")

class LogiTokenStorage(private val context: Context) {
    private val refreshKey = stringPreferencesKey("refresh_token_encrypted")

    init { AeadConfig.register() }

    private val aead: Aead by lazy {
        AndroidKeysetManager.Builder()
            .withSharedPref(context, "logi_master_keyset", "logi_prefs")
            .withKeyTemplate(KeyTemplates.get("AES256_GCM"))
            .withMasterKeyUri("android-keystore://logi_master_key")
            .build()
            .keysetHandle
            .getPrimitive(Aead::class.java)
    }

    suspend fun saveRefreshToken(token: String) {
        val ciphertext = aead.encrypt(token.toByteArray(), null)
        val encoded = Base64.encodeToString(ciphertext, Base64.NO_WRAP)
        context.tokenStore.edit { it[refreshKey] = encoded }
    }

    suspend fun loadRefreshToken(): String? {
        val encoded = context.tokenStore.data
            .map { it[refreshKey] }
            .firstOrNull() ?: return null
        val ciphertext = Base64.decode(encoded, Base64.NO_WRAP)
        return String(aead.decrypt(ciphertext, null))
    }
}

백업에서 토큰 제외 (필수)

Android의 auto-backup이 토큰을 Google Drive로 보내면 복원 시 키 불일치로 토큰이 무효화됩니다. 반드시 토큰 저장소를 백업 대상에서 제외하세요:

xml
<!-- app/src/main/res/xml/backup_rules.xml -->
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
  <exclude domain="sharedpref" path="logi_master_keyset.xml" />
  <exclude domain="file" path="datastore/logi_tokens.preferences_pb" />
</full-backup-content>
xml
<!-- AndroidManifest.xml -->
<application
    android:fullBackupContent="@xml/backup_rules"
    android:dataExtractionRules="@xml/data_extraction_rules">

민감 작업에 biometric 추가

Android Keystore의 setUserAuthenticationRequired(true)로 키 자체를 biometric으로 보호할 수 있습니다. high-assurance 액션 직전에만 사용하세요:

kotlin
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import javax.crypto.KeyGenerator

fun generateBiometricProtectedKey(alias: String) {
    val spec = KeyGenParameterSpec.Builder(
        alias,
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    )
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .setUserAuthenticationRequired(true)
        .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
        .setInvalidatedByBiometricEnrollment(true)  // 새 지문 추가 시 키 무효화
        .apply {
            // StrongBox 가능 시 사용 (제조사 의존)
            try { setIsStrongBoxBacked(true) } catch (_: Exception) {}
        }
        .build()

    KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
        .apply { init(spec) }
        .generateKey()
}

BiometricPrompt로 사용자 인증을 트리거한 뒤 cipher를 받아 사용합니다.

StrongBox는 보장되지 않습니다

StrongBox(전용 보안 칩)는 API 28+ 일부 디바이스에만 있습니다. StrongBoxUnavailableException을 catch해서 fallback하세요. logi SDK는 자동으로 fallback합니다.

Biometric 재등록 시 키 무효화

setInvalidatedByBiometricEnrollment(true)(권장)인 경우, 사용자가 새 지문을 추가하면 기존 키가 사용 불가가 됩니다. 토큰 재발급 + 재로그인 플로우를 준비하세요.

Device Bootstrap (device_secret)

logi의 device bootstrap은 dual mode입니다:

  1. 첫 호출 (bootstrap): POST /api/v1/devices with {device_uuid, platform} → 응답에 device_secret 1회 노출 + PAK 발급
  2. 이후 호출 (refresh): 같은 endpoint에 {device_uuid, platform, device_secret} → 서버가 digest 검증 후 새 PAK 발급. 누락/불일치 시 401.

device_secret을 잃어버리면 anonymous 사용자는 OAuth 로그인으로 재인증해야 합니다.

kotlin
data class BootstrapResponse(
    val accessToken: String,
    val deviceSecret: String?  // bootstrap/legacy grace에서만 존재
)

// 첫 호출 응답 처리
suspend fun handleBootstrap(resp: BootstrapResponse) {
    resp.deviceSecret?.let { storage.saveDeviceSecret(it) }
}

// 이후 PAK 갱신 시
val secret = storage.loadDeviceSecret()  // null이면 OAuth 재로그인 유도
val body = mapOf(
    "device_uuid" to uuid,
    "platform" to "android",
    "device_secret" to secret
)

refresh token과 별도 DataStore 키로 저장하세요:

kotlin
private val deviceSecretKey = stringPreferencesKey("device_secret_encrypted")

suspend fun saveDeviceSecret(secret: String) {
    val ciphertext = aead.encrypt(secret.toByteArray(), null)
    context.tokenStore.edit {
        it[deviceSecretKey] = Base64.encodeToString(ciphertext, Base64.NO_WRAP)
    }
}

절대 SharedPreferences 평문에 저장 금지 — 백업·루팅 디바이스에서 즉시 노출됩니다.

점검 체크리스트

  • [ ] EncryptedSharedPreferences 사용하지 않음 (deprecated)
  • [ ] DataStore + Tink로 암호화 저장
  • [ ] backup_rules.xml에 토큰 저장소 제외 명시
  • [ ] PKCE S256 사용 (plain 금지)
  • [ ] state 생성·검증
  • [ ] device_secret과 refresh token 별도 키로 저장
  • [ ] 민감 작업에만 biometric 적용 (background refresh와 양립 불가)
  • [ ] StrongBox 미지원 디바이스 fallback 처리

MIT License · Identity가 제품의 신뢰를 만듭니다.