Skip to content

fix(dashspend): fix DashSpend login & token-refresh auth#1492

Open
AshFrancis wants to merge 4 commits into
dashpay:masterfrom
AshFrancis:fix/dashspend-token-auth
Open

fix(dashspend): fix DashSpend login & token-refresh auth#1492
AshFrancis wants to merge 4 commits into
dashpay:masterfrom
AshFrancis:fix/dashspend-token-auth

Conversation

@AshFrancis

@AshFrancis AshFrancis commented Jun 12, 2026

Copy link
Copy Markdown

Issue being fixed or feature implemented

A set of related DashSpend (CTXSpend) authentication bugs that could prevent login or log users out unexpectedly.

1. First login failed even though the server returned 200 (most user-visible)

On a successful email verification — server returns 200 with accessToken/refreshToken, which the client parses and stores — the OTP screen decided navigation from viewModel.selectedProvider. That is non-persisted, nav-graph-scoped ViewModel state that resets to null when the ViewModel is recreated (process death or "Don't keep activities" — routine while the user leaves the app to read the emailed code). A null provider hit else -> error(...), which the generic catch painted over as "invalid code" even though login had actually succeeded. The user then retried the now-consumed code and got real 400s.

Verified against the spend-api source: verify-email returns 200 with tokens only on a correct code (and clears the code on the user's first-ever login via SetEmailVerified when LastLogin.IsZero()), which matches the observed login → verify(200) → verify → verify → login server log.

Fix:

  • Navigate using provider (the nav arg, which survives recreation), not viewModel.selectedProvider.
  • Restore viewModel.selectedProvider so downstream screens that read it (PurchaseGiftCardFragment/V2) don't crash next.
  • Drop the error() crash (when over the GiftCardProviderType sealed class is exhaustive).
  • Stop masking every failure as a silent no-op / generic "invalid code": log the real cause and show an error on the unsuccessful path too.

2. Token refresh could loop, log users out, or clobber tokens

  • The /refresh-token API was built with the standard client (it attached the TokenAuthenticator and the Authorization header), so a 401 on refresh recursed into another refresh — an infinite loop / wedged auth. It's now built with no authenticator and no Authorization header.
  • Tokens were wiped on any refresh failure (including transient ones). They're now cleared only on a genuine 401 rejection (confirmed: spend-api returns 401 ErrTokenInvalid for a bad refresh token); transient failures keep the session.
  • Rotated tokens are persisted under NonCancellable, so a refresh that completes is saved even if the caller is cancelled.
  • All refreshes funnel through one TokenAuthenticator.refreshAccessToken() behind a process-wide lock (correct: CTXSpendConfig is a singleton). Previously the OkHttp retry path and CTXSpendRepository.refreshToken() used separate per-instance locks, so a losing refresh could clear tokens another caller had just rotated.
  • checkToken() verifies the refresh token exists and refreshes when missing/expired instead of trusting the timestamp alone.
  • Centralized token state in CTXSpendConfig.saveTokenState() / clearTokenState().

3. verifyEmail NPE hardening

response?.accessToken!! / response.refreshToken!! could NPE on a 2xx with a null/partial body. Now returns false gracefully without persisting. (Raised by CodeRabbit; predated this PR.)

Related PR's and Dependencies

None.

Screenshots / Videos

N/A — networking/auth + navigation logic.

How Has This Been Tested?

  • New unit tests in CTXSpendRepositoryTokenTest (Robolectric): rotation persisted after caller cancels, tokens preserved on transient failure, tokens cleared on 401, no-network dedup path, and verifyEmail returning false on a token-less response.
  • ./gradlew :features:exploredash:testDebugUnitTest → all pass.
  • compileDebugKotlin + ktlint (main & test) clean.
  • Login-navigation fix verified by source analysis (root-caused against the spend-api handlers + the client ViewModel scoping); recommend a QA pass of first login with the app backgrounded during the OTP step.
  • QA (Mobile Team)

Checklist:

  • I have performed a self-review of my own code and added comments where necessary
  • I have added or updated relevant unit/integration/functional/e2e tests

Summary by CodeRabbit

  • Bug Fixes

    • More robust token refresh with mutual-exclusion, safer error handling, and proper clearing/preserving of stored auth state.
    • Token issuance requests no longer include the authorization header.
    • Email verification flow fixes: preserves selected provider, consolidates error handling/navigation, and logs failed attempts.
  • Refactor

    • Reorganized token, header, and persistence handling for clearer separation and safer concurrent behavior.
  • Tests

    • Added tests covering refresh resiliency, cancellation, transient failures, rejected tokens, and email-verification token handling.

The DashSpend (CTXSpend) token refresh flow could log users out or hang
because of how the refresh endpoint and token state were handled.

- Build the /refresh-token API without an Authenticator or Authorization
  header, so a 401 on refresh no longer recurses into another refresh
  (infinite refresh loop / wedged auth).
- Only drop the session when the refresh token is genuinely rejected
  (HTTP 401); transient failures (offline, 5xx) now keep the tokens so the
  user stays signed in.
- Persist rotated tokens under NonCancellable, so a refresh that wins the
  network race is saved even if the caller's coroutine is cancelled.
- Serialize all refreshes through a single process-wide lock and one
  TokenAuthenticator.refreshAccessToken() entry point shared by the OkHttp
  retry path and CTXSpendRepository.refreshToken(). Previously these used
  separate per-instance locks, so a losing refresh could clear tokens that
  a parallel refresh had just rotated.
- checkToken() now verifies the refresh token exists and refreshes when the
  stored token is missing/expired instead of trusting the timestamp alone.
- Centralize token persistence in CTXSpendConfig.saveTokenState() /
  clearTokenState().

Adds unit tests covering rotation-after-cancel, transient-failure
preservation, 401 rejection clearing, and the no-network dedup path.
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ae4c9ff0-cb84-4748-b106-98bd509798fc

📥 Commits

Reviewing files that changed from the base of the PR and between 533b13d and f574e77.

📒 Files selected for processing (1)
  • features/exploredash/src/main/java/org/dash/wallet/features/exploredash/ui/dashspend/DashSpendUserAuthFragment.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • features/exploredash/src/main/java/org/dash/wallet/features/exploredash/ui/dashspend/DashSpendUserAuthFragment.kt

📝 Walkthrough

Walkthrough

Refactors CTXSpend token handling: token-only Retrofit clients omit Authorization, headers injection is optional, refresh is synchronized by a process-wide mutex, token state persistence centralized, repository wiring updated, UI verifyEmail simplified, and tests cover rotation, failure, and 401 paths.

Changes

Token Refresh Architecture

Layer / File(s) Summary
Conditional authorization interception
features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/interceptor/HeadersInterceptor.kt
HeadersInterceptor constructor gains an includeAuthorization flag; token loading and Authorization header injection are conditional.
Token API isolation
features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/RemoteDataSource.kt, features/exploredash/src/main/java/org/dash/wallet/features/exploredash/di/ExploreDashModule.kt, features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/DashSpendRepositoryFactory.kt
RemoteDataSource adds buildTokenApi() and constructs its Retrofit client with getOkHttpClient(includeAuthorization = false); module and factory now use the dedicated token API builder.
Centralized token state persistence
features/exploredash/src/main/java/org/dash/wallet/features/exploredash/utils/CTXSpendConfig.kt
Adds suspend fun saveTokenState(...) and suspend fun clearTokenState() to persist/clear access and refresh tokens and timestamps under NonCancellable.
Token refresh synchronization and logic
features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/authenticator/TokenAuthenticator.kt
Introduces a companion-object Mutex, suspend fun refreshAccessToken(staleAccessToken: String? = null): String?, updates authenticate() to refresh and rewrite requests, and adds request/response/exception helpers.
Repository integration with token lifecycle
features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/CTXSpendRepository.kt
verifyEmail() uses saveTokenState(), reset() calls clearTokenState(), checkToken() inspects refresh token presence/expiration, refreshToken() delegates to TokenAuthenticator.refreshAccessToken().
Token refresh test suite
features/exploredash/src/test/java/org/dash/wallet/features/exploredash/CTXSpendRepositoryTokenTest.kt
Adds tests for rotation persistence under cancellation, transient failure preservation, 401 rejection clearing, concurrent-refresh short-circuiting, and verifyEmail() behavior when tokens are omitted; includes test doubles and helpers.
VerifyEmail UI flow
features/exploredash/src/main/java/org/dash/wallet/features/exploredash/ui/dashspend/DashSpendUserAuthFragment.kt
verifyEmail() sets viewModel.selectedProvider from the argument, navigates using the provider argument, centralizes error handling via showVerifyError().

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant TokenAuthenticator
  participant Mutex
  participant CTXSpendTokenApi
  participant CTXSpendConfig

  Caller->>TokenAuthenticator: refreshAccessToken(staleAccessToken)
  TokenAuthenticator->>Mutex: acquire()
  TokenAuthenticator->>CTXSpendConfig: get stored access/refresh token
  alt token already rotated
    CTXSpendConfig-->>TokenAuthenticator: new access token
    TokenAuthenticator->>Caller: return new token
  else token stale -> proceed to network
    TokenAuthenticator->>CTXSpendTokenApi: refreshToken(refreshToken)
    CTXSpendTokenApi-->>TokenAuthenticator: RefreshTokenResponse
    TokenAuthenticator->>CTXSpendConfig: saveTokenState(newAccess, newRefresh)
    TokenAuthenticator->>Caller: return new access token
  else 401 rejection
    TokenAuthenticator->>CTXSpendConfig: clearTokenState()
    TokenAuthenticator->>Caller: return null
  end
  TokenAuthenticator->>Mutex: release()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • abaranouski

Poem

🐰 Tokens hum where carrots gleam,

A mutex guards the single stream,
One save, one clear, no double chase,
Tests hop in to prove the case,
Rabbit cheers: refreshes now in place. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(dashspend): fix DashSpend login & token-refresh auth' accurately summarizes the main changes: fixes to DashSpend login navigation and token refresh authentication issues across multiple files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/CTXSpendRepository.kt`:
- Around line 122-127: In verifyEmail (CTXSpendRepository) avoid using the
non-null assertions on response and its tokens: check that email (from
CTXSpendConfig.PREFS_KEY_CTX_PAY_EMAIL) is non-null, call api.verifyEmail and
verify the returned RefreshTokenResponse and its accessToken/refreshToken are
non-null before calling config.saveTokenState; if any are missing, handle
gracefully (return false or surface a clear error) instead of using !!. Use safe
calls and explicit null checks around api.verifyEmail, response.accessToken and
response.refreshToken so saveTokenState is only invoked with valid tokens and
the method returns a safe Boolean result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 125d763c-0aac-4979-9317-454ab5aedf1c

📥 Commits

Reviewing files that changed from the base of the PR and between ccfd04a and f15244c.

📒 Files selected for processing (8)
  • features/exploredash/src/main/java/org/dash/wallet/features/exploredash/di/ExploreDashModule.kt
  • features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/RemoteDataSource.kt
  • features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/authenticator/TokenAuthenticator.kt
  • features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/interceptor/HeadersInterceptor.kt
  • features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/CTXSpendRepository.kt
  • features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/DashSpendRepositoryFactory.kt
  • features/exploredash/src/main/java/org/dash/wallet/features/exploredash/utils/CTXSpendConfig.kt
  • features/exploredash/src/test/java/org/dash/wallet/features/exploredash/CTXSpendRepositoryTokenTest.kt

verifyEmail used `response?.accessToken!!` / `response.refreshToken!!`, which
crashes if the verify endpoint returns a 2xx with a null/partial body. Replace
the non-null assertions with explicit null/blank checks that return false and
persist nothing, so a malformed response fails gracefully instead of throwing on
the auth-critical path.

Addresses CodeRabbit review on dashpay#1492. Adds a unit test covering the token-less
response case.
After a successful email verification (server returns 200 with tokens, which
are parsed and stored), the OTP screen decided navigation from
viewModel.selectedProvider. That is non-persisted, nav-graph-scoped ViewModel
state which resets to null when the ViewModel is recreated (process death or
"Don't keep activities" - routine while the user leaves the app to read the
emailed code). A null provider hit `else -> error(...)`, which the catch block
painted over as "invalid code" even though login had actually succeeded; the
user then retried the now-consumed code and got real 400s.

- Navigate using `provider` (the nav arg, which survives recreation), not
  viewModel.selectedProvider.
- Restore viewModel.selectedProvider so downstream screens that read it
  (PurchaseGiftCardFragment/V2) don't crash next.
- Drop the error() crash; the `when` over the GiftCardProviderType sealed class
  is exhaustive.
- Stop masking every failure as a silent no-op / generic "invalid code": log the
  real cause and show an error on the unsuccessful path too.
@AshFrancis AshFrancis changed the title fix(dashspend): fix DashSpend token refresh auth fix(dashspend): fix DashSpend login & token-refresh auth Jun 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@features/exploredash/src/main/java/org/dash/wallet/features/exploredash/ui/dashspend/DashSpendUserAuthFragment.kt`:
- Around line 257-267: The verify flow is collapsing server/transport exceptions
into the "invalid code" UX; change verifyEmail to return a richer result (e.g.,
sealed class or enum like VerifyResult { Success, InvalidCode,
ServerError(message) }) across CTXSpendRepository.verifyEmail and
DashSpendViewModel.verifyEmail, update the fragment's handling in
DashSpendUserAuthFragment to switch on that result and only call
showVerifyError(R.string.invaild_code) for InvalidCode while showing/logging
ServerError with the exception-derived message (or a localized server-failure
string) and preserve authUser()'s existing behavior for thrown exceptions; if
you cannot change signatures immediately, at minimum modify the catch block in
DashSpendUserAuthFragment to call showVerifyError(e.message ?:
getString(R.string.verification_failed)) and log the full exception instead of
mapping it to R.string.invaild_code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 87377259-c7ac-4c45-82ab-879f9b03d5dd

📥 Commits

Reviewing files that changed from the base of the PR and between 985570e and 533b13d.

📒 Files selected for processing (1)
  • features/exploredash/src/main/java/org/dash/wallet/features/exploredash/ui/dashspend/DashSpendUserAuthFragment.kt

verifyEmail() routed every failure to R.string.invaild_code, so a 5xx, network
drop, or backend anomaly was shown to the user as a wrong code. Distinguish a
genuine code rejection (CTXSpendException with HTTP 400) from other failures:
only the former shows "invalid code"; everything else shows a generic
"An error occurred, try again" (R.string.loading_error) and is logged. The
non-exception false path (200 with no tokens) is likewise a server anomaly, not
a wrong code, so it uses the generic message too.

Addresses CodeRabbit review on dashpay#1492.
@HashEngineering

Copy link
Copy Markdown
Collaborator

Thank you @AshFrancis, we will verify this with QA testing in the coming week.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants