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
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
4
|
+
var react = require('react');
|
|
5
|
+
|
|
6
|
+
const KEY = "spot_auth_tokens";
|
|
7
|
+
class LocalStorageTokenStore {
|
|
8
|
+
get() {
|
|
9
|
+
try {
|
|
10
|
+
const raw = localStorage.getItem(KEY);
|
|
11
|
+
return raw ? JSON.parse(raw) : null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
set(tokenData) {
|
|
17
|
+
localStorage.setItem(KEY, JSON.stringify(tokenData));
|
|
18
|
+
}
|
|
19
|
+
clear() {
|
|
20
|
+
localStorage.removeItem(KEY);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function generatePKCE() {
|
|
25
|
+
const array = new Uint8Array(96);
|
|
26
|
+
crypto.getRandomValues(array);
|
|
27
|
+
const codeVerifier = btoa(String.fromCharCode(...array)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
const data = encoder.encode(codeVerifier);
|
|
30
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
31
|
+
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
32
|
+
return { codeVerifier, codeChallenge };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildAuthUrl({ clientId, scope, redirectUri, state, codeChallenge }) {
|
|
36
|
+
const params = new URLSearchParams({
|
|
37
|
+
response_type: "code",
|
|
38
|
+
client_id: clientId,
|
|
39
|
+
scope,
|
|
40
|
+
redirect_uri: redirectUri,
|
|
41
|
+
state,
|
|
42
|
+
...codeChallenge && {
|
|
43
|
+
code_challenge_method: "S256",
|
|
44
|
+
code_challenge: codeChallenge
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return `https://accounts.spotify.com/authorize?${params}`;
|
|
48
|
+
}
|
|
49
|
+
async function exchangeCode({ clientId, clientSecret, code, redirectUri, codeVerifier }) {
|
|
50
|
+
const body = new URLSearchParams({
|
|
51
|
+
grant_type: "authorization_code",
|
|
52
|
+
code,
|
|
53
|
+
redirect_uri: redirectUri
|
|
54
|
+
});
|
|
55
|
+
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
56
|
+
if (codeVerifier) {
|
|
57
|
+
body.set("code_verifier", codeVerifier);
|
|
58
|
+
body.set("client_id", clientId);
|
|
59
|
+
} else {
|
|
60
|
+
headers["Authorization"] = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
|
|
61
|
+
}
|
|
62
|
+
const res = await fetch("https://accounts.spotify.com/api/token", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
body,
|
|
65
|
+
headers
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const err = await res.text();
|
|
69
|
+
throw new Error(`Token exchange failed (${res.status}): ${err}`);
|
|
70
|
+
}
|
|
71
|
+
return res.json();
|
|
72
|
+
}
|
|
73
|
+
async function refreshAccessToken({ clientId, clientSecret, refreshToken }) {
|
|
74
|
+
const body = new URLSearchParams({
|
|
75
|
+
grant_type: "refresh_token",
|
|
76
|
+
refresh_token: refreshToken,
|
|
77
|
+
client_id: clientId
|
|
78
|
+
});
|
|
79
|
+
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
80
|
+
if (clientSecret) {
|
|
81
|
+
headers["Authorization"] = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
|
|
82
|
+
}
|
|
83
|
+
const res = await fetch("https://accounts.spotify.com/api/token", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
body,
|
|
86
|
+
headers
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const err = await res.text();
|
|
90
|
+
throw new Error(`Token refresh failed (${res.status}): ${err}`);
|
|
91
|
+
}
|
|
92
|
+
return res.json();
|
|
93
|
+
}
|
|
94
|
+
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 * 1e3,
|
|
99
|
+
created_at: Date.now()
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SpotAuthContext = react.createContext(null);
|
|
104
|
+
const BUFFER_MS = 5 * 60 * 1e3;
|
|
105
|
+
function isExpired(tokenData) {
|
|
106
|
+
return !tokenData || Date.now() > tokenData.expires_at - BUFFER_MS;
|
|
107
|
+
}
|
|
108
|
+
function SpotAuthProvider({ clientId, scope, redirectUri, children }) {
|
|
109
|
+
const store = react.useRef(new LocalStorageTokenStore()).current;
|
|
110
|
+
const [tokenData, setTokenData] = react.useState(() => store.get());
|
|
111
|
+
const pendingRefresh = react.useRef(null);
|
|
112
|
+
const doRefresh = async (refreshToken, existingTokenData) => {
|
|
113
|
+
if (pendingRefresh.current) return pendingRefresh.current;
|
|
114
|
+
pendingRefresh.current = refreshAccessToken({ clientId, refreshToken }).then((res) => {
|
|
115
|
+
const td = normalizeTokenData(res, existingTokenData?.refresh_token);
|
|
116
|
+
store.set(td);
|
|
117
|
+
setTokenData(td);
|
|
118
|
+
return td;
|
|
119
|
+
}).finally(() => {
|
|
120
|
+
pendingRefresh.current = null;
|
|
121
|
+
});
|
|
122
|
+
return pendingRefresh.current;
|
|
123
|
+
};
|
|
124
|
+
const getAccessToken = async () => {
|
|
125
|
+
const cached = store.get();
|
|
126
|
+
if (cached && !isExpired(cached)) return cached.access_token;
|
|
127
|
+
if (cached?.refresh_token) {
|
|
128
|
+
const td = await doRefresh(cached.refresh_token, cached);
|
|
129
|
+
return td.access_token;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
};
|
|
133
|
+
const triggerAuth = async () => {
|
|
134
|
+
const { codeVerifier, codeChallenge } = await generatePKCE();
|
|
135
|
+
const state = Math.random().toString(36).substring(7);
|
|
136
|
+
sessionStorage.setItem("spot_auth_verifier", codeVerifier);
|
|
137
|
+
sessionStorage.setItem("spot_auth_state", state);
|
|
138
|
+
localStorage.setItem("spot_auth_return_url", window.location.href);
|
|
139
|
+
window.location.href = buildAuthUrl({ clientId, scope, redirectUri, state, codeChallenge });
|
|
140
|
+
};
|
|
141
|
+
const logout = () => {
|
|
142
|
+
store.clear();
|
|
143
|
+
setTokenData(null);
|
|
144
|
+
};
|
|
145
|
+
const isAuthenticated = !!tokenData && !isExpired(tokenData);
|
|
146
|
+
return /* @__PURE__ */ jsxRuntime.jsx(SpotAuthContext.Provider, { value: {
|
|
147
|
+
tokenData,
|
|
148
|
+
isAuthenticated,
|
|
149
|
+
getAccessToken,
|
|
150
|
+
triggerAuth,
|
|
151
|
+
logout,
|
|
152
|
+
clientId,
|
|
153
|
+
redirectUri
|
|
154
|
+
}, children });
|
|
155
|
+
}
|
|
156
|
+
function useSpotAuth() {
|
|
157
|
+
const ctx = react.useContext(SpotAuthContext);
|
|
158
|
+
if (!ctx) throw new Error("useSpotAuth must be used within a SpotAuthProvider");
|
|
159
|
+
return ctx;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function SpotAuthBarrier({ children, fallback = null }) {
|
|
163
|
+
const { isAuthenticated, triggerAuth } = useSpotAuth();
|
|
164
|
+
react.useEffect(() => {
|
|
165
|
+
if (!isAuthenticated) {
|
|
166
|
+
triggerAuth();
|
|
167
|
+
}
|
|
168
|
+
}, [isAuthenticated]);
|
|
169
|
+
if (!isAuthenticated) return fallback;
|
|
170
|
+
return children;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function SpotAuthCallbackBase({ onRedirect }) {
|
|
174
|
+
const { clientId, redirectUri } = useSpotAuth();
|
|
175
|
+
const [error, setError] = react.useState(null);
|
|
176
|
+
react.useEffect(() => {
|
|
177
|
+
const params = new URLSearchParams(window.location.search);
|
|
178
|
+
const code = params.get("code");
|
|
179
|
+
const state = params.get("state");
|
|
180
|
+
const errorParam = params.get("error");
|
|
181
|
+
const storedState = sessionStorage.getItem("spot_auth_state");
|
|
182
|
+
const codeVerifier = sessionStorage.getItem("spot_auth_verifier");
|
|
183
|
+
const returnUrl = localStorage.getItem("spot_auth_return_url") || "/";
|
|
184
|
+
if (errorParam) {
|
|
185
|
+
setError(`Spotify authorization error: ${errorParam}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!code) {
|
|
189
|
+
setError("No authorization code received.");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (state !== storedState) {
|
|
193
|
+
setError("State mismatch \u2014 possible CSRF attack. Please try again.");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
sessionStorage.removeItem("spot_auth_state");
|
|
197
|
+
sessionStorage.removeItem("spot_auth_verifier");
|
|
198
|
+
localStorage.removeItem("spot_auth_return_url");
|
|
199
|
+
exchangeCode({ clientId, code, redirectUri, codeVerifier }).then((res) => {
|
|
200
|
+
const tokenData = normalizeTokenData(res);
|
|
201
|
+
localStorage.setItem("spot_auth_tokens", JSON.stringify(tokenData));
|
|
202
|
+
if (onRedirect) {
|
|
203
|
+
onRedirect(returnUrl);
|
|
204
|
+
} else {
|
|
205
|
+
window.location.replace(returnUrl);
|
|
206
|
+
}
|
|
207
|
+
}).catch((err) => setError(err.message));
|
|
208
|
+
}, []);
|
|
209
|
+
if (error) return /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
|
|
210
|
+
"Authentication error: ",
|
|
211
|
+
error
|
|
212
|
+
] });
|
|
213
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { children: "Authenticating..." });
|
|
214
|
+
}
|
|
215
|
+
function SpotAuthCallback() {
|
|
216
|
+
return /* @__PURE__ */ jsxRuntime.jsx(SpotAuthCallbackBase, {});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
exports.SpotAuthBarrier = SpotAuthBarrier;
|
|
220
|
+
exports.SpotAuthCallback = SpotAuthCallback;
|
|
221
|
+
exports.SpotAuthCallbackBase = SpotAuthCallbackBase;
|
|
222
|
+
exports.SpotAuthProvider = SpotAuthProvider;
|
|
223
|
+
exports.useSpotAuth = useSpotAuth;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
4
|
+
var reactRouterDom = require('react-router-dom');
|
|
5
|
+
var react = require('react');
|
|
6
|
+
|
|
7
|
+
const KEY = "spot_auth_tokens";
|
|
8
|
+
class LocalStorageTokenStore {
|
|
9
|
+
get() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = localStorage.getItem(KEY);
|
|
12
|
+
return raw ? JSON.parse(raw) : null;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
set(tokenData) {
|
|
18
|
+
localStorage.setItem(KEY, JSON.stringify(tokenData));
|
|
19
|
+
}
|
|
20
|
+
clear() {
|
|
21
|
+
localStorage.removeItem(KEY);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function generatePKCE() {
|
|
26
|
+
const array = new Uint8Array(96);
|
|
27
|
+
crypto.getRandomValues(array);
|
|
28
|
+
const codeVerifier = btoa(String.fromCharCode(...array)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
29
|
+
const encoder = new TextEncoder();
|
|
30
|
+
const data = encoder.encode(codeVerifier);
|
|
31
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
32
|
+
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
33
|
+
return { codeVerifier, codeChallenge };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildAuthUrl({ clientId, scope, redirectUri, state, codeChallenge }) {
|
|
37
|
+
const params = new URLSearchParams({
|
|
38
|
+
response_type: "code",
|
|
39
|
+
client_id: clientId,
|
|
40
|
+
scope,
|
|
41
|
+
redirect_uri: redirectUri,
|
|
42
|
+
state,
|
|
43
|
+
...codeChallenge && {
|
|
44
|
+
code_challenge_method: "S256",
|
|
45
|
+
code_challenge: codeChallenge
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
return `https://accounts.spotify.com/authorize?${params}`;
|
|
49
|
+
}
|
|
50
|
+
async function exchangeCode({ clientId, clientSecret, code, redirectUri, codeVerifier }) {
|
|
51
|
+
const body = new URLSearchParams({
|
|
52
|
+
grant_type: "authorization_code",
|
|
53
|
+
code,
|
|
54
|
+
redirect_uri: redirectUri
|
|
55
|
+
});
|
|
56
|
+
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
57
|
+
if (codeVerifier) {
|
|
58
|
+
body.set("code_verifier", codeVerifier);
|
|
59
|
+
body.set("client_id", clientId);
|
|
60
|
+
} else {
|
|
61
|
+
headers["Authorization"] = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
|
|
62
|
+
}
|
|
63
|
+
const res = await fetch("https://accounts.spotify.com/api/token", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
body,
|
|
66
|
+
headers
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const err = await res.text();
|
|
70
|
+
throw new Error(`Token exchange failed (${res.status}): ${err}`);
|
|
71
|
+
}
|
|
72
|
+
return res.json();
|
|
73
|
+
}
|
|
74
|
+
async function refreshAccessToken({ clientId, clientSecret, refreshToken }) {
|
|
75
|
+
const body = new URLSearchParams({
|
|
76
|
+
grant_type: "refresh_token",
|
|
77
|
+
refresh_token: refreshToken,
|
|
78
|
+
client_id: clientId
|
|
79
|
+
});
|
|
80
|
+
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
81
|
+
if (clientSecret) {
|
|
82
|
+
headers["Authorization"] = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
|
|
83
|
+
}
|
|
84
|
+
const res = await fetch("https://accounts.spotify.com/api/token", {
|
|
85
|
+
method: "POST",
|
|
86
|
+
body,
|
|
87
|
+
headers
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
const err = await res.text();
|
|
91
|
+
throw new Error(`Token refresh failed (${res.status}): ${err}`);
|
|
92
|
+
}
|
|
93
|
+
return res.json();
|
|
94
|
+
}
|
|
95
|
+
function normalizeTokenData(response, existingRefreshToken = null) {
|
|
96
|
+
return {
|
|
97
|
+
access_token: response.access_token,
|
|
98
|
+
refresh_token: response.refresh_token || existingRefreshToken,
|
|
99
|
+
expires_at: Date.now() + response.expires_in * 1e3,
|
|
100
|
+
created_at: Date.now()
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const SpotAuthContext = react.createContext(null);
|
|
105
|
+
const BUFFER_MS = 5 * 60 * 1e3;
|
|
106
|
+
function isExpired(tokenData) {
|
|
107
|
+
return !tokenData || Date.now() > tokenData.expires_at - BUFFER_MS;
|
|
108
|
+
}
|
|
109
|
+
function SpotAuthProvider({ clientId, scope, redirectUri, children }) {
|
|
110
|
+
const store = react.useRef(new LocalStorageTokenStore()).current;
|
|
111
|
+
const [tokenData, setTokenData] = react.useState(() => store.get());
|
|
112
|
+
const pendingRefresh = react.useRef(null);
|
|
113
|
+
const doRefresh = async (refreshToken, existingTokenData) => {
|
|
114
|
+
if (pendingRefresh.current) return pendingRefresh.current;
|
|
115
|
+
pendingRefresh.current = refreshAccessToken({ clientId, refreshToken }).then((res) => {
|
|
116
|
+
const td = normalizeTokenData(res, existingTokenData?.refresh_token);
|
|
117
|
+
store.set(td);
|
|
118
|
+
setTokenData(td);
|
|
119
|
+
return td;
|
|
120
|
+
}).finally(() => {
|
|
121
|
+
pendingRefresh.current = null;
|
|
122
|
+
});
|
|
123
|
+
return pendingRefresh.current;
|
|
124
|
+
};
|
|
125
|
+
const getAccessToken = async () => {
|
|
126
|
+
const cached = store.get();
|
|
127
|
+
if (cached && !isExpired(cached)) return cached.access_token;
|
|
128
|
+
if (cached?.refresh_token) {
|
|
129
|
+
const td = await doRefresh(cached.refresh_token, cached);
|
|
130
|
+
return td.access_token;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
const triggerAuth = async () => {
|
|
135
|
+
const { codeVerifier, codeChallenge } = await generatePKCE();
|
|
136
|
+
const state = Math.random().toString(36).substring(7);
|
|
137
|
+
sessionStorage.setItem("spot_auth_verifier", codeVerifier);
|
|
138
|
+
sessionStorage.setItem("spot_auth_state", state);
|
|
139
|
+
localStorage.setItem("spot_auth_return_url", window.location.href);
|
|
140
|
+
window.location.href = buildAuthUrl({ clientId, scope, redirectUri, state, codeChallenge });
|
|
141
|
+
};
|
|
142
|
+
const logout = () => {
|
|
143
|
+
store.clear();
|
|
144
|
+
setTokenData(null);
|
|
145
|
+
};
|
|
146
|
+
const isAuthenticated = !!tokenData && !isExpired(tokenData);
|
|
147
|
+
return /* @__PURE__ */ jsxRuntime.jsx(SpotAuthContext.Provider, { value: {
|
|
148
|
+
tokenData,
|
|
149
|
+
isAuthenticated,
|
|
150
|
+
getAccessToken,
|
|
151
|
+
triggerAuth,
|
|
152
|
+
logout,
|
|
153
|
+
clientId,
|
|
154
|
+
redirectUri
|
|
155
|
+
}, children });
|
|
156
|
+
}
|
|
157
|
+
function useSpotAuth() {
|
|
158
|
+
const ctx = react.useContext(SpotAuthContext);
|
|
159
|
+
if (!ctx) throw new Error("useSpotAuth must be used within a SpotAuthProvider");
|
|
160
|
+
return ctx;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function SpotAuthCallbackBase({ onRedirect }) {
|
|
164
|
+
const { clientId, redirectUri } = useSpotAuth();
|
|
165
|
+
const [error, setError] = react.useState(null);
|
|
166
|
+
react.useEffect(() => {
|
|
167
|
+
const params = new URLSearchParams(window.location.search);
|
|
168
|
+
const code = params.get("code");
|
|
169
|
+
const state = params.get("state");
|
|
170
|
+
const errorParam = params.get("error");
|
|
171
|
+
const storedState = sessionStorage.getItem("spot_auth_state");
|
|
172
|
+
const codeVerifier = sessionStorage.getItem("spot_auth_verifier");
|
|
173
|
+
const returnUrl = localStorage.getItem("spot_auth_return_url") || "/";
|
|
174
|
+
if (errorParam) {
|
|
175
|
+
setError(`Spotify authorization error: ${errorParam}`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!code) {
|
|
179
|
+
setError("No authorization code received.");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (state !== storedState) {
|
|
183
|
+
setError("State mismatch \u2014 possible CSRF attack. Please try again.");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
sessionStorage.removeItem("spot_auth_state");
|
|
187
|
+
sessionStorage.removeItem("spot_auth_verifier");
|
|
188
|
+
localStorage.removeItem("spot_auth_return_url");
|
|
189
|
+
exchangeCode({ clientId, code, redirectUri, codeVerifier }).then((res) => {
|
|
190
|
+
const tokenData = normalizeTokenData(res);
|
|
191
|
+
localStorage.setItem("spot_auth_tokens", JSON.stringify(tokenData));
|
|
192
|
+
if (onRedirect) {
|
|
193
|
+
onRedirect(returnUrl);
|
|
194
|
+
} else {
|
|
195
|
+
window.location.replace(returnUrl);
|
|
196
|
+
}
|
|
197
|
+
}).catch((err) => setError(err.message));
|
|
198
|
+
}, []);
|
|
199
|
+
if (error) return /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
|
|
200
|
+
"Authentication error: ",
|
|
201
|
+
error
|
|
202
|
+
] });
|
|
203
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { children: "Authenticating..." });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function SpotAuthBarrier({ children, fallback = null }) {
|
|
207
|
+
const { isAuthenticated, triggerAuth } = useSpotAuth();
|
|
208
|
+
react.useEffect(() => {
|
|
209
|
+
if (!isAuthenticated) {
|
|
210
|
+
triggerAuth();
|
|
211
|
+
}
|
|
212
|
+
}, [isAuthenticated]);
|
|
213
|
+
if (!isAuthenticated) return fallback;
|
|
214
|
+
return children;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function SpotAuthCallback() {
|
|
218
|
+
const navigate = reactRouterDom.useNavigate();
|
|
219
|
+
return /* @__PURE__ */ jsxRuntime.jsx(SpotAuthCallbackBase, { onRedirect: (url) => navigate(url, { replace: true }) });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
exports.SpotAuthBarrier = SpotAuthBarrier;
|
|
223
|
+
exports.SpotAuthCallback = SpotAuthCallback;
|
|
224
|
+
exports.SpotAuthCallbackBase = SpotAuthCallbackBase;
|
|
225
|
+
exports.SpotAuthProvider = SpotAuthProvider;
|
|
226
|
+
exports.useSpotAuth = useSpotAuth;
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spot-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-friction Spotify OAuth for Node.js scripts and web apps",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"src",
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/Be-Casual-Studios/spot-auth.git"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/Be-Casual-Studios/spot-auth#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/Be-Casual-Studios/spot-auth/issues"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"import": "./src/core/SpotAuth.js",
|
|
22
|
+
"require": "./dist/core/SpotAuth.cjs"
|
|
23
|
+
},
|
|
24
|
+
"./node": {
|
|
25
|
+
"import": "./src/node/index.js",
|
|
26
|
+
"require": "./dist/node/index.cjs"
|
|
27
|
+
},
|
|
28
|
+
"./react": {
|
|
29
|
+
"import": "./src/react/index.js",
|
|
30
|
+
"require": "./dist/react/index.cjs"
|
|
31
|
+
},
|
|
32
|
+
"./react-router": {
|
|
33
|
+
"import": "./src/react-router/index.jsx",
|
|
34
|
+
"require": "./dist/react-router/index.cjs"
|
|
35
|
+
},
|
|
36
|
+
"./webcomponent": {
|
|
37
|
+
"import": "./src/webcomponent/spot-auth-barrier.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "rollup -c"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"open": "^10.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"react": ">=17",
|
|
48
|
+
"react-dom": ">=17",
|
|
49
|
+
"react-router-dom": ">=6"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"react": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"react-dom": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"react-router-dom": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"keywords": [
|
|
63
|
+
"spotify",
|
|
64
|
+
"oauth",
|
|
65
|
+
"authentication",
|
|
66
|
+
"pkce",
|
|
67
|
+
"react"
|
|
68
|
+
],
|
|
69
|
+
"license": "MIT",
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"rollup": "^4.60.4",
|
|
72
|
+
"rollup-plugin-esbuild": "^6.2.1"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const KEY = 'spot_auth_tokens';
|
|
2
|
+
|
|
3
|
+
export class LocalStorageTokenStore {
|
|
4
|
+
get() {
|
|
5
|
+
try {
|
|
6
|
+
const raw = localStorage.getItem(KEY);
|
|
7
|
+
return raw ? JSON.parse(raw) : null;
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
set(tokenData) {
|
|
14
|
+
localStorage.setItem(KEY, JSON.stringify(tokenData));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
clear() {
|
|
18
|
+
localStorage.removeItem(KEY);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a PKCE code_verifier and code_challenge pair.
|
|
3
|
+
* Uses the Web Crypto API (available in all modern browsers and Node 19+).
|
|
4
|
+
*
|
|
5
|
+
* code_verifier: random 128-char URL-safe base64 string
|
|
6
|
+
* code_challenge: base64url(SHA-256(code_verifier))
|
|
7
|
+
*/
|
|
8
|
+
export async function generatePKCE() {
|
|
9
|
+
const array = new Uint8Array(96);
|
|
10
|
+
crypto.getRandomValues(array);
|
|
11
|
+
|
|
12
|
+
const codeVerifier = btoa(String.fromCharCode(...array))
|
|
13
|
+
.replace(/\+/g, '-')
|
|
14
|
+
.replace(/\//g, '_')
|
|
15
|
+
.replace(/=/g, '');
|
|
16
|
+
|
|
17
|
+
const encoder = new TextEncoder();
|
|
18
|
+
const data = encoder.encode(codeVerifier);
|
|
19
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
20
|
+
|
|
21
|
+
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
22
|
+
.replace(/\+/g, '-')
|
|
23
|
+
.replace(/\//g, '_')
|
|
24
|
+
.replace(/=/g, '');
|
|
25
|
+
|
|
26
|
+
return { codeVerifier, codeChallenge };
|
|
27
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { refreshAccessToken } from './oauth.js';
|
|
2
|
+
|
|
3
|
+
const BUFFER_MS = 5 * 60 * 1000; // refresh 5 min before expiry
|
|
4
|
+
|
|
5
|
+
export class SpotAuth {
|
|
6
|
+
constructor(config, tokenStore) {
|
|
7
|
+
this.config = {
|
|
8
|
+
redirectUri: 'http://127.0.0.1:3000/callback',
|
|
9
|
+
...config,
|
|
10
|
+
};
|
|
11
|
+
this.store = tokenStore;
|
|
12
|
+
this._pendingTokenRequest = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns a valid access token. Handles caching, refresh, and full auth flow.
|
|
17
|
+
* Safe to call concurrently — duplicate in-flight requests are deduplicated.
|
|
18
|
+
*/
|
|
19
|
+
async getAccessToken() {
|
|
20
|
+
const cached = await this.store.get();
|
|
21
|
+
|
|
22
|
+
if (cached && !this._isExpired(cached)) {
|
|
23
|
+
return cached.access_token;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (cached?.refresh_token) {
|
|
27
|
+
return this._refresh(cached.refresh_token, cached);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return this._authenticate();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Refreshes the token, deduplicating concurrent calls so only one
|
|
35
|
+
* request is made even if getAccessToken() is called multiple times simultaneously.
|
|
36
|
+
*/
|
|
37
|
+
async _refresh(refreshToken, cachedTokenData) {
|
|
38
|
+
if (this._pendingTokenRequest) return this._pendingTokenRequest;
|
|
39
|
+
|
|
40
|
+
this._pendingTokenRequest = this._doRefresh(refreshToken, cachedTokenData)
|
|
41
|
+
.finally(() => { this._pendingTokenRequest = null; });
|
|
42
|
+
|
|
43
|
+
return this._pendingTokenRequest;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Override in subclasses to implement environment-specific refresh logic.
|
|
48
|
+
*/
|
|
49
|
+
async _doRefresh(_refreshToken, _cachedTokenData) {
|
|
50
|
+
throw new Error('_doRefresh must be implemented by subclass');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Override in subclasses to implement environment-specific full auth flow.
|
|
55
|
+
*/
|
|
56
|
+
async _authenticate() {
|
|
57
|
+
throw new Error('_authenticate must be implemented by subclass');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_isExpired(tokenData) {
|
|
61
|
+
return Date.now() > (tokenData.expires_at - BUFFER_MS);
|
|
62
|
+
}
|
|
63
|
+
}
|