@youversion/platform-core 0.6.0 → 0.8.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/.turbo/turbo-build.log +8 -8
- package/CHANGELOG.md +46 -0
- package/dist/index.cjs +473 -346
- package/dist/index.d.cts +106 -134
- package/dist/index.d.ts +106 -134
- package/dist/index.js +473 -343
- package/package.json +2 -1
- package/src/SignInWithYouVersionPKCE.ts +122 -0
- package/src/SignInWithYouVersionResult.ts +40 -39
- package/src/URLBuilder.ts +0 -21
- package/src/Users.ts +375 -94
- package/src/YouVersionPlatformConfiguration.ts +69 -25
- package/src/YouVersionUserInfo.ts +6 -6
- package/src/__tests__/SignInWithYouVersionPKCE.test.ts +418 -0
- package/src/__tests__/SignInWithYouVersionResult.test.ts +28 -0
- package/src/__tests__/StorageStrategy.test.ts +0 -72
- package/src/__tests__/URLBuilder.test.ts +0 -100
- package/src/__tests__/Users.test.ts +737 -0
- package/src/__tests__/YouVersionPlatformConfiguration.test.ts +192 -30
- package/src/__tests__/YouVersionUserInfo.test.ts +347 -0
- package/src/__tests__/highlights.test.ts +12 -12
- package/src/__tests__/mocks/browser.ts +90 -0
- package/src/__tests__/mocks/configuration.ts +53 -0
- package/src/__tests__/mocks/jwt.ts +93 -0
- package/src/__tests__/mocks/tokens.ts +69 -0
- package/src/index.ts +0 -3
- package/src/types/auth.ts +1 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -1
- package/src/AuthenticationStrategy.ts +0 -78
- package/src/WebAuthenticationStrategy.ts +0 -127
- package/src/__tests__/authentication.test.ts +0 -185
- package/src/authentication.ts +0 -27
package/src/Users.ts
CHANGED
|
@@ -1,137 +1,418 @@
|
|
|
1
1
|
import type { SignInWithYouVersionPermissionValues } from './types';
|
|
2
|
-
import { SignInWithYouVersionResult } from './SignInWithYouVersionResult';
|
|
3
2
|
import { YouVersionUserInfo } from './YouVersionUserInfo';
|
|
4
3
|
import { YouVersionPlatformConfiguration } from './YouVersionPlatformConfiguration';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
const MAX_RETRY_ATTEMPTS = 3;
|
|
10
|
-
const RETRY_DELAY_MS = 1000;
|
|
4
|
+
import { SignInWithYouVersionPKCEAuthorizationRequestBuilder } from './SignInWithYouVersionPKCE';
|
|
5
|
+
import { SignInWithYouVersionResult } from './SignInWithYouVersionResult';
|
|
6
|
+
import { SignInWithYouVersionPermission } from './SignInWithYouVersionResult';
|
|
11
7
|
|
|
12
8
|
export class YouVersionAPIUsers {
|
|
13
9
|
/**
|
|
14
10
|
* Presents the YouVersion login flow to the user and returns the login result upon completion.
|
|
15
11
|
*
|
|
16
12
|
* This function authenticates the user with YouVersion, requesting the specified required and optional permissions.
|
|
17
|
-
* The function
|
|
18
|
-
* returning the login result containing the authorization code and granted permissions.
|
|
13
|
+
* The function redirects to the YouVersion authorization URL and expects the callback to be handled separately.
|
|
19
14
|
*
|
|
20
|
-
* @param
|
|
21
|
-
* @param
|
|
22
|
-
* @
|
|
23
|
-
* @throws An error if authentication fails or is cancelled by the user.
|
|
15
|
+
* @param permissions - The set of permissions that must be granted by the user for successful login.
|
|
16
|
+
* @param redirectURL - The URL to redirect back to after authentication.
|
|
17
|
+
* @throws An error if authentication fails or configuration is invalid.
|
|
24
18
|
*/
|
|
25
19
|
static async signIn(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
): Promise<
|
|
29
|
-
// Validate inputs
|
|
30
|
-
if (!requiredPermissions || !(requiredPermissions instanceof Set)) {
|
|
31
|
-
throw new Error('Invalid requiredPermissions: must be a Set');
|
|
32
|
-
}
|
|
33
|
-
if (!optionalPermissions || !(optionalPermissions instanceof Set)) {
|
|
34
|
-
throw new Error('Invalid optionalPermissions: must be a Set');
|
|
35
|
-
}
|
|
36
|
-
|
|
20
|
+
permissions: Set<SignInWithYouVersionPermissionValues>,
|
|
21
|
+
redirectURL: string,
|
|
22
|
+
): Promise<void> {
|
|
37
23
|
const appKey = YouVersionPlatformConfiguration.appKey;
|
|
38
24
|
if (!appKey) {
|
|
39
25
|
throw new Error('YouVersionPlatformConfiguration.appKey must be set before calling signIn');
|
|
40
26
|
}
|
|
41
27
|
|
|
42
|
-
const
|
|
28
|
+
const authorizationRequest = await SignInWithYouVersionPKCEAuthorizationRequestBuilder.make(
|
|
29
|
+
appKey,
|
|
30
|
+
permissions,
|
|
31
|
+
new URL(redirectURL),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Store auth data for callback handler
|
|
35
|
+
localStorage.setItem(
|
|
36
|
+
'youversion-auth-code-verifier',
|
|
37
|
+
authorizationRequest.parameters.codeVerifier,
|
|
38
|
+
);
|
|
39
|
+
const redirectUrlString = redirectURL.toString().endsWith('/')
|
|
40
|
+
? redirectURL.toString().slice(0, -1)
|
|
41
|
+
: redirectURL.toString();
|
|
42
|
+
localStorage.setItem('youversion-auth-redirect-uri', redirectUrlString);
|
|
43
|
+
localStorage.setItem('youversion-auth-state', authorizationRequest.parameters.state);
|
|
44
|
+
|
|
45
|
+
// Simple redirect to authorization URL
|
|
46
|
+
window.location.href = authorizationRequest.url.toString();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Handles the OAuth callback after user authentication.
|
|
51
|
+
*
|
|
52
|
+
* Call this method when your app loads to check if the current URL contains
|
|
53
|
+
* an OAuth callback with authorization code. If found, it exchanges the code
|
|
54
|
+
* for tokens and stores them.
|
|
55
|
+
*
|
|
56
|
+
* @returns Promise<SignInWithYouVersionResult | null> - SignInWithYouVersionResult if callback was handled, null otherwise
|
|
57
|
+
* @throws An error if token exchange fails
|
|
58
|
+
*/
|
|
59
|
+
static async handleAuthCallback(): Promise<SignInWithYouVersionResult | null> {
|
|
60
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
61
|
+
const code = urlParams.get('code');
|
|
62
|
+
const state = urlParams.get('state');
|
|
63
|
+
const error = urlParams.get('error');
|
|
64
|
+
|
|
65
|
+
// Check if this is an OAuth callback
|
|
66
|
+
if (!state && !error) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Handle OAuth error
|
|
71
|
+
if (error) {
|
|
72
|
+
const errorDescription = urlParams.get('error_description') || error;
|
|
73
|
+
throw new Error(`OAuth authentication failed: ${errorDescription}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Verify state parameter
|
|
77
|
+
const storedState = localStorage.getItem('youversion-auth-state');
|
|
78
|
+
if (state !== storedState) {
|
|
79
|
+
throw new Error('Invalid state parameter - possible CSRF attack');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// If we don't have a code, this might be the first callback with user data
|
|
83
|
+
// We need to redirect to the server callback to get the authorization code
|
|
84
|
+
if (!code && state) {
|
|
85
|
+
this.obtainLocation(window.location.href, state);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Get stored auth data
|
|
89
|
+
const codeVerifier = localStorage.getItem('youversion-auth-code-verifier');
|
|
90
|
+
const redirectUri = localStorage.getItem('youversion-auth-redirect-uri');
|
|
91
|
+
|
|
92
|
+
if (!code || !codeVerifier || !redirectUri) {
|
|
93
|
+
throw new Error('Missing required authentication parameters');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Exchange authorization code for tokens
|
|
98
|
+
const tokenRequest = SignInWithYouVersionPKCEAuthorizationRequestBuilder.tokenURLRequest(
|
|
99
|
+
code,
|
|
100
|
+
codeVerifier,
|
|
101
|
+
redirectUri,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const response = await fetch(tokenRequest);
|
|
105
|
+
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(`Token exchange failed: ${response.status} ${response.statusText}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const responseText = await response.text();
|
|
111
|
+
|
|
112
|
+
const tokens = JSON.parse(responseText) as {
|
|
113
|
+
access_token: string;
|
|
114
|
+
expires_in: number;
|
|
115
|
+
id_token: string;
|
|
116
|
+
refresh_token: string;
|
|
117
|
+
scope: string;
|
|
118
|
+
token_type: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Extract user info from ID token
|
|
122
|
+
const result = this.extractSignInResult(tokens);
|
|
123
|
+
|
|
124
|
+
// Store tokens in configuration
|
|
125
|
+
YouVersionPlatformConfiguration.saveAuthData(
|
|
126
|
+
result.accessToken || null,
|
|
127
|
+
result.refreshToken || null,
|
|
128
|
+
result.idToken || null,
|
|
129
|
+
result.expiryDate || null,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Clean up localStorage
|
|
133
|
+
localStorage.removeItem('youversion-auth-code-verifier');
|
|
134
|
+
localStorage.removeItem('youversion-auth-redirect-uri');
|
|
135
|
+
localStorage.removeItem('youversion-auth-state');
|
|
136
|
+
|
|
137
|
+
// Clean up URL
|
|
138
|
+
const cleanUrl = new URL(window.location.href);
|
|
139
|
+
cleanUrl.search = '';
|
|
140
|
+
window.history.replaceState({}, '', cleanUrl.toString());
|
|
141
|
+
|
|
142
|
+
return result;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// Clean up on error
|
|
145
|
+
localStorage.removeItem('youversion-auth-code-verifier');
|
|
146
|
+
localStorage.removeItem('youversion-auth-redirect-uri');
|
|
147
|
+
localStorage.removeItem('youversion-auth-state');
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Redirects to the server callback endpoint to obtain authorization code
|
|
154
|
+
*/
|
|
155
|
+
private static obtainLocation(callbackURL: string, state: string): void {
|
|
156
|
+
const url = new URL(callbackURL);
|
|
157
|
+
const params = new URLSearchParams(url.search);
|
|
158
|
+
|
|
159
|
+
if (params.get('state') !== state) {
|
|
160
|
+
throw new Error('Invalid state parameter');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Redirect to the server callback endpoint with all the current parameters
|
|
164
|
+
const serverCallbackUrl = new URL(
|
|
165
|
+
`https://${YouVersionPlatformConfiguration.apiHost}/auth/callback`,
|
|
166
|
+
);
|
|
167
|
+
params.forEach((value, key) => {
|
|
168
|
+
serverCallbackUrl.searchParams.set(key, value);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
window.location.href = serverCallbackUrl.toString();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Extracts sign-in result from token response
|
|
176
|
+
*/
|
|
177
|
+
private static extractSignInResult(tokens: {
|
|
178
|
+
access_token: string;
|
|
179
|
+
expires_in: number;
|
|
180
|
+
id_token: string;
|
|
181
|
+
refresh_token: string;
|
|
182
|
+
scope: string;
|
|
183
|
+
token_type: string;
|
|
184
|
+
}): SignInWithYouVersionResult {
|
|
185
|
+
const idClaims = this.decodeJWT(tokens.id_token);
|
|
186
|
+
|
|
187
|
+
const permissions = tokens.scope
|
|
188
|
+
.split(' ')
|
|
189
|
+
.map((p) => p.trim())
|
|
190
|
+
.filter((p) => p.length > 0)
|
|
191
|
+
.filter((p): p is SignInWithYouVersionPermissionValues =>
|
|
192
|
+
Object.values(SignInWithYouVersionPermission).includes(
|
|
193
|
+
p as SignInWithYouVersionPermissionValues,
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const resultData = {
|
|
198
|
+
accessToken: tokens.access_token,
|
|
199
|
+
expiresIn: tokens.expires_in,
|
|
200
|
+
refreshToken: tokens.refresh_token,
|
|
201
|
+
idToken: tokens.id_token,
|
|
202
|
+
permissions,
|
|
203
|
+
yvpUserId: idClaims.sub as string,
|
|
204
|
+
name: idClaims.name as string,
|
|
205
|
+
profilePicture: idClaims.profile_picture as string,
|
|
206
|
+
email: idClaims.email as string,
|
|
207
|
+
};
|
|
43
208
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
209
|
+
return new SignInWithYouVersionResult(resultData);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Decodes JWT payload for UI display purposes.
|
|
214
|
+
*
|
|
215
|
+
* Note: We intentionally do not verify the JWT signature here because:
|
|
216
|
+
*
|
|
217
|
+
* 1. YouVersion's backend verifies all tokens on API requests
|
|
218
|
+
* 2. This decoded data is only used for UI display
|
|
219
|
+
* 3. No security decisions are made based on these claims
|
|
220
|
+
*
|
|
221
|
+
* @private
|
|
222
|
+
*/
|
|
48
223
|
|
|
49
|
-
|
|
50
|
-
|
|
224
|
+
private static decodeJWT(token: string): Record<string, any> {
|
|
225
|
+
const segments = token.split('.');
|
|
226
|
+
|
|
227
|
+
if (segments.length !== 3) {
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let base64 = segments[1]?.replace(/-/g, '+').replace(/_/g, '/');
|
|
232
|
+
|
|
233
|
+
while (base64 && base64.length % 4 !== 0) {
|
|
234
|
+
base64 += '=';
|
|
51
235
|
}
|
|
52
236
|
|
|
53
|
-
|
|
237
|
+
try {
|
|
238
|
+
if (base64) {
|
|
239
|
+
const data = atob(base64);
|
|
240
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
241
|
+
return JSON.parse(data);
|
|
242
|
+
} else {
|
|
243
|
+
return {};
|
|
244
|
+
}
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (process.env.NODE_ENV === 'development') {
|
|
247
|
+
console.error('JWT decode failed:', error);
|
|
248
|
+
}
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
54
251
|
}
|
|
55
252
|
|
|
56
253
|
static signOut(): void {
|
|
57
|
-
YouVersionPlatformConfiguration.
|
|
254
|
+
YouVersionPlatformConfiguration.clearAuthTokens();
|
|
58
255
|
}
|
|
59
256
|
|
|
60
257
|
/**
|
|
61
|
-
* Retrieves user information for the authenticated user
|
|
258
|
+
* Retrieves user information for the authenticated user by decoding the provided JWT access token.
|
|
62
259
|
*
|
|
63
|
-
* This function
|
|
260
|
+
* This function extracts the user's profile information directly from the JWT token payload.
|
|
64
261
|
*
|
|
65
|
-
* @param accessToken - The access token obtained from the login process.
|
|
262
|
+
* @param accessToken - The JWT access token obtained from the login process.
|
|
66
263
|
* @returns A Promise resolving to a YouVersionUserInfo object containing the user's profile information.
|
|
67
|
-
* @throws An error if the
|
|
264
|
+
* @throws An error if the access token is invalid or cannot be decoded.
|
|
68
265
|
*/
|
|
69
|
-
static
|
|
266
|
+
static userInfo(idToken: string): YouVersionUserInfo {
|
|
70
267
|
// Validate access token
|
|
71
|
-
if (!
|
|
268
|
+
if (!idToken || typeof idToken !== 'string') {
|
|
72
269
|
throw new Error('Invalid access token: must be a non-empty string');
|
|
73
270
|
}
|
|
74
271
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
272
|
+
try {
|
|
273
|
+
// Decode JWT payload to extract user information
|
|
274
|
+
const claims = this.decodeJWT(idToken);
|
|
275
|
+
|
|
276
|
+
if (!claims || Object.keys(claims).length === 0) {
|
|
277
|
+
throw new Error('Invalid JWT token: Unable to decode token payload');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Map JWT claims to YouVersionUserInfo format
|
|
281
|
+
const userInfoData = {
|
|
282
|
+
id: claims.sub as string,
|
|
283
|
+
name: claims.name as string,
|
|
284
|
+
avatar_url: claims.profile_picture as string,
|
|
285
|
+
email: claims.email as string,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
return new YouVersionUserInfo(userInfoData);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (error instanceof Error) {
|
|
291
|
+
throw new Error(`Failed to decode user information from JWT: ${error.message}`);
|
|
292
|
+
} else {
|
|
293
|
+
throw new Error('Failed to decode user information from JWT: Unknown error');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Refreshes the access token using the stored refresh token.
|
|
300
|
+
*
|
|
301
|
+
* @returns Promise<SignInWithYouVersionResult | null> - New tokens if refresh succeeds, null otherwise
|
|
302
|
+
* @throws An error if refresh fails or no refresh token is available
|
|
303
|
+
*/
|
|
304
|
+
static async refreshTokens(): Promise<SignInWithYouVersionResult | null> {
|
|
305
|
+
const refreshToken = YouVersionPlatformConfiguration.refreshToken;
|
|
306
|
+
const appKey = YouVersionPlatformConfiguration.appKey;
|
|
307
|
+
const existingIdToken = YouVersionPlatformConfiguration.idToken;
|
|
308
|
+
|
|
309
|
+
if (!refreshToken || !existingIdToken) {
|
|
310
|
+
throw new Error('No refresh token or id token available');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!appKey) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
'YouVersionPlatformConfiguration.appKey must be set before refreshing tokens',
|
|
85
316
|
);
|
|
86
317
|
}
|
|
87
318
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
319
|
+
try {
|
|
320
|
+
const url = new URL(`https://${YouVersionPlatformConfiguration.apiHost}/auth/token`);
|
|
321
|
+
|
|
322
|
+
const parameters = new URLSearchParams({
|
|
323
|
+
grant_type: 'refresh_token',
|
|
324
|
+
refresh_token: refreshToken,
|
|
325
|
+
client_id: appKey,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const request = new Request(url, {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
body: parameters,
|
|
331
|
+
headers: {
|
|
332
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const response = await fetch(request);
|
|
337
|
+
|
|
338
|
+
if (!response.ok) {
|
|
339
|
+
throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const tokens = (await response.json()) as {
|
|
343
|
+
access_token: string;
|
|
344
|
+
expires_in: number;
|
|
345
|
+
refresh_token: string;
|
|
346
|
+
scope: string;
|
|
347
|
+
token_type: string;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Create result with new tokens but preserve user info
|
|
351
|
+
const result = new SignInWithYouVersionResult({
|
|
352
|
+
accessToken: tokens.access_token,
|
|
353
|
+
expiresIn: tokens.expires_in,
|
|
354
|
+
refreshToken: tokens.refresh_token,
|
|
355
|
+
idToken: existingIdToken,
|
|
356
|
+
permissions: tokens.scope
|
|
357
|
+
.split(' ')
|
|
358
|
+
.map((p) => p.trim())
|
|
359
|
+
.filter((p) => p.length > 0)
|
|
360
|
+
.filter((p): p is SignInWithYouVersionPermissionValues =>
|
|
361
|
+
Object.values(SignInWithYouVersionPermission).includes(
|
|
362
|
+
p as SignInWithYouVersionPermissionValues,
|
|
363
|
+
),
|
|
364
|
+
),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Store updated tokens
|
|
368
|
+
YouVersionPlatformConfiguration.saveAuthData(
|
|
369
|
+
result.accessToken || null,
|
|
370
|
+
result.refreshToken || null,
|
|
371
|
+
result.idToken || null,
|
|
372
|
+
result.expiryDate || null,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
return result;
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (error instanceof Error) {
|
|
378
|
+
throw new Error(`Token refresh failed: ${error.message}`);
|
|
379
|
+
} else {
|
|
380
|
+
throw new Error('Token refresh failed: Unknown error');
|
|
132
381
|
}
|
|
133
382
|
}
|
|
383
|
+
}
|
|
134
384
|
|
|
135
|
-
|
|
385
|
+
/**
|
|
386
|
+
* Checks if the current access token is expired or about to expire.
|
|
387
|
+
*
|
|
388
|
+
* @returns true if token is expired or about to expire
|
|
389
|
+
*/
|
|
390
|
+
static isTokenExpired(): boolean {
|
|
391
|
+
const expiryDate = YouVersionPlatformConfiguration.tokenExpiryDate;
|
|
392
|
+
if (!expiryDate) {
|
|
393
|
+
return true; // No expiry date means no token or invalid token
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return new Date().getTime() >= expiryDate.getTime();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Refreshes the access token if it's expired or about to expire.
|
|
401
|
+
*
|
|
402
|
+
* @returns Promise<boolean> - true if refresh was successful or not needed, false if failed
|
|
403
|
+
*/
|
|
404
|
+
static async refreshTokenIfNeeded(): Promise<boolean> {
|
|
405
|
+
if (!this.isTokenExpired()) {
|
|
406
|
+
return true; // Token is still valid
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const result = await this.refreshTokens();
|
|
411
|
+
return !!result;
|
|
412
|
+
} catch {
|
|
413
|
+
// Refresh failed, clear tokens
|
|
414
|
+
YouVersionPlatformConfiguration.clearAuthTokens();
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
136
417
|
}
|
|
137
418
|
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Security Note: Tokens are stored in localStorage for persistence.
|
|
3
|
+
* Ensure your application follows XSS prevention best practices:
|
|
4
|
+
* - Sanitize user input
|
|
5
|
+
* - Use Content Security Policy headers
|
|
6
|
+
* - Avoid innerHTML with untrusted content
|
|
7
|
+
*/
|
|
3
8
|
export class YouVersionPlatformConfiguration {
|
|
4
9
|
private static _appKey: string | null = null;
|
|
5
10
|
private static _installationId: string | null = null;
|
|
6
|
-
private static _accessToken: string | null = null;
|
|
7
11
|
private static _apiHost: string = 'api.youversion.com';
|
|
8
|
-
private static
|
|
9
|
-
private static
|
|
12
|
+
private static _refreshTokenKey: string | null = null;
|
|
13
|
+
private static _expiryDateKey: string | null = null;
|
|
10
14
|
|
|
11
15
|
private static getOrSetInstallationId(): string {
|
|
12
16
|
if (typeof window === 'undefined') {
|
|
@@ -23,6 +27,58 @@ export class YouVersionPlatformConfiguration {
|
|
|
23
27
|
return newId;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
public static saveAuthData(
|
|
31
|
+
accessToken: string | null,
|
|
32
|
+
refreshToken: string | null,
|
|
33
|
+
idToken: string | null,
|
|
34
|
+
expiryDate: Date | null,
|
|
35
|
+
): void {
|
|
36
|
+
if (accessToken !== null) {
|
|
37
|
+
localStorage.setItem('accessToken', accessToken);
|
|
38
|
+
} else {
|
|
39
|
+
localStorage.removeItem('accessToken');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (refreshToken !== null) {
|
|
43
|
+
localStorage.setItem('refreshToken', refreshToken);
|
|
44
|
+
} else {
|
|
45
|
+
localStorage.removeItem('refreshToken');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (idToken !== null) {
|
|
49
|
+
localStorage.setItem('idToken', idToken);
|
|
50
|
+
} else {
|
|
51
|
+
localStorage.removeItem('idToken');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (expiryDate !== null) {
|
|
55
|
+
localStorage.setItem('expiryDate', expiryDate.toISOString());
|
|
56
|
+
} else {
|
|
57
|
+
localStorage.removeItem('expiryDate');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public static clearAuthTokens(): void {
|
|
62
|
+
this.saveAuthData(null, null, null, null);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public static get accessToken(): string | null {
|
|
66
|
+
return localStorage.getItem('accessToken');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public static get refreshToken(): string | null {
|
|
70
|
+
return localStorage.getItem('refreshToken');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public static get idToken(): string | null {
|
|
74
|
+
return localStorage.getItem('idToken');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public static get tokenExpiryDate(): Date | null {
|
|
78
|
+
const dateString = localStorage.getItem('expiryDate');
|
|
79
|
+
return dateString ? new Date(dateString) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
26
82
|
static get appKey(): string | null {
|
|
27
83
|
return this._appKey;
|
|
28
84
|
}
|
|
@@ -42,18 +98,6 @@ export class YouVersionPlatformConfiguration {
|
|
|
42
98
|
this._installationId = value || this.getOrSetInstallationId();
|
|
43
99
|
}
|
|
44
100
|
|
|
45
|
-
static setAccessToken(token: string | null): void {
|
|
46
|
-
// Validate token: if not null, must be a non-empty string
|
|
47
|
-
if (token !== null && (typeof token !== 'string' || token.trim().length === 0)) {
|
|
48
|
-
throw new Error('Access token must be a non-empty string or null');
|
|
49
|
-
}
|
|
50
|
-
this._accessToken = token;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
static get accessToken(): string | null {
|
|
54
|
-
return this._accessToken;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
101
|
static get apiHost(): string {
|
|
58
102
|
return this._apiHost;
|
|
59
103
|
}
|
|
@@ -62,19 +106,19 @@ export class YouVersionPlatformConfiguration {
|
|
|
62
106
|
this._apiHost = value;
|
|
63
107
|
}
|
|
64
108
|
|
|
65
|
-
static get
|
|
66
|
-
return this.
|
|
109
|
+
static get refreshTokenKey(): string | null {
|
|
110
|
+
return this._refreshTokenKey;
|
|
67
111
|
}
|
|
68
112
|
|
|
69
|
-
static set
|
|
70
|
-
this.
|
|
113
|
+
static set refreshTokenKey(value: string) {
|
|
114
|
+
this._refreshTokenKey = value;
|
|
71
115
|
}
|
|
72
116
|
|
|
73
|
-
static get
|
|
74
|
-
return this.
|
|
117
|
+
static get expiryDateKey(): string | null {
|
|
118
|
+
return this._expiryDateKey;
|
|
75
119
|
}
|
|
76
120
|
|
|
77
|
-
static set
|
|
78
|
-
this.
|
|
121
|
+
static set expiryDateKey(value: string) {
|
|
122
|
+
this._expiryDateKey = value;
|
|
79
123
|
}
|
|
80
124
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
export interface YouVersionUserInfoJSON {
|
|
2
|
-
|
|
3
|
-
last_name?: string;
|
|
2
|
+
name?: string;
|
|
4
3
|
id?: string;
|
|
5
4
|
avatar_url?: string;
|
|
5
|
+
email?: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export class YouVersionUserInfo {
|
|
9
|
-
readonly
|
|
10
|
-
readonly lastName?: string;
|
|
9
|
+
readonly name?: string;
|
|
11
10
|
readonly userId?: string;
|
|
11
|
+
readonly email?: string;
|
|
12
12
|
readonly avatarUrlFormat?: string;
|
|
13
13
|
|
|
14
14
|
constructor(data: YouVersionUserInfoJSON) {
|
|
@@ -16,9 +16,9 @@ export class YouVersionUserInfo {
|
|
|
16
16
|
throw new Error('Invalid user data provided');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
this.
|
|
20
|
-
this.lastName = data.last_name;
|
|
19
|
+
this.name = data.name;
|
|
21
20
|
this.userId = data.id;
|
|
21
|
+
this.email = data.email;
|
|
22
22
|
this.avatarUrlFormat = data.avatar_url;
|
|
23
23
|
}
|
|
24
24
|
|