Skip to content

Android (Kotlin)

Source: integrations/android.md · Live: https://docs.1pass.dev/integrations/android LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).

📍 Jump-to Index

  • L33-L92: ## SDK 빠른 시작 (권장)
  • L93-L104: ## 의존성
  • L105-L173: ## 수동 통합: OAuth + PKCE (SDK 미사용)
  • L174-L191: ## App-to-app first-try (Intent setPackage)
  • L192-L210: ## Manifest 설정
  • L211-L230: ## App Links + assetlinks.json
  • L231-L275: ## Refresh Token 저장 (DataStore + Tink)
  • L276-L286: ## 백업 제외 (필수)
  • L287-L316: ## Biometric 보호 (선택)
  • L317-L354: ## Device Bootstrap (device_secret)
  • L355-L362: ## 트러블슈팅
  • L363-L373: ## 체크리스트

Android (Kotlin)

⚠️ Warning: 콜백은 custom scheme 권장 App Link 호스트가 IdP 와 겹치면 무관한 deep link 가 OAuth 파서에 주입돼 missingCode 발생. 콜백은 <app>://oauth/1pass/callback 형태 custom scheme 사용. 진단: https://api.1pass.dev/diagnose

⚠️ Warning: EncryptedSharedPreferences 금지 androidx.security:security-crypto 1.1.0+ deprecated. DataStore + Tink 사용.

SDK 빠른 시작 (권장)

공식 Android SDK logi-auth-android 가 PKCE·state·nonce·app-to-app 핸드오프와 id_token 서명 검증(RS256) 을 모두 내장합니다 — 별도 백엔드 없이 public client 방어층을 제공하며, backend 가 있으면 서버 재검증이 audience 검증의 SoT 입니다. 아래 SDK 사용이 권장 경로이고, 아래 수동 통합: OAuth + PKCE 절은 fallback 입니다. (토큰 저장·biometric·device bootstrap 절은 SDK 사용 시에도 그대로 참고하세요.)

kotlin
// settings.gradle.kts — JitPack 저장소 추가
dependencyResolutionManagement {
    repositories {
        maven { url = uri("https://jitpack.io") }
    }
}

// app/build.gradle.kts
dependencies {
    implementation("com.github.dcode-co:logi-auth-android:1.0.1")
}
kotlin
// Application.onCreate 에서 1회
LogiAuth.configure(this, LogiAuthConfig(
    issuer = "https://api.1pass.dev",   // 기본 IdP
    clientId = "logi_xxx",
    redirectUri = "com.example.myapp://callback",
    // scopes 기본값 ["openid","profile:basic","email"] · tokenIssuer 기본값 "https://api.1pass.dev"(id_token 의 iss)
))
xml

<activity
    android:name="com.dcodelabs.logi.sdk.LogiAuthCallbackActivity"
    android:exported="true"
    android:launchMode="singleTask">
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="com.example.myapp" android:host="callback" />
  </intent-filter>
</activity>
kotlin
// 버튼 클릭 (Activity 안, coroutine)
lifecycleScope.launch {
    val session = LogiAuth.signIn(this@MyActivity).getOrElse { e ->
        Toast.makeText(this@MyActivity, e.message, Toast.LENGTH_LONG).show()
        return@launch
    }
    // session.sub         — 검증 완료된 subject (RS256 서명 + iss/aud/azp/exp/nonce 후)
    // session.idToken / session.accessToken / session.refreshToken / session.email
}

메서드: LogiAuth.configure(context, config) / LogiAuth.signIn(activity, scopes): Result<LogiSession> / LogiAuth.isLogiAppInstalled(context). 에러: LogiAuthError sealed class (UserCancelled, StateMismatch, IdTokenInvalid(code), JwksFetchFailed(status) 등).

💡 Tip: id_token 검증 순서 (SDK 내장 — 전 SDK 동일) alg==RS256kid→JWKS → 서명(RS256) → iss(=tokenIssuer "https://api.1pass.dev") → aud(내 client_id 포함) → azp(aud 가 복수일 때만 필수, 값=client_id)exp(skew 60s) → iatnonce(요청 시) → subat_hash(present-only, v1.0.1). azp 는 무조건이 아니라 멀티 aud 조건부. at_hash (v1.0.1): id_token 에 at_hash 가 있으면 base64url_nopad(SHA256(access_token 의 UTF-8 바이트)[처음 16바이트])(OIDC §3.1.3.6)로 access_token↔id_token 바인딩을 검증(불일치 시 AT_HASH_MISMATCH). signIn() 은 access_token 을 자동 배선하며, 스탠드얼론은 verifyIdToken(idToken, jwks, expected, accessToken=). v1.0.1 은 JWKS kty=="RSA"+use/alg 필터로 EC 키 혼입에 견고(JWKS). backend 가 있으면 서버(confidential SDK)가 재검증이 SoT.

의존성

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 (SDK 미사용)

SDK 를 못 쓰는 경우의 fallback 입니다. 이 경우 id_token 검증은 직접 구현하거나 access token 을 JWKS 로 검증해야 합니다.

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

<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" />
    
    <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 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 SHA256

Refresh 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

<?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)

  1. 첫 호출: POST /api/v1/devices {device_uuid, platform}device_secret 1회 노출 + PAK
  2. 이후: 같은 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 실패: StrongBoxUnavailableException catch → 일반 Keystore fallback
  • App Link 미동작: assetlinks.json SHA256 fingerprint 확인 (release/debug 별도)

체크리스트

  • [ ] EncryptedSharedPreferences 미사용
  • [ ] DataStore + Tink 암호화 저장
  • [ ] backup_rules.xml 토큰 제외
  • [ ] PKCE S256 (plain 금지)
  • [ ] state 생성·검증
  • [ ] device_secret 별도 키로 저장
  • [ ] Intent setPackage first-try → Custom Tabs fallback
  • [ ] StrongBox fallback 처리

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