App Extension Support (iOS Only)#

Platform-only: iOS. App Extensions cannot run the full HAAPI flow because DCAppAttestService is unavailable in extension contexts.

iOS App Extensions (Widgets, Action extensions, intents, etc.) have restricted access to device APIs. The framework’s attestation surface (DCAppAttestService) is among the APIs marked unavailable for extensions, which means an App Extension cannot run the full HAAPI flow to obtain a fresh access token. What it can do is manage tokens the host app has already obtained: refresh, revoke, and use them in OAuth-authenticated requests.

This page documents the working pattern. For the host-app integration, see iOS UIKit.

What the Extension Can and Can’t Do#

OperationSupported in App Extension?
Run a full HAAPI flow (HaapiManager.start(...))❌ Attestation is unavailable
Acquire a fresh access token without an existing refresh token❌ Same root cause
Refresh an access token via OAuthTokenManager✅
Revoke a refresh token via OAuthTokenManager✅
Use the access token in authenticated HTTP calls✅

The host app is responsible for running the flow and persisting the token. The extension reads, refreshes, and uses what’s already there.

Prerequisites#

  • A capability that lets both targets share storage. For the built-in keychain store (recommended, see below) that’s the Keychain Sharing capability with a matching access group; for a custom Storage backed by UserDefaults it’s an App Group.
  • Shared storage for the OAuth tokens. The built-in keychain store shares them for you; a custom store backed by App-Group UserDefaults works for simple cases; a Shared Keychain custom store is more secure.
  • The same HaapiConfiguration in both targets. Encoding it in a shared module is the simplest approach.

You no longer need to hand-implement a shared Storage to share the SDK’s own token and DPoP session state. Configure the storage with a keychain access group on the builder and the session is shared automatically between the host app and the extension:

let sharedStorage = StorageBuilder.Keychain()
    .accessGroup("group.example.shared")     // your Keychain Sharing access group
    .build()

// Host app — UI Layer
.setStorage(sharedStorage)

// Extension — SDK Layer
let configuration = HaapiConfiguration(
    // … your existing configuration …
    storage: sharedStorage
)
  • Add the Keychain Sharing capability with the matching access group to both targets’ entitlements (distinct from the App Group capability).
  • If that access group is misconfigured, the SDK logs an early warning during a non-throwing probe — it does not silently fall back to app-private storage. The first real keychain operation then surfaces the authoritative typed error; see Storage Errors.
  • Both targets use the same configuration; DCR data inherits this storage automatically unless you give DCRConfiguration a storage of its own.
  • Existing credentials from earlier SDK versions migrate transparently on first read — no re-authentication.
  • Optionally set the data-protection level with .accessibility(_:) (.afterFirstUnlockThisDeviceOnly default, or the stricter .whenUnlockedThisDeviceOnly).

The attestation restriction still governs running the HAAPI flow in an extension, regardless of storage: the host app runs the flow, and the extension only refreshes and uses the shared session.

Sharing the Token from Host to Extension#

This is the custom Storage route — use it when the built-in keychain store above doesn’t fit (for example, tokens you already persist in App-Group UserDefaults). In the host app, when the HAAPI flow completes, persist the SuccessfulTokenResponse to the shared storage. In the extension, read it back and pass it to OAuthTokenManager for refresh / use.

// Shared storage backed by App-Group UserDefaults
struct SharedTokenStorage: Storage {
    private let defaults = UserDefaults(suiteName: "group.com.example.app")!

    func read(key: String) throws -> Data? {
        defaults.data(forKey: key)
    }

    func write(key: String, data: Data) throws {
        defaults.set(data, forKey: key)
    }

    func delete(key: String) throws {
        defaults.removeObject(forKey: key)
    }
}

Use the storage in the host app to persist the token after the flow, and in the extension to read it before refresh / use.

Building the Accessor in the Extension#

In the extension, construct HaapiAccessor with .setHaapiAccessorOption(.oauth) — this skips the parts of the accessor that depend on DCAppAttestService and exposes only OAuthTokenManager:

let haapiAccessor = HaapiAccessorBuilder(haapiConfiguration: sharedConfiguration)
    .setHaapiAccessorOption(.oauth)
    .buildForHaapi()

let tokenResponse = try await haapiAccessor.oAuthTokenManager.refreshAccessToken(
    refreshToken: cachedRefreshToken
)

switch tokenResponse {
case .successfulToken(let success):
    // Persist the refreshed tokens back into shared storage
    try sharedStorage.write(key: "haapi.token", data: success.encodedData())
case .errorToken(let error):
    // Handle invalid_grant — extension can't recover; host app must re-authenticate
    break
case .error(let err):
    // Transport or framework error
    break
}

When DCR Fallback Is in Use#

The token refresh path is the same, but the DCR-generated client identity also needs to be readable from the extension. With the built-in keychain store above, DCR data inherits HaapiConfiguration.storage automatically, so a shared access group already covers it. To keep DCR on a separate store, configure DCRConfiguration with its own Shared Keychain Storage implementation so both host and extension see the same dynamic client.

let dcrConfig = DCRConfiguration(
    templateClientId: "dcr-template-client-haapi-ios",
    clientRegistrationEndpointUrl: registrationEndpoint,
    storage: SharedKeychainStorage(accessGroup: "TEAMID.com.example.app.shared")
)

The host app handles initial registration (it runs the flow); the extension reads the registered client identity from the keychain and uses it for token refresh.

App Extensions have a short lifetime and limited CPU budget. A token refresh that needs a network round trip is well within budget; running custom retry loops or batched operations isn’t. If refreshAccessToken returns a retryable error, propagate it — let the host app retry the next time it’s foregrounded rather than blocking the extension UI on a retry the system might terminate.

Was this helpful?