Skip to content

Android (Kotlin)

Source: en/integrations/android.md · Live: https://docs.1pass.dev/en/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 quick start (recommended)
  • L93-L104: ## Dependencies
  • L105-L173: ## Manual integration: OAuth + PKCE (without the SDK)
  • L174-L191: ## App-to-app first-try (Intent setPackage)
  • L192-L210: ## Manifest configuration
  • L211-L230: ## App Links + assetlinks.json
  • L231-L275: ## Storing the Refresh Token (DataStore + Tink)
  • L276-L286: ## Excluding from backup (required)
  • L287-L316: ## Biometric protection (optional)
  • L317-L354: ## Device Bootstrap (device_secret)
  • L355-L362: ## Troubleshooting
  • L363-L373: ## Checklist

Android (Kotlin)

⚠️ Warning: Prefer a custom scheme for the callback If the App Link host overlaps with the IdP, an unrelated deep link gets injected into the OAuth parser and produces missingCode. Use a custom scheme of the form <app>://oauth/1pass/callback for the callback. Diagnostics: https://api.1pass.dev/diagnose

⚠️ Warning: Do not use EncryptedSharedPreferences androidx.security:security-crypto 1.1.0+ is deprecated. Use DataStore + Tink.

The official Android SDK logi-auth-android bundles PKCE, state, nonce, the app-to-app handoff, and id_token signature verification (RS256) — giving public clients a defense layer without a backend; with a backend, the server re-verifying is the SoT for audience checks. The SDK path below is the recommended route; the Manual OAuth + PKCE section below is a fallback. (The token storage, biometric, and device bootstrap sections still apply when using the SDK.)

kotlin
// settings.gradle.kts — add the JitPack repository
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
// Once in Application.onCreate
LogiAuth.configure(this, LogiAuthConfig(
    issuer = "https://api.1pass.dev",   // default IdP
    clientId = "logi_xxx",
    redirectUri = "com.example.myapp://callback",
    // scopes default to ["openid","profile:basic","email"] · tokenIssuer defaults to "https://api.1pass.dev" (the 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
// On a button click (inside an Activity, in a 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         — verified subject (after RS256 signature + iss/aud/azp/exp/nonce)
    // session.idToken / session.accessToken / session.refreshToken / session.email
}

Methods: LogiAuth.configure(context, config) / LogiAuth.signIn(activity, scopes): Result<LogiSession> / LogiAuth.isLogiAppInstalled(context). Errors: LogiAuthError sealed class (UserCancelled, StateMismatch, IdTokenInvalid(code), JwksFetchFailed(status), etc.).

💡 Tip: id_token verification order (built into the SDK — identical across all SDKs) alg==RS256kid→JWKS → signature (RS256) → iss (=tokenIssuer "https://api.1pass.dev") → aud (must contain your client_id) → azp (required only when aud is multi-valued; value=client_id)exp (60s skew) → iatnonce (when requested) → subat_hash (present-only, v1.0.1). azp is not unconditional — it is conditional on a multi-audience token. at_hash (v1.0.1): when the id_token carries at_hash, it is verified as base64url_nopad(SHA256(access_token UTF-8 bytes)[first 16 bytes]) (OIDC §3.1.3.6) to bind access_token↔id_token (rejected with AT_HASH_MISMATCH). signIn() wires the access_token automatically; standalone is verifyIdToken(idToken, jwks, expected, accessToken=). v1.0.1 selects the signing key with a kty=="RSA"+use/alg filter, robust to EC-key mix-in (JWKS). With a backend, the server (confidential SDK) re-verifying is the SoT.

Dependencies

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")
}

Manual integration: OAuth + PKCE (without the SDK)

A fallback for when you can't use the SDK. In that case you must verify the id_token yourself or verify the access token via 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)

In onNewIntent, receive the redirect URI, verify state, then call exchangeCode.

App-to-app first-try (Intent setPackage)

If the logi app is installed, try it first; otherwise fall back to Custom Tabs:

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 configuration

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

If you also want to receive App Links, register the host with https:// and publish .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:..."]
  }
}]

Signing certificate SHA256:

bash
keytool -list -v -keystore release.keystore -alias myalias | grep SHA256

Storing the 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))
    }
}

Excluding from backup (required)

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 protection (optional)

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()
}

Authenticate with BiometricPrompt, then use the cipher. For StrongBox, catch StrongBoxUnavailableException and fall back. When a new fingerprint is enrolled the key is invalidated → you need a re-login flow.

Device Bootstrap (device_secret)

  1. On the first call, POST /api/v1/devices with {device_uuid, platform}, and the response includes device_secret once, along with a PAK.
  2. On later calls, send {device_uuid, platform, device_secret} to the same endpoint to receive a new PAK. A missing or mismatched secret returns 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()  // if null, re-login via OAuth
val body = mapOf(
    "device_uuid" to uuid,
    "platform" to "android",
    "device_secret" to secret
)

Store it encrypted under a separate key:

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

❌ Do not store it as plaintext in SharedPreferences.

Troubleshooting

  • missingCode: App Link host conflict → switch to a custom scheme
  • 401 on refresh: device_secret missing/mismatched → prompt an OAuth re-login
  • Tokens invalid after a backup restore: backup_rules.xml missing
  • StrongBox failure: catch StrongBoxUnavailableException → fall back to the regular Keystore
  • App Link not working: check the assetlinks.json SHA256 fingerprint (release/debug are separate)

Checklist

  • [ ] EncryptedSharedPreferences not used
  • [ ] DataStore + Tink encrypted storage
  • [ ] backup_rules.xml excludes tokens
  • [ ] PKCE S256 (no plain)
  • [ ] state generated and verified
  • [ ] device_secret stored under a separate key
  • [ ] Intent setPackage first-try → Custom Tabs fallback
  • [ ] StrongBox fallback handled

최종 수정:

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