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/callbackfor the callback. Diagnostics: https://api.1pass.dev/diagnose
⚠️ Warning: Do not use EncryptedSharedPreferences
androidx.security:security-crypto1.1.0+ is deprecated. UseDataStore + Tink.
SDK quick start (recommended)
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.)
// 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")
}// 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)
))
<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>// 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==RS256→kid→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) →iat→nonce(when requested) →sub→at_hash(present-only, v1.0.1).azpis not unconditional — it is conditional on a multi-audience token. at_hash (v1.0.1): when the id_token carriesat_hash, it is verified asbase64url_nopad(SHA256(access_token UTF-8 bytes)[first 16 bytes])(OIDC §3.1.3.6) to bind access_token↔id_token (rejected withAT_HASH_MISMATCH).signIn()wires the access_token automatically; standalone isverifyIdToken(idToken, jwks, expected, accessToken=). v1.0.1 selects the signing key with akty=="RSA"+use/alg filter, robust to EC-key mix-in (JWKS). With a backend, the server (confidential SDK) re-verifying is the SoT.
Dependencies
// 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.
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:
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
<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 Links + assetlinks.json
If you also want to receive App Links, register the host with https:// and publish .well-known/assetlinks.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:
keytool -list -v -keystore release.keystore -alias myalias | grep SHA256Storing the Refresh Token (DataStore + Tink)
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 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)
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)
- On the first call,
POST /api/v1/deviceswith{device_uuid, platform}, and the response includesdevice_secretonce, along with a PAK. - 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.
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:
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_secretmissing/mismatched → prompt an OAuth re-login - Tokens invalid after a backup restore:
backup_rules.xmlmissing - StrongBox failure: catch
StrongBoxUnavailableException→ fall back to the regular Keystore - App Link not working: check the
assetlinks.jsonSHA256 fingerprint (release/debug are separate)
Checklist
- [ ]
EncryptedSharedPreferencesnot used - [ ] DataStore + Tink encrypted storage
- [ ]
backup_rules.xmlexcludes tokens - [ ] PKCE S256 (no plain)
- [ ]
stategenerated and verified - [ ]
device_secretstored under a separate key - [ ] Intent
setPackagefirst-try → Custom Tabs fallback - [ ] StrongBox fallback handled