@youversion/platform-core 0.4.1
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/.env.example +7 -0
- package/.env.local +10 -0
- package/.turbo/turbo-build.log +18 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +201 -0
- package/README.md +369 -0
- package/dist/index.cjs +1330 -0
- package/dist/index.d.cts +737 -0
- package/dist/index.d.ts +737 -0
- package/dist/index.js +1286 -0
- package/package.json +46 -0
- package/src/AuthenticationStrategy.ts +78 -0
- package/src/SignInWithYouVersionResult.ts +53 -0
- package/src/StorageStrategy.ts +81 -0
- package/src/URLBuilder.ts +71 -0
- package/src/Users.ts +137 -0
- package/src/WebAuthenticationStrategy.ts +127 -0
- package/src/YouVersionAPI.ts +27 -0
- package/src/YouVersionPlatformConfiguration.ts +80 -0
- package/src/YouVersionUserInfo.ts +49 -0
- package/src/__tests__/StorageStrategy.test.ts +404 -0
- package/src/__tests__/URLBuilder.test.ts +289 -0
- package/src/__tests__/YouVersionPlatformConfiguration.test.ts +150 -0
- package/src/__tests__/authentication.test.ts +174 -0
- package/src/__tests__/bible.test.ts +356 -0
- package/src/__tests__/client.test.ts +109 -0
- package/src/__tests__/handlers.ts +41 -0
- package/src/__tests__/highlights.test.ts +485 -0
- package/src/__tests__/languages.test.ts +139 -0
- package/src/__tests__/setup.ts +17 -0
- package/src/authentication.ts +27 -0
- package/src/bible.ts +272 -0
- package/src/client.ts +162 -0
- package/src/highlight.ts +16 -0
- package/src/highlights.ts +173 -0
- package/src/index.ts +20 -0
- package/src/languages.ts +80 -0
- package/src/schemas/bible-index.ts +48 -0
- package/src/schemas/book.ts +34 -0
- package/src/schemas/chapter.ts +24 -0
- package/src/schemas/collection.ts +28 -0
- package/src/schemas/highlight.ts +23 -0
- package/src/schemas/index.ts +11 -0
- package/src/schemas/language.ts +38 -0
- package/src/schemas/passage.ts +14 -0
- package/src/schemas/user.ts +10 -0
- package/src/schemas/verse.ts +17 -0
- package/src/schemas/version.ts +31 -0
- package/src/schemas/votd.ts +10 -0
- package/src/types/api-config.ts +9 -0
- package/src/types/auth.ts +15 -0
- package/src/types/book.ts +116 -0
- package/src/types/chapter.ts +5 -0
- package/src/types/highlight.ts +9 -0
- package/src/types/index.ts +22 -0
- package/src/utils/constants.ts +219 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +12 -0
- package/vitest.config.ts +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@youversion/platform-core",
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/youversion/platform-sdk-react",
|
|
11
|
+
"directory": "packages/core"
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.cjs",
|
|
14
|
+
"module": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"require": "./dist/index.cjs"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@vitest/coverage-v8": "^4.0.4",
|
|
25
|
+
"dotenv-cli": "7.4.2",
|
|
26
|
+
"eslint": "9.38.0",
|
|
27
|
+
"jsdom": "^24.0.0",
|
|
28
|
+
"msw": "2.11.6",
|
|
29
|
+
"typescript": "5.9.3",
|
|
30
|
+
"vitest": "4.0.4",
|
|
31
|
+
"@internal/eslint-config": "0.0.0",
|
|
32
|
+
"@internal/tsconfig": "0.0.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"tsup": "8.5.0",
|
|
36
|
+
"zod": "4.1.12"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
40
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
41
|
+
"lint": "eslint . --max-warnings 0",
|
|
42
|
+
"test": "dotenv -e .env.local -- vitest run",
|
|
43
|
+
"test:watch": "dotenv -e .env.local -- vitest",
|
|
44
|
+
"test:coverage": "dotenv -e .env.local -- vitest run --coverage"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-agnostic authentication strategy interface
|
|
3
|
+
*
|
|
4
|
+
* Implementations should handle platform-specific authentication flows
|
|
5
|
+
* and return the callback URL containing the authentication result.
|
|
6
|
+
*/
|
|
7
|
+
export interface AuthenticationStrategy {
|
|
8
|
+
/**
|
|
9
|
+
* Opens the authentication flow and returns the callback URL
|
|
10
|
+
*
|
|
11
|
+
* @param authUrl - The YouVersion authorization URL to open
|
|
12
|
+
* @returns Promise that resolves to the callback URL with auth result
|
|
13
|
+
* @throws Error if authentication fails or is cancelled
|
|
14
|
+
*/
|
|
15
|
+
authenticate(authUrl: URL): Promise<URL>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Registry for platform-specific authentication strategies
|
|
20
|
+
*
|
|
21
|
+
* This singleton registry manages the current authentication strategy
|
|
22
|
+
* and ensures only one strategy is active at a time.
|
|
23
|
+
*/
|
|
24
|
+
export class AuthenticationStrategyRegistry {
|
|
25
|
+
private static strategy: AuthenticationStrategy | null = null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Registers a platform-specific authentication strategy
|
|
29
|
+
*
|
|
30
|
+
* @param strategy - The authentication strategy to register
|
|
31
|
+
* @throws Error if strategy is null, undefined, or missing required methods
|
|
32
|
+
*/
|
|
33
|
+
static register(strategy: AuthenticationStrategy): void {
|
|
34
|
+
if (!strategy) {
|
|
35
|
+
throw new Error('Authentication strategy cannot be null or undefined');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (typeof strategy.authenticate !== 'function') {
|
|
39
|
+
throw new Error('Authentication strategy must implement authenticate method');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.strategy = strategy;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Gets the currently registered authentication strategy
|
|
47
|
+
*
|
|
48
|
+
* @returns The registered authentication strategy
|
|
49
|
+
* @throws Error if no strategy has been registered
|
|
50
|
+
*/
|
|
51
|
+
static get(): AuthenticationStrategy {
|
|
52
|
+
if (!this.strategy) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
'No authentication strategy registered. Please register a platform-specific strategy using AuthenticationStrategyRegistry.register()',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return this.strategy;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Checks if a strategy is currently registered
|
|
62
|
+
*
|
|
63
|
+
* @returns true if a strategy is registered, false otherwise
|
|
64
|
+
*/
|
|
65
|
+
static isRegistered(): boolean {
|
|
66
|
+
return this.strategy !== null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resets the registry by removing the current strategy
|
|
71
|
+
*
|
|
72
|
+
* This method is primarily intended for testing scenarios
|
|
73
|
+
* where you need to clean up between test cases.
|
|
74
|
+
*/
|
|
75
|
+
static reset(): void {
|
|
76
|
+
this.strategy = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SignInWithYouVersionPermissionValues } from './types';
|
|
2
|
+
|
|
3
|
+
export const SignInWithYouVersionPermission = {
|
|
4
|
+
bibles: 'bibles',
|
|
5
|
+
highlights: 'highlights',
|
|
6
|
+
votd: 'votd',
|
|
7
|
+
demographics: 'demographics',
|
|
8
|
+
bibleActivity: 'bible_activity',
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export class SignInWithYouVersionResult {
|
|
12
|
+
public readonly accessToken: string | null;
|
|
13
|
+
public readonly permissions: SignInWithYouVersionPermissionValues[];
|
|
14
|
+
public readonly errorMsg: string | null;
|
|
15
|
+
public readonly yvpUserId: string | null;
|
|
16
|
+
|
|
17
|
+
constructor(url: URL) {
|
|
18
|
+
const queryParams = new URLSearchParams(url.search);
|
|
19
|
+
|
|
20
|
+
const status = queryParams.get('status');
|
|
21
|
+
const userId = queryParams.get('yvp_user_id');
|
|
22
|
+
const latValue = queryParams.get('lat');
|
|
23
|
+
const grants = queryParams.get('grants');
|
|
24
|
+
|
|
25
|
+
const perms =
|
|
26
|
+
grants
|
|
27
|
+
?.split(',')
|
|
28
|
+
.map((grant) => grant.trim())
|
|
29
|
+
.filter((grant) =>
|
|
30
|
+
Object.values(SignInWithYouVersionPermission).includes(
|
|
31
|
+
grant as SignInWithYouVersionPermissionValues,
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
.map((grant) => grant as SignInWithYouVersionPermissionValues) ?? [];
|
|
35
|
+
|
|
36
|
+
if (status === 'success' && latValue && userId) {
|
|
37
|
+
this.accessToken = latValue;
|
|
38
|
+
this.permissions = perms;
|
|
39
|
+
this.errorMsg = null;
|
|
40
|
+
this.yvpUserId = userId;
|
|
41
|
+
} else if (status === 'canceled') {
|
|
42
|
+
this.accessToken = null;
|
|
43
|
+
this.permissions = [];
|
|
44
|
+
this.errorMsg = null;
|
|
45
|
+
this.yvpUserId = null;
|
|
46
|
+
} else {
|
|
47
|
+
this.accessToken = null;
|
|
48
|
+
this.permissions = [];
|
|
49
|
+
this.errorMsg = 'Authentication failed';
|
|
50
|
+
this.yvpUserId = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract storage strategy for auth-related data (callbacks, return URLs).
|
|
3
|
+
* Implementations can use different mechanisms (sessionStorage, memory, etc.)
|
|
4
|
+
*
|
|
5
|
+
* WARNING: Session storage has known XSS vulnerabilities. An attacker that gains
|
|
6
|
+
* script execution access (via XSS) can read all values in sessionStorage. Consider
|
|
7
|
+
* using a secure, HTTP-only cookie mechanism for production applications.
|
|
8
|
+
*/
|
|
9
|
+
export interface StorageStrategy {
|
|
10
|
+
setItem(key: string, value: string): void;
|
|
11
|
+
getItem(key: string): string | null;
|
|
12
|
+
removeItem(key: string): void;
|
|
13
|
+
clear(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default storage strategy using the browser's sessionStorage.
|
|
18
|
+
* This provides temporary storage for authentication state during a single session.
|
|
19
|
+
*
|
|
20
|
+
* SECURITY WARNING: SessionStorage is vulnerable to XSS attacks. If an attacker
|
|
21
|
+
* can inject JavaScript into your application, they can access all sessionStorage values.
|
|
22
|
+
* For production applications handling sensitive authentication tokens, consider:
|
|
23
|
+
* 1. Using secure, HTTP-only cookies instead
|
|
24
|
+
* 2. Storing tokens in memory only (with redirection to re-authenticate on page reload)
|
|
25
|
+
* 3. Using a custom storage backend that implements additional security measures
|
|
26
|
+
*/
|
|
27
|
+
export class SessionStorageStrategy implements StorageStrategy {
|
|
28
|
+
setItem(key: string, value: string): void {
|
|
29
|
+
if (typeof sessionStorage === 'undefined') {
|
|
30
|
+
console.warn('SessionStorage is not available in this environment');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
sessionStorage.setItem(key, value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getItem(key: string): string | null {
|
|
37
|
+
if (typeof sessionStorage === 'undefined') {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return sessionStorage.getItem(key);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
removeItem(key: string): void {
|
|
44
|
+
if (typeof sessionStorage === 'undefined') {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
sessionStorage.removeItem(key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
clear(): void {
|
|
51
|
+
if (typeof sessionStorage === 'undefined') {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
sessionStorage.clear();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* In-memory storage strategy that stores data in a Map.
|
|
60
|
+
* This storage is cleared when the page is refreshed.
|
|
61
|
+
* Provides better XSS protection than sessionStorage but requires re-authentication on page reload.
|
|
62
|
+
*/
|
|
63
|
+
export class MemoryStorageStrategy implements StorageStrategy {
|
|
64
|
+
private store = new Map<string, string>();
|
|
65
|
+
|
|
66
|
+
setItem(key: string, value: string): void {
|
|
67
|
+
this.store.set(key, value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getItem(key: string): string | null {
|
|
71
|
+
return this.store.get(key) ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
removeItem(key: string): void {
|
|
75
|
+
this.store.delete(key);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
clear(): void {
|
|
79
|
+
this.store.clear();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { SignInWithYouVersionPermissionValues } from './types';
|
|
2
|
+
import { YouVersionPlatformConfiguration } from './YouVersionPlatformConfiguration';
|
|
3
|
+
|
|
4
|
+
export class URLBuilder {
|
|
5
|
+
private static get baseURL(): URL {
|
|
6
|
+
return new URL(`https://${YouVersionPlatformConfiguration.apiHost}`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
static authURL(
|
|
10
|
+
appId: string,
|
|
11
|
+
requiredPermissions: Set<SignInWithYouVersionPermissionValues> = new Set<SignInWithYouVersionPermissionValues>(),
|
|
12
|
+
optionalPermissions: Set<SignInWithYouVersionPermissionValues> = new Set<SignInWithYouVersionPermissionValues>(),
|
|
13
|
+
): URL {
|
|
14
|
+
if (typeof appId !== 'string' || appId.trim().length === 0) {
|
|
15
|
+
throw new Error('appId must be a non-empty string');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(this.baseURL);
|
|
20
|
+
url.pathname = '/auth/login';
|
|
21
|
+
|
|
22
|
+
// Add query parameters
|
|
23
|
+
const searchParams = new URLSearchParams();
|
|
24
|
+
searchParams.append('app_id', appId);
|
|
25
|
+
searchParams.append('language', 'en'); // TODO: load from system
|
|
26
|
+
|
|
27
|
+
if (requiredPermissions.size > 0) {
|
|
28
|
+
const requiredList = Array.from(requiredPermissions).map((p) => p.toString());
|
|
29
|
+
searchParams.append('required_perms', requiredList.join(','));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (optionalPermissions.size > 0) {
|
|
33
|
+
const optionalList = Array.from(optionalPermissions).map((p) => p.toString());
|
|
34
|
+
searchParams.append('opt_perms', optionalList.join(','));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const installationId = YouVersionPlatformConfiguration.installationId;
|
|
38
|
+
if (installationId) {
|
|
39
|
+
searchParams.append('x-yvp-installation-id', installationId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
url.search = searchParams.toString();
|
|
43
|
+
return url;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Failed to construct auth URL: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static userURL(accessToken: string): URL {
|
|
52
|
+
if (typeof accessToken !== 'string' || accessToken.trim().length === 0) {
|
|
53
|
+
throw new Error('accessToken must be a non-empty string');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(this.baseURL);
|
|
58
|
+
url.pathname = '/auth/me';
|
|
59
|
+
|
|
60
|
+
const searchParams = new URLSearchParams();
|
|
61
|
+
searchParams.append('lat', accessToken);
|
|
62
|
+
|
|
63
|
+
url.search = searchParams.toString();
|
|
64
|
+
return url;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Failed to construct user URL: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/Users.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { SignInWithYouVersionPermissionValues } from './types';
|
|
2
|
+
import { SignInWithYouVersionResult } from './SignInWithYouVersionResult';
|
|
3
|
+
import { YouVersionUserInfo } from './YouVersionUserInfo';
|
|
4
|
+
import { YouVersionPlatformConfiguration } from './YouVersionPlatformConfiguration';
|
|
5
|
+
import { YouVersionAPI } from './YouVersionAPI';
|
|
6
|
+
import { URLBuilder } from './URLBuilder';
|
|
7
|
+
import { AuthenticationStrategyRegistry } from './AuthenticationStrategy';
|
|
8
|
+
|
|
9
|
+
const MAX_RETRY_ATTEMPTS = 3;
|
|
10
|
+
const RETRY_DELAY_MS = 1000;
|
|
11
|
+
|
|
12
|
+
export class YouVersionAPIUsers {
|
|
13
|
+
/**
|
|
14
|
+
* Presents the YouVersion login flow to the user and returns the login result upon completion.
|
|
15
|
+
*
|
|
16
|
+
* This function authenticates the user with YouVersion, requesting the specified required and optional permissions.
|
|
17
|
+
* The function returns a promise that resolves when the user completes or cancels the login flow,
|
|
18
|
+
* returning the login result containing the authorization code and granted permissions.
|
|
19
|
+
*
|
|
20
|
+
* @param requiredPermissions - The set of permissions that must be granted by the user for successful login.
|
|
21
|
+
* @param optionalPermissions - The set of permissions that will be requested from the user but are not required for successful login.
|
|
22
|
+
* @returns A Promise resolving to a SignInWithYouVersionResult containing the authorization code and granted permissions upon successful login.
|
|
23
|
+
* @throws An error if authentication fails or is cancelled by the user.
|
|
24
|
+
*/
|
|
25
|
+
static async signIn(
|
|
26
|
+
requiredPermissions: Set<SignInWithYouVersionPermissionValues>,
|
|
27
|
+
optionalPermissions: Set<SignInWithYouVersionPermissionValues>,
|
|
28
|
+
): Promise<SignInWithYouVersionResult> {
|
|
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
|
+
|
|
37
|
+
const appId = YouVersionPlatformConfiguration.appId;
|
|
38
|
+
if (!appId) {
|
|
39
|
+
throw new Error('YouVersionPlatformConfiguration.appId must be set before calling signIn');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const url = URLBuilder.authURL(appId, requiredPermissions, optionalPermissions);
|
|
43
|
+
|
|
44
|
+
// Use the registered authentication strategy
|
|
45
|
+
const strategy = AuthenticationStrategyRegistry.get();
|
|
46
|
+
const callbackUrl = await strategy.authenticate(url);
|
|
47
|
+
const result = new SignInWithYouVersionResult(callbackUrl);
|
|
48
|
+
|
|
49
|
+
if (result.accessToken) {
|
|
50
|
+
YouVersionPlatformConfiguration.setAccessToken(result.accessToken);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static signOut(): void {
|
|
57
|
+
YouVersionPlatformConfiguration.setAccessToken(null);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Retrieves user information for the authenticated user using the provided access token.
|
|
62
|
+
*
|
|
63
|
+
* This function fetches the user's profile information from the YouVersion API, decoding it into a YouVersionUserInfo model.
|
|
64
|
+
*
|
|
65
|
+
* @param accessToken - The access token obtained from the login process.
|
|
66
|
+
* @returns A Promise resolving to a YouVersionUserInfo object containing the user's profile information.
|
|
67
|
+
* @throws An error if the URL is invalid, the network request fails, or the response cannot be decoded.
|
|
68
|
+
*/
|
|
69
|
+
static async userInfo(accessToken: string): Promise<YouVersionUserInfo> {
|
|
70
|
+
// Validate access token
|
|
71
|
+
if (!accessToken || typeof accessToken !== 'string') {
|
|
72
|
+
throw new Error('Invalid access token: must be a non-empty string');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check for preview mode if configured
|
|
76
|
+
if (YouVersionPlatformConfiguration.isPreviewMode && accessToken === 'preview') {
|
|
77
|
+
return (
|
|
78
|
+
YouVersionPlatformConfiguration.previewUserInfo ||
|
|
79
|
+
new YouVersionUserInfo({
|
|
80
|
+
first_name: 'Preview',
|
|
81
|
+
last_name: 'User',
|
|
82
|
+
id: 'preview-user',
|
|
83
|
+
avatar_url: undefined,
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const url = URLBuilder.userURL(accessToken);
|
|
89
|
+
|
|
90
|
+
const request = YouVersionAPI.addStandardHeaders(url);
|
|
91
|
+
|
|
92
|
+
// Retry logic for transient failures
|
|
93
|
+
let lastError: Error | null = null;
|
|
94
|
+
for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) {
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(request);
|
|
97
|
+
|
|
98
|
+
if (response.status === 401) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
'Authentication failed: Invalid or expired access token. Please sign in again.',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (response.status === 403) {
|
|
105
|
+
throw new Error('Access denied: Insufficient permissions to retrieve user information');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (response.status !== 200) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Failed to retrieve user information: Server responded with status ${response.status}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const data = (await response.json()) as YouVersionUserInfo;
|
|
115
|
+
return data;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
lastError = error instanceof Error ? error : new Error('Failed to parse server response');
|
|
118
|
+
|
|
119
|
+
// Don't retry on authentication errors
|
|
120
|
+
if (
|
|
121
|
+
error instanceof Error &&
|
|
122
|
+
(error.message.includes('401') || error.message.includes('403'))
|
|
123
|
+
) {
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Retry on network errors
|
|
128
|
+
if (attempt < MAX_RETRY_ATTEMPTS) {
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * attempt));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
throw lastError || new Error('Failed to retrieve user information after multiple attempts');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { AuthenticationStrategy } from './AuthenticationStrategy';
|
|
2
|
+
import { SessionStorageStrategy, type StorageStrategy } from './StorageStrategy';
|
|
3
|
+
|
|
4
|
+
/* Web-based authentication strategy
|
|
5
|
+
* Uses redirect flow
|
|
6
|
+
*/
|
|
7
|
+
export class WebAuthenticationStrategy implements AuthenticationStrategy {
|
|
8
|
+
private redirectUri: string;
|
|
9
|
+
private callbackPath: string;
|
|
10
|
+
private timeout: number;
|
|
11
|
+
private storage: StorageStrategy;
|
|
12
|
+
private static pendingAuthResolve: ((url: URL) => void) | null = null;
|
|
13
|
+
private static pendingAuthReject: ((error: Error) => void) | null = null;
|
|
14
|
+
private static timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
15
|
+
|
|
16
|
+
constructor(options?: {
|
|
17
|
+
redirectUri?: string;
|
|
18
|
+
callbackPath?: string;
|
|
19
|
+
timeout?: number;
|
|
20
|
+
storage?: StorageStrategy;
|
|
21
|
+
}) {
|
|
22
|
+
this.callbackPath = options?.callbackPath ?? '/auth/callback';
|
|
23
|
+
this.redirectUri = options?.redirectUri ?? window.location.origin + this.callbackPath;
|
|
24
|
+
this.timeout = options?.timeout ?? 300000; // 5 minutes default
|
|
25
|
+
this.storage = options?.storage ?? new SessionStorageStrategy();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async authenticate(authUrl: URL): Promise<URL> {
|
|
29
|
+
// Update the redirect URI in the auth URL
|
|
30
|
+
authUrl.searchParams.set('redirect_uri', this.redirectUri);
|
|
31
|
+
|
|
32
|
+
return this.authenticateWithRedirect(authUrl);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Call this method when your app loads to handle the redirect callback
|
|
37
|
+
*/
|
|
38
|
+
static handleCallback(callbackPath: string = '/auth/callback'): boolean {
|
|
39
|
+
const currentUrl = new URL(window.location.href);
|
|
40
|
+
|
|
41
|
+
// Check if this is a callback URL
|
|
42
|
+
if (currentUrl.pathname === callbackPath && currentUrl.searchParams.has('status')) {
|
|
43
|
+
// For web apps, use the HTTP URL directly (no need to convert to youversionauth scheme)
|
|
44
|
+
const callbackUrl = new URL(currentUrl.toString());
|
|
45
|
+
|
|
46
|
+
if (WebAuthenticationStrategy.pendingAuthResolve) {
|
|
47
|
+
WebAuthenticationStrategy.pendingAuthResolve(callbackUrl);
|
|
48
|
+
WebAuthenticationStrategy.cleanup();
|
|
49
|
+
} else {
|
|
50
|
+
// Store the callback URL for later retrieval
|
|
51
|
+
const storageStrategy = new SessionStorageStrategy();
|
|
52
|
+
storageStrategy.setItem('youversion-auth-callback', callbackUrl.toString());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const storageStrategy = new SessionStorageStrategy();
|
|
56
|
+
const returnUrl = storageStrategy.getItem('youversion-auth-return') ?? '/';
|
|
57
|
+
storageStrategy.removeItem('youversion-auth-return');
|
|
58
|
+
window.history.replaceState({}, '', returnUrl);
|
|
59
|
+
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Clean up pending authentication state
|
|
68
|
+
*/
|
|
69
|
+
static cleanup(): void {
|
|
70
|
+
if (WebAuthenticationStrategy.timeoutId) {
|
|
71
|
+
clearTimeout(WebAuthenticationStrategy.timeoutId);
|
|
72
|
+
WebAuthenticationStrategy.timeoutId = null;
|
|
73
|
+
}
|
|
74
|
+
WebAuthenticationStrategy.pendingAuthResolve = null;
|
|
75
|
+
WebAuthenticationStrategy.pendingAuthReject = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Retrieve stored callback result if available
|
|
80
|
+
*/
|
|
81
|
+
static getStoredCallback(): URL | null {
|
|
82
|
+
const storageStrategy = new SessionStorageStrategy();
|
|
83
|
+
const stored = storageStrategy.getItem('youversion-auth-callback');
|
|
84
|
+
if (stored) {
|
|
85
|
+
storageStrategy.removeItem('youversion-auth-callback');
|
|
86
|
+
try {
|
|
87
|
+
return new URL(stored);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private authenticateWithRedirect(authUrl: URL): Promise<URL> {
|
|
96
|
+
// Clean up any existing state
|
|
97
|
+
WebAuthenticationStrategy.cleanup();
|
|
98
|
+
|
|
99
|
+
// Store return URL in configured storage
|
|
100
|
+
this.storage.setItem('youversion-auth-return', window.location.href);
|
|
101
|
+
|
|
102
|
+
// Set up the promise that will be resolved when we come back
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
WebAuthenticationStrategy.pendingAuthResolve = resolve;
|
|
105
|
+
WebAuthenticationStrategy.pendingAuthReject = reject;
|
|
106
|
+
|
|
107
|
+
// Set up timeout
|
|
108
|
+
WebAuthenticationStrategy.timeoutId = setTimeout(() => {
|
|
109
|
+
WebAuthenticationStrategy.cleanup();
|
|
110
|
+
reject(new Error('Authentication timeout'));
|
|
111
|
+
}, this.timeout);
|
|
112
|
+
|
|
113
|
+
// Handle cases where navigation might fail
|
|
114
|
+
try {
|
|
115
|
+
// Redirect to auth URL
|
|
116
|
+
window.location.href = authUrl.toString();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
WebAuthenticationStrategy.cleanup();
|
|
119
|
+
reject(
|
|
120
|
+
new Error(
|
|
121
|
+
`Failed to navigate to auth URL: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { YouVersionPlatformConfiguration } from './YouVersionPlatformConfiguration';
|
|
2
|
+
|
|
3
|
+
export class YouVersionAPI {
|
|
4
|
+
static addStandardHeaders(url: URL): Request {
|
|
5
|
+
const headers: Record<string, string> = {
|
|
6
|
+
Accept: 'application/json',
|
|
7
|
+
'Content-Type': 'application/json',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Add app ID header
|
|
11
|
+
const appId = YouVersionPlatformConfiguration.appId;
|
|
12
|
+
if (appId) {
|
|
13
|
+
headers['X-App-Id'] = appId;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Add installation ID header
|
|
17
|
+
const installationId = YouVersionPlatformConfiguration.installationId;
|
|
18
|
+
if (installationId) {
|
|
19
|
+
headers['x-yvp-installation-id'] = installationId;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const request = new Request(url.toString(), {
|
|
23
|
+
headers,
|
|
24
|
+
});
|
|
25
|
+
return request;
|
|
26
|
+
}
|
|
27
|
+
}
|