Skip to main content

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:

AttributeDescription
client-idOpenID Connect client ID registered with your tenant
issuer-urlOIDC issuer URL (used for discovery)
redirect-urlRedirect URI registered with the authorization server
info

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.

warning

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.

ResultMeaning
GuestLoginResult.SuccessA guest session was minted and stored.
GuestLoginResult.AlreadyGuestA guest session already existed. A no-op, not a failure.
GuestLoginResult.AccountSessionActiveAn account is signed in. Returned before anything is written.
GuestLoginResult.LoginInProgressAnother sign-in is running. Returned before anything is written.
GuestLoginResult.ErrorMinting 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.

ParameterDescription
userIdA unique, stable identifier for the user. The SDK scopes token storage to it, and reports it back as the session's userId.
opaqueTokenThe 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.

ResultMeaning
OpaqueLoginResult.SuccessThe session was stored.
OpaqueLoginResult.InvalidUserIduserId is empty or blank. Returned before anything is written.
OpaqueLoginResult.InvalidTokenopaqueToken is empty or blank. Returned before anything is written.
OpaqueLoginResult.LoginInProgresslogin(), loginAsGuest() or another third-party token login is running. Returned before anything is written.
OpaqueLoginResult.ErrorStoring the session, or the backend request made while signing in, failed. See error.

Authentication State​

KapeAuthClient exposes a StateFlow:

auth.authState // StateFlow<KapeAuthState>
StateMeaning
KapeAuthState.InitializingSDK is starting up, state not yet determined
KapeAuthState.AuthenticatedUser is logged in
KapeAuthState.UnauthenticatedUser 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​

MethodDescription
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?:

PropertyTypeDescription
userIdString?The account's identifier. For a guest, a machine-generated one.
emailString?The registered e-mail, if the account has one.
displayNameString?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.
authenticationMethodKapeAuthenticationMethodHow the session was authenticated.
isGuestBooleantrue 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.

info

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.

when (val result = auth.registerUserEmailAddress("[email protected]")) {
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.

ResultMeaning
RegisterEmailResult.SuccessThe email was registered.
RegisterEmailResult.InvalidEmailFormatThe email is empty or not correctly formatted. Returned before any network call.
RegisterEmailResult.EmailAlreadyRegisteredThe account already has an email set, or the email is already used by another account.
RegisterEmailResult.ErrorThe 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}")
}
}