fix(auth): persist pending OAuth across process death (closes #15) #32

Merged
starsetbyte merged 3 commits from fix/oauth-pending-auth-process-death into main 2026-06-21 16:11:56 +00:00
Owner

Problem

The in-flight OAuth authorization — DPoP key pair, PKCE code verifier, CSRF state, and discovery info — was held only in memory: on AtProtoOAuthClient (a @Singleton) and duplicated as pendingState on OAuthLoginViewModel. Android routinely kills the app while the user is authorizing in the browser (a heavy foreground Custom Tab). On return, both were gone and completeLogin failed with No pending auth, forcing a full restart (issue #15).

Fix

New PendingAuthStore persists the pending request to EncryptedSharedPreferences (master key in the Android Keystore, mirroring OAuthSessionStore) before the browser opens, and completeLogin restores it when the in-memory copy is missing.

  • beginLogin saves (synchronous commit()) inside its IO block.
  • completeLogin(redirectUrl) drops the caller-supplied expectedState: it restores pending (memory → disk) and validates the redirect's state against pending.state. This also removes the ViewModel's separately-lost pendingState field entirely — the persisted state is now the single source of truth for CSRF validation.
  • On success the entry is cleared.
  • DPoP EC key persisted as Base64 PKCS8/X509, exactly like the existing session store.

Tests

  • PendingAuthStoreTest — encode/decode round-trip preserves all fields and the EC key pair (so the token-exchange DPoP proof still matches the dpop_jkt from PAR). Written test-first.
  • AtProtoOAuthClientTest — new process-death test: begin login, null out the in-memory pending, stub the store to return it, and assert completeLogin restores and succeeds (and clears the store).
  • Updated AtProtoOAuthClient / OAuthLoginViewModel / NetworkModule wiring tests for the new signatures.

Full unit suite green except the pre-existing, unrelated NotificationsRepositoryTest failure (fixed in PR #29).

Follow-up (out of scope)

Hardware-backed, non-exportable DPoP keys (generated in the Keystore) for both the pending and session stores — today both export the EC key into encrypted prefs. The issue mentions "private key behind Android Keystore"; this PR matches the existing session-key handling (encrypted-at-rest via the Keystore master key) rather than diverging the two key paths in one change.

🤖 Generated with Claude Code

https://claude.ai/code/session_0151pbAku7AeA9fgzWKVQLPE

## Problem The in-flight OAuth authorization — DPoP key pair, PKCE code verifier, CSRF `state`, and discovery info — was held **only in memory**: on `AtProtoOAuthClient` (a `@Singleton`) and duplicated as `pendingState` on `OAuthLoginViewModel`. Android routinely kills the app while the user is authorizing in the browser (a heavy foreground Custom Tab). On return, both were gone and `completeLogin` failed with `No pending auth`, forcing a full restart (issue #15). ## Fix New `PendingAuthStore` persists the pending request to `EncryptedSharedPreferences` (master key in the Android Keystore, mirroring `OAuthSessionStore`) **before the browser opens**, and `completeLogin` restores it when the in-memory copy is missing. - `beginLogin` saves (synchronous `commit()`) inside its IO block. - `completeLogin(redirectUrl)` **drops the caller-supplied `expectedState`**: it restores pending (memory → disk) and validates the redirect's `state` against `pending.state`. This also removes the ViewModel's separately-lost `pendingState` field entirely — the persisted state is now the single source of truth for CSRF validation. - On success the entry is cleared. - DPoP EC key persisted as Base64 PKCS8/X509, exactly like the existing session store. ## Tests - `PendingAuthStoreTest` — encode/decode round-trip preserves all fields **and** the EC key pair (so the token-exchange DPoP proof still matches the `dpop_jkt` from PAR). Written test-first. - `AtProtoOAuthClientTest` — new process-death test: begin login, null out the in-memory pending, stub the store to return it, and assert `completeLogin` restores and succeeds (and clears the store). - Updated `AtProtoOAuthClient` / `OAuthLoginViewModel` / `NetworkModule` wiring tests for the new signatures. Full unit suite green except the pre-existing, unrelated `NotificationsRepositoryTest` failure (fixed in PR #29). ## Follow-up (out of scope) Hardware-backed, non-exportable DPoP keys (generated in the Keystore) for both the pending and session stores — today both export the EC key into encrypted prefs. The issue mentions "private key behind Android Keystore"; this PR matches the existing session-key handling (encrypted-at-rest via the Keystore master key) rather than diverging the two key paths in one change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0151pbAku7AeA9fgzWKVQLPE
fix(auth): persist pending OAuth across process death — closes #15
All checks were successful
peregrine-ci / assembleDebug Build succeeded (28s)
5a720ce564
The in-flight OAuth authorization (DPoP key pair, PKCE code verifier, CSRF
state, and discovery info) was held only in memory — on AtProtoOAuthClient
(a @Singleton) and duplicated as `pendingState` on OAuthLoginViewModel.
Android frequently kills the app while the user is authorizing in the
browser; on return both were gone and completeLogin failed with "No pending
auth", forcing the user to restart the whole flow.

Add `PendingAuthStore`, which persists the pending request to
EncryptedSharedPreferences (master key in the Android Keystore, mirroring
OAuthSessionStore) before the browser opens, and restore it in completeLogin
when the in-memory copy is missing. The persisted CSRF state becomes the
source of truth for validation, so the ViewModel no longer needs to hold
`pendingState` at all. The entry is cleared once login succeeds.

- `beginLogin` saves (synchronous commit) inside its IO block.
- `completeLogin(redirectUrl)` drops the caller-supplied `expectedState`;
  it restores pending (memory → disk) and validates the redirect's state
  against `pending.state`.
- DPoP EC key persisted as Base64 PKCS8/X509, same as the session store.

Tests: PendingAuthStore encode/decode round-trip (incl. key restoration);
a process-death completeLogin test (in-memory pending nulled, restored from
a stubbed store); updated AtProtoOAuthClient/OAuthLoginViewModel/NetworkModule
tests for the new signatures.

Follow-up (out of scope): hardware-backed (non-exportable) DPoP keys for both
pending and session stores — today both export the EC key into encrypted prefs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151pbAku7AeA9fgzWKVQLPE
Merge remote-tracking branch 'origin/main' into fix/oauth-pending-auth-process-death
All checks were successful
peregrine-ci / assembleDebug Build succeeded (21s)
f84946940f
Merge branch 'main' into fix/oauth-pending-auth-process-death
All checks were successful
peregrine-ci / assembleDebug Build succeeded (11s)
4b2a0b8b0d
Sign in to join this conversation.
No description provided.