iOS — Versola Docs
/docs
0.1.0versola.ioGitHub

iOS

Add the Versola OAuth SDK to an iOS app with Swift Package Manager, supply a presentation anchor, set up the Universal Link, sign in and handle errors.

This guide takes an iOS app to a signed-in user and an authenticated API call with OAuthClient from the Versola OAuth SDK. It targets iOS 17.4 or later, and you add the package with Swift Package Manager.

Prerequisites

  • Xcode with Swift 6 and an iOS 17.4 or later deployment target.
  • A public client registered at your provider with the exact redirect URI you will use. A provider that needs offline_access for a refresh token is listed on Provider guides.
  • A domain you control for the Universal Link, where you can serve the apple-app-site-association file.

Install

Add the package https://github.com/versolauth/versola-sdk at the current version. You depend on the product VersolaOAuth and import the module OAuthClient, which also exports OAuthCore. The package is source, not a binary framework, and Swift Package Manager is how you add it.

// Package.swift of the app
dependencies: [
    .package(url: "https://github.com/versolauth/versola-sdk", from: "<version>"),
],
targets: [
    .target(
        name: "App",
        dependencies: [.product(name: "VersolaOAuth", package: "versola-sdk")]
    ),
]

In Xcode, use File > Add Package Dependencies with the same URL and add VersolaOAuth to the app target. The package: value is the dependency’s identity, the last path component of the URL: versola-sdk.

Which iOS versions does the SDK support?

The SDK targets iOS 17.4 and later. The reason is the browser leg. ASWebAuthenticationSession accepts an https callback (a Universal Link) only through Callback.https(host:path:), which Apple makes available from iOS 17.4, and a Universal Link is the redirect the SDK prefers.

The manifest also lists macOS 14.4, but only as a test host that lets swift test compile the same AuthenticationServices, Keychain and CryptoKit APIs on a Mac. The SDK’s platform is iOS.

Configure the client

import OAuthClient
import UIKit

let configuration = try OAuthConfiguration(
    issuer: "https://id.example.com",
    clientId: "acme-app",
    redirectUri: "https://app.example.com/oauth",
    postLogoutRedirectUri: "https://app.example.com/signed-out",
    scopes: ["openid", "profile", "offline_access"],
    protectedOrigins: ["https://api.example.com"]
)

let oauth = try await OAuthClient.create(
    configuration: configuration,
    presentationAnchor: { window }          // a UIWindow the app owns
)

OAuthConfiguration validates eagerly: a bad value throws OAuthError.configuration(.invalidConfiguration) from the initializer, not later behind a sign-in screen. It has no clientSecret parameter, on purpose. scopes gets openid if it is missing, and nothing else: the SDK never adds offline_access.

create is static and async. It returns the existing client for the same issuer and client ID, and refuses a different configuration for the same pair. It does no network I/O and generates no key.

FieldMeaning
pushedAuthorization.whenAdvertised by default: the SDK pushes the request when the provider advertises a PAR endpoint. .never and .required are the other values, see Authorization request
tokenBinding.bearer by default. .dpop() opts in to DPoP, see Sender-constrained tokens
retryPolicy.default: how often and how long the SDK repeats a failed request, see Server errors, 429 and Retry-After

Present the browser sheet

The browser sheet is presented from a window you supply. The SDK never looks for one itself, so there is no UIApplication.shared.keyWindow in it. The presentationAnchor closure is @escaping @MainActor @Sendable and returns an ASPresentationAnchor, which is a UIWindow on iOS.

Set up the redirect

ASWebAuthenticationSession returns its own redirect, so in the normal flow you write no redirect code. For an https redirect URI you need two things:

  • The webcredentials associated domain for the redirect URI’s host in your app, with the matching apple-app-site-association file served on that host. applinks alone is not enough for the https callback of ASWebAuthenticationSession.
  • The exact redirect URI registered with the provider.

For an authorization that outlives the process that started it, hand the link your app received to the client from your URL handler, and resume on launch:

// In the handler for a Universal Link (for example onOpenURL or application(_:continue:)).
let handled = await oauth.handleRedirect(url)   // true when the URL was this client's redirect

// On launch, when the restored state is .authorizing.
try await oauth.resumePendingAuthorization()

handleRedirect matches the URL by scheme, host and path against the configured redirect URI. A cold-start redirect needs the explicit resumePendingAuthorization() call.

A private-use scheme redirect is accepted with a logged warning on every create, because any app on the device can register the same scheme. A cleartext http redirect can’t be received by ASWebAuthenticationSession and is refused.

LoginOptions(ephemeralBrowser: true) maps to prefersEphemeralWebBrowserSession. It is off by default because it breaks passkeys and single sign-on.

Sign in, call an API, sign out

do {
    try await oauth.login()
    let session = try oauth.authenticatedSession()         // origins default to protectedOrigins
    let request = URLRequest(url: URL(string: "https://api.example.com/me")!)
    let (data, response) = try await session.data(for: request)
    let claims = await oauth.claims                        // the validated ID token's claims
    let outcome = await oauth.logout()                     // sessionEnded, tokenRevoked
} catch let error as OAuthError {
    // errors are handled below
}

AuthenticatedSession.data(for:) does the following:

  • Attaches the token only to requests for the protected origins and passes every other request through untouched.
  • On a 401, refreshes once and replays once.
  • Retries once on a DPoP nonce challenge.
  • Raises stepUp on an RFC 9470 challenge.
  • Never follows redirects.

oauth.accessToken() returns the token as a value for other HTTP stacks. A token handed to app code is one the app can log.

To push the request with PAR, or to pass prompt, loginHint, resources or provider parameters, see Authorization request: PAR and extra parameters.

Observe the session state

sessionStates() creates one AsyncStream<SessionState> per call, so several observers don’t starve one another. The first value is the restored state.

Task {
    for await state in await oauth.sessionStates() { render(state) }
}

The seven states are on Session states and errors.

Handle errors

OAuthError has the four categories as enum cases, each with a payload naming the case. The category tells you what to do:

catch let error as OAuthError {
    switch error {
    case .recoverable(.server(_, let retryAfterMillis, _, _)): retryLater(retryAfterMillis)   // nil: the SDK spent its budget
    case .recoverable(let cause): retryOrAskAgain(cause)
    case .reauthenticate(let reason): showSignIn(reason)
    case .stepUp(let acrValues, let maxAge):
        try await oauth.stepUp(acrValues: acrValues, maxAge: maxAge)   // then repeat the call
    case .configuration: throw error
    }
}

The switch is exhaustive. Session states and errors lists what each category means. When a provider answers 500, 503 or 429, the recoverable cause is server: see Server errors, 429 and Retry-After for what the SDK retries before you see it and for the full example.

Where are tokens and keys stored?

  • The refresh token, the raw ID token and the pending request are Keychain items with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly and kSecAttrSynchronizable = false. A background refresh can use them after the first unlock, they are never restored onto another device, and they are never in iCloud Keychain. Under unlockPolicy: .onAppUnlock the items use kSecAttrAccessibleWhenUnlockedThisDeviceOnly instead.
  • DPoP is opt-in with tokenBinding: .dpop() and applies when the provider advertises ES256. The key is a Secure Enclave P-256 key. Its encrypted dataRepresentation is kept in the Keychain as AfterFirstUnlockThisDeviceOnly, not synchronizable, whatever the unlock policy. It is not gated on user presence, because a proof is signed on every request.
  • A device without a Secure Enclave, and the simulator, get a software key. try await oauth.keyAttestation() reports which tier the loaded key is: secureEnclave or software.
  • .dpop(required: true) fails with providerUnsupported("dpop") when the provider doesn’t advertise DPoP, instead of falling back to bearer.
  • Caches (discovery metadata and JWKS) are files under Application Support, excluded from backup.
  • Nothing credential-shaped is logged. The default logger, OSLogLogger, emits warnings only.

How does the SDK use Swift concurrency?

The package builds in Swift 6 language mode, so a concurrency diagnostic is a compile error. OAuthClient is a Sendable final class. Mutable state lives in actors, with one session coordinator, so a refresh is single-flight and callers share its result. Every public operation is async or async throws; there are no completion-handler APIs.

Cancelling the calling task dismisses the browser sheet, but a token that already came back from the provider is still persisted. The SDK schedules no background work: your app owns BGAppRefreshTask, and a refresh started from one can be cancelled.

How is the iOS SDK tested?

  • XCTest unit tests run on the iOS simulator.
  • Integration tests run OAuthClient against Keycloak 26.4 and Ory Hydra v26.2.0 containers.

Next steps