Android (AppAuth)
All CamelTech Android apps integrate with AppAuth-Android (opens in a new tab) — never a hand-rolled PKCE implementation and never a WebView. AppAuth launches Custom Tabs, which gives you the shared browser cookie that cross-app SSO depends on.
1. Dependency
dependencies {
implementation "net.openid:appauth:0.11.1"
}2. Declare the redirect capture
AppAuth hands the redirect back to your app via an intent filter. In AndroidManifest.xml:
<activity android:name=".LoginCallbackActivity" 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="bajeti" android:host="oauth" android:path="/callback" />
</intent-filter>
</activity>This must equal the registered redirect URI bajeti://oauth/callback — exactly.
3. Configure from discovery
Auto-configuration from the OIDC discovery document (recommended over hardcoding endpoints):
val issuer = Uri.parse("https://accounts.camelcreatives.com")
AuthorizationServiceConfiguration.fetchFromIssuer(
issuer,
{ config, ex ->
if (config != null) {
AuthorizationServiceConfiguration.saveToPrefs(context, config) // persist
} else ex?.printStackTrace()
}
)4. Start the flow
AppAuth generates the PKCE pair and state internally:
class LoginActivity : AppCompatActivity() {
private lateinit var authService: AuthorizationService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
authService = AuthorizationService(this)
}
fun startLogin() {
val config = AuthorizationServiceConfiguration.getFromPrefs(this) // saved in step 3
val request = AuthorizationRequest.Builder(
config,
"bajeti-android", // client_id
ResponseTypeValues.CODE,
Uri.parse("bajeti://oauth/callback") // exact registered redirect
)
.setScope("openid profile")
.build()
val completeIntent = Intent(this, LoginCallbackActivity::class.java)
val pendingIntent = authService.getAuthorizationRequestIntent(request)
startActivityForResult(pendingIntent, RC_AUTH)
}
}5. Receive the redirect & exchange the code
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.data?.let { uri ->
val resp = AuthorizationResponse.fromIntent(uri.let { Intent().setData(it) }
.also { it.putExtras(intent.extras ?: Bundle()) })
?: AuthorizationResponse.fromIntent(intent)
val ex = AuthorizationException.fromIntent(intent)
when {
resp != null -> exchangeCode(resp)
ex != null -> showError(ex) // user denied → error=access_denied lands here too
}
}
}
private fun exchangeCode(resp: AuthorizationResponse) {
authService.performTokenRequest(
resp.createTokenExchangeRequest() // includes code_verifier automatically
) { tokenResp, tokenEx ->
when {
tokenResp != null -> {
authState.update(tokenResp, tokenEx) // keep an AuthState instance
authState.persist(context)
goToApp()
}
else -> showError(tokenEx) // invalid_grant: code >90s old / reused
}
}
}Use AppAuth's AuthState class to hold tokens; it serializes itself and tracks the latest refresh token for you.
6. Refresh
Access tokens expire in 15 minutes. AuthState.performActionWithFreshToken refreshes transparently using the current (latest rotated) refresh token:
authState.performActionWithFreshToken(authService) { accessToken, ex ->
if (accessToken != null) {
api.call("Bearer $accessToken")
} else {
// expired family / theft detection / network — send to login
startLogin()
}
}If the refresh fails permanently (invalid_grant), the only correct response is interactive re-login.
Platform checklist
| Item | Value |
|---|---|
| Client type | public — no secret in the APK |
| Redirect | custom scheme yourapp://oauth/callback, exact match |
| Browser | Custom Tabs via AppAuth (never WebView — breaks SSO) |
| Scopes | openid profile (add email phone only if needed) |
| User key | store sub from userinfo as the local user's identity column |
| Multiple apps | SSO is automatic through the shared browser session |