Architecture Decision Record
ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback
Status
Accepted (2026-07-15). The framework leg is fully implemented in MMCA.Common: the OAuth
custom-scheme returnUrl allowlist in CompleteAsync, the app-association endpoint helper
MapAppAssociationEndpoints
(Source/Presentation/MMCA.Common.API/Startup/AppAssociationEndpointExtensions.cs:35, with
AppAssociationOptions alongside), and the MAUI MauiExternalAuthBroker
(Source/Presentation/MMCA.Common.UI.Maui/Capabilities/MauiExternalAuthBroker.cs:19). The ADC
consumer's deep-link wave has shipped: MMCA.ADC.UI.Web serves the two well-known association
documents through the shared helper
(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:162), the Identity service allow-lists the
atldevcon scheme (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/appsettings.json:37), and
the native heads register the callback: iOS carries both the custom-scheme URL type
(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Platforms/iOS/Info.plist:16) and the associated-domains
entitlement (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Platforms/iOS/Entitlements.plist:11), while
Android registers the custom-scheme WebAuthenticatorCallbackActivity
(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Platforms/Android/WebAuthenticatorCallbackActivity.cs:14).
Android's AutoVerify https App Links intent filter is not yet in
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Platforms/Android/AndroidManifest.xml (which today carries
only package-visibility queries), so the Android https app-link leg is still outstanding. MMCA.Store
has not adopted the wave: no association endpoints, allowlist config, or platform callback
registrations exist there yet.
Context
Three mobile flows all need a URL to leave the web world and land inside the MAUI app:
- Shared links and QR codes. The share sheet and QR codes carry ordinary https web URLs. With
Android App Links (
assetlinks.json) and iOS Universal Links (apple-app-site-association), the OS opens those URLs in the installed app instead of the browser. - Notification taps and app actions, which are app-internal and covered by the
IDeepLinkDispatcherboundary (ADR-042). - External OAuth on a native head. Google and GitHub reject OAuth inside embedded WebViews, so
the MAUI head must run the provider flow in the system browser (
WebAuthenticator) and needs the API to redirect its completion BACK to the app. TodayOAuthControllerBase.CompleteAsyncredirects only to the config-pinnedOAuth:UIBaseUrl(Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs), which a native app cannot intercept.
The completion redirect design (ADR-036 lineage) has a hard security property worth preserving:
tokens never ride a redirect URL. CompleteAsync stashes the token pair server-side under a
single-use code and the UI exchanges it out-of-band via POST.
Decision
- Custom-scheme returnUrl allowlist in the framework.
CompleteAsyncconsultsOAuth:AllowedReturnUrlSchemes(a config array, default empty). When the challenge's stashedreturnUrlis an absolute URI whose scheme appears in the allowlist (for exampleatldevcon://oauth-complete), the completion redirect (and completion errors) target that URL instead ofOAuth:UIBaseUrl, carrying only the same single-use code. The redirect echoes the client'sUri.OriginalStringbecause URI normalization would append a trailing slash and native callback matching can be exact.http/httpsschemes never match even if listed, so web destinations always flow through the pinned base URL and the allowlist cannot become an open redirect. An empty allowlist reproduces the previous behavior byte for byte. - Client flow. The MAUI head calls
WebAuthenticatorwith{gateway}/auth/oauth/{provider}?returnUrl={scheme}://oauth-complete, capturescodefrom the callback, POSTs the existing anonymous/auth/oauth/exchange, and stores the pair viaITokenStorageService. This rides behind theIExternalAuthBrokercontract (ADR-042); the default broker is unavailable, which keeps the shared Login page on its anchor flow for web heads. - Association files are served by each app's UI.Web host, not the gateway: the shared web URLs
are UI-host URLs, and the gateway's
/.well-knownalready forwards to Identity for JWKS. Explicit anonymous endpoints returnassetlinks.json(with the PLAY APP SIGNING certificate fingerprint, not the local keystore's) andapple-app-site-association(team id + bundle id + the shared route paths). Platform side:AutoVerifyintent filters on Android, the associated-domains entitlement on iOS, plus theWebAuthenticatorCallbackActivity/CFBundleURLTypesscheme registrations. - Incoming URIs reuse the ADR-042 dispatcher. App-link and callback URIs are reduced to their
path and query and published to
IDeepLinkDispatcher; because all heads share one Blazor route table, no mapping layer exists.
Rationale
- Reusing the single-use-code exchange keeps the token-never-in-URL invariant identical across web and native; the only new surface is WHERE the code lands.
- A scheme allowlist in configuration keeps the framework generic (Store can register its own scheme) while defaulting closed.
- Serving association files from the UI host keeps them next to the URLs they describe and out of the gateway's routing table.
Trade-offs
- The app-facing hostname is baked into store binaries (intent filters, entitlements). The apps currently ride the Azure Container Apps default domain, which changes if the environment is ever recreated and would force store resubmissions. Every occurrence stays parameterized; a custom domain is the durable fix.
- A custom-scheme URI's host and path are attacker-choosable on a device with a hostile app registered for the same scheme (scheme hijack). Accepted: the redirect carries only a two-minute single-use code, the exchange is one-shot, and platform app-link verification does not exist for custom schemes anywhere.
- Completion failures that occur before authentication properties exist cannot know the native callback and still land on the web login page; the broker times out and the user retries.