Kindly Android SDK — Authentication Reference
Deep reference for authenticated Kindly chat sessions on Android. Companion to SKILL.md.
Your backend or identity provider issues short-lived credentials that prove the chat user's identity; the SDK calls a provider you supply whenever it needs fresh ones. On Android a provider is a fun interface with a single suspend function.
The Kindly platform verifies those credentials in one of three ways, configured per bot:
Strategy | What you return | Verified by |
|---|---|---|
Kindly JWT | A JWT your own backend signs | Your PEM public key, held by Kindly |
Identity provider | A JWT from your IdP, on its own or paired with an access token. An access token on its own also works | The IdP's JWKS |
Introspection | An opaque bearer token | RFC 7662 introspection against the IdP |
The first is the single-string case. Use KindlyAuthTokenProvider and you are done. The other two can need more than one value, which is what KindlyAuthCredentialsProvider is for.
The KindlyAuthTokenProvider
fun interface KindlyAuthTokenProvider {
suspend fun fetchToken(request: AuthTokenRequest): String
}
class AuthTokenRequest(
val chatId: String,
val reason: Reason,
) {
enum class Reason {
INITIAL_CONNECT,
EXPIRED,
}
}Parameter | Meaning |
|---|---|
request.chatId | The current chat session ID. Pass it through to your backend so it can scope the token to this session. |
request.reason | Why the SDK is asking. INITIAL_CONNECT — first token for a new session. EXPIRED — the cached token is expired or about to expire; mint a fresh token, never return a cached one. |
return | The JWT as a String. Throw an exception to signal failure. |
Because fetchToken is a suspend function, the SDK invokes it from its own coroutine — you don't manage a CoroutineScope, Deferred, or threading. The SDK still runs auth and socket connection concurrently internally, so startup stays parallel.
SDK-side guarantees
- 15-second timeout — if fetchToken doesn't return in time, the fetch is abandoned (a warning is logged and a failure metric emitted) and the session continues unauthenticated. A provider that hangs can no longer wedge chat startup.
- Expired-on-arrival rejection — if the returned JWT is already expired, the SDK discards it instead of saving it, breaking the refresh loop that would otherwise occur.
- No-exp tokens are valid — JWTs without an exp claim are treated as non-expiring (per the JWT spec, exp is optional) and are never proactively refreshed.
- Background refresh is crash-safe — an exception thrown during an interceptor-triggered background refresh is caught and logged; it does not crash the host app.
The KindlyAuthCredentialsProvider
fun interface KindlyAuthCredentialsProvider {
suspend fun fetchCredentials(request: AuthTokenRequest): KindlyAuthCredentials
}
data class KindlyAuthCredentials(
val idToken: String? = null,
val accessToken: String? = null,
val issuer: String? = null,
val expiresAt: Long? = null, // Unix seconds
)Identical contract to KindlyAuthTokenProvider: same AuthTokenRequest, same 15-second timeout, same expired-on-arrival rejection. Only what you return differs.
The four fields
Field | When you need it |
|---|---|
idToken | The OIDC id token, when your provider issues one. Optional. It is the preferred bearer, not a mandatory field |
accessToken | The access token, when your provider issues one. Optional. Alongside an id token it is the dual-token companion; on its own it becomes the bearer |
issuer | Bots with more than one identity provider configured, and any opaque token, because Kindly cannot read an iss claim out of a token it cannot decode |
expiresAt | Opaque tokens. The SDK reads exp off a JWT itself, but an opaque token tells it nothing, and a credential with no known expiry is never proactively refreshed. Unix seconds |
What the SDK requires is that at least one of idToken / accessToken is set. Credentials carrying neither cannot authenticate anything, and the chat stays anonymous.
What actually goes on the wire
Three derived properties spell out the rule, so you can read it rather than re-derive it:
Property | Value | Header |
|---|---|---|
bearerToken | idToken ?: accessToken, first non-empty, else null | Authorization: Bearer |
hasToken | bearerToken != null | none. This is the validity check, never idToken != null |
companionAccessToken | The access token, but only when an id token is also set and differs from it, else null | X-Access-Token |
Authorization is mandatory on the backend's auth route, so whichever token you have goes there. X-Access-Token is sent only when a real companion exists, and X-Auth-Issuer only when you set issuer. All three are read by the backend on auth/chat, message and trigger, and nowhere else.
companionAccessToken is deliberately null in the two cases that are easy to get wrong: when the access token is the bearer, and when it merely duplicates the id token. The backend verifies the companion against the id token's at_hash and rejects a mismatched pair outright, so sending a duplicate would fail an auth that would otherwise have succeeded. The SDK drops it for you.
A provider that issues only an access token
Normal and supported, not a workaround. Leave idToken unset and the access token becomes the bearer, verified by whichever strategy the bot is configured for:
KindlyAuthCredentials(
accessToken = tokens.accessToken,
issuer = "https://idp.example.com/",
expiresAt = tokens.expiresAtUnixSeconds,
)Do not copy the same value into both fields to "be safe". That is the duplicate case above, and the only reason it does not break your auth is that the SDK deduplicates it.
Wiring the credentials provider
import no.kindly.chatsdk.chat.KindlyAuthCredentials
import no.kindly.chatsdk.chat.KindlyAuthCredentialsProvider
KindlySDK.start(
application = this,
botKey = "YOUR_BOT_KEY",
market = "YOUR_MARKET",
authCredentialsProvider = KindlyAuthCredentialsProvider { request ->
val tokens = MyAuth.fetchTokens(request.chatId)
KindlyAuthCredentials(
idToken = tokens.idToken,
accessToken = tokens.accessToken,
issuer = "https://idp.example.com/",
expiresAt = tokens.expiresAtUnixSeconds,
)
},
)A single-JWT integration never needs this type. Keep returning a String from KindlyAuthTokenProvider. When more than one auth parameter is passed to start(...), precedence is authCredentialsProvider > authTokenProvider > authTokenCallback (deprecated).
When the SDK Calls It
The provider fires on:
- Initial connection after start(...) — to obtain the first JWT (reason = INITIAL_CONNECT).
- Token expiry — when the cached JWT is about to expire (within a 30s grace window) or has been rejected by the backend (reason = EXPIRED).
- Forced refresh — internal recovery paths after socket reconnect (reason = EXPIRED).
The SDK persists the most recent credentials in its private LocalDataStore (SharedPreferences). You don't need to manage caching yourself.
When "about to expire" actually fires
A credential can expire for three different reasons, and the SDK refreshes on whichever comes first, 30 seconds early:
- expires_at from POST auth/chat, the backend's own view. This is the earliest expiry across every token it verified, so on a dual-token setup it accounts for the access token dying before the id token does, and on introspection it is the only expiry that exists at all
- KindlyAuthCredentials.expiresAt, your hint for tokens the SDK cannot decode
- The exp claim of the bearer, when it is a JWT. The bearer, not the id token specifically: when you supplied only an access token, that token is the bearer and its exp is the only claim there is
refresh when: now >= min(backend expires_at, your expiresAt, exp claim) - 30sIf none of the three is known the credential is treated as non-expiring and never proactively refreshed. That is correct for a JWT with no exp (the claim is optional per spec) but it is a trap for an opaque token. Supply expiresAt or the SDK will keep using it until the backend rejects it.
A token the SDK cannot parse means expiry unknown, never "expired". Opaque bearers are a supported credential, not a malformed one, and the SDK will not discard one on arrival for being undecodable. What it cannot do is guess when it dies, which is what expiresAt is for.
Wiring the token provider at start()
The provider is passed as the authTokenProvider parameter on KindlySDK.start(...):
import no.kindly.chatsdk.chat.KindlyAuthTokenProvider
class App : Application() {
override fun onCreate() {
super.onCreate()
KindlySDK.start(
application = this,
botKey = "YOUR_BOT_KEY",
languageCode = "en",
market = "YOUR_MARKET",
authTokenProvider = KindlyAuthTokenProvider { request ->
MyAuth.fetchKindlyJwt(request.chatId) // suspend call
},
)
}
}Migrating from AuthTokenCallback (deprecated)
Earlier SDK versions used a Deferred-based callback:
@Deprecated("Use KindlyAuthTokenProvider with KindlySDK.start(authTokenProvider = ...) instead.")
typealias AuthTokenCallback = (chatId: String) -> Deferred<String>Existing integrations do not break. The authTokenCallback parameter still exists on start(...) and is adapted internally to a provider (so it also benefits from the timeout and expired-token guards). It is deprecated — the compiler shows a deprecation warning — and new integrations should use authTokenProvider. If both are passed, authTokenProvider wins.
Migration is mechanical — drop the scope/Deferred plumbing:
// Before (deprecated)
authTokenCallback = { chatId ->
CoroutineScope(Dispatchers.IO).async { MyAuth.fetchKindlyJwt(chatId) }
}
// After
authTokenProvider = KindlyAuthTokenProvider { request ->
MyAuth.fetchKindlyJwt(request.chatId)
}Hand-off Patterns
Expiry is handled for you. These two are for when the identity changes, which the SDK cannot detect on its own:
fun authenticate(): Deferred<Unit>
fun deauthenticate(): Deferred<Unit>Awaiting either is optional; do it when you need to know the backend agreed.
User logs in mid-session
You started Kindly anonymously and the user has now signed in. Call authenticate():
fun onUserSignedIn() {
lifecycleScope.launch {
runCatching { KindlySDK.authenticate().await() }
.onFailure { Log.w(TAG, "could not re-authenticate", it) }
}
}This asks your provider for fresh credentials and re-binds them to the chat that is already open, keeping the conversation the user is in the middle of. Ending and restarting the chat also works but throws away the history and issues a new chatId, so only do that when you actually want a clean session.
User logs out
KindlySDK.deauthenticate()Unbinds the identity on the backend and forgets the stored credentials. The chat carries on anonymously with its history intact. This is a sign-out, not an endChat(). Safe to call when already anonymous; it completes immediately.
If the backend call fails the local credentials are still cleared (a sign-out is never left half done) but the Deferred completes exceptionally, so you can tell the backend did not hear about it.
Use kill() instead when you want the SDK itself gone. It tears down sockets, dismisses UI and clears in-memory caches, but does not unbind the identity or clear the credential store.
End-to-End Pattern
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.MediaType.Companion.toMediaType
import org.json.JSONObject
import no.kindly.chatsdk.chat.KindlyAuthTokenProvider
object KindlyAuthBridge {
private val http = OkHttpClient()
fun provider() = KindlyAuthTokenProvider { request ->
fetchJwt(request.chatId)
}
private suspend fun fetchJwt(chatId: String): String =
withContext(Dispatchers.IO) {
val body = JSONObject().put("chatId", chatId)
.toString()
.toRequestBody("application/json".toMediaType())
val req = Request.Builder()
.url("https://your-backend/kindly-jwt")
.header("Authorization", "Bearer ${MySession.appToken}")
.post(body)
.build()
http.newCall(req).execute().use { resp ->
check(resp.isSuccessful) { "Kindly JWT fetch failed: ${resp.code}" }
JSONObject(resp.body!!.string()).getString("jwt")
}
}
}
class App : Application() {
override fun onCreate() {
super.onCreate()
KindlySDK.start(
application = this,
botKey = "YOUR_BOT_KEY",
market = "YOUR_MARKET",
authTokenProvider = KindlyAuthBridge.provider(),
)
}
}Debugging Failed Auth
Symptom | Likely cause |
|---|---|
Chat opens then immediately disconnects | Backend returned a JWT but it was malformed or signed with the wrong key. Check Kindly admin → "Authenticated chat" → public key. |
Provider never called | It was passed to a different start(...) overload, or start(...) was never reached (e.g., crashed earlier in Application.onCreate). |
Fetch abandoned after 15s | Your backend is too slow or fetchToken blocks indefinitely. The SDK logs auth token fetch timed out and continues unauthenticated. |
already-expired token, discarding in logs | Your backend returned a JWT whose exp is already in the past (or within the 30s grace window). Mint tokens with a longer lifetime, and always mint fresh on reason == EXPIRED. |
Token works once then chat freezes | Your backend is returning the same expired token on refresh. Make sure your endpoint generates a new JWT every time it's called. |
HTTP 401 in logs | Kindly rejected the token signature/claims. Verify the JWT contains the configured iss claim and is signed with the configured algorithm/key. |
X-Access-Token requires a JWT id_token in Authorization | You put an opaque token in idToken and set accessToken. The companion is only valid alongside a JWT id token. For introspection, send the opaque token as idToken with an issuer and no accessToken |
Provider returns only an access token and you are unsure where to put it | accessToken, with idToken left unset. It becomes the bearer. Do not mirror it into idToken as well, that is the duplicate case and the SDK drops the companion anyway |
Chat stays anonymous, no error anywhere | The credentials carried neither token, so hasToken was false and there was nothing to send. Check the branch of your provider that ran |
Opaque token never refreshes | Nothing to decode, so the SDK has no expiry unless you give it one. Set expiresAt on the credentials |
Chat drops to anonymous long before the token's exp | The backend's expires_at is shorter than your claim and it wins. Check what your IdP puts on the access token. On a dual-token setup the earlier of the two is what counts |
Deprecation warning on authTokenCallback | Expected — the Deferred-based callback is deprecated. It keeps working; migrate to authTokenProvider when convenient. |
Enable verbose logging to see the auth handshake:
KindlySDK.verboseLogging = trueSecurity Notes
- Never embed long-lived secrets in the Android app. Mint short-lived JWTs server-side.
- Credentials are stored in the SDK's private SharedPreferences (sandboxed to your app). Not accessible to other apps under standard Android permissions.
- fetchToken is called from an SDK-managed coroutine off the main thread; still use withContext(Dispatchers.IO) around blocking I/O inside your implementation.
- The chatId you receive is generated by the SDK on first connection and is stable for the lifetime of the session.
- JWTs without an exp claim are accepted and treated as non-expiring. Prefer short-lived tokens with exp in production — the no-exp path exists for compatibility, not as a recommendation.