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.
- package/README.md +228 -0
- package/dist/core/SpotAuth.cjs +55 -0
- package/dist/node/index.cjs +257 -0
- package/dist/react/index.cjs +223 -0
- package/dist/react-router/index.cjs +226 -0
- package/package.json +74 -0
- package/src/browser/LocalStorageTokenStore.js +20 -0
- package/src/browser/pkce.js +27 -0
- package/src/core/SpotAuth.js +63 -0
- package/src/core/oauth.js +101 -0
- package/src/node/FileTokenStore.js +27 -0
- package/src/node/LocalAuthServer.js +48 -0
- package/src/node/gitignore.js +22 -0
- package/src/node/index.js +71 -0
- package/src/react/SpotAuthBarrier.jsx +22 -0
- package/src/react/SpotAuthCallback.jsx +71 -0
- package/src/react/SpotAuthContext.jsx +87 -0
- package/src/react/index.js +3 -0
- package/src/react-router/index.jsx +19 -0
- package/src/webcomponent/spot-auth-barrier.js +190 -0
package/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# spot-auth
|
|
2
|
+
|
|
3
|
+
Zero-friction Spotify OAuth for Node.js scripts and web apps.
|
|
4
|
+
|
|
5
|
+
- **Scripts** — one call to `getAccessToken()`, browser opens automatically, token cached to disk
|
|
6
|
+
- **React** — wrap your app in `<SpotAuthProvider>`, gate content with `<SpotAuthBarrier>`
|
|
7
|
+
- **Web Component** — `<spot-auth-barrier>` works in any framework or plain HTML
|
|
8
|
+
- **No backend required** — web flows use PKCE, no `client_secret` in the browser
|
|
9
|
+
- **Auto-refresh** — expired tokens are silently refreshed; concurrent calls are deduplicated
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install spot-auth
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Spotify Developer Setup
|
|
22
|
+
|
|
23
|
+
1. Go to [https://developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) and create an app
|
|
24
|
+
2. Add your redirect URIs under **Edit Settings**:
|
|
25
|
+
- Scripts: `http://127.0.0.1:3000/callback`
|
|
26
|
+
- Web apps: `https://yourdomain.com/auth/callback` (and `http://localhost:5173/auth/callback` for local dev)
|
|
27
|
+
3. Note your **Client ID** (and **Client Secret** for scripts only)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Script / Node.js
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
import { SpotAuth } from 'spot-auth/node';
|
|
35
|
+
|
|
36
|
+
const auth = new SpotAuth({
|
|
37
|
+
clientId: process.env.CLIENT_ID,
|
|
38
|
+
clientSecret: process.env.CLIENT_SECRET,
|
|
39
|
+
scope: 'playlist-read-private user-library-read',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const token = await auth.getAccessToken();
|
|
43
|
+
// First run: browser opens automatically, you log in once
|
|
44
|
+
// Subsequent runs: token loaded from .spotify-tokens.json instantly
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Config options
|
|
48
|
+
|
|
49
|
+
| Option | Type | Default | Description |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| `clientId` | string | required | Spotify app Client ID |
|
|
52
|
+
| `clientSecret` | string | required | Spotify app Client Secret |
|
|
53
|
+
| `scope` | string | required | Space-separated Spotify scopes |
|
|
54
|
+
| `redirectUri` | string | `http://127.0.0.1:3000/callback` | Must match Spotify dashboard |
|
|
55
|
+
| `tokenCachePath` | string | `process.cwd()/.spotify-tokens.json` | Override token cache location |
|
|
56
|
+
|
|
57
|
+
### How it works
|
|
58
|
+
|
|
59
|
+
1. On first call, opens your browser to Spotify login and starts a temporary local server to capture the callback
|
|
60
|
+
2. Exchanges the code for tokens and caches them to `.spotify-tokens.json` (auto-added to `.gitignore`)
|
|
61
|
+
3. On subsequent calls, loads the cached token — if expired, silently refreshes using the stored refresh token
|
|
62
|
+
4. Multiple concurrent calls to `getAccessToken()` share a single in-flight request
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## React
|
|
67
|
+
|
|
68
|
+
### 1. Wrap your app with `SpotAuthProvider`
|
|
69
|
+
|
|
70
|
+
```jsx
|
|
71
|
+
// main.jsx
|
|
72
|
+
import { SpotAuthProvider } from 'spot-auth/react';
|
|
73
|
+
|
|
74
|
+
createRoot(document.getElementById('root')).render(
|
|
75
|
+
<SpotAuthProvider
|
|
76
|
+
clientId={import.meta.env.VITE_SPOTIFY_CLIENT_ID}
|
|
77
|
+
scope="playlist-read-private user-library-read"
|
|
78
|
+
redirectUri={`${window.location.origin}/auth/callback`}
|
|
79
|
+
>
|
|
80
|
+
<App />
|
|
81
|
+
</SpotAuthProvider>
|
|
82
|
+
);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 2. Mount `SpotAuthCallback` on your callback route
|
|
86
|
+
|
|
87
|
+
```jsx
|
|
88
|
+
// Router-agnostic — renders on the /auth/callback page
|
|
89
|
+
import { SpotAuthCallback } from 'spot-auth/react';
|
|
90
|
+
<SpotAuthCallback />
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```jsx
|
|
94
|
+
// React Router
|
|
95
|
+
import { SpotAuthCallback } from 'spot-auth/react-router';
|
|
96
|
+
<Route path="/auth/callback" element={<SpotAuthCallback />} />
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 3. Gate content with `SpotAuthBarrier`
|
|
100
|
+
|
|
101
|
+
```jsx
|
|
102
|
+
import { SpotAuthBarrier } from 'spot-auth/react';
|
|
103
|
+
|
|
104
|
+
<SpotAuthBarrier fallback={<div>Loading...</div>}>
|
|
105
|
+
<MySpotifyPage />
|
|
106
|
+
</SpotAuthBarrier>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Users who aren't authenticated are redirected to Spotify login automatically. After login they're returned to the page they were on.
|
|
110
|
+
|
|
111
|
+
### 4. Access the token anywhere
|
|
112
|
+
|
|
113
|
+
```jsx
|
|
114
|
+
import { useSpotAuth } from 'spot-auth/react';
|
|
115
|
+
|
|
116
|
+
function MyComponent() {
|
|
117
|
+
const { accessToken, isAuthenticated, logout } = useSpotAuth();
|
|
118
|
+
|
|
119
|
+
const fetchProfile = async () => {
|
|
120
|
+
const res = await fetch('https://api.spotify.com/v1/me', {
|
|
121
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
122
|
+
});
|
|
123
|
+
return res.json();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div>
|
|
128
|
+
{isAuthenticated && <button onClick={logout}>Log out</button>}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### `SpotAuthProvider` props
|
|
135
|
+
|
|
136
|
+
| Prop | Type | Description |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `clientId` | string | Spotify app Client ID |
|
|
139
|
+
| `scope` | string | Space-separated Spotify scopes |
|
|
140
|
+
| `redirectUri` | string | Must match Spotify dashboard and your callback route |
|
|
141
|
+
|
|
142
|
+
### `SpotAuthBarrier` props
|
|
143
|
+
|
|
144
|
+
| Prop | Type | Default | Description |
|
|
145
|
+
|---|---|---|---|
|
|
146
|
+
| `children` | ReactNode | required | Content to render when authenticated |
|
|
147
|
+
| `fallback` | ReactNode | `null` | Content to render while unauthenticated |
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Web Component
|
|
152
|
+
|
|
153
|
+
Works in any framework or plain HTML. No build step required.
|
|
154
|
+
|
|
155
|
+
```html
|
|
156
|
+
<script type="module" src="/node_modules/spot-auth/webcomponent/spot-auth-barrier.js"></script>
|
|
157
|
+
|
|
158
|
+
<spot-auth-barrier
|
|
159
|
+
client-id="your_client_id"
|
|
160
|
+
scope="playlist-read-private user-library-read"
|
|
161
|
+
redirect-uri="https://yourapp.com/auth/callback"
|
|
162
|
+
>
|
|
163
|
+
<div>This content is hidden until the user is authenticated.</div>
|
|
164
|
+
</spot-auth-barrier>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The component handles both the initial auth redirect and the callback detection automatically — no separate callback page needed.
|
|
168
|
+
|
|
169
|
+
### Accessing the token
|
|
170
|
+
|
|
171
|
+
**Option 1 — `getSpotToken()` helper** (use anywhere after the barrier is in the DOM):
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
import { getSpotToken } from '/node_modules/spot-auth/webcomponent/spot-auth-barrier.js';
|
|
175
|
+
|
|
176
|
+
// Always returns a valid token — silently refreshes if expired,
|
|
177
|
+
// triggers full auth redirect if no token exists at all.
|
|
178
|
+
const token = await getSpotToken();
|
|
179
|
+
|
|
180
|
+
const res = await fetch('https://api.spotify.com/v1/me', {
|
|
181
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Option 2 — `spot-auth-ready` event** (fired by the component when auth is confirmed):
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
document.querySelector('spot-auth-barrier')
|
|
189
|
+
.addEventListener('spot-auth-ready', (event) => {
|
|
190
|
+
const { accessToken } = event.detail;
|
|
191
|
+
// safe to make Spotify API calls now
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The event fires on every page load where a valid token exists (including after a silent refresh), not just the first time.
|
|
196
|
+
|
|
197
|
+
### Attributes
|
|
198
|
+
|
|
199
|
+
| Attribute | Description |
|
|
200
|
+
|---|---|
|
|
201
|
+
| `client-id` | Spotify app Client ID |
|
|
202
|
+
| `scope` | Space-separated Spotify scopes |
|
|
203
|
+
| `redirect-uri` | Must match Spotify dashboard |
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## How PKCE Works (Web / Browser)
|
|
208
|
+
|
|
209
|
+
The browser and web component flows use PKCE (Proof Key for Code Exchange), which lets you authenticate without a `client_secret`:
|
|
210
|
+
|
|
211
|
+
1. A random `code_verifier` is generated in the browser
|
|
212
|
+
2. Its SHA-256 hash (`code_challenge`) is sent to Spotify during the auth redirect
|
|
213
|
+
3. After login, Spotify sends back an auth code
|
|
214
|
+
4. The browser exchanges the code + original verifier directly with Spotify — no backend needed
|
|
215
|
+
5. Spotify validates that the verifier matches the challenge it stored
|
|
216
|
+
|
|
217
|
+
Refresh tokens are **rotating** with PKCE — each refresh returns a new refresh token, which is saved automatically.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## localStorage / sessionStorage Keys
|
|
222
|
+
|
|
223
|
+
| Key | Storage | Contents |
|
|
224
|
+
|---|---|---|
|
|
225
|
+
| `spot_auth_tokens` | localStorage | `{ access_token, refresh_token, expires_at, created_at }` |
|
|
226
|
+
| `spot_auth_return_url` | localStorage | URL to redirect to after auth (cleared after use) |
|
|
227
|
+
| `spot_auth_verifier` | sessionStorage | PKCE code_verifier (cleared after use) |
|
|
228
|
+
| `spot_auth_state` | sessionStorage | OAuth state for CSRF protection (cleared after use) |
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const BUFFER_MS = 5 * 60 * 1e3;
|
|
4
|
+
class SpotAuth {
|
|
5
|
+
constructor(config, tokenStore) {
|
|
6
|
+
this.config = {
|
|
7
|
+
redirectUri: "http://127.0.0.1:3000/callback",
|
|
8
|
+
...config
|
|
9
|
+
};
|
|
10
|
+
this.store = tokenStore;
|
|
11
|
+
this._pendingTokenRequest = null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Returns a valid access token. Handles caching, refresh, and full auth flow.
|
|
15
|
+
* Safe to call concurrently — duplicate in-flight requests are deduplicated.
|
|
16
|
+
*/
|
|
17
|
+
async getAccessToken() {
|
|
18
|
+
const cached = await this.store.get();
|
|
19
|
+
if (cached && !this._isExpired(cached)) {
|
|
20
|
+
return cached.access_token;
|
|
21
|
+
}
|
|
22
|
+
if (cached?.refresh_token) {
|
|
23
|
+
return this._refresh(cached.refresh_token, cached);
|
|
24
|
+
}
|
|
25
|
+
return this._authenticate();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Refreshes the token, deduplicating concurrent calls so only one
|
|
29
|
+
* request is made even if getAccessToken() is called multiple times simultaneously.
|
|
30
|
+
*/
|
|
31
|
+
async _refresh(refreshToken, cachedTokenData) {
|
|
32
|
+
if (this._pendingTokenRequest) return this._pendingTokenRequest;
|
|
33
|
+
this._pendingTokenRequest = this._doRefresh(refreshToken, cachedTokenData).finally(() => {
|
|
34
|
+
this._pendingTokenRequest = null;
|
|
35
|
+
});
|
|
36
|
+
return this._pendingTokenRequest;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Override in subclasses to implement environment-specific refresh logic.
|
|
40
|
+
*/
|
|
41
|
+
async _doRefresh(_refreshToken, _cachedTokenData) {
|
|
42
|
+
throw new Error("_doRefresh must be implemented by subclass");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Override in subclasses to implement environment-specific full auth flow.
|
|
46
|
+
*/
|
|
47
|
+
async _authenticate() {
|
|
48
|
+
throw new Error("_authenticate must be implemented by subclass");
|
|
49
|
+
}
|
|
50
|
+
_isExpired(tokenData) {
|
|
51
|
+
return Date.now() > tokenData.expires_at - BUFFER_MS;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
exports.SpotAuth = SpotAuth;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var open = require('open');
|
|
4
|
+
var fs = require('fs');
|
|
5
|
+
var path = require('path');
|
|
6
|
+
var http = require('http');
|
|
7
|
+
var url = require('url');
|
|
8
|
+
|
|
9
|
+
function buildAuthUrl({ clientId, scope, redirectUri, state, codeChallenge }) {
|
|
10
|
+
const params = new URLSearchParams({
|
|
11
|
+
response_type: "code",
|
|
12
|
+
client_id: clientId,
|
|
13
|
+
scope,
|
|
14
|
+
redirect_uri: redirectUri,
|
|
15
|
+
state,
|
|
16
|
+
...codeChallenge && {
|
|
17
|
+
code_challenge_method: "S256",
|
|
18
|
+
code_challenge: codeChallenge
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
return `https://accounts.spotify.com/authorize?${params}`;
|
|
22
|
+
}
|
|
23
|
+
async function exchangeCode({ clientId, clientSecret, code, redirectUri, codeVerifier }) {
|
|
24
|
+
const body = new URLSearchParams({
|
|
25
|
+
grant_type: "authorization_code",
|
|
26
|
+
code,
|
|
27
|
+
redirect_uri: redirectUri
|
|
28
|
+
});
|
|
29
|
+
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
30
|
+
if (codeVerifier) {
|
|
31
|
+
body.set("code_verifier", codeVerifier);
|
|
32
|
+
body.set("client_id", clientId);
|
|
33
|
+
} else {
|
|
34
|
+
headers["Authorization"] = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
|
|
35
|
+
}
|
|
36
|
+
const res = await fetch("https://accounts.spotify.com/api/token", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
body,
|
|
39
|
+
headers
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const err = await res.text();
|
|
43
|
+
throw new Error(`Token exchange failed (${res.status}): ${err}`);
|
|
44
|
+
}
|
|
45
|
+
return res.json();
|
|
46
|
+
}
|
|
47
|
+
async function refreshAccessToken({ clientId, clientSecret, refreshToken }) {
|
|
48
|
+
const body = new URLSearchParams({
|
|
49
|
+
grant_type: "refresh_token",
|
|
50
|
+
refresh_token: refreshToken,
|
|
51
|
+
client_id: clientId
|
|
52
|
+
});
|
|
53
|
+
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
54
|
+
if (clientSecret) {
|
|
55
|
+
headers["Authorization"] = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
|
|
56
|
+
}
|
|
57
|
+
const res = await fetch("https://accounts.spotify.com/api/token", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
body,
|
|
60
|
+
headers
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const err = await res.text();
|
|
64
|
+
throw new Error(`Token refresh failed (${res.status}): ${err}`);
|
|
65
|
+
}
|
|
66
|
+
return res.json();
|
|
67
|
+
}
|
|
68
|
+
function normalizeTokenData(response, existingRefreshToken = null) {
|
|
69
|
+
return {
|
|
70
|
+
access_token: response.access_token,
|
|
71
|
+
refresh_token: response.refresh_token || existingRefreshToken,
|
|
72
|
+
expires_at: Date.now() + response.expires_in * 1e3,
|
|
73
|
+
created_at: Date.now()
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const BUFFER_MS = 5 * 60 * 1e3;
|
|
78
|
+
class SpotAuth {
|
|
79
|
+
constructor(config, tokenStore) {
|
|
80
|
+
this.config = {
|
|
81
|
+
redirectUri: "http://127.0.0.1:3000/callback",
|
|
82
|
+
...config
|
|
83
|
+
};
|
|
84
|
+
this.store = tokenStore;
|
|
85
|
+
this._pendingTokenRequest = null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Returns a valid access token. Handles caching, refresh, and full auth flow.
|
|
89
|
+
* Safe to call concurrently — duplicate in-flight requests are deduplicated.
|
|
90
|
+
*/
|
|
91
|
+
async getAccessToken() {
|
|
92
|
+
const cached = await this.store.get();
|
|
93
|
+
if (cached && !this._isExpired(cached)) {
|
|
94
|
+
return cached.access_token;
|
|
95
|
+
}
|
|
96
|
+
if (cached?.refresh_token) {
|
|
97
|
+
return this._refresh(cached.refresh_token, cached);
|
|
98
|
+
}
|
|
99
|
+
return this._authenticate();
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Refreshes the token, deduplicating concurrent calls so only one
|
|
103
|
+
* request is made even if getAccessToken() is called multiple times simultaneously.
|
|
104
|
+
*/
|
|
105
|
+
async _refresh(refreshToken, cachedTokenData) {
|
|
106
|
+
if (this._pendingTokenRequest) return this._pendingTokenRequest;
|
|
107
|
+
this._pendingTokenRequest = this._doRefresh(refreshToken, cachedTokenData).finally(() => {
|
|
108
|
+
this._pendingTokenRequest = null;
|
|
109
|
+
});
|
|
110
|
+
return this._pendingTokenRequest;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Override in subclasses to implement environment-specific refresh logic.
|
|
114
|
+
*/
|
|
115
|
+
async _doRefresh(_refreshToken, _cachedTokenData) {
|
|
116
|
+
throw new Error("_doRefresh must be implemented by subclass");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Override in subclasses to implement environment-specific full auth flow.
|
|
120
|
+
*/
|
|
121
|
+
async _authenticate() {
|
|
122
|
+
throw new Error("_authenticate must be implemented by subclass");
|
|
123
|
+
}
|
|
124
|
+
_isExpired(tokenData) {
|
|
125
|
+
return Date.now() > tokenData.expires_at - BUFFER_MS;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
class FileTokenStore {
|
|
130
|
+
constructor(tokenCachePath) {
|
|
131
|
+
this.path = tokenCachePath || path.join(process.cwd(), ".spotify-tokens.json");
|
|
132
|
+
}
|
|
133
|
+
get() {
|
|
134
|
+
try {
|
|
135
|
+
if (!fs.existsSync(this.path)) return null;
|
|
136
|
+
return JSON.parse(fs.readFileSync(this.path, "utf8"));
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
set(tokenData) {
|
|
142
|
+
fs.writeFileSync(this.path, JSON.stringify(tokenData, null, 2));
|
|
143
|
+
}
|
|
144
|
+
clear() {
|
|
145
|
+
try {
|
|
146
|
+
if (fs.existsSync(this.path)) fs.unlinkSync(this.path);
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function waitForCallback(port = 3e3) {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const server = http.createServer((req, res) => {
|
|
155
|
+
const url$1 = new url.URL(req.url, `http://127.0.0.1:${port}`);
|
|
156
|
+
if (url$1.pathname !== "/callback") {
|
|
157
|
+
res.writeHead(404).end();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const code = url$1.searchParams.get("code");
|
|
161
|
+
const error = url$1.searchParams.get("error");
|
|
162
|
+
const state = url$1.searchParams.get("state");
|
|
163
|
+
if (error) {
|
|
164
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
165
|
+
res.end("<h1>Authorization failed</h1><p>You can close this window.</p>");
|
|
166
|
+
server.close();
|
|
167
|
+
reject(new Error(`Spotify authorization error: ${error}`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
171
|
+
res.end("<h1>Authorization successful!</h1><p>You can close this window and return to the terminal.</p>");
|
|
172
|
+
server.close();
|
|
173
|
+
resolve({ code, state });
|
|
174
|
+
});
|
|
175
|
+
server.listen(port, "127.0.0.1", () => {
|
|
176
|
+
});
|
|
177
|
+
server.on("error", (err) => {
|
|
178
|
+
if (err.code === "EADDRINUSE") {
|
|
179
|
+
reject(new Error(`spot-auth: port ${port} is already in use. Set a different redirectUri port in your config.`));
|
|
180
|
+
} else {
|
|
181
|
+
reject(err);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function ensureGitignored(filename = ".spotify-tokens.json") {
|
|
188
|
+
const gitignorePath = path.join(process.cwd(), ".gitignore");
|
|
189
|
+
try {
|
|
190
|
+
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : "";
|
|
191
|
+
const lines = existing.split("\n").map((l) => l.trim());
|
|
192
|
+
if (!lines.includes(filename)) {
|
|
193
|
+
fs.appendFileSync(gitignorePath, `
|
|
194
|
+
# spot-auth token cache
|
|
195
|
+
${filename}
|
|
196
|
+
`);
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.warn(`spot-auth: could not update .gitignore (${err.message}). Add "${filename}" manually.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
class NodeSpotAuth extends SpotAuth {
|
|
204
|
+
constructor(config) {
|
|
205
|
+
super(config, new FileTokenStore(config.tokenCachePath));
|
|
206
|
+
}
|
|
207
|
+
async _authenticate() {
|
|
208
|
+
const state = Math.random().toString(36).substring(7);
|
|
209
|
+
const authUrl = buildAuthUrl({
|
|
210
|
+
clientId: this.config.clientId,
|
|
211
|
+
scope: this.config.scope,
|
|
212
|
+
redirectUri: this.config.redirectUri,
|
|
213
|
+
state
|
|
214
|
+
// No codeChallenge — node flow uses Authorization Code with client_secret
|
|
215
|
+
});
|
|
216
|
+
console.log("\n\u{1F511} Spotify authentication required.");
|
|
217
|
+
try {
|
|
218
|
+
await open(authUrl);
|
|
219
|
+
console.log("Browser opened. Waiting for authorization...\n");
|
|
220
|
+
} catch {
|
|
221
|
+
console.log("Could not open browser automatically. Please open this URL:\n");
|
|
222
|
+
console.log(` ${authUrl}
|
|
223
|
+
`);
|
|
224
|
+
}
|
|
225
|
+
const { code } = await waitForCallback(this._getCallbackPort());
|
|
226
|
+
const tokenResponse = await exchangeCode({
|
|
227
|
+
clientId: this.config.clientId,
|
|
228
|
+
clientSecret: this.config.clientSecret,
|
|
229
|
+
code,
|
|
230
|
+
redirectUri: this.config.redirectUri
|
|
231
|
+
});
|
|
232
|
+
const tokenData = normalizeTokenData(tokenResponse);
|
|
233
|
+
this.store.set(tokenData);
|
|
234
|
+
ensureGitignored();
|
|
235
|
+
console.log("\u2705 Spotify authentication successful.\n");
|
|
236
|
+
return tokenData.access_token;
|
|
237
|
+
}
|
|
238
|
+
async _doRefresh(refreshToken, cachedTokenData) {
|
|
239
|
+
const tokenResponse = await refreshAccessToken({
|
|
240
|
+
clientId: this.config.clientId,
|
|
241
|
+
clientSecret: this.config.clientSecret,
|
|
242
|
+
refreshToken
|
|
243
|
+
});
|
|
244
|
+
const tokenData = normalizeTokenData(tokenResponse, cachedTokenData?.refresh_token);
|
|
245
|
+
this.store.set(tokenData);
|
|
246
|
+
return tokenData.access_token;
|
|
247
|
+
}
|
|
248
|
+
_getCallbackPort() {
|
|
249
|
+
try {
|
|
250
|
+
return parseInt(new URL(this.config.redirectUri).port) || 3e3;
|
|
251
|
+
} catch {
|
|
252
|
+
return 3e3;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
exports.SpotAuth = NodeSpotAuth;
|