I should be better at implementing auth in my apps by now.
Overview
The short of it is that auth has been a bit of a challenge recently, given OAuth, ATProto, and mobile app links each impose different constraints.
OAuth binds the authorization code to the chosen redirect URI.
ATProto adds decentralized PDS/auth-server discovery plus PAR, PKCE, DPoP, and public client metadata.
Mobile platforms then add messy return-to-app behavior. Custom schemes are reliable but not domain-verified, while Android App Links/iOS Universal Links are cleaner when verified but depend on signing, hosted association files, OS cache state, and browser behavior.
Lazurite has two auth paths
App password auth (gated behind debug mode in local development)
Resolve the user's PDS, call
createSession, store an app-password session.
OAuth auth
Resolve identity and auth authority, perform PAR, launch an external browser, receive an app link/deep link callback, exchange the authorization code, store an OAuth session.
The configured redirect URIs are
Primary verified link
https://lazurite.stormlightlabs.org/oauth/callback
Custom scheme
org.stormlightlabs.lazurite:/oauth/callback
Mobile builds prefer the HTTPS callback. The custom scheme remains in client metadata and in native link handling because it is still useful when the hosted HTTPS callback page has loaded in a browser and needs to hand the already-issued query parameters back to the app.
Flow
- 1.
Normalize and validate the input handle or DID.
- 2.
Resolve handle to DID when needed.
- 3.
Resolve DID document and extract the user's PDS.
- 4.
Fetch the PDS OAuth protected-resource metadata.
- 5.
Build OAuth authorization-server candidates from discovered auth server, PDS host, provider preference (BlueSky or BlackSky).
- 6.
Load Lazurite client metadata
- 7.
Select exactly one redirect URI for this OAuth attempt.
- 8.
Creates an
OAuthClientwith metadata narrowed to that one redirect URI. - 9.
POST PAR to the chosen authorization server.
- 10.
Launch the authorization URL in the external browser.
- 11.
Wait for one callback into the app.
- 12.
Exchange the authorization code using the same redirect URI selected before PAR.
- 13.
Save the OAuth session and mark the new account active.
The authorization-server fallback chain is only valid before browser launch. If PAR fails before the browser launches, trying another candidate is acceptable.
Once PAR succeeds and the user leaves the app, the OAuth attempt is bound to that authorization server, state, PKCE verifier, DPoP nonce, and redirect URI. Callback failure after that point must fail the attempt without starting another browser flow.
Firefox
Firefox is a difficult redirect environment because it is not just the Android OS App Links verifier and has its own "open links in apps" behavior and can choose to keep links inside the browser even when the app is installed. In that state, OAuth redirects can land in limbo.
If the redirect is a custom scheme, Firefox may block, prompt for, or mishandle the external app navigation.
If the redirect is HTTPS and Android App Links are verified, Firefox may still keep the URL in Firefox depending on browser setting and browser behavior.
If Firefox loads the hosted HTTPS callback page instead of opening Lazurite directly, the page must use JavaScript to bridge to the custom scheme.
If that bridge fires repeatedly, the browser can display repeated app-open prompts, blank/black transient pages, or get stuck on a non-useful callback page while the app is either already processing or no longer has pending OAuth state.
The "blank screen" is therefore is the browser/OS handoff failing or being repeated at the most fragile part of OAuth, after the authorization code has been issued but before the native app has completed token exchange.
Lazurite addresses this in the following ways
Preferring HTTPS App Links / Universal Links for builds, because verified HTTPS callbacks are the highest-integrity native redirect transport
Keeping the hosted HTTPS callback page simple and bounded.
Having the hosted callback page attempt one custom-scheme reopen per callback URL using
sessionStorage.Leaving a manual
Open Lazuritelink instead of using hidden iframes or multiple timedwindow.locationretries.Supporting the custom scheme in-app for fallback delivery.
Normalizing multiple callback shapes, including
org.stormlightlabs.lazurite://oauth/callback?...,/oauth/callback?..., and/callback?....Treating unsupported or late callbacks as ignored rather than starting a new OAuth attempt.
The target here is an entirely bounded failure such that there are no no repeated prompts, no repeated code redemption, no second OAuth launch, and enough UI/logging for a user to retry cleanly.
App-Links
Custom schemes are easy to register and useful as a fallback, but they are not domain verified. Any installed app can attempt to claim many custom schemes. They also leave more behavior up to the browser.
HTTPS App Links and Universal Links are better for OAuth because they bind the app to a domain.
Android verifies manifest declarations against assetlinks.json.
The asset links file must match the app package and signing certificate fingerprint.
iOS verifies the app entitlement
applinks:lazurite.stormlightlabs.orgagainst the hostedapple-app-site-associationfile.The OAuth
client_idand HTTPS redirect share the same origin, satisfying atproto native-client requirements for HTTPS redirects.
The cost is operational, including signing fingerprints, associated-domain files, CDN/device caching, browser settings, and the line up of install state.
For both platforms
The app must accept only known OAuth callback hosts/paths.
The app must reject unsupported callback URIs.
The app must ignore callbacks when no OAuth flow is active.
Callback handling must be idempotent at the app layer.
Login state must survive the browser trip long enough for callback exchange, but stale state must not be reused for a new attempt.
Browser And Hosted Callback Requirements
The hosted HTTPS callback page exists only as a fallback when the browser loads the HTTPS URL instead of the OS sending it directly to Lazurite.
AT Protocol OAuth spec: https://atproto.com/specs/oauth
Android App Links docs: https://developer.android.com/training/app-links/about
Android App Links verification docs: https://developer.android.com/training/app-links/verify-android-applinks
Apple Associated Domains docs: https://developer.apple.com/documentation/xcode/configuring-an-associated-domain
Apple Universal Links docs: https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app
Apple Universal Links debugging: https://developer.apple.com/documentation/technotes/tn3155-debugging-universal-links