fix(stores): drop expired Epic and Amazon tokens when a refresh fails (#804)

A failed token refresh returned the stored access token even after it had expired, so the next request failed with a generic error instead of asking to sign in again. The stored token is now returned only while it is still valid.

The Epic docs now say that achievements are sent with Epic's launcher user agent, which the achievements service requires.
This commit is contained in:
The412Banner authored and GitHub committed 2026-10-14 07:43:02 -04:00
1 parent 4b17bc9a23
commit d99d728ba5
4 files changed
+17 -9

No files matched your search

@@ -70,6 +70,9 @@ object AmazonCredentialStore {
private fun fresh(a: Account): Boolean = private fun fresh(a: Account): Boolean =
a.expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS || a.refreshToken.isEmpty() a.expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS || a.refreshToken.isEmpty()
private fun unexpired(a: Account): String? =
a.accessToken.takeIf { a.expiresAt <= 0 || a.expiresAt > System.currentTimeMillis() }
private fun refreshLocked(context: Context, a: Account): String? { private fun refreshLocked(context: Context, a: Account): String? {
if (a.refreshToken.isEmpty()) return null if (a.refreshToken.isEmpty()) return null
val answer = AmazonNative.call("auth.refresh", JSONObject().put("refreshToken", a.refreshToken)) val answer = AmazonNative.call("auth.refresh", JSONObject().put("refreshToken", a.refreshToken))
@@ -90,7 +93,7 @@ object AmazonCredentialStore {
synchronized(refreshLock) { synchronized(refreshLock) {
val again = load(context) ?: return null val again = load(context) ?: return null
if (fresh(again)) return again.accessToken if (fresh(again)) return again.accessToken
return refreshLocked(context, again) ?: again.accessToken return refreshLocked(context, again) ?: unexpired(again)
} }
} }
@@ -85,6 +85,9 @@ object EpicCredentialStore {
private fun fresh(c: Credentials): Boolean = private fun fresh(c: Credentials): Boolean =
c.expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS || c.refreshToken.isNullOrEmpty() c.expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS || c.refreshToken.isNullOrEmpty()
private fun unexpired(c: Credentials): String? =
c.accessToken.takeIf { c.expiresAt <= 0 || c.expiresAt > System.currentTimeMillis() }
@JvmStatic @JvmStatic
fun getValidAccessToken(context: Context): String? { fun getValidAccessToken(context: Context): String? {
val current = load(context) ?: return null val current = load(context) ?: return null
@@ -96,7 +99,7 @@ object EpicCredentialStore {
val result = answer?.let(TokenResult::from) val result = answer?.let(TokenResult::from)
if (result == null) { if (result == null) {
Log.w(TAG, "token refresh failed: ${answer?.optString("error").orEmpty().ifEmpty { "no answer" }}") Log.w(TAG, "token refresh failed: ${answer?.optString("error").orEmpty().ifEmpty { "no answer" }}")
return again.accessToken return unexpired(again)
} }
save(context, result) save(context, result)
return result.accessToken return result.accessToken
+4 -3
View File
@@ -118,9 +118,10 @@ On-disk protocol (what a resumed run, by either engine, relies on): a file is as
Everything Epic runs in the Rust `epic` module: the web-service calls (sign-in, refresh, exchange Everything Epic runs in the Rust `epic` module: the web-service calls (sign-in, refresh, exchange
codes, library, catalog, build information, shop shelves, achievements, cloud saves), manifest codes, library, catalog, build information, shop shelves, achievements, cloud saves), manifest
parsing and the install engine. Tokens are passed in per call and never stored or logged by the parsing and the install engine. Tokens are passed in per call and never stored or logged by the
library; the Kotlin facade keeps them in the shared Keystore-sealed account store. All requests send library; the Kotlin facade keeps them in the shared Keystore-sealed account store. Requests send
one User-Agent, `DroidDeck/<versionName>` (`epic/net.rs::USER_AGENT_PRODUCT`; the version comes from `DroidDeck/<versionName>` (`epic/net.rs::USER_AGENT_PRODUCT`; the version comes from
`nativeConfigure`). `nativeConfigure`), except achievements, which Epic only answers for its launcher agent
(`epic/net.rs::EPIC_LAUNCHER_USER_AGENT`).
| Method | JNI signature | Symbol suffix | | Method | JNI signature | Symbol suffix |
|---|---|---| |---|---|---|
+5 -4
View File
@@ -66,9 +66,10 @@ Numbers in brackets refer to the spec's list of unknowns.
**Chosen where the spec left it open** **Chosen where the spec left it open**
3. **User-Agent** [1]: every HTTP request (account, library, catalog, build info, CDNs, store 3. **User-Agent** [1]: every HTTP request (account, library, catalog, build info, CDNs, store
GraphQL, promotions, achievements, cloud saves) sends `DroidDeck/<versionName>`, defined once in GraphQL, promotions, cloud saves) sends `DroidDeck/<versionName>`, defined once in
`epic/net.rs` (`USER_AGENT_PRODUCT`). Whether every endpoint accepts it is untested; changing that `epic/net.rs` (`USER_AGENT_PRODUCT`); changing that constant is the one place to adjust.
constant is the one place to adjust. The sign-in **WebView** keeps the launcher-style UA it used Achievements are the exception: Epic's achievements service rejects other agents, so
`epic/achievements.rs` sends Epic's launcher agent (`net.rs::EPIC_LAUNCHER_USER_AGENT`). The sign-in **WebView** keeps the launcher-style UA it used
before, because a sign-in page that rejects the UA would lock every user out; that string lives before, because a sign-in page that rejects the UA would lock every user out; that string lives
in `epic/account.rs` (`WEBVIEW_USER_AGENT`). in `epic/account.rs` (`WEBVIEW_USER_AGENT`).
4. **Shared fetch core** [14]: chunk downloads reuse the crate's store-agnostic `fetch_core` (also 4. **Shared fetch core** [14]: chunk downloads reuse the crate's store-agnostic `fetch_core` (also
@@ -138,6 +139,6 @@ Numbers in brackets refer to the spec's list of unknowns.
- Sign-in through the WebView, and that an **existing** saved account (sealed `credentials.json`) - Sign-in through the WebView, and that an **existing** saved account (sealed `credentials.json`)
stays signed in after the update. stays signed in after the update.
- Every endpoint with the DroidDeck User-Agent: library, catalog, build info, CDN downloads, store - Every endpoint with the DroidDeck User-Agent: library, catalog, build info, CDN downloads, store
GraphQL and promotions, achievements, cloud saves. GraphQL and promotions, cloud saves (achievements use the launcher agent, see above).
- Library sync and shelves; install, pause/resume, cancel, update and repair; a card install with - Library sync and shelves; install, pause/resume, cancel, update and repair; a card install with
the internal scratch cache; launch with an exchange code; cloud-save up/down. the internal scratch cache; launch with an exchange code; cloud-save up/down.