Building a native macOS app with OIDCCAEPClient
This guide shows how to add OpenID Connect sign-in and CAEP to a native macOS app with the OIDCCAEPClient library from this package. By the end, your app signs people in through the system browser, keeps their tokens in the Keychain and fresh, calls your APIs with sender-constrained tokens, and reacts within seconds when the identity provider reports a change about a signed-in user through CAEP.
The app is a public client: it has no client secret, because anything shipped in an app can be read out of it. Sign-in happens in the person’s browser through ASWebAuthenticationSession, as RFC 8252 (OAuth 2.0 for Native Apps) requires. The app never sees the password, and never shows the provider’s pages in an embedded web view.
The API reference lists every option, method and event. This guide is the walkthrough. It explains which pieces to use, in what order, and why. The demo in this repository (Demo/) is a working SwiftUI app built this way.
Who this is for. macOS apps written in Swift, with SwiftUI or AppKit. The package targets macOS 13 and later.
Contents
- How the pieces fit together
- Add the package
- Register the app at the identity provider
- Create OIDCConfiguration.plist
- Create the client
- Where tokens and keys are kept
- Sign in
- Call your APIs
- Receive CAEP events
- Sign out
- Errors
- Sandboxed and signed apps
- Security checklist
- Troubleshooting
1. How the pieces fit together
| Piece | What it does |
|---|---|
OIDCAppConfiguration |
Reads the app’s settings from OIDCConfiguration.plist and creates the client. |
OIDCClient |
Sign-in (Authorization Code + PKCE, PAR, DPoP), ID token validation, refresh, UserInfo, sign-out. |
SystemWebAuthenticator |
Shows the provider’s pages in the system browser with ASWebAuthenticationSession, and returns the redirect. |
TokenManager |
Caches one person’s tokens and refreshes them before they expire. |
AccountManager |
Several people signed in to one app, with one active account. Use it instead of TokenManager when you need that; it also suits a single account. |
SSFReceiver |
Subscribes to the identity provider’s Shared Signals stream, polls it, validates each event and hands it to your handlers. |
| Stores | Where tokens and the DPoP key are kept: the Keychain, the Secure Enclave, or memory. |
Your macOS app Identity provider
────────────── ─────────────────
SystemWebAuthenticator ── system browser: sign-in ─────▶ authorization endpoint
▲ │
└──── redirect to com.example.myapp:/callback ◀─────────┘
OIDCClient ──── PAR, token, UserInfo (DPoP proofs) ────▶ OpenID Connect endpoints
AccountManager / TokenManager (Keychain cache, refresh)
SSFReceiver ──── create / read stream, poll ───────────▶ SSF transmitter
▲ │
└──────────── signed security events (SETs) ◀───────────┘
your handlers: end the session, refresh tokens, ask for step-up, ...
your APIs ◀──── fetchResource(): access token + DPoP proof
The identity provider is usually both the OpenID provider and the SSF transmitter. The receiver authenticates to the transmitter with the signed-in person’s own access token, so it can only see that person’s stream. An app has no public endpoint for the transmitter to push to, so delivery is always poll (RFC 8936).
2. Add the package
With Xcode: choose File → Add Package Dependencies…, enter https://github.com/rcbj/swift-oidc-caep-native-app.git, choose a version rule (for example Up to Next Minor Version from 0.1.0), and add the OIDCCAEPClient library to your app target.
With Package.swift:
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/rcbj/swift-oidc-caep-native-app.git", from: "0.1.0"),
],
targets: [
.executableTarget(
name: "MyApp",
dependencies: [.product(name: "OIDCCAEPClient", package: "swift-oidc-caep-native-app")],
resources: [.process("Resources")] // OIDCConfiguration.plist, see step 4
),
]
)
from: "0.1.0" accepts every later version below 1.0.0. While the version is 0.x, a new minor version may change the API; use .upToNextMinor(from: "0.1.0") to stay on 0.1. The package needs macOS 13 or later and Swift 5.9 or later. Its only dependency is swift-crypto, which is CryptoKit on Apple platforms.
3. Register the app at the identity provider
Register your app as a public client:
| Setting | Value |
|---|---|
| Client type | public, token_endpoint_auth_method: none |
| Grant types | authorization_code, refresh_token |
| Response type | code |
| Redirect URI | a private-use scheme, e.g. com.example.myapp:/callback |
| Post-logout redirect URI | e.g. com.example.myapp:/logged-out |
| Allowed scopes | openid profile email, offline_access for refresh tokens, and the CAEP scopes below |
The redirect URI. RFC 8252 §7.1 gives a native app a private-use URI scheme named after a domain you control, in reverse order: example.com becomes com.example, and a scheme such as com.example.myapp. The redirect URI is that scheme, a colon, and one slash: com.example.myapp:/callback. The library refuses a scheme without a period, since a single word such as myapp could belong to any app. You don’t need to declare the scheme in Info.plist (CFBundleURLTypes): ASWebAuthenticationSession catches the redirect itself, and only for the session it started.
Other redirect URIs:
httpson a domain the app claims with an associated domain works withSystemWebAuthenticatoron macOS 14.4 and later.- Loopback (
http://127.0.0.1:port/…, RFC 8252 §7.3) is accepted byOIDCClient, butASWebAuthenticationSessioncannot receive it, soSystemWebAuthenticatorrefuses it. Using it means writing your ownWebAuthenticatorwith a local listener.
No CORS. Unlike a browser app, a native app’s requests are not subject to CORS, so the provider needs no web origin for it.
TLS. Every provider endpoint must be https. Loopback http (localhost, 127.0.0.1) is allowed for development. The Mac must trust the provider’s certificate: URLSession and the browser both check it. With a self-signed development certificate, add it to the keychain and mark it trusted, or use a local CA such as mkcert.
CAEP scopes. These are the CAEP Interoperability Profile’s names:
| Scope | Lets the receiver |
|---|---|
ssf.read |
read its stream and its status, and poll for events |
ssf.manage |
everything ssf.read does, plus create, change or delete a stream, add or remove subjects, change the stream’s status and request a verification event |
An app that only consumes a stream someone else created needs ssf.read. An app that creates its own stream, chooses its event types or adds the user as the stream’s subject needs ssf.manage.
CAEPEnabled defaults to true, so a plist that lists these scopes still receives events. Set it to false to turn off CAEP and RISC at an identity provider that does not implement Shared Signals: the receiver is not started, and ssf.read and ssf.manage are left out of the authorization request even when Scopes lists them.
iya-sts
With iya-sts, both scopes are gated by role. A person is issued ssf.read only while two things are true: they are in the group ssf-read, and your application is an application member of the native role SSF_READ. ssf.manage works the same way through the group ssf-manage and the role SSF_MANAGE, which does not authorize ssf.read, so ask for both. Every realm has these roles and groups by default. Applications have to be added to the roles; nothing adds them for you.
scripts/register-iya-sts-client.mjs does all of this for the demo, from the demo’s OIDCConfiguration.plist:
- registers the public client with the plist’s redirect and post-logout redirect URIs;
- adds it to
SSF_READandSSF_MANAGE; - creates a test user in
ssf-readandssf-manage.
Pass --config to run it on your own app’s plist, or read it as an example of the management API calls involved. Running the demo shows how to run it.
4. Create OIDCConfiguration.plist
The app’s OpenID Connect settings live in a property list, the usual place for configuration that ships with a macOS app. Create OIDCConfiguration.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>MetadataURL</key>
<string>https://idp.example.com/.well-known/openid-configuration</string>
<key>ClientID</key>
<string>my-native-app</string>
<key>Scopes</key>
<array>
<string>openid</string>
<string>profile</string>
<string>email</string>
<string>offline_access</string>
<string>ssf.read</string>
<string>ssf.manage</string>
</array>
<key>RedirectURI</key>
<string>com.example.myapp:/callback</string>
<key>PostLogoutRedirectURI</key>
<string>com.example.myapp:/logged-out</string>
</dict>
</plist>
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
MetadataURL |
string | yes | — | The provider’s metadata URL. It must contain /.well-known/, e.g. …/.well-known/openid-configuration. |
ClientID |
string | yes | — | The public client’s client_id. |
Scopes |
array of strings, or a space-separated string | no | openid profile email, plus offline_access when the provider offers it |
The scopes requested at sign-in. |
RedirectURI |
string | yes | — | A private-use scheme (com.example.myapp:/callback), or https with an associated domain. |
PostLogoutRedirectURI |
string | no | — | Where the provider returns after logout. Needed to sign out in the browser (step 10). |
Resource |
array or string | no | — | RFC 8707 resource indicators: the APIs the tokens are for. |
DPoP |
bool | no | true |
Bind tokens to a key (RFC 9449). |
PAR |
string | no | auto |
Pushed authorization requests: auto, always or never. |
IDTokenSignedResponseAlg |
string | no | RS256 when the provider offers it, else the first algorithm it offers that the library verifies |
The one ID token alg accepted. |
ACRValues |
string | no | — | acr_values for your sign-in requests. |
PrefersEphemeralWebBrowserSession |
bool | no | false |
Sign in without the browser’s cookies. |
SSFTransmitterIssuer |
string | no | the provider’s issuer | The CAEP transmitter, when it is not the provider. |
CAEPEnabled |
bool | no | true |
Receive CAEP and RISC events. false turns both off: the receiver is not started, and ssf.read and ssf.manage are left out of the authorization request, so the app can sign in at an identity provider that does not implement Shared Signals. |
TrustedCertificates |
array | no | — | PEM or DER files (or inline PEM) to trust for TLS: a private CA’s root, or a whole chain. Relative paths are resolved against the plist’s folder. See Linux and macOS. |
TrustSystemRoots |
bool | no | true |
Whether the system’s roots still count beside TrustedCertificates. |
InsecureSkipTLSVerification |
bool | no | false |
Accept any TLS certificate. Local test providers only. |
makeClient() applies the first nine keys. When CAEPEnabled is false it also leaves ssf.read and ssf.manage out of the scopes on the authorization request. If nothing else was listed, the client’s default applies: openid profile email, plus offline_access when the provider offers it, and that default does not add the SSF scopes. Sign-in does not fetch SSF transmitter metadata; only a started receiver does, and false means the receiver is not started. That one flag turns off both CAEP and RISC. ACRValues, PrefersEphemeralWebBrowserSession, SSFTransmitterIssuer and CAEPEnabled are still read into OIDCAppConfiguration for your code, and the examples below show where: acrValues in the sign-in options, prefersEphemeralWebBrowserSession in the SystemWebAuthenticator, and ssfTransmitterIssuer and caepEnabled when starting the receiver.
Add the file to the app target.
- Xcode: drag it into the project and tick your app target, so it appears under Build Phases → Copy Bundle Resources.
OIDCAppConfiguration.load()reads it fromBundle.main. - Swift Package Manager: put it in the target’s resources (
resources: [.process("Resources")], as in step 2) and load it from the package’s bundle:OIDCAppConfiguration.load(bundle: .module).
Instead of a separate file, the same dictionary can sit under an OIDCConfiguration key in the app’s Info.plist; load() looks there when there is no file. OIDCAppConfiguration.load(contentsOf:) reads a plist from any URL, and OIDCAppConfiguration(metadataURL:clientID:…) builds the configuration in code.
5. Create the client
import OIDCCAEPClient
let configuration = try OIDCAppConfiguration.load() // .load(bundle: .module) in a Swift package
let client = try await configuration.makeClient(dpopKeyStore: await preferredDPoPKeyStore())
makeClient(dpopKeyStore:) fetches the provider’s metadata from MetadataURL and creates an OIDCClient. Before it returns, it checks the provider:
- the metadata’s
issuermust be the oneMetadataURLwas built from (Discovery §4.3); - every endpoint must use
https; - the provider must offer PKCE
S256and accept public clients (none); - it must sign ID tokens with an algorithm the library verifies;
- PAR is used when the provider offers it (
PARauto).
It also loads the DPoP key, or creates one. With no dpopKeyStore, makeClient keeps it in secure storage: preferredDPoPKeyStore(), which returns SecureEnclaveKeyStore when the Mac’s Secure Enclave can create a key and KeychainKeyStore otherwise (memory where neither exists). Tokens cached in the Keychain are bound to that key, so they can still be refreshed after a relaunch. Step 6 explains the difference between the two stores.
Without a plist, create the client from options:
let client = try await OIDCClient.discover(
metadataURL: URL(string: "https://idp.example.com/.well-known/openid-configuration")!,
options: OIDCClientOptions(
clientID: "my-native-app",
redirectURI: URL(string: "com.example.myapp:/callback")!,
postLogoutRedirectURI: URL(string: "com.example.myapp:/logged-out")!,
dpop: DPoPOptions(keyStore: await preferredDPoPKeyStore())))
client.securityFeatures summarizes the protections in effect (PKCE, PAR, DPoP algorithm and key thumbprint, whether the key is extractable). The demo shows it as Protections in effect.
6. Where tokens and keys are kept
| What | Default | Kept |
|---|---|---|
Tokens, and AccountManager’s account list |
defaultTokenStore() |
KeychainTokenStore on Apple platforms; MemoryTokenStore where there is no Keychain |
| The DPoP key | preferredDPoPKeyStore() (when makeClient is given none) |
SecureEnclaveKeyStore when available, else KeychainKeyStore |
| A sign-in in progress (state, nonce, PKCE verifier) | MemoryTransactionStore |
memory; the redirect comes back to the process that started the sign-in |
Tokens are cached in the platform’s secure storage when it has one, otherwise in memory. On macOS that is the Keychain: the tokens are encrypted at rest, survive relaunches, and every signed-in account stays signed in. Where no Keychain exists (Linux, where the tests run), defaultTokenStore() returns MemoryTokenStore, and a relaunch means signing in again.
The DPoP key. A key made in the Secure Enclave can be used to sign but never read out, not by the app and not by malware running as the user. Stolen access or refresh tokens are then useless on any other machine. Macs with Apple silicon or a T2 chip have one. What the Keychain holds for it is a handle only this Mac’s Secure Enclave can use. The Secure Enclave creates ES256 keys only. Without it, KeychainKeyStore keeps a software key’s private bytes in the Keychain: still sender-constrained, but the key could be copied along with the tokens.
Keychain service names. By default they are derived from the app’s bundle identifier (defaultKeychainService(_:): <bundle id>.oidc-tokens and <bundle id>.oidc-dpop), so two apps never share items. A binary with no bundle identifier (swift run) falls back to oidc-caep-client.tokens and oidc-caep-client.dpop. To choose them yourself:
let keyStore = await preferredDPoPKeyStore(service: "com.example.myapp.dpop")
let client = try await configuration.makeClient(dpopKeyStore: keyStore)
let accounts = AccountManager(client: client, options: AccountManagerOptions(
store: defaultTokenStore(service: "com.example.myapp.tokens")))
Other choices:
- Memory only:
AccountManagerOptions(store: MemoryTokenStore()). Nothing persists; after a relaunch,resumegets new tokens without a screen while the provider’s session lasts. - The data protection keychain:
KeychainTokenStore(service:accessGroup:useDataProtectionKeychain: true), and the same flag on the key store. Its items arekSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. It needs a signed app with akeychain-access-groupsentitlement (step 12). The default is the login keychain. - Your own store: implement
TokenStore(get,set,delete,isPersistent) orDPoPKeyStore.
AccountManager logs a warning when tokens go to persistent storage with DPoP off: anything that can read the store could use them anywhere.
7. Sign in
let authenticator = SystemWebAuthenticator(
prefersEphemeralWebBrowserSession: configuration.prefersEphemeralWebBrowserSession)
let accounts = AccountManager(client: client)
try await accounts.restore() // the accounts from the last launch, and the active one
do {
let result = try await accounts.signIn(using: authenticator,
AuthorizationRequestOptions(acrValues: configuration.acrValues))
print("Signed in as", result.account.name ?? result.account.sub)
} catch let error as OIDCError where error.isUserCancellation {
// The person closed the browser window.
}
signIn(using:) builds the authorization request (PKCE, state, nonce, PAR, dpop_jkt), opens it with ASWebAuthenticationSession, waits for the redirect, and completes the sign-in: the state is checked, the code is redeemed with the PKCE verifier and a DPoP proof, and the ID token is validated. The account is cached and becomes the active account.
SystemWebAuthenticator presents the browser over the app’s key window. Pass anchor: to choose another window. Leave prefersEphemeralWebBrowserSession off unless you have a reason: sharing the browser’s session is what makes single sign-on, prompt=none and several accounts work.
Choosing the request. Each has a using: form that runs the browser step:
| Call | Request | Use |
|---|---|---|
signIn(using:) |
the provider decides | Its account picker when several people are signed in there. |
chooseAccount(using:) |
prompt=select_account |
Always the provider’s picker. |
addAccount(using:) |
prompt=login |
A fresh sign-in, added as another account. |
resume(key, using:) |
prompt=none, naming the account |
New tokens without a screen while its session at the provider lasts. |
reauthenticate(key, using:) |
prompt=login, naming the account |
Sign that person in again, e.g. step-up with acrValues or maxAge. |
resume and reauthenticate send login_hint and the cached id_token_hint. If the provider signs in somebody else, the sign-in is refused with account_mismatch and the tokens it got are revoked.
After a relaunch. restore() loads the account list from the store and reactivates the account that was active, refreshing its tokens if they are due. An account whose cached tokens can’t be used has status .tokensMissing; get new ones without a screen, and fall back to a visible sign-in:
if let account = await accounts.active, account.status == .tokensMissing {
do {
try await accounts.resume(account.key, using: authenticator)
} catch where needsInteraction(error) {
try await accounts.reauthenticate(account.key, using: authenticator)
}
}
Several accounts in one app
Some providers keep several people signed in in one browser and let each request say which one it is for. iya-sts does this with authn.multipleAccounts, on by default. AccountManager is the app’s side of that:
- One token cache. Every signed-in account’s tokens are cached until that account is signed out or its session ends, so any of them can become active without a password.
- One active account. Only its tokens can be reached:
getTokenSet()takes no account. Only its tokens are refreshed in the background. The others are dormant: stored, not used, not refreshed.
await accounts.list() // every account, in the order added
try await accounts.activate(otherKey) // the previous account goes dormant
let tokenSet = try await accounts.getTokenSet() // the ACTIVE account's, refreshed when due
Activating a dormant account refreshes its tokens first if they are due, then runs onActivate (step 9), and only then makes it active. If either fails, the previously active account is reactivated and the error is thrown.
Statuses: .signedIn, .tokensMissing, .signedOut and .ended (ended by the provider, a security event or a refused refresh; endedReason says why). Signed-out accounts stay listed for rememberSignedOutDays (default 30). Past maxAccounts (default 10), the account that left longest ago is forgotten; when all of them are signed in, a new sign-in is refused (too_many_accounts).
Following changes. accounts.events delivers .added, .updated, .activated, .refreshed, .signedOut, .ended, .forgotten, .activationError and .change, as callbacks or as an AsyncStream:
Task { @MainActor in
for await event in accounts.events.stream() {
if case .ended(let account, let reason) = event { model.show("\(account.name ?? account.sub): \(reason ?? "session ended")") }
if case .change = event { await model.reload(from: accounts) }
}
}
One person only
Without an account list, use OIDCClient and TokenManager directly:
let tokens = TokenManager(client: client) // the Keychain by default, auto-refreshing
let result = try await client.authorize(using: authenticator)
try await tokens.set("user", result.tokenSet)
let tokenSet = try await tokens.getTokenSet("user") // refreshed first when (nearly) expired
8. Call your APIs
fetchResource sends a request with the access token. A DPoP-bound token gets a fresh proof for that method and URL. When the resource server asks for a DPoP nonce, it retries once with it.
let tokenSet = try await accounts.getTokenSet()
let response = try await client.fetchResource(tokenSet, url: URL(string: "https://api.example.com/me")!)
if response.isOK {
let me = try JSONValue.parse(response.body)
}
It takes method:, headers: and body: too. To send the request with your own URLSession, ask for the headers instead:
let url = URL(string: "https://api.example.com/orders")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
for (name, value) in try client.resourceHeaders(for: tokenSet, method: "POST", url: url) {
request.setValue(value, forHTTPHeaderField: name) // authorization, and dpop when bound
}
Make the headers again for every request: a DPoP proof is for one request only. resourceHeaders doesn’t retry on a nonce challenge; fetchResource does.
UserInfo. try await client.userinfo(tokenSet) returns the claims, after checking that their sub is the ID token’s.
A 401 from your API usually means the access token expired or was revoked. Call accounts.refresh() and retry once. If the refresh fails with invalid_grant, the account’s tokens are dropped and it is ended; ask the person to sign in again.
9. Receive CAEP events
Wire a receiver to the active account
Each account has its own stream at the transmitter: iya-sts gives a stream created with a person’s token to that person, and sends it only events about them. So the receiver follows the active account. AccountManager’s three hooks are where it starts and stops.
CAEPEnabled defaults to true. Set it to false to turn off CAEP and RISC at an identity provider that does not implement Shared Signals. Do not start the receiver (the example below already checks configuration.caepEnabled), and makeClient() leaves ssf.read and ssf.manage out of the authorization request. Sign-in itself does not read /.well-known/ssf-configuration.
onActivateruns before an account becomes active, with access to its tokens. Start its receiver here withcatchUp: true, so the events queued while it was dormant are acted on before the account is used.onDeactivateruns when it stops being active. Stop polling; its stream keeps queueing.onLeaveruns when it is signed out or ended, while its tokens still work. Delete its stream.
The receiver’s handlers call accounts.end(_:reason:) to end the account an event is about. That runs onLeave and onDeactivate while the handler is still running. Calling SSFReceiver.stop() there is safe: from inside a handler it returns at once, and the poll running the handler sends the last acknowledgements when it finishes.
import OIDCCAEPClient
/// The active account's SSF receiver.
actor Signals {
private let client: OIDCClient
private let transmitter: String
private let stepUp: @Sendable (_ key: String, _ why: String) async -> Void
private var accounts: AccountManager?
private var receiver: SSFReceiver?
private var key: String?
init(client: OIDCClient, transmitter: String, stepUp: @escaping @Sendable (String, String) async -> Void) {
self.client = client
self.transmitter = transmitter
self.stepUp = stepUp
}
func attach(_ accounts: AccountManager) { self.accounts = accounts }
/// onActivate: open the account's stream and act on what it queued while it was dormant.
func start(_ account: Account, _ access: AccountAccess) async {
let receiver = SSFReceiver(SSFReceiverConfiguration(
transmitterIssuer: transmitter,
// Every call to the transmitter carries this person's token, with a DPoP proof when bound.
authorization: .oidc(client) { try await access.getTokenSet() }))
register(receiver, account, access)
self.receiver = receiver
key = account.key
do {
try await receiver.start(catchUp: true)
try? await receiver.addSubject(Subject.issSub(account.iss, account.sub)) // needs ssf.manage
} catch {
print("CAEP is not running for \(account.sub): \(error)")
}
// A handler may have ended the account during the catch-up, which stopped this receiver.
if self.receiver !== receiver { await receiver.stop() }
}
/// onDeactivate.
func stop() {
guard let receiver else { return }
self.receiver = nil
key = nil
Task { await receiver.stop() }
}
/// onLeave: the account's tokens still work, so its stream can be deleted (needs ssf.manage).
func leave(_ account: Account) async {
guard let receiver, key == account.key else { return }
try? await receiver.deleteStream()
stop()
}
private func register(_ receiver: SSFReceiver, _ account: Account, _ access: AccountAccess) {
let key = account.key
let identity = account.identity
// Act only on events about this account: another of the person's sessions is not this one.
let mine: @Sendable (SecurityEvent) -> Bool = { subjectMatches($0.subject, identity) }
receiver
.onSessionRevoked { event in
if mine(event) { await self.end(key, event.reasonUser?["en"] ?? "The session was revoked.") }
}
.onTokenClaimsChange { event in
if mine(event) { _ = try await access.getTokenSet(forceRefresh: true) }
}
.onCredentialChange { event in
if mine(event), let change = event.payload["change_type"]?.stringValue, ["revoke", "delete"].contains(change) {
await self.end(key, "A credential was \(change)d. Sign in again.")
}
}
.onAssuranceLevelChange { event in
if mine(event), event.payload["change_direction"]?.stringValue == "decrease" {
await self.stepUp(key, "Assurance dropped.")
}
}
.onDeviceComplianceChange { event in
if mine(event), event.payload["current_status"]?.stringValue == "not-compliant" {
await self.end(key, "This device no longer meets policy.")
}
}
.onRiskLevelChange { event in
guard mine(event) else { return }
switch event.payload["current_level"]?.stringValue {
case "HIGH": await self.end(key, "Unusual activity was detected.")
case "MEDIUM": await self.stepUp(key, "The risk level was raised.")
default: break
}
}
.onError { error, context in
print("CAEP", context.phase.rawValue, error.code ?? "", error.message)
}
}
private func end(_ key: String, _ reason: String) async {
_ = try? await accounts?.end(key, reason: reason)
}
}
Create it before the account manager, give the manager the hooks, then attach it and restore:
let signals = Signals(client: client,
transmitter: configuration.ssfTransmitterIssuer ?? client.issuer,
stepUp: { key, why in await model.requireStepUp(key, why) })
let caep = configuration.caepEnabled
let accounts = AccountManager(client: client, options: AccountManagerOptions(
store: defaultTokenStore(service: "com.example.myapp.tokens"),
onActivate: { account, access in if caep { await signals.start(account, access) } },
onDeactivate: { _ in await signals.stop() },
onLeave: { account, _ in await signals.leave(account) }))
await signals.attach(accounts)
try await accounts.restore() // runs onActivate for the account that was active
model.requireStepUp stands for your UI: show a banner, and call accounts.reauthenticate(key, using: authenticator, AuthorizationRequestOptions(acrValues: …)) when the person agrees.
Here a receiver that fails to start is reported and the account is activated anyway. To refuse activation instead, rethrow from start: an error thrown by onActivate aborts the activation.
start() either opens an existing stream or creates one:
- Opening an existing stream: pass its id as
SSFReceiverConfiguration(streamID:). Keep the id, for example inUserDefaults, to reopen the same stream later. - Creating a stream: this needs
ssf.manage. If the transmitter refuses the create because the person already has streams (HTTP 409, or HTTP 403access_deniedwhen they hold as many as it allows; iya-sts allowsssf.maxStreams, 25), one of their streams is reused and re-pointed at this receiver: the one whosedescriptionis this receiver’s, else the first. So an app that lost its stored stream id doesn’t fail at the limit.
With ssf.read only, you can’t create a stream. Find one with listStreams() and pass its id as streamID.
Choose the event types
A new stream asks for every CAEP event type (CAEP.all) unless you pass eventsRequested. To change an existing stream’s types (needs ssf.manage):
try await receiver.updateStream(["events_requested": JSONValue([CAEP.sessionRevoked, CAEP.credentialChange, CAEP.riskLevelChange])])
let delivered = await receiver.stream?["events_delivered"] // what the transmitter will actually send
The transmitter decides events_delivered: the types you asked for that it supports. The scopes control which operations you may perform on the stream, not which event types you may receive. The demo’s CAEP events tab lets the person tick types and applies them with this call. getStatus() returns the stream’s status, and requestVerification() asks the transmitter for a verification event and returns it once it arrives.
Handle the events
Each handler gets a validated SecurityEvent: type, name, payload, subject, eventTimestamp, initiatingEntity, reasonAdmin, reasonUser, jti and the whole SET. A handler that throws leaves the event for the transmitter to send again.
Which events need action is your policy. These are reasonable defaults, and the ones the demo uses:
| Event | Reasonable reaction |
|---|---|
| session-revoked | End the account and discard its tokens: accounts.end(key, reason:). |
| token-claims-change | Refresh tokens so the new claims (payload["claims"]) take effect. |
| credential-change | Revoked or deleted: end the account. Otherwise tell the person. |
| assurance-level-change | Decrease: require step-up before sensitive actions. |
| device-compliance-change | Not compliant: end the account, or limit what the app allows. |
| risk-level-change | HIGH: end the account. MEDIUM: require step-up. |
| session-established, session-presented | Informational; record or display them. |
Check who an event is about. A stream can carry events about other sessions of the same person. Compare the event’s subject with the account before acting: subjectMatches(event.subject, account.identity), or accounts.accountsForSubject(subId) to find every listed account it is about. An email or phone number subject counts only when the ID token said it was verified. An event that names a session matches only the account holding that sid.
Add your own logic for every event
onEventReceived runs once for every accepted event, before the typed handlers. By default it is logEventReceived, which logs the event’s name and jti through OIDCLog, leaving out the payload and subject, which can carry personal data. Put logging, auditing or analytics here:
let configuration = SSFReceiverConfiguration(
transmitterIssuer: transmitter,
authorization: .oidc(client) { try await access.getTokenSet() },
onEventReceived: { event in
try await logEventReceived(event) // keep the log line, or drop it
await audit.record(event.name, event.subject, event.eventTimestamp)
})
Pass onEventReceived: nil to turn it off. If it throws, onError gets the error with phase .hook, and the typed handlers still run. OIDCLog.handler decides where the library’s log lines go; replace it to send them to os.Logger or nowhere.
What the receiver checks for you
Every event (a signed SET) is refused unless all of these hold:
- The signature verifies against the transmitter’s keys, uses an asymmetric algorithm, and has
typ: secevent+jwt. issis the transmitter.audis your stream’s.iatisn’t in the future. There’s no age limit by default, so events queued while nobody was polling are still acted on when the receiver catches up.eventsholds exactly one event, and a stream event (verification, stream-updated) names this stream.jtihasn’t been seen before. A repeat is dropped.
The receiver is careful about what it tells the transmitter, because an acknowledged event, or one reported as an error, is never sent again:
- An invalid event goes to
onError, is reported back to the transmitter on the next poll, and never reaches your handlers. - An event that couldn’t be checked for a reason that may pass (a network error fetching the keys, a key the cached set doesn’t hold yet) is left for the transmitter to send again.
- An event is acknowledged only after your handlers succeed. If one throws, the event comes back on a later poll, up to
maxDeliveryAttemptstimes (default 5). Write handlers so that running twice is harmless. - The poll URL must use https and be at the transmitter’s origin, because every poll carries the person’s access token.
Without an onError, errors are logged through OIDCLog, so a lost event always leaves a trace.
Stop and clean up
await receiver.stop() // stop polling; the stream keeps queueing events
await receiver.stop(deleteStream: true) // with ssf.manage: also remove the stream
stop() sends the acknowledgements still owed. Called from inside a handler, it leaves that to the poll running the handler.
When to delete the stream, and when not:
- Signing out or removing an account: delete it when the token holds
ssf.manage(stop(deleteStream: true), inonLeavewhile the tokens still work); otherwise just stop polling. The demo does this. - The app is quit, put in the background, or its account made dormant: don’t delete it. The transmitter keeps queueing events for it, and
start(catchUp: true)acts on them when the app comes back. Keep the stream id (the demo usesUserDefaults) and pass it asstreamIDon the next launch. - An app that never comes back (killed, uninstalled) leaves its stream behind. The transmitter removes it: iya-sts deletes a person’s poll stream after
ssf.personStreamInactivityTimeoutS(a day by default) with no poll (iya-sts#1094). Within that time, a reinstalled app that lost the id reuses the stream (see above).
10. Sign out
do {
try await accounts.signOut(using: authenticator) // the active account
} catch let error as OIDCError where error.isUserCancellation {
// Signed out of the app already; the provider's session was left as it was.
}
signOut(using:) signs the account out of the app first: onLeave runs, its tokens are revoked at the provider (RFC 7009) and removed from the Keychain, and it stays listed as .signedOut. If it was the active account, the most recently added signed-in account becomes active. Then it opens the provider’s logout page in the browser (RP-Initiated Logout), naming that account’s ID token so the provider ends that session only, and checks the state it returns with.
- Pass a key to sign out another account:
accounts.signOut(key, using: authenticator). accounts.signOutAll(using: authenticator)signs every account out, with one logout request that names nobody, so the provider asks which sessions to end.- The browser step needs
PostLogoutRedirectURI, so that the window closes when the provider is done. Without one (or without anend_session_endpoint),signOut(using:)signs out locally and opens no browser window. - To sign out of the app only and leave the provider’s session:
accounts.signOut(endSession: false).
With TokenManager, do the same steps yourself:
let tokenSet = try await tokens.getTokenSet("user")
let logout = try await client.createEndSessionRequest(idTokenHint: tokenSet.idToken)
try await tokens.delete("user", revoke: true)
try await client.endSession(using: authenticator, request: logout)
11. Errors
Every error the library throws is an OIDCError. Match on kind and code, not on the message.
kind |
When | Examples of code |
|---|---|---|
.oauth |
The provider answered with an OAuth error. oauthErrorDescription and errorURI hold the rest. |
login_required, consent_required, account_selection_required, invalid_grant, invalid_scope |
.validation |
A response, token or provider setting failed a check. | state_missing, state_mismatch, state_reused, transaction_expired, issuer_mismatch, nonce_mismatch, invalid_id_token, acr_mismatch, account_mismatch, front_channel_token, insecure_endpoint, insecure_redirect_uri, pkce_not_supported, public_client_not_supported, dpop_downgrade |
.setValidation |
A security event was refused. Reported to onError. |
invalid_request, invalid_key, invalid_issuer, invalid_audience |
.ssf |
The transmitter can’t be used as configured, or a stream call failed. | insecure_endpoint, untrusted_endpoint, verification_timeout, handler_failed, http_error |
.general |
Everything else. | user_cancelled, web_authentication_failed, network_error, http_error, keychain_error, secure_enclave_unavailable, no_tokens, not_active, too_many_accounts, invalid_argument |
Two helpers:
error.isUserCancellationis true when the person closed the browser window (user_cancelled). Treat it as “never mind”, not as a failure.needsInteraction(error)is true when only a visible sign-in can fix the problem:login_required,interaction_required,consent_requiredoraccount_selection_required. When it is true, sign the person in with a screen rather than retrying silently.
An error from a resume or reauthenticate carries the account it was for in error.account, and the request’s context in error.context. status and response hold the HTTP status and body when there was one.
12. Sandboxed and signed apps
App Sandbox. A sandboxed app needs the outgoing network entitlement to reach the provider and the transmitter. Without it, every request fails with network_error.
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
ASWebAuthenticationSession needs no entitlement, and the private-use scheme needs no CFBundleURLTypes entry.
Keychain.
- The login keychain (the default) needs no entitlement. Its items are tied to the code signature of the app that created them. An app signed with a stable identity (Apple Development, Developer ID) reads them without a prompt across updates. An ad-hoc-signed build has a new signature each time it is built, so macOS may ask whether the new build may use the items.
- The data protection keychain (
useDataProtectionKeychain: true) needs an app signed with a team identity and akeychain-access-groupsentitlement. Without them the Keychain answerserrSecMissingEntitlement, which the library reports askeychain_error. PassaccessGroup:to share the items with other apps of the same team.
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.example.myapp</string>
</array>
Secure Enclave. Creating a Secure Enclave key can fail for an unsigned or ad-hoc-signed build. preferredDPoPKeyStore() then falls back to KeychainKeyStore, so the app still works; client.securityFeatures.dpop?.extractable tells you which you got. Sign the app to get the non-extractable key.
Hardened runtime needs no exception for this library.
13. Security checklist
- The client is public. The app holds no secret.
- The redirect URI is a private-use scheme in reverse-domain form that you control, registered exactly at the provider.
- Sign-in happens in the system browser (
SystemWebAuthenticator), never in aWKWebView. - DPoP is on, and
makeClientis given a persistent key store, the Secure Enclave where available. - Tokens are in the Keychain under your app’s own service name.
- The app asks for only the scopes it uses.
ssf.manageis only for apps that manage their own stream. - The receiver catches up (
start(catchUp: true)) inonActivate, before a restored account is used. - CAEP handlers check
subjectMatches()before acting, and fail safe. - Signing out revokes tokens, deletes or stops the stream, and clears any stored events.
- A sandboxed app has
com.apple.security.network.clientand nothing broader.
14. Troubleshooting
| Symptom | Likely cause |
|---|---|
invalid_argument: “No OIDCConfiguration.plist in …” |
The plist isn’t in the app’s bundle. In Xcode, tick the app target for it; in a Swift package, list it in resources and call load(bundle: .module). |
invalid_argument: “MetadataURL must be the provider’s metadata URL” |
MetadataURL is the issuer. Use the full …/.well-known/openid-configuration URL. |
issuer_mismatch at makeClient |
The metadata’s issuer isn’t the one its URL was built from. Check the provider’s configured issuer. |
network_error on discovery, token or SSF calls |
The Mac doesn’t trust the provider’s certificate, the provider isn’t reachable, or a sandboxed app lacks com.apple.security.network.client. |
insecure_endpoint / insecure_redirect_uri |
An endpoint isn’t https, or the redirect URI’s scheme has no period (myapp:/callback). Use com.example.myapp:/callback. Only loopback may use http. |
invalid_argument: “ASWebAuthenticationSession cannot receive a loopback http redirect” |
The redirect URI is http://127.0.0.1…. Use a private-use scheme with SystemWebAuthenticator. |
| The provider shows “invalid redirect_uri” in the browser | The URI in the plist isn’t registered exactly as written at the provider. |
web_authentication_failed |
The browser session couldn’t start (for example, no window to present it over) or ended without a redirect. |
public_client_not_supported / pkce_not_supported |
The provider’s metadata doesn’t list none in token_endpoint_auth_methods_supported, or S256 in code_challenge_methods_supported. |
dpop_not_supported |
The provider accepts no DPoP algorithm the key store creates. The Secure Enclave creates ES256 only; use KeychainKeyStore for ES384 or ES512. |
| Every account is signed out after a relaunch | makeClient was given no key store, so the DPoP key was in memory and the cached tokens can’t be refreshed; or the token store is MemoryTokenStore. |
| macOS asks for the keychain password, or to allow access, at launch | The build’s signature changed (ad-hoc signing), so the login keychain asks before giving the new build the items. Choose Always Allow, or sign with a stable identity. |
keychain_error |
With useDataProtectionKeychain, the app lacks the keychain-access-groups entitlement or a team signature. |
state_mismatch or transaction_expired |
The sign-in was started in an earlier launch, or took longer than 10 minutes. Start again. |
invalid_scope, or a scope missing from the token |
The client isn’t allowed that scope, or (iya-sts) the person or application lacks the role. iya-sts logs STS-ADMIN-0821 saying which condition failed. |
| The SSF calls answer 401 or 403 | The access token lacks ssf.read / ssf.manage, or the stream belongs to somebody else. |
untrusted_endpoint |
The stream’s poll URL isn’t at the transmitter’s origin. Add its origin to trustedPollOrigins if it is legitimately elsewhere. |
| No events arrive | The stream doesn’t ask for that type (stream["events_delivered"]), has no subject, or isn’t polling (onError with phase .poll). |
account_mismatch |
A request that named an account came back signed in as somebody else. The tokens were revoked; ask the person to sign in as the named account. |
To check a setup end to end, run the demo against your provider (Running the demo), and send test events from the provider’s admin console or API.