Upgrade — HAAPI request target hardening#
A HAAPI request carries the field values the person just typed — passwords, one-time codes — along with the access token and a DPoP proof bound to the address it is sent to. The addresses in a representation’s forms and links are chosen by the server, so they are the input to the decision of where to send that request, never the authority for it.
Link handling by rel and type closed the first half of this: followLink refuses an off-origin target. Two gaps remained, and both are now closed on Android and on iOS.
- Form submission was unguarded — the one path that transmits credentials in a request body, and the one reachable with no user interaction, because a server-supplied redirection step is auto-submitted when
isAutoRedirectis left at its default oftrue. - A
302bypassed every check — nothing disabled redirect following, so the transport resolved redirects itself and aLocationheader moved the request to an address nothing had validated.
This is a breaking change in a minor release. Breaking changes are normally reserved for major versions; the exception is made here because it fixes a security issue: a HAAPI request could carry credentials, an access token and a DPoP proof to an address the application never configured. A deployment that answers the SDK with HTTP redirects, emits targets outside the configured addresses, or is addressed over plain http fails where it used to work, so check yours before upgrading.
What changed#
| Before | After |
|---|---|
Only followLink checked its target | Every HAAPI request is checked: session start, form submission, auto-redirection, link follow |
| The check compared against the base URL alone | The target may match the base URL or the authorization endpoint URL. followLink still accepts the base URL alone |
| A redirect was followed by the transport | A 301, 302, 303, 307 or 308 with a Location is refused with haapi_redirect_refused; nothing is followed |
An http identity server worked silently | An http address is refused when the session is constructed, unless you opt in |
non_haapi_link is unchanged in identity and behaviour. If you already handle it, that code keeps its meaning.
Affected surfaces#
- SDK Layer, Android — implemented.
HaapiConfiguration.allowInsecureHttpEndpoints; the codesforeign_haapi_target,haapi_redirect_refusedandinsecure_haapi_configuration, exposed asDriverErrorCodesconstants, andHaapiInsecureConfigurationRefusal, thecauseof the setup refusal. The redirect refusal is covered in Upgrade — Redirects Are Refused. - UI Layer, Android — handled for you, with
WidgetConfiguration.Builder.setAllowInsecureHttpEndpoints(…)as the passthrough. - SDK Layer, iOS — implemented. A new
HaapiConfigurationinitializer that takesallowInsecureHttpEndpoints; the codesforeign_haapi_target,haapi_redirect_refusedandinsecure_haapi_configuration, exposed asHaapiErrorCodecases, andHaapiInsecureConfigurationRefusal, thecauseof the setup refusal. The redirect refusal is covered in Upgrade — Redirects Are Refused. - UI Layer, iOS — handled for you, with
HaapiUIKitConfigurationBuilder.setAllowInsecureHttpEndpoints(value:)as the passthrough. - SDK Layer, React Native — follows the platform SDK underneath it.
Two behavioural changes to plan for#
Neither is source-breaking for Swift or Kotlin code — existing code compiles unchanged — but both change what a running app does, which is why this is a breaking change. On iOS the change is binary-compatible too: the existing HaapiConfiguration initializer is kept. On Android, HaapiConfiguration’s data-class primary constructor and copy() gained a parameter: code compiled against an earlier version that calls either must be rebuilt, and Java code that calls the primary constructor must pass the new argument.
1. An http identity server is refused at construction#
The check runs when the session is built, not per request, so a misconfiguration fails immediately and visibly instead of part-way through an authentication flow. It covers the base URL, the authorization endpoint, the token endpoint, the revocation endpoint when one is configured and — when DCR is configured — the client registration endpoint, and the refusal names which one is at fault.
If you run a local or test deployment over plain http, opt in:
let baseURL = URL(string: "http://localhost:8443")!
let haapiConfiguration = HaapiConfiguration(
name: "ios-client",
clientId: "haapi-ios-client",
baseURL: baseURL,
tokenEndpointURL: URL(string: "/oauth/v2/oauth-token", relativeTo: baseURL)!,
authorizationEndpointURL: URL(string: "/oauth/v2/oauth-authorize", relativeTo: baseURL)!,
appRedirect: "com.example.app://haapi",
httpHeadersProvider: nil,
authorizationParametersProvider: nil,
allowInsecureHttpEndpoints: true
)allowInsecureHttpEndpoints is accepted by a new HaapiConfiguration initializer. The existing initializer is unchanged and requires https.
Through the UI Layer:
HaapiUIKitConfigurationBuilder(...)
.setAllowInsecureHttpEndpoints(value: true)
.build()val haapiConfiguration = HaapiConfiguration(
keyStoreAlias = "haapi-android-key",
clientId = "haapi-android-client",
baseUri = URI.create("http://10.0.2.2:8443"),
tokenEndpointUri = URI.create("http://10.0.2.2:8443/oauth/v2/oauth-token"),
authorizationEndpointUri = URI.create("http://10.0.2.2:8443/oauth/v2/oauth-authorize"),
appRedirect = "com.example.app://haapi",
allowInsecureHttpEndpoints = true
)allowInsecureHttpEndpoints is a HaapiConfiguration property, defaulting to false.
Through the UI Layer, or pass a HaapiConfiguration created with it to the WidgetConfiguration constructor:
WidgetConfiguration.Builder(
clientId = "haapi-android-client",
baseUri = URI.create("http://10.0.2.2:8443"),
tokenEndpointUri = URI.create("http://10.0.2.2:8443/oauth/v2/oauth-token"),
authorizationEndpointUri = URI.create("http://10.0.2.2:8443/oauth/v2/oauth-authorize"),
appRedirect = "com.example.app://haapi"
)
.setAllowInsecureHttpEndpoints(true)
.build()Only a HaapiConfiguration can permit http. A HaapiConfigurable of your own always requires https, on both platforms.
Without the opt-in, creating a HaapiManager or an accessor fails with insecure_haapi_configuration, and nothing is sent. The error’s cause, a HaapiInsecureConfigurationRefusal, names which address, never its value, and its description is the same on both platforms:
HaapiManager(haapiConfiguration:) and HaapiAccessorBuilder (buildForHaapi(), buildForOAuth() and the deprecated async build()) throw a HaapiError whose error is insecure_haapi_configuration:
do {
let haapiAccessor = try await HaapiAccessorBuilder(haapiConfiguration: haapiConfiguration)
.buildForHaapi()
} catch let idsvrError as IdsvrError
where idsvrError.error == HaapiErrorCode.insecureHaapiConfiguration.rawValue
{
// A configured address does not use https; nothing was sent.
let refusal = idsvrError.cause as? HaapiInsecureConfigurationRefusal
// refusal?.address: .baseURL, .authorizationEndpoint, .tokenEndpoint, .revocationEndpoint or .clientRegistrationEndpoint
}Its recovery suggestion is .unrecoverable(action: .modifyConfiguration(reason:)), and the deprecated completion-handler HaapiAccessorBuilder.build delivers the same error.
HaapiManager(haapiConfiguration) and HaapiAccessorFactory (createForHaapi(), createForOAuth() and the deprecated create()) throw an IdsvrHaapiException.Unrecoverable whose error is insecure_haapi_configuration:
try {
val haapiAccessor = HaapiAccessorFactory(haapiConfiguration).createForHaapi()
} catch (e: IdsvrHaapiException) {
if (e.error == DriverErrorCodes.INSECURE_HAAPI_CONFIGURATION) {
// A configured address does not use https; nothing was sent.
val refusal = e.cause as? HaapiInsecureConfigurationRefusal
// refusal?.address: BASE_URL, AUTHORIZATION_ENDPOINT, TOKEN_ENDPOINT, REVOCATION_ENDPOINT or CLIENT_REGISTRATION_ENDPOINT
}
}Its action is UnrecoverableAction.ModifyConfiguration.
In the UI Layer, on both platforms, a flow start and every OAuthLifecycle token operation — refresh, revocation, DPoP retrieval — fail with insecure_haapi_configuration.
Loopback addresses (localhost, 127.0.0.1) get no exemption; they need the opt-in too. The opt-in only permits configuring an http address: every address is still checked, and any other scheme, or none, is refused. It does not by itself allow cleartext traffic — App Transport Security on iOS and the network security configuration on Android still decide that — and it never widens which targets a request may reach. The check does not cover a HaapiTokenManager used without the SDK, nor, on iOS, a directly constructed OAuthTokenManager; the platform’s cleartext policy remains the backstop there.
The flag defaults to false. Leave it that way in anything you ship — it exists for a development deployment, not as a way to soften the rule in production.
Set this only for a deployment you control on a network you trust. With it on, the address check no longer implies transport security, and a cleartext HAAPI leg exposes credentials, the access token and the DPoP proof to anyone on the path.
2. Transport redirects are no longer followed#
HAAPI expresses redirection in the representation, so an HTTP-level redirect on a HAAPI leg is not something a correct deployment produces. Neither platform follows one any more, on any request the SDK sends to the identity server — HAAPI requests, the attestation and attested token requests, OAuth token exchange, refresh and revocation, and dynamic client registration. A 301, 302, 303, 307 or 308 response with a non-empty Location fails with haapi_redirect_refused, whose cause is a HaapiRedirectRefusal carrying the status code, rather than being resolved to an address that no check has seen; any other 3xx is handled like any other response. See Upgrade — Redirects Are Refused for the affected requests, the error, the Android OkHttp client and the reverse-proxy configuration.
If you see this code, look at the server or the reverse proxy in front of it — something is answering an SDK request with a redirect instead of a direct response.
What stays the same#
The guard is deliberately narrow, so ordinary deployments see no change:
- An absolute reference on the same host — permitted.
- A relative reference — resolved against the base URL, as before.
- A differing port on an otherwise matching address — permitted, and noted in the log once per
HaapiManager. A reverse proxy can legitimately answer on another port. - An authorization endpoint on a separate host from the base URL — permitted for form submission and auto-redirection, because both are addresses you configured.
followLinkis held to the base URL alone: a link to the authorization endpoint’s host is refused withnon_haapi_link.
Matching is on scheme and host, and a target that cannot be matched is refused rather than allowed by omission. On both platforms the scheme must equal the configured address’s scheme, and the host is compared after percent-decoding, ignoring the case of ASCII letters only (no Unicode case mapping), through the same comparison that guards followLink. A host containing a non-ASCII character matches nothing, so configure an internationalized host in its ASCII (xn--) form. A target with no scheme, a scheme other than http or https, or no host — such as ///next, // or https: — is refused as a foreign target, with non_haapi_link for a link. Before iOS 26, Foundation resolves ///next and // onto the base URL’s host, so there they are sent to the configured server like any same-host reference.
- Android — matching uses the same normalization that produces a DPoP proof’s
htuclaim, so the check and a signed proof can never disagree about what a host is. - iOS — an ASCII host is matched under the same normalization that produces a DPoP proof’s
htuclaim. On iOS 17 and later, Foundation converts a raw Unicode host in anhrefto itsxn--form before the check, so anhrefwhose raw host converts to the configured ASCII host is that same host.
An href that is empty or malformed — for example, one stating a signed or non-numeric port — is refused before anything is sent, but not as a foreign target: on iOS with the error networking, on Android with invalid_input, both described as “Cannot create a valid URL”.
Handling the refusals#
All three arrive through the same error field as non_haapi_link, and none of their messages reproduces a host or an address, because the message may reach an end user. On both platforms the refused target’s host and the configured hosts go to the log instead, through the logger’s masking; a refused redirect is logged with its status code alone. See Error Handling for the full table.
| Refusal | Android error | iOS error |
|---|---|---|
| A HAAPI request whose target is neither the base URL nor the authorization endpoint. Nothing is transmitted, and no token or proof is obtained for that target. | foreign_haapi_target. followLink reports non_haapi_link instead, for any target not on the base URL’s scheme and host. | foreign_haapi_target. followLink reports non_haapi_link instead, for any target not on the base URL’s scheme and host. |
A request answered with a 301, 302, 303, 307 or 308 that carries a Location. | haapi_redirect_refused, on every SDK request, with a HaapiRedirectRefusal as its cause — see Upgrade — Redirects Are Refused. | haapi_redirect_refused, on every SDK request, with a HaapiRedirectRefusal as its cause — see Upgrade — Redirects Are Refused. |
A configured address that does not use https, while allowInsecureHttpEndpoints is false. | insecure_haapi_configuration, thrown as an IdsvrHaapiException.Unrecoverable with a HaapiInsecureConfigurationRefusal as its cause when creating a HaapiManager or an accessor — and, with the opt-in, for any scheme other than https and http. | insecure_haapi_configuration, thrown as a HaapiError with a HaapiInsecureConfigurationRefusal as its cause when creating a HaapiManager or an accessor — and, with the opt-in, for any scheme other than https and http. |