feat(server): persist PSN OAuth tokens to disk so NPSSO only needs renewing after long downtime - #665
Conversation
Adds psn-auth-store.ts, a small module that persists each PSN account's access/refresh token pair to a JSON file (atomic write, 0600 permissions), keyed by accountId when known or a SHA-256 hash of the NPSSO otherwise. The store directory defaults to /data (survives Home Assistant add-on restarts) with a PSN_AUTH_STORE_DIR env var override and a homedir fallback for standalone Docker use.
exchangeNpssoForPsnAccount() now checks the auth store for tokens matching the configured NPSSO first and, if the refresh token is still valid, refreshes through it and re-fetches the profile instead of re-exchanging the NPSSO. Only when there's nothing usable persisted does it fall through to the original NPSSO flow. Since PSN rotates the refresh token on every use, this keeps the chain alive indefinitely across add-on restarts, so the NPSSO is only needed on first setup or after being off long enough for the refresh token itself to expire (~60 days). getRefreshedAccountAuthInfo (used by the periodic presence-check saga) now persists refreshed tokens back to the store as well, so token rotation during normal operation isn't lost on the next restart. Reads that don't actually refresh (access token still valid) skip the write. If both the persisted tokens and the NPSSO turn out to be expired, the error is logged with a clear pointer to regenerate the NPSSO instead of crashing the add-on — device discovery doesn't depend on PSN accounts, and the existing per-account try/catch in app.ts already keeps the other accounts and the rest of the add-on running.
Explains that the NPSSO is now only needed for the first login (or after the add-on has been off long enough for the persisted refresh token to expire), documents the PSN_AUTH_STORE_DIR env var and its defaults (/data on the HA add-on, ~/.config/ps5-mqtt standalone), and shows how to point it at an already-mounted volume for durable standalone Docker persistence. docs/DOCS.md is the source CI copies into both add-on DOCS.md files at release time; mirrored here so the tree stays in sync until the next release.
|
Hi @wizardmelon . Thanks for the contribution! I plan to evaluate the feature request and implementation soon; hopefully tomorrow. |
|
This is a minor change that would be nice to have. Sync fs I/O on the saga hot path
|
|
This request helps ensure this feature's design is in alignment with the rest of the application. The core problem is that the auth file introduces a second source of truth; between the file and the in-memory store. The tl;dr of the feedback: let's keep the store the source of the truth and use a side effect to persist the values to disk. On start up, seed the store with the values on disk. More details below: Two sources of truth for account identity/tokens Why Right now there are two independent places that decide "what are this account's current tokens": the in-process control flow in Proposed approach, in plain language The codebase already has a precedent for this exact problem, elsewhere: device state changes get Same shape here: Redux state becomes the single source of truth for "what tokens does this account currently have" (it already receives every update via the existing Implementation steps
Edge case: pre- The current PR persists tokens under To preserve that safety net without reopening a second write path, introduce a narrow, dedicated action for just this moment, e.g. yield takeEvery(
["UPDATE_PSN_ACCOUNT", "PERSIST_PROVISIONAL_PSN_TOKENS"],
sagas.persistPsnAccount,
)This keeps |
…g the event loop
readStore/writeStore (and the /data writability check) used the
synchronous fs API. Since getRefreshedAccountAuthInfo runs inside the
checkPsnPresence saga's call() effect on every account whose token gets
refreshed, and everything else on that path is already async, the sync
disk I/O had no reason to block the event loop for the duration of the
write — it'd stall MQTT and the rest of the server for that window.
Swap to fs.promises (readFile/writeFile/rename/chmod/mkdir/access)
throughout, replacing the existsSync check with catching ENOENT from
readFile. Update the store's unit tests accordingly — fs.promises is a
lazily-defined getter on the real "fs" module that jest.mock("fs")'s
automock doesn't populate, so the test now uses an explicit mock factory
that keeps the rest of the real module intact and only replaces the
promises API.
Addresses review feedback from andrew-codes on FunkeyFlo#665.
PsnAuthStore.save() was being called from three separate places in psn-account.ts's control flow (getAccount twice, getAccountFromStoredAuthInfo, getRefreshedAccountAuthInfo), each responsible for remembering to persist at the right moment with the right key — easy to get subtly out of sync, and "is the file up to date" wasn't answerable by looking in one place. Follows the existing precedent in this codebase (device state changes are put() as UPDATE_HOME_ASSISTANT, and update-ha.ts is the only thing that publishes to MQTT in reaction): Redux state becomes the single source of truth for an account's current tokens, and a new persist-psn-account saga is the only thing that calls PsnAuthStore.save(), reacting to the UPDATE_PSN_ACCOUNT action already dispatched by check-psn-presence.ts on every refresh. - psn-account.ts no longer touches disk: getAccount, getAccountFromStoredAuthInfo and getRefreshedAccountAuthInfo purely compute and return account/token state. The read side (findByNpsso, resolveKey, hashNpsso) is unchanged, still used at bootstrap to decide whether persisted tokens are usable. - New PERSIST_PROVISIONAL_PSN_TOKENS action covers the one case with no Account to put() yet: right after a fresh NPSSO exchange yields tokens but before the profile fetch resolves an accountId, so a crash in that narrow window doesn't strand the freshly obtained tokens. getAccount dispatches it via a Dispatch passed down from app.ts. - persist-psn-account saga listens for both actions via takeEvery (not takeLatest — two accounts updating in quick succession shouldn't let takeLatest cancel the first one's in-flight write). - UPDATE_PSN_ACCOUNT fires on every presence-check tick regardless of whether tokens actually changed (it also carries fresh activity data), so the saga reads the currently-persisted entry first and skips the write when nothing changed — otherwise this would regress to writing to disk every accountInterval tick instead of only on actual refreshes. - app.ts now creates the store/saga middleware before bootstrapping accounts (previously accounts were fetched first to seed preloadedState); bootstrapping dispatches UPDATE_PSN_ACCOUNT for each successfully-authenticated account instead of building a record to seed the store with, unifying bootstrap and periodic refresh through the same path. Addresses review feedback from andrew-codes on FunkeyFlo#665.
It is not specific to psn-account.
…g the event loop
readStore/writeStore (and the /data writability check) used the
synchronous fs API. Since getRefreshedAccountAuthInfo runs inside the
checkPsnPresence saga's call() effect on every account whose token gets
refreshed, and everything else on that path is already async, the sync
disk I/O had no reason to block the event loop for the duration of the
write — it'd stall MQTT and the rest of the server for that window.
Swap to fs.promises (readFile/writeFile/rename/chmod/mkdir/access)
throughout, replacing the existsSync check with catching ENOENT from
readFile. Update the store's unit tests accordingly — fs.promises is a
lazily-defined getter on the real "fs" module that jest.mock("fs")'s
automock doesn't populate, so the test now uses an explicit mock factory
that keeps the rest of the real module intact and only replaces the
promises API.
Addresses review feedback from andrew-codes on FunkeyFlo#665.
PsnAuthStore.save() was being called from three separate places in psn-account.ts's control flow (getAccount twice, getAccountFromStoredAuthInfo, getRefreshedAccountAuthInfo), each responsible for remembering to persist at the right moment with the right key — easy to get subtly out of sync, and "is the file up to date" wasn't answerable by looking in one place. Follows the existing precedent in this codebase (device state changes are put() as UPDATE_HOME_ASSISTANT, and update-ha.ts is the only thing that publishes to MQTT in reaction): Redux state becomes the single source of truth for an account's current tokens, and a new persist-psn-account saga is the only thing that calls PsnAuthStore.save(), reacting to the UPDATE_PSN_ACCOUNT action already dispatched by check-psn-presence.ts on every refresh. - psn-account.ts no longer touches disk: getAccount, getAccountFromStoredAuthInfo and getRefreshedAccountAuthInfo purely compute and return account/token state. The read side (findByNpsso, resolveKey, hashNpsso) is unchanged, still used at bootstrap to decide whether persisted tokens are usable. - New PERSIST_PROVISIONAL_PSN_TOKENS action covers the one case with no Account to put() yet: right after a fresh NPSSO exchange yields tokens but before the profile fetch resolves an accountId, so a crash in that narrow window doesn't strand the freshly obtained tokens. getAccount dispatches it via a Dispatch passed down from app.ts. - persist-psn-account saga listens for both actions via takeEvery (not takeLatest — two accounts updating in quick succession shouldn't let takeLatest cancel the first one's in-flight write). - UPDATE_PSN_ACCOUNT fires on every presence-check tick regardless of whether tokens actually changed (it also carries fresh activity data), so the saga reads the currently-persisted entry first and skips the write when nothing changed — otherwise this would regress to writing to disk every accountInterval tick instead of only on actual refreshes. - app.ts now creates the store/saga middleware before bootstrapping accounts (previously accounts were fetched first to seed preloadedState); bootstrapping dispatches UPDATE_PSN_ACCOUNT for each successfully-authenticated account instead of building a record to seed the store with, unifying bootstrap and periodic refresh through the same path. Addresses review feedback from andrew-codes on FunkeyFlo#665.
Closes #669
Summary
psn-auth-store.ts: persists each PSN account's access/refresh token pair to a JSON file (atomic write,0600permissions), keyed byaccountId(falling back to a SHA-256 hash of the NPSSO before the account ID is known). Store directory defaults to/data(survives HA add-on restarts/updates) with aPSN_AUTH_STORE_DIRenv var override and a homedir fallback for standalone Docker.exchangeNpssoForPsnAccount()now checks the store first: if a persisted refresh token isn't expired, it refreshes through it and re-fetches the profile instead of touching the NPSSO. Falls back to the original NPSSO flow only when nothing usable is persisted. Since PSN rotates the refresh token on every use, this keeps the chain alive indefinitely across add-on restarts — the NPSSO should now only be needed on first setup, or after the add-on has been off long enough for the persisted refresh token itself to expire (~60 days).getRefreshedAccountAuthInfo, used by the presence-check saga) now persists rotated tokens back to the store too, but only writes when a refresh actually happens (no extra disk I/O on the common "access token still valid" path).app.ts.docs/DOCS.md,docs/DOCKER.md) updated to explain the new behavior and thePSN_AUTH_STORE_DIRenv var.Why
NPSSO tokens expire after ~2 months, requiring a manual copy-paste from the browser to keep the PSN account activity tracking alive. The underlying PSN access/refresh token chain is renewable indefinitely (each refresh returns a new refresh token too), but
authInfopreviously only lived in the in-memory Redux store, so every add-on restart re-derived everything from the NPSSO — if it had expired, the account silently stopped working until someone noticed and regenerated it.Testing
yarn test/unit— all suites pass, including new unit tests for the store (psn-auth-store.test.ts, mockedfs) and for the new bootstrap cascade (psn-account.test.ts, mockedpsn-api+ the store)yarn typecheck— clean/data/psn-auth.json; a subsequent restart with the same (by-then-invalid-if-reused) NPSSO left untouched succeeds using only the persisted refresh token, with logs confirming no NPSSO exchange was attemptedTest plan for reviewers
psn_accountsentry with a valid NPSSO, start the add-on, confirm normal authentication + apsn-auth.json(or configuredPSN_AUTH_STORE_DIR) file appears