Authentication
The SDK handles OpenID Connect authentication using the Authorization Code + PKCE flow via AppAuth. Tokens are exchanged and managed automatically.
On tvOS, where no in-app browser exists, the SDK uses the OAuth 2.0 Device Authorization Grant instead — see tvOS — Device Authorization.
Configuration
OIDC parameters are set in KapeSDK.plist:
| Key | Description |
|---|---|
ClientId | OpenID Connect client ID registered with your tenant |
IssuerURL | OIDC issuer URL (used for discovery) |
RedirectURL | Redirect URI registered with the authorization server |
The RedirectURL scheme must be registered in your app's Info.plist under URL Types so that the system routes the OAuth callback back to your app.
Issuer Resolution
If your YAML config sets a top-level login_realm key (e.g. login_realm: "your-realm"), login
resolves the issuer dynamically instead of always using the static IssuerURL: the SDK asks the
Low Level SDK for a reachable auth server for that realm and builds the issuer as
https://<host>/realms/<login_realm>/. This lets the same client keep working if the primary auth
host becomes unreachable, without a client update.
IssuerURL remains required — it is used as-is when login_realm isn't set in your YAML config,
and as a fallback if the reachable-server lookup fails for any reason.
Login
import KapeAuth
import KapeCore
let settings = try KapeSDKSettings()
let handle = try KapeSdkHandle(settings: settings)
let auth = KapeAuthClient(handle: handle)
// iOS — requires a UIViewController
try await auth.login(presenting: viewController)
Only one browser sign-in runs at a time. Calling login again while one is in flight (a
double-tapped button, say) throws KapeAuthError.authorizationInProgress and
leaves the pending sign-in and its .authenticating state untouched. To start over, call
cancelLogin() and wait for the first login to throw.
tvOS — Device Authorization
Apple TV has no in-app browser, so there is no login(presenting:) there. tvOS instead uses the
OAuth 2.0 Device Authorization Grant (RFC 8628): the TV
shows a short code and a URL, the user approves on their phone or laptop, and the SDK polls until
they do.
try await auth.login { authorization in
// Called once, on the main actor, before the wait for approval begins.
self.userCode = authorization.userCode // e.g. "WDJB-MJHT"
self.verificationURI = authorization.verificationURI // where the user goes
}
// Returns once the user has approved. The SDK does the polling for you.
onDeviceAuthorization receives a KapeDeviceAuthorization:
| Property | Meaning |
|---|---|
userCode | The code the user types. Display it verbatim — do not reformat it. |
verificationURI | Where the user goes to enter the code. |
verificationURIComplete | The same URL with the code embedded, when the provider supplies one. |
preferredVerificationURI | verificationURIComplete when present, else verificationURI. This is what to encode in a QR code — scanning the complete form fills the code in for the user. |
expiresAt | When the code stops working. Show a countdown rather than letting the screen go stale. |
pollInterval | The provider's requested seconds between polls. Informational — the SDK already honours it. |
Call cancelLogin() — or cancel the enclosing Task — to abandon a pending approval.
Your identity provider must advertise device_authorization_endpoint in its discovery document,
and the client identified by ClientId must be enabled for the device grant. On Keycloak this
is the per-client OAuth 2.0 Device Authorization Grant toggle — enabling it on the realm is not
enough. Without the endpoint, login throws KapeAuthError.deviceAuthorizationUnsupported.
RedirectURL is unused on tvOS — this flow has no redirect — and handleCallbackURL(_:) always
returns false there. It stays on the API surface so a shared app target can forward onOpenURL
without a platform conditional. allowSingleSignOn likewise does not apply: no browser opens on
the TV itself.
Sign-in can also fail with KapeAuthError.deviceCodeExpired if the user never approves before
expiresAt, or KapeAuthError.malformedDeviceAuthorization if the provider's response omits a
required field.
Only one device authorization runs at a time. Calling login again while one is pending throws
KapeAuthError.deviceAuthorizationInProgress and leaves the pending one untouched — its code may
already be on the user's phone. To show a fresh code, call cancelLogin() and wait for the first
login to throw before starting another.
Single Sign-On Across Apps
Every browser-based login(...) overload — iOS and macOS — takes allowSingleSignOn: Bool = true.
It controls AppAuth's prefersEphemeralWebBrowserSession (inverted):
| Value | Browser session | Effect |
|---|---|---|
true (default) | Shared with Safari | The identity provider can keep an SSO session, so a user already signed in through another app on the device may not be prompted for credentials. |
false | Ephemeral, app-private | No cookies are shared or persisted. Every login prompts for credentials, and the "…would like to use provider to Sign In" system dialog is skipped. |
logout() ends the shared session — see below — so the default is safe for a personal device.
Prefer false for shared or kiosk devices, where no cross-app session should exist in the first
place.
Forwarding the Callback URL
The OAuth redirect must be forwarded to the SDK. Without this, the login flow will hang. (Not
applicable on tvOS, which has no redirect — forwarding it there is harmless and simply returns
false.)
SwiftUI:
.onOpenURL { url in
auth.handleCallbackURL(url)
}
UIKit (SceneDelegate):
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
if let url = URLContexts.first?.url {
auth.handleCallbackURL(url)
}
}
Guest Login
try await auth.loginAsGuest()
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 publishes .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 await auth.currentAuthenticationSession() == nil {
try await auth.loginAsGuest()
}
The guest identity is stable: it survives logout() and, wherever the keychain survives one, a
reinstall. A returning user comes back as the same guest, with the same entitlements.
loginAsGuest() throws KapeGuestLoginError.accountSessionActive if an account is already
signed in — it will not replace a real account with a guest one. Call logout() first if that is
genuinely what you want. Calling it while a guest session already exists succeeds and does
nothing.
It also throws KapeGuestLoginError.signInInProgress if another sign-in — login(...),
login(inAppPurchase:...) or a concurrent loginAsGuest() — is still running, because that
sign-in may be about to store an account the guest call would replace. Conversely, a login(...)
started while loginAsGuest() is running waits for it to finish first. So a login screen that
offers both "Sign in" and "Continue without an account" cannot accidentally downgrade the user.
| Error | Meaning |
|---|---|
KapeGuestLoginError.accountSessionActive | An account is already signed in. Raised before anything is written. |
KapeGuestLoginError.signInInProgress | Another sign-in is running. Raised before anything is written; observe authState and retry once it settles if a guest session is still wanted. |
KapeError | The SDK build ships no guest signing key, or session storage failed. |
Telling a Guest Apart from an Account
if let session = await auth.currentAuthenticationSession(), session.isGuest {
showCreateAccountUpsell()
}
A guest session reports authenticationMethod == .guest and isGuest == true. Its displayName
is nil — 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 [], and ?.active is nil. 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.
Upgrading a Guest Through an In-App Purchase
When a guest buys a subscription, hand the StoreKit transaction to
login(inAppPurchase:presenting:) (login(inAppPurchase:) on macOS), exactly as for a signed-out
user. The backend creates an account for the purchase, or finds the existing one, and the SDK signs
in to it through the OpenID Connect flow. The guest session is replaced by the account session, so
currentAuthenticationSession()?.isGuest becomes false.
Don't use processInAppPurchase(_:) for a guest. It attaches a purchase to the account that is
already signed in, and a guest has none, so it never signs the user in to the new account and the
session stays a guest one.
Third-Party Token Login
try await auth.login(userId: partnerUser.id, opaqueToken: partnerUser.accessToken)
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 publishes
.authenticated, currentAuthenticationSession() returns a session whose authenticationMethod
is .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. Calling it with a
different userId is a user switch: the previous user is signed out. Any existing session —
third-party, OpenID Connect or guest — is replaced.
Sign out with logout() as usual.
| Error | Meaning |
|---|---|
KapeOpaqueLoginError.invalidUserId | userId is empty or whitespace. Raised before anything is written. |
KapeOpaqueLoginError.invalidToken | opaqueToken is empty or whitespace. Raised before anything is written. |
KapeError | Storing the session failed, or the backend request made while signing in failed. |
Authentication State
KapeAuthClient exposes a Combine publisher:
auth.authState // AnyPublisher<KapeAuthState, Never>
| State | Meaning |
|---|---|
.initializing | Client was just created; session check is in flight |
.authenticated | User is logged in |
.unauthenticated | User is logged out |
auth.authState
.receive(on: DispatchQueue.main)
.sink { state in
switch state {
case .initializing:
showSplash = true
case .authenticated:
showSplash = false
showLogin = false
case .unauthenticated:
showSplash = false
showLogin = true
}
}
.store(in: &cancellables)
Querying the Session
| Method | Description |
|---|---|
currentAuthenticationSession() async | Returns the session after validating tokens. nil if signed out or tokens expired. |
lastKnownAuthenticationSession() async | 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. nil 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 | Bool | 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),
.none, or .unknown for a method this SDK version does not recognise.
// Fast restore on app launch
if let session = await auth.lastKnownAuthenticationSession() {
let name = session.displayName ?? (session.isGuest ? "Guest" : "there")
print("Welcome back, \(name)")
}
Only settled states are persisted, so a guest cold-starts as .authenticated before the session
resolves. lastKnownAuthenticationSession() maps authenticationMethod, so isGuest is available
on that fast path too — you do not have to wait for a validated session to branch your UI.
Logout
try await auth.logout()
Clears all stored credentials and publishes .unauthenticated.
It also ends the browser-level session at the identity provider, by issuing
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 published.
Tearing the VPN down
logout() does not touch the VPN — KapeAuth has no dependency on KapeVPN. On its own it
leaves the tunnel running, the VPN configuration installed and Connect on Demand free to re-dial
for the next user. Call KapeVPNManager.signOut() first:
await vpn.signOut()
try await auth.logout()
signOut() stops the tunnel, disables Connect on Demand, removes the installed VPN configuration
and resets every persisted VPN setting: the selected location, the Dedicated IP selection, the
active subscription, the configured proxies and their keychain secrets, the Connect on Demand
rules and pause state, and the protocol, transport, MTU, cipher, Lightway SNI, kill switch, split
tunnel, custom DNS and Advanced Protection preferences. The next account starts from SDK defaults,
so re-apply any settings your app manages after the next sign-in. Nothing is kept back, so nothing
a signing-in user could read is left behind.
That includes the device model you set with setDeviceModel(_:). Until your app sets it again at
the next launch, the Packet Tunnel reports the hardware identifier (iPhone17,1) instead, so
telemetry is never blank. File logging is off again after a sign-out.
Call it unconditionally: it assumes nothing is connected and nothing is installed, so it is safe with no tunnel running and safe to call twice. It never throws — every step is best-effort and a failure in one is logged without blocking the rest, or your sign-out.
Registering an Email Address
Some accounts are created without an e-mail registered — for example, accounts created via an Apple
in-app purchase without a customer e-mail (KapeApplePurchase.customerEmail). Use
registerUserEmailAddress(email:) on an already signed-in account to add one, so the user can
later recover it or receive account e-mails.
do {
} catch KapeAccountManagementError.invalidEmailFormat {
} catch KapeAccountManagementError.emailAlreadyRegistered {
// the account already has an email, or this one belongs to another account
} catch let error as KapeError {
print("[\(error.domain)] \(error.localizedDescription)")
}
email is validated locally (non-empty, [email protected] shape) before any network call, so a
malformed address never reaches the backend.
| Error | Meaning |
|---|---|
KapeAccountManagementError.invalidEmailFormat | email is empty or not correctly formatted. Raised before any network call. |
KapeAccountManagementError.emailAlreadyRegistered | The signed-in account already has an email set, or the requested address is already used by another account. |
Error Handling
All operations throw KapeError:
do {
try await auth.login(presenting: viewController)
} catch let error as KapeError {
print("[\(error.domain)] \(error.localizedDescription)")
}