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,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
+ }