Skip to main content

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:

KeyDescription
ClientIdOpenID Connect client ID registered with your tenant
IssuerURLOIDC issuer URL (used for discovery)
RedirectURLRedirect URI registered with the authorization server
info

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:

PropertyMeaning
userCodeThe code the user types. Display it verbatim — do not reformat it.
verificationURIWhere the user goes to enter the code.
verificationURICompleteThe same URL with the code embedded, when the provider supplies one.
preferredVerificationURIverificationURIComplete when present, else verificationURI. This is what to encode in a QR code — scanning the complete form fills the code in for the user.
expiresAtWhen the code stops working. Show a countdown rather than letting the screen go stale.
pollIntervalThe provider's requested seconds between polls. Informational — the SDK already honours it.

Call cancelLogin() — or cancel the enclosing Task — to abandon a pending approval.

warning

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.

info

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):

ValueBrowser sessionEffect
true (default)Shared with SafariThe 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.
falseEphemeral, app-privateNo 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.

warning

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.

ErrorMeaning
KapeGuestLoginError.accountSessionActiveAn account is already signed in. Raised before anything is written.
KapeGuestLoginError.signInInProgressAnother sign-in is running. Raised before anything is written; observe authState and retry once it settles if a guest session is still wanted.
KapeErrorThe 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.

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. 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.

ErrorMeaning
KapeOpaqueLoginError.invalidUserIduserId is empty or whitespace. Raised before anything is written.
KapeOpaqueLoginError.invalidTokenopaqueToken is empty or whitespace. Raised before anything is written.
KapeErrorStoring the session failed, or the backend request made while signing in failed.

Authentication State​

KapeAuthClient exposes a Combine publisher:

auth.authState // AnyPublisher<KapeAuthState, Never>
StateMeaning
.initializingClient was just created; session check is in flight
.authenticatedUser is logged in
.unauthenticatedUser 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​

MethodDescription
currentAuthenticationSession() asyncReturns the session after validating tokens. nil if signed out or tokens expired.
lastKnownAuthenticationSession() asyncReturns 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. 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.
authenticationMethodKapeAuthenticationMethodHow the session was authenticated.
isGuestBooltrue 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)")
}
info

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.

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 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 {
try await auth.registerUserEmailAddress(email: "[email protected]")
} catch KapeAccountManagementError.invalidEmailFormat {
// "[email protected]" doesn't look like an email — show a validation error
} 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.

ErrorMeaning
KapeAccountManagementError.invalidEmailFormatemail is empty or not correctly formatted. Raised before any network call.
KapeAccountManagementError.emailAlreadyRegisteredThe 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)")
}