Architecture Decision Record
ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback
Status
Accepted (2026-07-15). Revised 2026-07-28 (the Android https App Links leg is recorded as shipped,
the outstanding Android item is restated as the served certificate fingerprint, and the client-flow
attribution is corrected; see Revision below). Revised 2026-08-01 (the served Android fingerprint is
no longer a placeholder, which closes the last outstanding ADC item, and one Program.cs anchor is
corrected). Revised 2026-08-07 (the two Program.cs anchors moved one line again, and the
hostname trade-off now names its three occurrences instead of calling them parameterized). Revised
2026-08-31 (anchors re-pinned across MMCA.Common and MMCA.ADC, the Android manifest permission count
is corrected from seven to nine, the hostname trade-off separates binary occurrences from runbook
mentions, and the shared exchange call now returns a Result; see Revision below). 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/Endpoints/AppAssociationEndpointExtensions.cs:35, with
AppAssociationOptions alongside), and the MAUI MauiExternalAuthBroker
(Source/Presentation/MMCA.Common.UI.Maui/Capabilities/Auth/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:181), the Identity service allow-lists the
atldevcon scheme (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/appsettings.json:56-58), 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 in place too, declared as a C# attribute on
MainActivity rather than in XML
(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Platforms/Android/MainActivity.cs:26-31, with the public web
host constant at :39 and the verified link reduced to path plus query and published to
IDeepLinkDispatcher at :78-79). The checked-in
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Platforms/Android/AndroidManifest.xml carries nine
uses-permission entries (:4-26) above the package-visibility queries block (:29-42);
activities and their intent filters are attributes in code, which .NET for Android merges into the
generated manifest at build time. The SERVED fingerprint has landed as well:
AppAssociation:AndroidCertFingerprints now carries the production Play App Signing SHA-256
fingerprint (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/appsettings.json:34-36, the single value at
:35) in place of the former
REPLACE_WITH_PLAY_APP_SIGNING_SHA256_FINGERPRINT placeholder, and the helper copies that array
verbatim into the document's sha256_cert_fingerprints
(Source/Presentation/MMCA.Common.API/Startup/Endpoints/AppAssociationEndpointExtensions.cs:63), so the
served assetlinks.json names a real certificate. No other setting of that key exists in the ADC
repo. 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. Before this decision,OAuthControllerBase.CompleteAsyncredirected only to the config-pinnedOAuth:UIBaseUrl(Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs), which a native app could not 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-completeand capturescodefrom the custom-scheme callback (Source/Presentation/MMCA.Common.UI.Maui/Capabilities/Auth/MauiExternalAuthBroker.cs:71-76), then hands the code to the shared/auth/oauth-completepage by navigating to it (:80). That page owns the rest, exactly as it does on web heads:Source/Presentation/MMCA.Common.UI/Pages/Auth/OAuthComplete.razor:65callsIAuthUIService.ExchangeOAuthCodeAsync, which returnsResult<AuthenticationResponse>so the page branches onresult.IsFailure(:66) rather than on an exception. The service'sExchangeOAuthCodeAsync(Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:68) POSTs the existing anonymousauth/oauth/exchangethrough the sharedAuthenticateAsynchelper (:76, the helper itself at:262), which stores the pair viaITokenStorageService(:290), so the single-use-code contract lives in exactly one place. 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. Three places in the ADC repo put the host string
inside the app binary:
PublicSite:BaseUrlin the MAUI head'sappsettings.json(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/appsettings.json:22, compiled in as anEmbeddedResourceperMMCA.ADC.UI.csproj:130), a raw literal in the iOS associated-domains array (Platforms/iOS/Entitlements.plist:11), and thePublicWebHostcompile-time constant that feeds the Android intent-filter attribute (Platforms/Android/MainActivity.cs:39). Only the first is read through configuration; the two native manifests take literals, because neither an entitlement nor an attribute argument can read config. A cutover is therefore a three-spot edit plus a rebuild, not a setting change (the comment atMainActivity.cs:37-38still describes it as touching two spots), plus the two verification commands inMMCA.ADC/Docs/MobileReleaseRunbook.md:48and:52, which repeat the host but ship nothing; 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.
Revision (2026-07-28)
Correction pass from an ADR audit. No decision or behavior changed; the Status section had the Android leg backwards and the Decision section attributed the token exchange to the wrong component.
- The Android https App Links intent filter is present, not outstanding. The Status section
looked for it in
Platforms/Android/AndroidManifest.xmland, not finding it there, called the leg unshipped. It is declared in code instead: an[IntentFilter]attribute onMainActivitywithAutoVerify = true,DataScheme = "https", andDataHostbound to the public web host constant (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Platforms/Android/MainActivity.cs:26-31, constant at:39), and a verified link arrives throughOnCreate/OnNewIntent, is reduced to path plus query, and is published toIDeepLinkDispatcher(:78-79, insidePublishDeepLinkat:65-80). The checked-in manifest carries more than package visibility as well: nineuses-permissionentries (:4-26) sit above thequeriesblock (:29-42). (This entry counted seven permissions when it was written; the count is now nine, see the 2026-08-31 entry.) - What is outstanding is the served fingerprint, not the platform registration.
AppAssociation:AndroidCertFingerprintsstill holds the literal"REPLACE_WITH_PLAY_APP_SIGNING_SHA256_FINGERPRINT"(the key now sits atMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/appsettings.json:34), and the mapper serializes that array straight intosha256_cert_fingerprints(Source/Presentation/MMCA.Common.API/Startup/Endpoints/AppAssociationEndpointExtensions.cs:63). No other setting of that key exists in the ADC repo; the only other mention is the rotation procedure inMMCA.ADC/Docs/MobileReleaseRunbook.md. Until the real Play App Signing fingerprint is supplied, the served document names a certificate no build carries and Android cannot auto-verify the filter that is already shipped. That item was closed hours later on the same day, so read it as the state at the time of writing; the 2026-08-01 entry below records the replacement. - The completion page performs the exchange, not the broker. The Decision's client-flow bullet
read as if the MAUI broker POSTed the exchange and stored the tokens. The broker captures the
code from the
WebAuthenticatorresult (Source/Presentation/MMCA.Common.UI.Maui/Capabilities/Auth/MauiExternalAuthBroker.cs:71-76) and then navigates to/auth/oauth-complete?code=...(:80);OAuthComplete.razor:65callsIAuthUIService.ExchangeOAuthCodeAsync, declared at (Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:68, the POST at:76) and callsITokenStorageService.SetTokensAsync(:290). The net effect is what the ADR described; the division of labor is not, and it matters because the native path reuses the web completion page rather than duplicating it. - Anchor and tense maintenance.
MapAppAssociationEndpointsis called atMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:178(the previously cited:162pointed at theAndroidCertFingerprintsline inside the options initializer, which now sits at:185; see the 2026-08-01, 2026-08-07, and 2026-08-31 entries). The Context statement aboutCompleteAsyncredirecting only toOAuth:UIBaseUrlis now past tense, since the decision shipped:BuildSuccessRedirectUrltargets the allow-listed native URL whenever one is in play (Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:137-140, called from the success path at:134).
Revision (2026-08-01)
Status pass from an ADR audit. No decision and no behavior changed; the one item the previous revision left open is closed, and the anchor that revision itself introduced had already moved.
- The served fingerprint is no longer a placeholder.
AppAssociation:AndroidCertFingerprintsnow holds the production Play App Signing SHA-256 fingerprint (nowMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/appsettings.json:34-36), set by MMCA.ADC commitd5fd0e9(PR #80, merged 2026-07-28), which landed after the previous revision was written on the same day. The mapper still serializes that array straight intosha256_cert_fingerprints(Source/Presentation/MMCA.Common.API/Startup/Endpoints/AppAssociationEndpointExtensions.cs:63), so the servedassetlinks.jsonnow names a real certificate and nothing in the ADC deep-link wave is outstanding. One companion document lags the code: the rotation procedure inMMCA.ADC/Docs/MobileReleaseRunbook.md:32still describes the checked-in value as a placeholder. Program.csanchor correction. The same commit inserted a four-line comment above theAndroidPackageNameassignment (explaining that the Release Android head overridesApplicationId, so that is the package Digital Asset Links must name), which pushed the options initializer down.AndroidCertFingerprintsis atMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:185, not:162, and theMapAppAssociationEndpointscall site is at:178. (Both anchors sat at:167and:160when this entry was written; later commits shifted them twice, see the 2026-08-07 and 2026-08-31 entries.)
Revision (2026-08-07)
Anchor and precision pass from an ADR audit. No decision and no behavior changed.
- The two
Program.csanchors moved one line. MMCA.ADC commit886fa189(PR #100, merged 2026-08-03, DataProtection plus client idempotency keys) inserted a line above the association block.app.MapAppAssociationEndpoints(new AppAssociationOptionsmoved toMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:169(:168being the precedingGetSection("AppAssociation")line), withAndroidCertFingerprintsat:176,AndroidPackageNameat:175and the four-line comment from the 2026-08-01 entry at:171-174. Every citation of those two anchors is updated above, the ones inside the earlier revisions included. (Those are the values as of this entry; a further commit shifted the block again, see the 2026-08-31 entry.) - The hostname trade-off names its three occurrences. The bullet said every occurrence "stays
parameterized", which reads as if the host were configurable everywhere. It is not: the string
reaches the binary from three places and only one of them is read through configuration
(
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/appsettings.json:22). The other two are compile-time literals the platforms require:Platforms/iOS/Entitlements.plist:11and thePublicWebHostconstant atPlatforms/Android/MainActivity.cs:39. All three ship inside the binary, so the resubmission cost the bullet warns about is unchanged; what changed is the description of the edit. The in-code comment atMainActivity.cs:37-38still calls the cutover a two-spot change, which is a documentation lag in MMCA.ADC, not a behavior difference. (This entry said "occurs three times in the ADC repo"; the repo has two further mentions inMMCA.ADC/Docs/MobileReleaseRunbook.md, see the 2026-08-31 entry.)
Revision (2026-08-31)
Anchor and count pass from an ADR audit. No decision and no behavior changed.
- The ADC
Program.csassociation block moved again. MMCA.ADC commit6323a7b9(PR #155) shifted it nine lines:app.MapAppAssociationEndpoints(new AppAssociationOptionsis atMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:181, the precedingGetSection("AppAssociation")line at:180,AndroidPackageNameat:187,AndroidCertFingerprintsat:188, and the Appleapplinkscomponents at:190; the four-line package-id comment from the 2026-08-01 entry is at:183-186, above which:174-179is now an ADR-043 block comment. Every citation of those anchors is updated above. - The MMCA.Common exchange call returns a
Result.IAuthUIService.ExchangeOAuthCodeAsyncreturnsResult<AuthenticationResponse>(Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:68), so the shared completion page branches onresult.IsFailure(Source/Presentation/MMCA.Common.UI/Pages/Auth/OAuthComplete.razor:65-66) instead of relying on the surroundingtry/catchalone. Inside the service the POST toauth/oauth/exchangegoes through the sharedAuthenticateAsynchelper (:76, the helper itself at:262), which is also whereITokenStorageService.SetTokensAsyncis called (:290). The division of labor the 2026-07-28 entry corrected is unchanged: the page still owns the exchange, the broker still only hands over the code. - The Android manifest carries nine permissions, not seven. The 2026-07-28 count is stale:
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Platforms/Android/AndroidManifest.xml:4-26declaresACCESS_NETWORK_STATE,INTERNET,POST_NOTIFICATIONS,VIBRATE,ACCESS_COARSE_LOCATION,ACCESS_FINE_LOCATION,USE_BIOMETRIC,RECORD_AUDIO, andCAMERA, above the package-visibilityqueriesblock at:29-42. The extra permissions belong to the ADR-042 and ADR-045 device-capability waves and touch nothing in this decision; only the count was wrong. - The hostname trade-off separates binary occurrences from documentation. The bullet said the
host string occurs "in exactly three places in the ADC repo, and all three ship inside the app
binary". Three places put it in the binary (
MMCA.ADC.UI/appsettings.json:22,Platforms/iOS/Entitlements.plist:11,Platforms/Android/MainActivity.cs:39), which is the claim that carries the resubmission cost, but the repo holds two further mentions that ship nothing: thecurland Digital Asset Links verification commands inMMCA.ADC/Docs/MobileReleaseRunbook.md:48and:52. A cutover has to touch those too, or the runbook verifies the old host. - Remaining ADC anchor drift. The MAUI head's
appsettings.jsonis compiled in as anEmbeddedResourceatMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MMCA.ADC.UI.csproj:130(the onlyEmbeddedResourcein that file), the Identity service's OAuth allowlist section is atMMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/appsettings.json:56-58with theatldevconentry at:57, the served fingerprint key is atMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/appsettings.json:34-36, the Android intent filter is atPlatforms/Android/MainActivity.cs:26-31withPublishDeepLinkat:65-80, andBuildSuccessRedirectUrlis defined atSource/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:137-140and called at:134. The rotation procedure inMMCA.ADC/Docs/MobileReleaseRunbook.md:32still calls the checked-in fingerprint a placeholder, so that documentation lag from the 2026-08-01 entry is still open.