Android (Kotlin)
콜백은 custom scheme 권장
App Link 호스트가 IdP 와 겹치면 무관한 deep link 가 OAuth 파서에 주입돼 missingCode 발생. 콜백은 <app>://oauth/1pass/callback 형태 custom scheme 사용. 진단: https://api.1pass.dev/diagnose
EncryptedSharedPreferences 금지
androidx.security:security-crypto 1.1.0+ deprecated. DataStore + Tink 사용.
의존성
kotlin
// app/build.gradle.kts
dependencies {
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("com.google.crypto.tink:tink-android:1.13.0")
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://api.1pass.dev"
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> {
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) }
)
val url = Uri.parse("$base/oauth/authorize").buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", redirectUri)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid profile:basic email")
.appendQueryParameter("state", state)
.appendQueryParameter("code_challenge", challenge)
.appendQueryParameter("code_challenge_method", "S256")
.build()
CustomTabsIntent.Builder().build().launchUrl(context, url)
return verifier to state
}
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 ->
return parseToken(resp.body!!.string())
}
}
}
data class TokenResponse(val accessToken: String, val refreshToken: String)onNewIntent에서 redirect URI 받아 state 검증 후 exchangeCode 호출.
App-to-app first-try (Intent setPackage)
설치된 logi 앱이 있으면 먼저 시도, 없으면 Custom Tabs fallback:
kotlin
fun launchAuthorize(context: Context, url: Uri) {
val appIntent = android.content.Intent(android.content.Intent.ACTION_VIEW, url).apply {
setPackage("dev.onepass.app")
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(appIntent)
} catch (_: android.content.ActivityNotFoundException) {
CustomTabsIntent.Builder().build().launchUrl(context, url)
}
}Manifest 설정
xml
<!-- AndroidManifest.xml -->
<activity android:name=".OAuthCallbackActivity" android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Custom scheme callback -->
<data android:scheme="com.example.myapp" android:host="callback" />
</intent-filter>
</activity>
<application
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules">App Links + assetlinks.json
App Link 도 같이 받으려면 호스트를 https:// 로 등록하고 .well-known/assetlinks.json 게시:
json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": ["AA:BB:CC:..."]
}
}]서명 인증서 SHA256:
bash
keytool -list -v -keystore release.keystore -alias myalias | grep SHA256Refresh Token 저장 (DataStore + Tink)
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))
}
}백업 제외 (필수)
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>Biometric 보호 (선택)
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 {
try { setIsStrongBoxBacked(true) } catch (_: Exception) {}
}
.build()
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
.apply { init(spec) }
.generateKey()
}BiometricPrompt 로 인증 후 cipher 사용. StrongBox 는 StrongBoxUnavailableException catch fallback. 새 지문 등록 시 키 무효화 → 재로그인 플로우 필요.
Device Bootstrap (device_secret)
- 첫 호출:
POST /api/v1/devices{device_uuid, platform}→device_secret1회 노출 + PAK - 이후: 같은 endpoint 에
{device_uuid, platform, device_secret}→ 새 PAK. 누락/불일치 시 401
kotlin
data class BootstrapResponse(
val accessToken: String,
val deviceSecret: String?
)
suspend fun handleBootstrap(resp: BootstrapResponse) {
resp.deviceSecret?.let { storage.saveDeviceSecret(it) }
}
val secret = storage.loadDeviceSecret() // null이면 OAuth 재로그인
val body = mapOf(
"device_uuid" to uuid,
"platform" to "android",
"device_secret" to secret
)별도 키로 암호화 저장:
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 평문 저장 금지.
트러블슈팅
missingCode: App Link 호스트 충돌 → custom scheme 으로 전환- 401 on refresh:
device_secret누락/불일치 → OAuth 재로그인 유도 - 백업 복원 후 토큰 무효:
backup_rules.xml누락 - StrongBox 실패:
StrongBoxUnavailableExceptioncatch → 일반 Keystore fallback - App Link 미동작:
assetlinks.jsonSHA256 fingerprint 확인 (release/debug 별도)
체크리스트
- [ ]
EncryptedSharedPreferences미사용 - [ ] DataStore + Tink 암호화 저장
- [ ]
backup_rules.xml토큰 제외 - [ ] PKCE S256 (plain 금지)
- [ ]
state생성·검증 - [ ]
device_secret별도 키로 저장 - [ ] Intent
setPackagefirst-try → Custom Tabs fallback - [ ] StrongBox fallback 처리