How to Use HAAPI in an App Extension or App Widget#

You want an iOS App Extension (Widget, Action extension, intent) or an Android App Widget to refresh and use access tokens without forcing the user back through the full HAAPI flow. The trick: extensions and widgets cannot run the full flow (attestation APIs aren’t available; widget processes can’t host the activity), but they can refresh / revoke / use tokens that the host app already obtained.

For per-platform restriction detail, see App Extension Support (iOS Only) and App Widget Support (Android Only) . This page is the cross-platform task walkthrough.

What’s Allowed#

OperationiOS App ExtensionAndroid App Widget
Run a full HAAPI flow (HaapiManager.start)❌ Attestation unavailable❌ Requires full Activity
Acquire a fresh access token from scratch❌ Same root cause❌ Same root cause
Refresh an access token via OAuthTokenManager
Revoke a refresh token via OAuthTokenManager
Use the access token in authenticated HTTP calls

Prerequisites#

  • A working host-app HAAPI integration that already obtains tokens.
  • iOS: App Group entitlement on host app + extension target.
  • Android: A storage location shared between the host process and widget process (SharedPreferences is automatically scoped to the app’s package; works for both).
  • Encoded HaapiConfiguration shared between the two targets (a shared module is the simplest way).

Step-by-Step#

1. Pick shared storage#

Both platforms need somewhere both processes can read/write tokens.

SDK Layer#

Implement a 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) }
}

For DCR fallback, use a Shared Keychain-backed Storage instead (set the keychain access group via kSecAttrAccessGroup to your TEAMID.com.example.app.shared group).

UI Layer#

The host app uses HaapiUIKitConfigurationBuilder for the full flow; the extension does not — it operates at the SDK Layer. There is no UI-Layer entry point inside an App Extension.

2. Build the OAuth-only accessor in the extension/widget#

Both platforms have a dedicated builder option that skips the parts of the accessor depending on attestation / Activity — exposing only OAuthTokenManager.

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

// refresh the token
let tokenResponse = try await haapiAccessor.oAuthTokenManager.refreshAccessToken(
    refreshToken: cachedRefreshToken
)

3. Handle invalid_grant by deferring to the host#

If refreshAccessToken returns an ErrorTokenResponse with error == "invalid_grant", the refresh token is dead — the SDK clears any bound DPoP keys automatically. The extension / widget cannot recover by itself; it must clear the shared token store and signal the host app to prompt re-authentication on next launch.

Verify It Works#

  • Host app authenticates once and persists tokens to shared storage.
  • Force-quit the host app; trigger the extension or widget.
  • Confirm the extension / widget refreshes the token, persists the new one, and successfully fetches data from your resource server.

Pitfalls#

  • Short lifetime, limited CPU budget. Extensions and widgets get short-lived processes. A token refresh that needs a single round trip is fine; custom retry loops or batched operations are not. On retryable errors, propagate — let the host app retry on next foreground.
  • iOS App Extension can’t do attestation. Don’t try to run HaapiManager.start(...) in the extension. It’ll fail with an attestation error and confuse the user. Always use .setHaapiAccessorOption(.oauth) to expose only the refresh surface.
  • Plain UserDefaults / SharedPreferences for tokens. Convenient but not encrypted at rest. For production deployments where the tokens grant access to user data, use Shared Keychain (iOS) or EncryptedSharedPreferences (Android).
  • Configuration drift. Two targets, two sources of truth. Encode HaapiConfiguration in a shared module so the host and extension/widget always agree. Mismatched configurations cause refresh requests to be rejected with invalid_client.
  • Android GlobalScope.launch. The upstream example uses GlobalScope.launch(Dispatchers.IO); modern Android prefers lifecycle-scoped coroutines or WorkManager. If your app already uses one of those patterns, use it here too — GlobalScope is just the upstream’s defensive choice for short-lived widget processes.

Was this helpful?