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 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;