Kindly iOS SDK — Authentication Reference
Deep reference for authenticated Kindly chat sessions on iOS. Companion to SKILL.md.
Your backend or identity provider issues short-lived credentials that prove the chat user's identity; the SDK calls a closure you provide whenever it needs fresh ones.
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, so use AuthTokenCallback and you are done. The other two can need more than one value, which is what KindlyAuthCredentials and AuthCredentialsCallback are for.
The AuthTokenCallback
public typealias AuthTokenCallback = (_ chatId: String, _ promise: Promise<String>) -> VoidParameter | Meaning |
|---|---|
chatId | The current chat session ID. Pass it through to your backend so it can scope the token to this session. |
promise | A Promise<String> (Kindly's lightweight async result). You must call promise.fulfill("…") with the JWT, or promise.reject(error) on failure. |
The SDK awaits the promise for up to 15 seconds. If you never fulfill or reject it gives up at that point and the chat connects anonymously, so resolve the promise either way.
The AuthCredentialsCallback
public typealias AuthCredentialsCallback = (_ chatId: String, _ promise: Promise<KindlyAuthCredentials>) -> VoidIdentical to AuthTokenCallback except for what you fulfil the promise with:
public struct KindlyAuthCredentials {
public let idToken: String? // optional
public let accessToken: String? // optional
public let issuer: String? // optional
public let expiresAt: Date? // optional
// derived, read-only
public var bearerToken: String? // idToken ?? accessToken
public var hasToken: Bool // bearerToken != nil
public var companionAccessToken: String? // accessToken, only next to a different idToken
}Every field is optional, but at least one of idToken / accessToken has to be set. Credentials carrying neither cannot authenticate anything: the SDK reads them as an empty result from your callback, and the chat stays anonymous.
Field | Sent as | When you need it |
|---|---|---|
idToken | Authorization: Bearer, when set | The OIDC id token, if your provider issues one. For introspection this is the opaque token itself |
accessToken | X-Access-Token next to a different id token, or Authorization: Bearer when it is the only token you have | Dual-token IdP setups, where Kindly forwards this to your webhooks rather than the id token, and providers that issue no id token at all. The Kindly JWT strategy ignores the companion form |
issuer | X-Auth-Issuer | Bots with more than one identity provider configured, and any opaque token, since Kindly cannot read an iss claim out of a token it cannot decode |
expiresAt | not sent | 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 |
Which token becomes the bearer
Authorization is mandatory on Kindly's auth route, so whichever token you have goes there: idToken first, falling back to accessToken. A provider that returns only an access token is a normal, supported case and not a workaround. That access token becomes the bearer, and the backend verifies it by whichever strategy the bot is configured for.
Three read-only accessors tell you exactly what the SDK will do, so you never have to re-derive the rules:
credentials.bearerToken // idToken ?? accessToken, first non-empty. nil if neither is set
credentials.hasToken // bearerToken != nil, the real "can this authenticate?" check
credentials.companionAccessToken // the value that will be sent as X-Access-Token, or nilcompanionAccessToken is the access token in its companion role only, so it is nil in the two cases that are easy to get wrong:
- the access token is itself the bearer, because no id token was supplied, so there is nothing to accompany
- the access token merely duplicates the id token
That second one has teeth. Kindly verifies the companion against the id token's at_hash and rejects a mismatched pair outright, so sending a duplicate fails an auth that would otherwise have succeeded. The SDK drops it for you.
What goes on the wire
Header | Sent |
|---|---|
Authorization: Bearer <bearerToken> | whenever hasToken is true |
X-Access-Token: <companionAccessToken> | only when a companion exists |
X-Auth-Issuer: <issuer> | only when you set an issuer |
Kindly reads the last two on auth/chat, message and trigger only. No other route looks at them.
Wire it the same two ways as the token callback:
KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET") { chatId, promise in
Task {
do {
let tokens = try await MyAuth.fetchTokens(chatId: chatId)
promise.fulfill(
KindlyAuthCredentials(
idToken: tokens.idToken,
accessToken: tokens.accessToken,
issuer: "https://idp.example.com/",
expiresAtUnixSeconds: tokens.expiresAt
)
)
} catch {
promise.reject(error)
}
}
}KindlySDK.config.getAuthCredentials = { chatId, promise in /* … */ }getAuthCredentials wins when both it and getAuthToken are set.
When the SDK Calls It
The callback fires on:
- Initial connection after start(...) — to obtain the first credentials.
- Token expiry — when the stored credentials are about to expire or have been rejected (HTTP 401 from Kindly).
- Forced refresh — internal recovery paths after socket reconnect.
The SDK caches the most recent credentials in the iOS Keychain (automatically, after promise.fulfill).
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 token, when it is a JWT. That is the id token when you supplied one, and the access token when it is the only token you have
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: there is no claim to read, so supply expiresAt or the SDK will keep using it until the backend rejects it.
Two Equivalent Wiring Patterns
Inline at start()
KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET") { chatId, promise in
Task {
do {
let jwt = try await MyAuth.fetchKindlyJWT(chatId: chatId)
promise.fulfill(jwt)
} catch {
promise.reject(error)
}
}
}Set on KindlySDK.config after start()
KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET")
KindlySDK.config.getAuthToken = { chatId, promise in
Task {
do {
let jwt = try await MyAuth.fetchKindlyJWT(chatId: chatId)
promise.fulfill(jwt)
} catch {
promise.reject(error)
}
}
}Pick the inline form when you have credentials available at SDK boot. Pick the post-start form when auth becomes available later (e.g., after the user signs in to your app).
Manual Token Save
If you already have a valid JWT (e.g., received from your own login flow), bypass the callback by saving it directly:
@discardableResult
public class func saveAuthToken(_ token: String) -> Boollet saved = KindlySDK.saveAuthToken("eyJhbGciOi...")
if !saved {
// false when start() has not run yet, or the Keychain refused the write
}saveAuthToken writes to the Keychain and the SDK will use that token until it expires or is rejected — at which point it falls back to your AuthTokenCallback if one is set.
The credentials equivalent:
@discardableResult
public class func saveAuthCredentials(_ credentials: KindlyAuthCredentials) -> BoolBoth replace the stored credential wholesale. Saving an id token alone clears any access token and issuer that were there before. A partial overwrite would pair one credential's access token with another's id token, which the backend rejects outright.
Re-authenticating Mid-Session
The SDK handles expiry on its own. These two are for when the identity changes, which it cannot detect:
@discardableResult public class func authenticate() -> Promise<Void>
@discardableResult public class func deauthenticate() -> Promise<Void>authenticate() asks your callback for fresh credentials and re-binds them to the live chat. The chat, its history and its socket are all kept. Use it when the user signs in, or switches account, while the chat is already open.
deauthenticate() posts to auth/chat/invalidate to unbind the identity on the backend, then forgets the stored credentials. The chat carries on anonymously with its history intact, so this is a sign-out and not an endChat().
Safe to call when already anonymous; it fulfils immediately. If the backend call fails the local credentials are still cleared (a sign-out is never left half done) but the promise rejects, so you can tell the backend did not hear about it.
func onUserSignedIn() {
KindlySDK.authenticate().catch { error in
print("could not re-authenticate: \(error)")
}
}
func onUserSignedOut() {
KindlySDK.deauthenticate()
}End-to-End Pattern
import Kindly
final class MyAuthIntegration {
static let shared = MyAuthIntegration()
func bootKindly() {
KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET") { chatId, promise in
self.fetchKindlyJWT(chatId: chatId, promise: promise)
}
}
private func fetchKindlyJWT(chatId: String, promise: Promise<String>) {
Task {
do {
let req = try await buildRequest(chatId: chatId)
let (data, _) = try await URLSession.shared.data(for: req)
let token = try JSONDecoder().decode(TokenResponse.self, from: data).jwt
promise.fulfill(token)
} catch {
promise.reject(error)
}
}
}
private func buildRequest(chatId: String) async throws -> URLRequest {
var req = URLRequest(url: URL(string: "https://your-backend/kindly-jwt")!)
req.httpMethod = "POST"
req.setValue("Bearer \(myAppSessionToken)", forHTTPHeaderField: "Authorization")
req.httpBody = try JSONEncoder().encode(["chatId": chatId])
return req
}
}
struct TokenResponse: Decodable { let jwt: String }Hand-off Patterns
User logs in mid-session
You started Kindly anonymously and the user has now signed in. Set the callback, then call authenticate():
KindlySDK.config.getAuthToken = { chatId, promise in
Task {
let jwt = try? await MyAuth.fetchKindlyJWT(chatId: chatId)
if let jwt { promise.fulfill(jwt) } else { promise.reject(MyError.noJwt) }
}
}
KindlySDK.authenticate()This keeps 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.kill().then {
print("SDK fully shut down — call start() again on next sign-in")
}kill() tears down sockets, dismisses UI, and clears in-memory caches. It does not clear the Keychain. To sign the user out of the chat without tearing anything down, use deauthenticate(), which both unbinds the identity on the backend and clears local storage.
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. |
AuthTokenCallback never called | You set KindlySDK.config.getAuthToken before start(); the config object is replaced inside start(). Set it via the start(...) overload or after start() returns. |
Promise never resolves | Your async branch threw before reaching fulfill/reject. The SDK gives up after 15s and connects anonymously. Wrap in do/catch and always reject on failure. |
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. |
Chat stays anonymous although the callback fulfilled | The credentials you built carry neither idToken nor accessToken, so there is nothing to send. Check hasToken before fulfilling. |
Auth works with the id token alone, fails when you add the access token | The two values are not a matching pair from the same token response. Kindly checks the companion against the id token's at_hash. An access token equal to the id token is fine, the SDK drops it. |
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. |
Enable verbose logging to see the auth handshake:
KindlySDK.verboseLogging = trueSecurity Notes
- Never embed long-lived secrets in the iOS app. Mint short-lived JWTs server-side.
- Credentials are stored in the iOS Keychain, scoped to your app. Not reachable from other apps, but readable on a jailbroken device.
- The callback is invoked from a background thread. Use Task { ... } (or DispatchQueue) — do not block by calling synchronous network APIs on the calling thread.
- The chatId you receive is generated by the SDK on first connection and is stable for the lifetime of the session.