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#

  • App Group entitlement on both the host app and the extension. This is the only iOS mechanism that allows two targets in the same app to share storage.
  • Shared storage for the OAuth tokens. UserDefaults backed by the App Group works for simple cases; Shared Keychain (when DCR is in use, see below) is more secure.
  • The same HaapiConfiguration in both targets. Encoding it in a shared module is the simplest approach.

Sharing the Token from Host to Extension#

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. Configure DCRConfiguration with a 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?