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#
| Operation | iOS App Extension | Android 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 (
SharedPreferencesis automatically scoped to the app’s package; works for both). - Encoded
HaapiConfigurationshared 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.
SDK Layer#
Use the app-scoped SharedPreferences directly — it’s automatically shared across the host and widget processes:
val appSharedPreferences: SharedPreferences =
getSharedPreferences("haapi_token_store", Context.MODE_PRIVATE)
fun saveToken(token: SuccessfulTokenResponse) {
appSharedPreferences.edit(commit = true) {
putString("ACCESS_TOKEN", token.accessToken)
putString("REFRESH_TOKEN", token.refreshToken)
}
}For production, EncryptedSharedPreferences is the recommended choice over plain SharedPreferences — tokens shouldn’t sit unencrypted on the file system.
UI Layer#
The host app uses WidgetConfiguration.Builder for the full flow; the widget does not — it operates at the SDK Layer. There is no UI-Layer entry point inside an App Widget.
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
) GlobalScope.launch(Dispatchers.IO) {
val accessor = HaapiAccessorFactory(haapiConfiguration).createForOAuth()
val tokenResponse = accessor.oAuthTokenManager.refreshAccessToken(
refreshToken = refreshToken,
onCoroutineContext = this.coroutineContext
)
when (tokenResponse) {
is SuccessfulTokenResponse -> saveToken(tokenResponse)
is ErrorTokenResponse -> {
// invalid_grant — clear shared storage; host app must re-authenticate
}
}
} 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
HaapiConfigurationin a shared module so the host and extension/widget always agree. Mismatched configurations cause refresh requests to be rejected withinvalid_client. - Android
GlobalScope.launch. The upstream example usesGlobalScope.launch(Dispatchers.IO); modern Android prefers lifecycle-scoped coroutines orWorkManager. If your app already uses one of those patterns, use it here too —GlobalScopeis just the upstream’s defensive choice for short-lived widget processes.