Skip to content

feat(auth): token refresh retry on 401 responses#111

Open
trungvose wants to merge 10 commits into
mainfrom
feature/token-refresh-on-401
Open

feat(auth): token refresh retry on 401 responses#111
trungvose wants to merge 10 commits into
mainfrom
feature/token-refresh-on-401

Conversation

@trungvose
Copy link
Copy Markdown
Owner

Summary

  • Added tryRefreshToken() public method to AuthStore that refreshes the Spotify access token, persists new tokens to localStorage and store state, and returns the new access token
  • Rewrote UnauthorizedInterceptor to silently refresh expired tokens on 401 responses (up to 2 attempts) and replay the original failed request with the fresh token
  • Token expired modal now only appears after both refresh attempts fail — previously it showed immediately on any 401

Flow

Request → API → 401 response
  │
  ├─ Is a refresh already in progress?
  │    ├─ YES → wait for it to complete, get new token, replay request
  │    └─ NO  → attempt 1:
  │              │
  │              ├─ authStore.tryRefreshToken() succeeds
  │              │    → replay original request with new Bearer token
  │              │
  │              └─ fails → attempt 2:
  │                   ├─ succeeds → replay original request
  │                   └─ fails → uiStore.showUnauthorizedModal()
  │                        → return error

Test plan

  • 5 unit tests for AuthStore.tryRefreshToken() (success, localStorage persistence, state update, missing token error, API error propagation)
  • 8 unit tests for UnauthorizedInterceptor (passthrough, non-401 errors, skip URLs, refresh+replay, 2 failures then modal, second attempt success, concurrent 401 coalescing, concurrent failure propagation)
  • Build succeeds with no compile errors
  • Manual: clear access_token from localStorage while app is running → verify silent refresh
  • Manual: clear both access_token and refresh_token → verify modal appears

🤖 Generated with Claude Code

trungvose and others added 10 commits April 11, 2026 15:47
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds public tryRefreshToken() method that wraps the existing private
refreshAccessToken() with token persistence and state updates, then
returns the new access token string for use by the upcoming
UnauthorizedInterceptor. Also configures jest-preset-angular for the
web-auth-data-access library so Angular testing utilities work correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Spotify Web Playback SDK calls getOAuthToken multiple times (on
expiry, reconnect, wake from sleep). Previously the callback captured
a static token snapshot, so after a refresh the SDK kept using the
stale token, causing authentication_error and NO_ACTIVE_DEVICE on
play. Now reads the latest token from localStorage each time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Moves the LOCALSTORAGE_KEYS constant from auth.store.ts (private) to
local-storage.service.ts (exported) so both AuthStore and
PlaybackService can reference keys without hardcoded strings or
circular dependencies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per spotify/web-api#1325, passing device_id avoids 404 "No active
device found" errors when Spotify loses track of the active device.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PlayerApiService now stores the deviceId from transferUserPlayback()
and automatically appends it as a query param on play() calls.
Per spotify/web-api#1325, this avoids 404 "No active device found".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@trungvose trungvose force-pushed the feature/token-refresh-on-401 branch from 99f23a0 to 187a198 Compare April 11, 2026 15:08
@trungvose trungvose added the wip label Apr 18, 2026
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines 62 to 66
player.addListener('authentication_error', ({ message }) => {
console.error(message);
console.error('[Angular Spotify] Authentication error, reconnecting...', message);
player.disconnect();
player.connect();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Unbounded reconnect loop on authentication_error when token is still invalid

The authentication_error handler calls player.disconnect() then player.connect() with no retry limit, delay, or guard. When connect() runs, it invokes the getOAuthToken callback at playback.service.ts:53, which reads the token from localStorage. If the token in localStorage is still stale (e.g., the interceptor's 401 refresh hasn't completed yet, or the refresh itself failed), the SDK receives the same invalid token, fails to authenticate, and fires authentication_error again — creating a tight infinite loop of disconnect→connect→authenticate→fail→disconnect→connect… This will spam Spotify's servers with rapid WebSocket connection attempts, flood the console, and potentially freeze the browser tab.

Prompt for agents
The authentication_error listener in playback.service.ts (lines 62-66) calls player.disconnect() then player.connect() unconditionally, with no retry limit or backoff. If the token stored in localStorage is still invalid when connect() triggers getOAuthToken, the SDK will fail again and fire authentication_error again, creating an infinite loop.

To fix this, add a reconnect attempt counter and a maximum retry limit (e.g., 3 attempts). Additionally, consider adding an exponential backoff delay (e.g., using setTimeout) between reconnect attempts. Reset the counter on a successful ready event. If the max retries are exceeded, log a final error and stop retrying.

Relevant code:
- playback.service.ts lines 62-66: the authentication_error handler
- playback.service.ts lines 52-53: the getOAuthToken callback that reads from localStorage
- playback.service.ts lines 97-103: the ready listener that fires on successful reconnect (good place to reset the counter)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant