Lazurite's Auth Implementation

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

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 Lazurite link instead of using hidden iframes or multiple timed window.location retries.

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.org against the hosted apple-app-site-association file.

The OAuth client_id and 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