Deluan Quintão edffca24b1
fix(lastfm): require signed state token on link callback (#5521)
* fix(lastfm): require signed state token on link callback

The Last.fm OAuth callback at /api/lastfm/link/callback trusted a raw
\`uid\` query parameter and wrote the resulting Last.fm session key under
that user with no ownership check. Any authenticated user who learned a
victim's internal user ID (e.g. from playlist ownerId) could redirect the
victim's scrobbles to an attacker-controlled Last.fm account by calling
the callback directly with the victim's uid and a Last.fm token obtained
for their own account.

The callback cannot use the regular auth middleware because it is reached
via a browser redirect from Last.fm, which cannot carry a JWT header.
Instead, GET /api/lastfm/link (authenticated) now also returns a short-
lived (5 min) HMAC-signed link token bound to the requesting user, with a
dedicated "lastfm-link" scope claim. The callback verifies the signature,
scope and expiry before deriving the user ID from the token; the \`uid\`
query value is no longer trusted as a user identifier. The UI fetches
this token at link-flow start and passes it in place of the raw user ID.

Reuses the existing HS256 secret via auth.EncodeToken/DecodeAndVerifyToken
so no new key management is introduced.

* fix(ui): keep Last.fm popup tied to user gesture for Safari

Opening the Last.fm OAuth tab after an awaited fetch causes the popup to
be blocked on Safari and on Firefox with strict popup blocking enabled,
because the browser's transient-activation window has already elapsed by
the time window.open is reached. Linking became impossible on those
browsers in the previous commit.

Move the click handler up to the parent component and open a placeholder
about:blank tab synchronously from the click; the linkToken fetch then
runs in parallel and we redirect the existing tab to Last.fm's auth URL
once it resolves. The user gesture stays attached to the window.open
call, so popup blockers no longer fire.

The polling/progress UI is unchanged; it now receives the openedTab ref
from the parent instead of owning it.

* fix(lastfm): require exp claim on link tokens

jwtauth.VerifyToken treats a JWT without an exp claim as non-expiring, so
verifyLinkToken used to delegate expiry handling entirely. A future
regression in createLinkToken that dropped the exp field would silently
turn link tokens into permanent bearer credentials.

Assert presence of an exp claim explicitly and add a regression test
covering the missing-exp case. Also tightens the wrong-scope test to use
a freshly-minted token with all claims present except the scope, instead
of relying on auth.CreatePublicToken which happens to also be missing
exp.

* style(lastfm): simplify comments in link token code

Trim doc comments on createLinkToken/verifyLinkToken/callback/startLink
to the load-bearing lines: keep the non-obvious 'jwtauth treats missing
exp as non-expiring' note and the popup-blocker hint, drop the rest
since the function names already describe behavior.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(lastfm): address review feedback on link token PR

- Wrap openInNewTab in a try/catch in startLink: openInNewTab calls
  win.focus() unconditionally, so if the browser blocks the popup
  (window.open returns null) it throws a TypeError synchronously,
  before the catch() on the link-token fetch is attached. The throw
  used to escape the click handler, leaving the UI without a
  notification. Now the failure is surfaced as lastfmLinkFailure and
  the toggle stays usable.
- Rename the link-token "subject" rejection message to "user ID" since
  the claim is uid, not the JWT sub field.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-23 12:16:15 -03:00
..
2020-01-26 20:09:25 -05:00
2021-07-20 20:22:15 -04:00