Skip to content

feat(server): persist PSN OAuth tokens to disk so NPSSO only needs renewing after long downtime - #665

Merged
andrew-codes merged 8 commits into
FunkeyFlo:mainfrom
wizardmelon:upstream-psn-token-persistence
Jul 23, 2026
Merged

feat(server): persist PSN OAuth tokens to disk so NPSSO only needs renewing after long downtime#665
andrew-codes merged 8 commits into
FunkeyFlo:mainfrom
wizardmelon:upstream-psn-token-persistence

Conversation

@wizardmelon

@wizardmelon wizardmelon commented Jul 21, 2026

Copy link
Copy Markdown

Closes #669

Summary

  • Add psn-auth-store.ts: persists each PSN account's access/refresh token pair to a JSON file (atomic write, 0600 permissions), keyed by accountId (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 a PSN_AUTH_STORE_DIR env 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).
  • The periodic refresh path (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).
  • If both the persisted tokens and the NPSSO turn out to be expired, this is logged with a clear message pointing at regenerating the NPSSO, without crashing the add-on — device discovery doesn't depend on PSN accounts, and per-account failures were already isolated in app.ts.
  • Docs (docs/DOCS.md, docs/DOCKER.md) updated to explain the new behavior and the PSN_AUTH_STORE_DIR env 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 authInfo previously 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, mocked fs) and for the new bootstrap cascade (psn-account.test.ts, mocked psn-api + the store)
  • yarn typecheck — clean
  • Manually verified end-to-end on a real Home Assistant instance (aarch64/amd64 multi-arch build of this branch): first boot with a fresh NPSSO authenticates and writes /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 attempted

Test plan for reviewers

  • Configure a psn_accounts entry with a valid NPSSO, start the add-on, confirm normal authentication + a psn-auth.json (or configured PSN_AUTH_STORE_DIR) file appears
  • Restart the add-on without changing the NPSSO, confirm it authenticates via the persisted refresh token (no NPSSO exchange in the logs)
  • Multi-account config: confirm each account persists/loads independently
  • Change the configured NPSSO for an account and confirm the old persisted tokens for that account are ignored (falls back to the new NPSSO)

claude added 3 commits July 21, 2026 09:28
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.
@wizardmelon wizardmelon changed the title Persist PSN OAuth tokens to disk so NPSSO only needs renewing after long downtime feat(server): persist PSN OAuth tokens to disk so NPSSO only needs renewing after long downtime Jul 21, 2026
@github-actions github-actions Bot added the minor label Jul 21, 2026
@andrew-codes
andrew-codes self-requested a review July 21, 2026 18:07
@andrew-codes

Copy link
Copy Markdown
Collaborator

Hi @wizardmelon . Thanks for the contribution! I plan to evaluate the feature request and implementation soon; hopefully tomorrow.

@andrew-codes

andrew-codes commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

This is a minor change that would be nice to have.

Sync fs I/O on the saga hot path

psn-auth-store.ts's readStore/writeStore use the synchronous fs API (readFileSync, writeFileSync, renameSync, chmodSync, mkdirSync, plus accessSync in getStoreDir's writability check). These get invoked from getRefreshedAccountAuthInfo, which runs inside the checkPsnPresence saga's call() effect on every account whose token gets refreshed. Since everything else on this path is already async (the PSN HTTP calls), there's no reason for the disk I/O to block the event loop synchronously for the duration of the write, it'll stall MQTT and the rest of the server's event loop for that window. Suggest swapping to fs.promises (readFile/writeFile/rename/chmod/mkdir/access) throughout.

@andrew-codes

andrew-codes commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

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 psn-account.ts (which mutates authInfo as it moves through getAccount, getAccountFromStoredAuthInfo, and getRefreshedAccountAuthInfo), and psn-auth.json on disk, which is supposed to mirror that. They're kept in sync by three separate persistAuthInfo() call sites scattered across those functions, each responsible for remembering to call PsnAuthStore.save at the right moment with the right key. That's easy to get subtly out of sync, e.g. a future change to one of those three functions that forgets to persist, or persists with a stale key, and it means "is the file up to date" isn't answerable by looking in one place.

Proposed approach, in plain language

The codebase already has a precedent for this exact problem, elsewhere: device state changes get put() as a Redux action (UPDATE_HOME_ASSISTANT), and a dedicated saga (update-ha.ts) is the only thing that actually publishes to MQTT in reaction to that action. The business logic that computes the new device state never talks to MQTT directly.

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 UPDATE_PSN_ACCOUNT action, dispatched today in check-psn-presence.ts). The file becomes a mirror, written by exactly one saga that reacts to that action. Importantly, this doesn't change PsnAuthStore.save() itself at all; it's still the same function, same signature. What changes is who calls it: today it's called from three places inside psn-account.ts's control flow; after this change, it's called from exactly one saga, triggered by the action that already represents "this account's state changed."

Implementation steps

  1. Leave PsnAuthStore's read side alone, findByNpsso/findByAccountId/resolveKey/hashNpsso keep being used at bootstrap (getAccount, getAccountFromStoredAuthInfo) to decide whether persisted tokens are usable. No change needed there.
  2. In psn-account.ts, delete persistAuthInfo() and its three call sites (inside getAccount, getAccountFromStoredAuthInfo, and getRefreshedAccountAuthInfo). Those functions go back to purely computing/returning a PsnAccount/PsnAccountAuthenticationInfo , no disk writes anywhere in this file.
  3. Add a new saga, e.g. redux/sagas/persist-psn-account.ts:
    function* persistPsnAccount({ payload: account }: UpdatePsnAccountAction) {
      yield call(
        PsnAuthStore.save,
        PsnAuthStore.resolveKey(account.accountId, account.npsso),
        {
          npssoHash: PsnAuthStore.hashNpsso(account.npsso),
          accountId: account.accountId,
          accountName: account.accountName,
          authInfo: account.authInfo,
        },
      )
    }
  4. Wire it in saga.ts as yield takeEvery("UPDATE_PSN_ACCOUNT", sagas.persistPsnAccount); deliberately takeEvery, not takeLatest. The existing updateAccountSaga uses takeLatest on this same action type, which is fine for its own purpose (device matching), but if the persistence saga reused takeLatest, two accounts updating in quick succession could cancel the first account's in-flight persistence before it writes, silently dropping that write. takeEvery guarantees every account's update gets persisted regardless of timing.
  5. Export the new saga from redux/sagas/index.ts like the others.

Edge case: pre-accountId provisional persistence

The current PR persists tokens under hash(npsso) immediately after the initial token exchange but before the profile fetch resolves accountId, specifically so a crash in that narrow window doesn't strand a freshly-obtained NPSSO exchange. There's no Account object yet at that point, so nothing to put() under UPDATE_PSN_ACCOUNT.

To preserve that safety net without reopening a second write path, introduce a narrow, dedicated action for just this moment, e.g. PERSIST_PROVISIONAL_PSN_TOKENS, dispatched once from getAccount right after the token exchange succeeds (before the profile fetch). Have the same persistPsnAccount saga listen for both:

yield takeEvery(
  ["UPDATE_PSN_ACCOUNT", "PERSIST_PROVISIONAL_PSN_TOKENS"],
  sagas.persistPsnAccount,
)

This keeps PsnAuthStore.save called from exactly one saga, while still covering the bootstrap crash window the current design protects against.

claude and others added 5 commits July 23, 2026 12:14
…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.
@andrew-codes
andrew-codes merged commit 331e8ba into FunkeyFlo:main Jul 23, 2026
13 checks passed
wizardmelon pushed a commit to wizardmelon/ps5-mqtt that referenced this pull request Jul 23, 2026
…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.
wizardmelon pushed a commit to wizardmelon/ps5-mqtt that referenced this pull request Jul 23, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PSN account tracking silently stops working after add-on restart if NPSSO has expired

3 participants