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 =
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? {
if (a.refreshToken.isEmpty()) return null
val answer = AmazonNative.call("auth.refresh", JSONObject().put("refreshToken", a.refreshToken))
@@ -90,7 +93,7 @@ object AmazonCredentialStore {
synchronized(refreshLock) {
val again = load(context) ?: return null
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 =
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
fun getValidAccessToken(context: Context): String? {
val current = load(context) ?: return null
@@ -96,7 +99,7 @@ object EpicCredentialStore {
val result = answer?.let(TokenResult::from)
if (result == null) {
Log.w(TAG, "token refresh failed: ${answer?.optString("error").orEmpty().ifEmpty { "no answer" }}")
return again.accessToken
return unexpired(again)
}
save(context, result)
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
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
library; the Kotlin facade keeps them in the shared Keystore-sealed account store. All requests send
one User-Agent, `DroidDeck/<versionName>` (`epic/net.rs::USER_AGENT_PRODUCT`; the version comes from
`nativeConfigure`).
library; the Kotlin facade keeps them in the shared Keystore-sealed account store. Requests send
`DroidDeck/<versionName>` (`epic/net.rs::USER_AGENT_PRODUCT`; the version comes from
`nativeConfigure`), except achievements, which Epic only answers for its launcher agent
(`epic/net.rs::EPIC_LAUNCHER_USER_AGENT`).
| 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**
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
`epic/net.rs` (`USER_AGENT_PRODUCT`). Whether every endpoint accepts it is untested; changing that
constant is the one place to adjust. The sign-in **WebView** keeps the launcher-style UA it used
GraphQL, promotions, cloud saves) sends `DroidDeck/<versionName>`, defined once in
`epic/net.rs` (`USER_AGENT_PRODUCT`); changing that constant is the one place to adjust.
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
in `epic/account.rs` (`WEBVIEW_USER_AGENT`).
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`)
stays signed in after the update.
- 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
the internal scratch cache; launch with an exchange code; cloud-save up/down.