spot-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Builds the Spotify authorization URL.
3
+ * Used by both node (Authorization Code) and browser (PKCE) flows.
4
+ */
5
+ export function buildAuthUrl({ clientId, scope, redirectUri, state, codeChallenge }) {
6
+ const params = new URLSearchParams({
7
+ response_type: 'code',
8
+ client_id: clientId,
9
+ scope,
10
+ redirect_uri: redirectUri,
11
+ state,
12
+ ...(codeChallenge && {
13
+ code_challenge_method: 'S256',
14
+ code_challenge: codeChallenge,
15
+ }),
16
+ });
17
+ return `https://accounts.spotify.com/authorize?${params}`;
18
+ }
19
+
20
+ /**
21
+ * Exchanges an authorization code for tokens.
22
+ * - Node flow: pass clientSecret → uses Basic auth header
23
+ * - PKCE flow: pass codeVerifier → uses client_id + verifier, no secret
24
+ */
25
+ export async function exchangeCode({ clientId, clientSecret, code, redirectUri, codeVerifier }) {
26
+ const body = new URLSearchParams({
27
+ grant_type: 'authorization_code',
28
+ code,
29
+ redirect_uri: redirectUri,
30
+ });
31
+
32
+ const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
33
+
34
+ if (codeVerifier) {
35
+ // PKCE — no secret, verifier proves identity
36
+ body.set('code_verifier', codeVerifier);
37
+ body.set('client_id', clientId);
38
+ } else {
39
+ // Authorization Code — use Basic auth with client_secret
40
+ headers['Authorization'] = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
41
+ }
42
+
43
+ const res = await fetch('https://accounts.spotify.com/api/token', {
44
+ method: 'POST',
45
+ body,
46
+ headers,
47
+ });
48
+
49
+ if (!res.ok) {
50
+ const err = await res.text();
51
+ throw new Error(`Token exchange failed (${res.status}): ${err}`);
52
+ }
53
+
54
+ return res.json();
55
+ }
56
+
57
+ /**
58
+ * Refreshes an access token.
59
+ * - Node flow: pass clientSecret → uses Basic auth
60
+ * - PKCE flow: pass only clientId → Spotify accepts without secret for PKCE tokens
61
+ */
62
+ export async function refreshAccessToken({ clientId, clientSecret, refreshToken }) {
63
+ const body = new URLSearchParams({
64
+ grant_type: 'refresh_token',
65
+ refresh_token: refreshToken,
66
+ client_id: clientId,
67
+ });
68
+
69
+ const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
70
+
71
+ if (clientSecret) {
72
+ headers['Authorization'] = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
73
+ }
74
+
75
+ const res = await fetch('https://accounts.spotify.com/api/token', {
76
+ method: 'POST',
77
+ body,
78
+ headers,
79
+ });
80
+
81
+ if (!res.ok) {
82
+ const err = await res.text();
83
+ throw new Error(`Token refresh failed (${res.status}): ${err}`);
84
+ }
85
+
86
+ return res.json();
87
+ }
88
+
89
+ /**
90
+ * Normalizes a Spotify token response into the shape used by token stores.
91
+ * Accepts an optional existing refresh_token as fallback for PKCE rotating tokens
92
+ * in case the response omits it (defensive).
93
+ */
94
+ export function normalizeTokenData(response, existingRefreshToken = null) {
95
+ return {
96
+ access_token: response.access_token,
97
+ refresh_token: response.refresh_token || existingRefreshToken,
98
+ expires_at: Date.now() + (response.expires_in * 1000),
99
+ created_at: Date.now(),
100
+ };
101
+ }
@@ -0,0 +1,27 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ export class FileTokenStore {
5
+ constructor(tokenCachePath) {
6
+ this.path = tokenCachePath || path.join(process.cwd(), '.spotify-tokens.json');
7
+ }
8
+
9
+ get() {
10
+ try {
11
+ if (!fs.existsSync(this.path)) return null;
12
+ return JSON.parse(fs.readFileSync(this.path, 'utf8'));
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ set(tokenData) {
19
+ fs.writeFileSync(this.path, JSON.stringify(tokenData, null, 2));
20
+ }
21
+
22
+ clear() {
23
+ try {
24
+ if (fs.existsSync(this.path)) fs.unlinkSync(this.path);
25
+ } catch { /* non-fatal */ }
26
+ }
27
+ }
@@ -0,0 +1,48 @@
1
+ import http from 'http';
2
+ import { URL } from 'url';
3
+
4
+ /**
5
+ * Starts a temporary local HTTP server that waits for Spotify's OAuth callback.
6
+ * Resolves with { code, state } when the callback is received, then shuts down.
7
+ */
8
+ export function waitForCallback(port = 3000) {
9
+ return new Promise((resolve, reject) => {
10
+ const server = http.createServer((req, res) => {
11
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
12
+
13
+ if (url.pathname !== '/callback') {
14
+ res.writeHead(404).end();
15
+ return;
16
+ }
17
+
18
+ const code = url.searchParams.get('code');
19
+ const error = url.searchParams.get('error');
20
+ const state = url.searchParams.get('state');
21
+
22
+ if (error) {
23
+ res.writeHead(400, { 'Content-Type': 'text/html' });
24
+ res.end('<h1>Authorization failed</h1><p>You can close this window.</p>');
25
+ server.close();
26
+ reject(new Error(`Spotify authorization error: ${error}`));
27
+ return;
28
+ }
29
+
30
+ res.writeHead(200, { 'Content-Type': 'text/html' });
31
+ res.end('<h1>Authorization successful!</h1><p>You can close this window and return to the terminal.</p>');
32
+ server.close();
33
+ resolve({ code, state });
34
+ });
35
+
36
+ server.listen(port, '127.0.0.1', () => {
37
+ // Ready — caller opens the browser and awaits this promise
38
+ });
39
+
40
+ server.on('error', (err) => {
41
+ if (err.code === 'EADDRINUSE') {
42
+ reject(new Error(`spot-auth: port ${port} is already in use. Set a different redirectUri port in your config.`));
43
+ } else {
44
+ reject(err);
45
+ }
46
+ });
47
+ });
48
+ }
@@ -0,0 +1,22 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Appends the token cache filename to the project's .gitignore if not already present.
6
+ * Non-fatal — logs a warning on failure but does not throw.
7
+ */
8
+ export function ensureGitignored(filename = '.spotify-tokens.json') {
9
+ const gitignorePath = path.join(process.cwd(), '.gitignore');
10
+ try {
11
+ const existing = fs.existsSync(gitignorePath)
12
+ ? fs.readFileSync(gitignorePath, 'utf8')
13
+ : '';
14
+
15
+ const lines = existing.split('\n').map(l => l.trim());
16
+ if (!lines.includes(filename)) {
17
+ fs.appendFileSync(gitignorePath, `\n# spot-auth token cache\n${filename}\n`);
18
+ }
19
+ } catch (err) {
20
+ console.warn(`spot-auth: could not update .gitignore (${err.message}). Add "${filename}" manually.`);
21
+ }
22
+ }
@@ -0,0 +1,71 @@
1
+ import open from 'open';
2
+ import { SpotAuth } from '../core/SpotAuth.js';
3
+ import { FileTokenStore } from './FileTokenStore.js';
4
+ import { waitForCallback } from './LocalAuthServer.js';
5
+ import { ensureGitignored } from './gitignore.js';
6
+ import { buildAuthUrl, exchangeCode, refreshAccessToken, normalizeTokenData } from '../core/oauth.js';
7
+
8
+ class NodeSpotAuth extends SpotAuth {
9
+ constructor(config) {
10
+ super(config, new FileTokenStore(config.tokenCachePath));
11
+ }
12
+
13
+ async _authenticate() {
14
+ const state = Math.random().toString(36).substring(7);
15
+ const authUrl = buildAuthUrl({
16
+ clientId: this.config.clientId,
17
+ scope: this.config.scope,
18
+ redirectUri: this.config.redirectUri,
19
+ state,
20
+ // No codeChallenge — node flow uses Authorization Code with client_secret
21
+ });
22
+
23
+ console.log('\n🔑 Spotify authentication required.');
24
+
25
+ try {
26
+ await open(authUrl);
27
+ console.log('Browser opened. Waiting for authorization...\n');
28
+ } catch {
29
+ console.log('Could not open browser automatically. Please open this URL:\n');
30
+ console.log(` ${authUrl}\n`);
31
+ }
32
+
33
+ const { code } = await waitForCallback(this._getCallbackPort());
34
+
35
+ const tokenResponse = await exchangeCode({
36
+ clientId: this.config.clientId,
37
+ clientSecret: this.config.clientSecret,
38
+ code,
39
+ redirectUri: this.config.redirectUri,
40
+ });
41
+
42
+ const tokenData = normalizeTokenData(tokenResponse);
43
+ this.store.set(tokenData);
44
+ ensureGitignored();
45
+
46
+ console.log('✅ Spotify authentication successful.\n');
47
+ return tokenData.access_token;
48
+ }
49
+
50
+ async _doRefresh(refreshToken, cachedTokenData) {
51
+ const tokenResponse = await refreshAccessToken({
52
+ clientId: this.config.clientId,
53
+ clientSecret: this.config.clientSecret,
54
+ refreshToken,
55
+ });
56
+
57
+ const tokenData = normalizeTokenData(tokenResponse, cachedTokenData?.refresh_token);
58
+ this.store.set(tokenData);
59
+ return tokenData.access_token;
60
+ }
61
+
62
+ _getCallbackPort() {
63
+ try {
64
+ return parseInt(new URL(this.config.redirectUri).port) || 3000;
65
+ } catch {
66
+ return 3000;
67
+ }
68
+ }
69
+ }
70
+
71
+ export { NodeSpotAuth as SpotAuth };
@@ -0,0 +1,22 @@
1
+ import { useEffect } from 'react';
2
+ import { useSpotAuth } from './SpotAuthContext.jsx';
3
+
4
+ /**
5
+ * Auth gate component. Renders children only when authenticated.
6
+ * Automatically triggers the Spotify auth flow if no valid token exists.
7
+ *
8
+ * @param {React.ReactNode} children - Content to render when authenticated
9
+ * @param {React.ReactNode} fallback - Content to render while unauthenticated (default: null)
10
+ */
11
+ export function SpotAuthBarrier({ children, fallback = null }) {
12
+ const { isAuthenticated, triggerAuth } = useSpotAuth();
13
+
14
+ useEffect(() => {
15
+ if (!isAuthenticated) {
16
+ triggerAuth();
17
+ }
18
+ }, [isAuthenticated]);
19
+
20
+ if (!isAuthenticated) return fallback;
21
+ return children;
22
+ }
@@ -0,0 +1,71 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useSpotAuth } from './SpotAuthContext.jsx';
3
+ import { exchangeCode, normalizeTokenData } from '../core/oauth.js';
4
+
5
+ /**
6
+ * Base callback handler. Reads ?code= from the URL, exchanges it for tokens
7
+ * via PKCE, saves to localStorage, then redirects to the original URL.
8
+ *
9
+ * @param {(url: string) => void} [onRedirect] - Override navigation (e.g. React Router's navigate()).
10
+ * Defaults to window.location.replace().
11
+ */
12
+ export function SpotAuthCallbackBase({ onRedirect }) {
13
+ const { clientId, redirectUri } = useSpotAuth();
14
+ const [error, setError] = useState(null);
15
+
16
+ useEffect(() => {
17
+ const params = new URLSearchParams(window.location.search);
18
+ const code = params.get('code');
19
+ const state = params.get('state');
20
+ const errorParam = params.get('error');
21
+
22
+ const storedState = sessionStorage.getItem('spot_auth_state');
23
+ const codeVerifier = sessionStorage.getItem('spot_auth_verifier');
24
+ const returnUrl = localStorage.getItem('spot_auth_return_url') || '/';
25
+
26
+ if (errorParam) {
27
+ setError(`Spotify authorization error: ${errorParam}`);
28
+ return;
29
+ }
30
+
31
+ if (!code) {
32
+ setError('No authorization code received.');
33
+ return;
34
+ }
35
+
36
+ if (state !== storedState) {
37
+ setError('State mismatch — possible CSRF attack. Please try again.');
38
+ return;
39
+ }
40
+
41
+ // Clean up PKCE session state
42
+ sessionStorage.removeItem('spot_auth_state');
43
+ sessionStorage.removeItem('spot_auth_verifier');
44
+ localStorage.removeItem('spot_auth_return_url');
45
+
46
+ exchangeCode({ clientId, code, redirectUri, codeVerifier })
47
+ .then(res => {
48
+ const tokenData = normalizeTokenData(res);
49
+ localStorage.setItem('spot_auth_tokens', JSON.stringify(tokenData));
50
+ if (onRedirect) {
51
+ onRedirect(returnUrl);
52
+ } else {
53
+ window.location.replace(returnUrl);
54
+ }
55
+ })
56
+ .catch(err => setError(err.message));
57
+ }, []);
58
+
59
+ if (error) return <div>Authentication error: {error}</div>;
60
+ return <div>Authenticating...</div>;
61
+ }
62
+
63
+ /**
64
+ * Router-agnostic callback component. Mount this on your /auth/callback route.
65
+ * Uses window.location.replace() to navigate back after auth.
66
+ *
67
+ * For React Router apps, import from 'spot-auth/react-router' instead.
68
+ */
69
+ export function SpotAuthCallback() {
70
+ return <SpotAuthCallbackBase />;
71
+ }
@@ -0,0 +1,87 @@
1
+ import { createContext, useContext, useState, useRef } from 'react';
2
+ import { LocalStorageTokenStore } from '../browser/LocalStorageTokenStore.js';
3
+ import { generatePKCE } from '../browser/pkce.js';
4
+ import { buildAuthUrl, refreshAccessToken, normalizeTokenData } from '../core/oauth.js';
5
+
6
+ const SpotAuthContext = createContext(null);
7
+ const BUFFER_MS = 5 * 60 * 1000;
8
+
9
+ function isExpired(tokenData) {
10
+ return !tokenData || Date.now() > (tokenData.expires_at - BUFFER_MS);
11
+ }
12
+
13
+ export function SpotAuthProvider({ clientId, scope, redirectUri, children }) {
14
+ const store = useRef(new LocalStorageTokenStore()).current;
15
+ const [tokenData, setTokenData] = useState(() => store.get());
16
+ const pendingRefresh = useRef(null);
17
+
18
+ const doRefresh = async (refreshToken, existingTokenData) => {
19
+ if (pendingRefresh.current) return pendingRefresh.current;
20
+
21
+ pendingRefresh.current = refreshAccessToken({ clientId, refreshToken })
22
+ .then(res => {
23
+ const td = normalizeTokenData(res, existingTokenData?.refresh_token);
24
+ store.set(td);
25
+ setTokenData(td);
26
+ return td;
27
+ })
28
+ .finally(() => { pendingRefresh.current = null; });
29
+
30
+ return pendingRefresh.current;
31
+ };
32
+
33
+ /**
34
+ * Returns a valid access token. Refreshes silently if expired.
35
+ * Returns null if no token exists (SpotAuthBarrier handles triggering full auth).
36
+ */
37
+ const getAccessToken = async () => {
38
+ const cached = store.get();
39
+ if (cached && !isExpired(cached)) return cached.access_token;
40
+ if (cached?.refresh_token) {
41
+ const td = await doRefresh(cached.refresh_token, cached);
42
+ return td.access_token;
43
+ }
44
+ return null;
45
+ };
46
+
47
+ /**
48
+ * Saves current URL, generates PKCE params, redirects to Spotify auth.
49
+ */
50
+ const triggerAuth = async () => {
51
+ const { codeVerifier, codeChallenge } = await generatePKCE();
52
+ const state = Math.random().toString(36).substring(7);
53
+
54
+ sessionStorage.setItem('spot_auth_verifier', codeVerifier);
55
+ sessionStorage.setItem('spot_auth_state', state);
56
+ localStorage.setItem('spot_auth_return_url', window.location.href);
57
+
58
+ window.location.href = buildAuthUrl({ clientId, scope, redirectUri, state, codeChallenge });
59
+ };
60
+
61
+ const logout = () => {
62
+ store.clear();
63
+ setTokenData(null);
64
+ };
65
+
66
+ const isAuthenticated = !!tokenData && !isExpired(tokenData);
67
+
68
+ return (
69
+ <SpotAuthContext.Provider value={{
70
+ tokenData,
71
+ isAuthenticated,
72
+ getAccessToken,
73
+ triggerAuth,
74
+ logout,
75
+ clientId,
76
+ redirectUri,
77
+ }}>
78
+ {children}
79
+ </SpotAuthContext.Provider>
80
+ );
81
+ }
82
+
83
+ export function useSpotAuth() {
84
+ const ctx = useContext(SpotAuthContext);
85
+ if (!ctx) throw new Error('useSpotAuth must be used within a SpotAuthProvider');
86
+ return ctx;
87
+ }
@@ -0,0 +1,3 @@
1
+ export { SpotAuthProvider, useSpotAuth } from './SpotAuthContext.jsx';
2
+ export { SpotAuthBarrier } from './SpotAuthBarrier.jsx';
3
+ export { SpotAuthCallback, SpotAuthCallbackBase } from './SpotAuthCallback.jsx';
@@ -0,0 +1,19 @@
1
+ import { useNavigate } from 'react-router-dom';
2
+ import { SpotAuthCallbackBase } from '../react/SpotAuthCallback.jsx';
3
+
4
+ /**
5
+ * React Router-aware callback component.
6
+ * Uses useNavigate() to redirect back after auth (preserves browser history).
7
+ * Mount this on your /auth/callback route.
8
+ *
9
+ * Import from 'spot-auth/react-router' instead of 'spot-auth/react' to use this.
10
+ */
11
+ export function SpotAuthCallback() {
12
+ const navigate = useNavigate();
13
+ return <SpotAuthCallbackBase onRedirect={(url) => navigate(url, { replace: true })} />;
14
+ }
15
+
16
+ // Re-export everything else from spot-auth/react unchanged
17
+ export { SpotAuthProvider, useSpotAuth } from '../react/SpotAuthContext.jsx';
18
+ export { SpotAuthBarrier } from '../react/SpotAuthBarrier.jsx';
19
+ export { SpotAuthCallbackBase } from '../react/SpotAuthCallback.jsx';
@@ -0,0 +1,190 @@
1
+ import { generatePKCE } from '../browser/pkce.js';
2
+ import { buildAuthUrl, exchangeCode, refreshAccessToken, normalizeTokenData } from '../core/oauth.js';
3
+ import { LocalStorageTokenStore } from '../browser/LocalStorageTokenStore.js';
4
+
5
+ const BUFFER_MS = 5 * 60 * 1000;
6
+ const store = new LocalStorageTokenStore();
7
+
8
+ // Holds a reference to the active <spot-auth-barrier> instance so getSpotToken()
9
+ // can refresh or trigger auth without the caller needing to pass config.
10
+ let _activeInstance = null;
11
+
12
+ /**
13
+ * Returns a valid Spotify access token.
14
+ * - If the cached token is still valid, returns it immediately.
15
+ * - If expired but a refresh token exists, silently refreshes and returns the new token.
16
+ * - If no token exists at all, triggers the full PKCE auth redirect (page navigates away).
17
+ *
18
+ * Requires a <spot-auth-barrier> element to be present in the DOM.
19
+ *
20
+ * @returns {Promise<string>}
21
+ *
22
+ * @example
23
+ * const token = await getSpotToken();
24
+ * const res = await fetch('https://api.spotify.com/v1/me', {
25
+ * headers: { Authorization: `Bearer ${token}` }
26
+ * });
27
+ */
28
+ export async function getSpotToken() {
29
+ const tokenData = store.get();
30
+
31
+ if (tokenData && Date.now() < tokenData.expires_at - BUFFER_MS) {
32
+ return tokenData.access_token;
33
+ }
34
+
35
+ if (!_activeInstance) {
36
+ throw new Error('spot-auth: getSpotToken() requires a <spot-auth-barrier> element in the DOM.');
37
+ }
38
+
39
+ if (tokenData?.refresh_token) {
40
+ return _activeInstance._refreshAndReturn(tokenData);
41
+ }
42
+
43
+ // No token at all — trigger full auth (page redirects away, promise never resolves)
44
+ await _activeInstance._triggerAuth();
45
+ }
46
+
47
+ /**
48
+ * <spot-auth-barrier> web component.
49
+ *
50
+ * Hides its children until a valid Spotify token exists.
51
+ * Handles the full PKCE auth flow, including detecting the callback URL.
52
+ *
53
+ * Dispatches a 'spot-auth-ready' CustomEvent on the element when auth is
54
+ * confirmed, with `event.detail.accessToken` available.
55
+ *
56
+ * Attributes:
57
+ * client-id (required) Spotify app client ID
58
+ * scope (required) Space-separated Spotify scopes
59
+ * redirect-uri (required) Must match a URI registered in your Spotify app
60
+ *
61
+ * Usage:
62
+ * <spot-auth-barrier
63
+ * client-id="..."
64
+ * scope="playlist-read-private user-library-read"
65
+ * redirect-uri="https://yourapp.com/auth/callback"
66
+ * >
67
+ * <div>Protected content</div>
68
+ * </spot-auth-barrier>
69
+ */
70
+ class SpotAuthBarrier extends HTMLElement {
71
+ connectedCallback() {
72
+ _activeInstance = this;
73
+ this.style.display = 'none';
74
+ this._pendingRefresh = null;
75
+ this._init();
76
+ }
77
+
78
+ disconnectedCallback() {
79
+ if (_activeInstance === this) _activeInstance = null;
80
+ }
81
+
82
+ get _config() {
83
+ return {
84
+ clientId: this.getAttribute('client-id'),
85
+ scope: this.getAttribute('scope'),
86
+ redirectUri: this.getAttribute('redirect-uri'),
87
+ };
88
+ }
89
+
90
+ _ready(accessToken) {
91
+ this.style.display = '';
92
+ this.dispatchEvent(new CustomEvent('spot-auth-ready', {
93
+ bubbles: true,
94
+ detail: { accessToken },
95
+ }));
96
+ }
97
+
98
+ async _refreshAndReturn(cachedTokenData) {
99
+ if (this._pendingRefresh) return this._pendingRefresh;
100
+
101
+ this._pendingRefresh = refreshAccessToken({
102
+ clientId: this._config.clientId,
103
+ refreshToken: cachedTokenData.refresh_token,
104
+ })
105
+ .then(res => {
106
+ const tokenData = normalizeTokenData(res, cachedTokenData.refresh_token);
107
+ store.set(tokenData);
108
+ return tokenData.access_token;
109
+ })
110
+ .catch(err => {
111
+ store.clear();
112
+ throw err;
113
+ })
114
+ .finally(() => { this._pendingRefresh = null; });
115
+
116
+ return this._pendingRefresh;
117
+ }
118
+
119
+ async _init() {
120
+ const params = new URLSearchParams(window.location.search);
121
+ if (params.has('code') || params.has('error')) {
122
+ await this._handleCallback(params);
123
+ return;
124
+ }
125
+
126
+ const cached = store.get();
127
+
128
+ if (cached && Date.now() < cached.expires_at - BUFFER_MS) {
129
+ this._ready(cached.access_token);
130
+ return;
131
+ }
132
+
133
+ if (cached?.refresh_token) {
134
+ try {
135
+ const accessToken = await this._refreshAndReturn(cached);
136
+ this._ready(accessToken);
137
+ return;
138
+ } catch {
139
+ // Fall through to full auth
140
+ }
141
+ }
142
+
143
+ await this._triggerAuth();
144
+ }
145
+
146
+ async _triggerAuth() {
147
+ const { codeVerifier, codeChallenge } = await generatePKCE();
148
+ const state = Math.random().toString(36).substring(7);
149
+
150
+ sessionStorage.setItem('spot_auth_verifier', codeVerifier);
151
+ sessionStorage.setItem('spot_auth_state', state);
152
+ localStorage.setItem('spot_auth_return_url', window.location.href);
153
+
154
+ window.location.href = buildAuthUrl({ ...this._config, state, codeChallenge });
155
+ }
156
+
157
+ async _handleCallback(params) {
158
+ const code = params.get('code');
159
+ const state = params.get('state');
160
+ const error = params.get('error');
161
+
162
+ if (error) {
163
+ console.error(`spot-auth: Spotify authorization error: ${error}`);
164
+ return;
165
+ }
166
+
167
+ const storedState = sessionStorage.getItem('spot_auth_state');
168
+ const codeVerifier = sessionStorage.getItem('spot_auth_verifier');
169
+ const returnUrl = localStorage.getItem('spot_auth_return_url') || '/';
170
+
171
+ if (state !== storedState) {
172
+ console.error('spot-auth: state mismatch — possible CSRF. Aborting.');
173
+ return;
174
+ }
175
+
176
+ sessionStorage.removeItem('spot_auth_state');
177
+ sessionStorage.removeItem('spot_auth_verifier');
178
+ localStorage.removeItem('spot_auth_return_url');
179
+
180
+ try {
181
+ const res = await exchangeCode({ ...this._config, code, codeVerifier });
182
+ store.set(normalizeTokenData(res));
183
+ window.location.replace(returnUrl);
184
+ } catch (err) {
185
+ console.error(`spot-auth: token exchange failed: ${err.message}`);
186
+ }
187
+ }
188
+ }
189
+
190
+ customElements.define('spot-auth-barrier', SpotAuthBarrier);