Integrations
Android (AppAuth)

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

ItemValue
Client typepublic — no secret in the APK
Redirectcustom scheme yourapp://oauth/callback, exact match
BrowserCustom Tabs via AppAuth (never WebView — breaks SSO)
Scopesopenid profile (add email phone only if needed)
User keystore sub from userinfo as the local user's identity column
Multiple appsSSO is automatic through the shared browser session