Authentication
The SDK handles OpenID Connect authentication using the Authorization Code + PKCE flow via AppAuth. The flow runs in a Chrome Custom Tab and tokens are exchanged automatically.
Configuration
OIDC parameters are set in res/xml/kape_sdk_settings.xml:
| Attribute | Description |
|---|---|
client-id | OpenID Connect client ID registered with your tenant |
issuer-url | OIDC issuer URL (used for discovery) |
redirect-url | Redirect URI registered with the authorization server |
The redirect-url scheme must be registered in your AndroidManifest.xml so that Android routes the OAuth callback back to your app. See Installation for the full settings reference.
Manifest Setup
AppAuth's RedirectUriReceiverActivity must be able to intercept your redirect URI scheme. There are two ways to set this up.
Option A: Gradle placeholder (recommended). Set appAuthRedirectScheme in your build.gradle.kts. AppAuth's own manifest entry already uses this placeholder, so no changes to AndroidManifest.xml are needed:
// app/build.gradle.kts
android {
defaultConfig {
manifestPlaceholders["appAuthRedirectScheme"] = "myapp"
}
}
The value must be the scheme part of your redirect-url (e.g. "myapp" for myapp://callback).
Option B: Manual intent filter. Declare the intent filter directly in AndroidManifest.xml:
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
tools:node="replace">
<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="myapp" android:host="callback" />
</intent-filter>
</activity>
Login
import com.kape.platformsdk.auth.KapeAuthClient
import com.kape.platformsdk.auth.LoginResult
import com.kape.platformsdk.core.KapeSDKSettings
import com.kape.platformsdk.core.KapeSdkHandle
val settings = KapeSDKSettings.fromXml(context, R.xml.kape_sdk_settings)
val handle = KapeSdkHandle(settings, context)
val auth = KapeAuthClient(handle)
// In a coroutine:
when (val result = auth.login(context)) {
is LoginResult.Success -> { /* user is authenticated */ }
is LoginResult.AuthCancelled -> { /* user or system canceled */ }
is LoginResult.AuthError -> { /* result.message, result.underlyingException */ }
}
login() is a suspend function that opens the authorization UI in a Chrome Custom Tab and resumes once the flow completes, is cancelled, or encounters an error.
Guest Login
import com.kape.platformsdk.auth.GuestLoginResult
when (val result = auth.loginAsGuest(context)) {
is GuestLoginResult.Success -> { /* a guest session was minted */ }
is GuestLoginResult.AlreadyGuest -> { /* already a guest — nothing to do */ }
is GuestLoginResult.AccountSessionActive -> { /* an account is signed in; logout() first */ }
is GuestLoginResult.LoginInProgress -> { /* login() is running; let it finish */ }
is GuestLoginResult.Error -> { /* result.error */ }
}
Signs the user in without an account: no credentials, no browser. The
SDK mints an identity token locally and stores it as the session token; the backend exchanges it
for your guest tenant's entitlements, typically a freemium set. Everything downstream behaves as
with a normal session — authState moves to KapeAuthState.Authenticated,
currentAuthenticationSession() returns a session, and subscriptions resolves.
Call it once, when the app has no session — typically a "continue without an account" button on your login screen. There is nothing to refresh and nothing to schedule: the token is short-lived on purpose and the SDK re-mints it on the request path before it can expire.
if (auth.currentAuthenticationSession() == null) {
auth.loginAsGuest(context)
}
The guest identity is stable: it survives logout() and, wherever the SDK's storage survives
one, a reinstall. A returning user comes back as the same guest, with the same entitlements.
context is used only to read Settings.Secure.ANDROID_ID, which is what lets the SDK re-derive
the same guest after a reinstall that wiped its storage. It needs no permission, is never stored
and never transmitted — only a salted HMAC of it is kept.
loginAsGuest() returns GuestLoginResult.AccountSessionActive if an account is already signed
in — it will not replace a real account with a guest one, because the underlying call would log that
account out. Call logout() first if that is genuinely what you want. Calling it while a guest
session already exists returns GuestLoginResult.AlreadyGuest and does nothing, so it is safe to
call defensively on launch.
It also returns GuestLoginResult.LoginInProgress, doing nothing, if login() or another
loginAsGuest() is still running, because that sign-in may be about to store an account the guest
call would replace. Likewise login() returns LoginResult.LoginInProgress while a guest login is
running. So a login screen that offers both "Sign in" and "Continue without an account" cannot
accidentally downgrade the user.
| Result | Meaning |
|---|---|
GuestLoginResult.Success | A guest session was minted and stored. |
GuestLoginResult.AlreadyGuest | A guest session already existed. A no-op, not a failure. |
GuestLoginResult.AccountSessionActive | An account is signed in. Returned before anything is written. |
GuestLoginResult.LoginInProgress | Another sign-in is running. Returned before anything is written. |
GuestLoginResult.Error | Minting failed — e.g. this SDK build ships no guest signing key. See error. |
Telling a Guest Apart from an Account
val session = auth.currentAuthenticationSession()
if (session?.isGuest == true) {
showCreateAccountUpsell()
}
A guest session reports authenticationMethod == KapeAuthenticationMethod.Guest and
isGuest == true. Its displayName is null — a guest's userId is a machine-generated
identifier, not a name — so supply your own localized label. See
Querying the Session.
Guests Have No Subscriptions
A guest has no account, so there is nothing for subscriptions to list and the SDK does not attempt
a fetch for one: it resolves to an empty list, and .active on it is null. This is expected, not
an error.
A guest is not the same as a free user: a free user has a real account and appears in
subscriptions with a free-tier KapeSubscription, while a guest has no account at all. Both are
likely — but not guaranteed — to be offered the same VPN locations. See
Guest vs Free User.
Third-Party Token Login
import com.kape.platformsdk.auth.OpaqueLoginResult
when (val result = auth.login(userId = partnerUser.id, opaqueToken = partnerUser.accessToken)) {
is OpaqueLoginResult.Success -> { /* signed in */ }
is OpaqueLoginResult.InvalidUserId -> { /* userId was blank */ }
is OpaqueLoginResult.InvalidToken -> { /* opaqueToken was blank */ }
is OpaqueLoginResult.LoginInProgress -> { /* another sign-in is running; retry once it settles */ }
is OpaqueLoginResult.Error -> { /* result.error */ }
}
For integrations whose users sign in with your own identity system rather than OpenID Connect.
No browser is involved: the SDK stores the token as the session token, without validating or
interpreting it, and forwards it as-is to the backend, which exchanges it for the user's
entitlements. Everything downstream behaves as with a normal session — authState moves to
KapeAuthState.Authenticated, currentAuthenticationSession() returns a session whose
authenticationMethod is KapeAuthenticationMethod.Opaque, and subscriptions resolves.
| Parameter | Description |
|---|---|
userId | A unique, stable identifier for the user. The SDK scopes token storage to it, and reports it back as the session's userId. |
opaqueToken | The token issued by your identity system, forwarded verbatim. |
The SDK cannot refresh the token. Whenever your identity system issues a new one, call
login(userId, opaqueToken) again with the same userId and the new token. That is treated as
a refresh of the same session, so a subscription chosen with KapeSdkHandle.setActiveSubscriptionId
is kept. Calling it with a different userId is a user switch: the previous user is signed out,
and subscriptions resets and resolves for the new user. Any existing session — third-party,
OpenID Connect or guest — is replaced.
Sign out with logout() as usual.
| Result | Meaning |
|---|---|
OpaqueLoginResult.Success | The session was stored. |
OpaqueLoginResult.InvalidUserId | userId is empty or blank. Returned before anything is written. |
OpaqueLoginResult.InvalidToken | opaqueToken is empty or blank. Returned before anything is written. |
OpaqueLoginResult.LoginInProgress | login(), loginAsGuest() or another third-party token login is running. Returned before anything is written. |
OpaqueLoginResult.Error | Storing the session, or the backend request made while signing in, failed. See error. |
Authentication State
KapeAuthClient exposes a StateFlow:
auth.authState // StateFlow<KapeAuthState>
| State | Meaning |
|---|---|
KapeAuthState.Initializing | SDK is starting up, state not yet determined |
KapeAuthState.Authenticated | User is logged in |
KapeAuthState.Unauthenticated | User is logged out |
viewModelScope.launch {
auth.authState.collect { state ->
when (state) {
is KapeAuthState.Authenticated -> showHome()
is KapeAuthState.Unauthenticated -> showLogin()
is KapeAuthState.Initializing -> showLoading()
}
}
}
Querying the Session
| Method | Description |
|---|---|
currentAuthenticationSession() | Returns the session after validating tokens. null if signed out or tokens expired. |
lastKnownAuthenticationSession() | Returns the last successful session without validation. Fast, no network call. |
Both return KapeAuthenticationSession?:
| Property | Type | Description |
|---|---|---|
userId | String? | The account's identifier. For a guest, a machine-generated one. |
email | String? | The registered e-mail, if the account has one. |
displayName | String? | A name fit to show in a UI: email, falling back to userId. null when there is nothing human-readable — no e-mail and no id, or a guest session. The SDK substitutes no placeholder of its own, since that text is app copy and needs localizing. |
authenticationMethod | KapeAuthenticationMethod | How the session was authenticated. |
isGuest | Boolean | true for a session from loginAsGuest(). |
KapeAuthenticationMethod is OpenId (the SDK's supported login), Guest, LegacyXv (a legacy
pre-OpenID session — migrate it with login(hint = ...)), Opaque (a
third-party token), or
None.
// Fast restore on app launch
val session = auth.lastKnownAuthenticationSession()
if (session != null) {
val name = session.displayName ?: if (session.isGuest) "Guest" else "there"
println("Welcome back, $name")
}
Logout
auth.logout()
Clears all stored credentials and emits KapeAuthState.Unauthenticated.
It also ends the browser-level session at the identity provider, by issuing an RP-initiated
logout against the provider's end_session_endpoint. Without that step signing out would clear your app's tokens while
leaving the provider's SSO session alive — and the next person to tap login on the device could be
signed straight back in as the previous user. Because logout() awaits it, the session really is
gone by the time the call returns.
This step is best-effort and never fails the sign-out: if the provider can't be reached, the local
credentials are still cleared and Unauthenticated is still emitted.
Tearing the VPN down
logout() does not touch the VPN — KapeAuth has no dependency on KapeVPN. On its own it
leaves the tunnel and the VPN service running, and Connect on Demand free to re-dial for the next
user. Call KapeVpnManager.signOut() first:
vpnManager.signOut()
authClient.logout()
signOut() stops the tunnel and the VPN service and resets every persisted VPN setting: the
last location, the OpenVPN credentials, the configured proxies, the Connect on Demand and
connect-on-startup settings, and the protocol, transport, kill switch, split tunnel, custom DNS,
Advanced Protection, cipher, Lightway SNI and obfuscation preferences. It also clears
KapeSdkHandle.activeSubscriptionId immediately. The next account starts
from SDK defaults, so re-apply any settings your app manages after the next sign-in.
Call it unconditionally: it assumes nothing is connected, so it is safe with no session running and safe to call twice.
Registering an Email Address
Some accounts are created without an e-mail on file. Use registerUserEmailAddress(email) on an
already signed-in account to add one, so the user can later recover it or receive account e-mails.
is RegisterEmailResult.Success -> { /* email registered */ }
is RegisterEmailResult.InvalidEmailFormat -> { /* show a validation error */ }
is RegisterEmailResult.EmailAlreadyRegistered -> { /* the account already has an email, or this one belongs to another account */ }
is RegisterEmailResult.Error -> println("[${result.error.domain}] ${result.error.message}")
}
email is validated locally (non-empty, [email protected] shape) before any network call, so a
malformed address never reaches the backend.
| Result | Meaning |
|---|---|
RegisterEmailResult.Success | The email was registered. |
RegisterEmailResult.InvalidEmailFormat | The email is empty or not correctly formatted. Returned before any network call. |
RegisterEmailResult.EmailAlreadyRegistered | The account already has an email set, or the email is already used by another account. |
RegisterEmailResult.Error | The request failed for another reason. See result.error for details. |
Error Handling
login() never throws — it returns a LoginResult sealed class:
when (val result = auth.login(context)) {
is LoginResult.Success -> { /* proceed */ }
is LoginResult.AuthCancelled -> { /* user dismissed the login screen */ }
is LoginResult.AuthError -> {
println("Auth failed: ${result.message}")
println("Caused by: ${result.underlyingException}")
}
}