From abcf0b603e980228ecc64e23240a94b6745a8bc2 Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Mon, 1 Jun 2026 22:54:18 -0500 Subject: [PATCH] feat(auth)!: stop persisting ID token; persist decoded profile instead - Decode ID token once at sign-in to derive userInfo, then discard it - Persist the decoded profile (Zod-validated on read) so it survives reloads - Clear stored profile on sign-out and on unrecoverable session expiry - core: drop idToken from saveAuthData; add saveUserInfo/storedUserInfo and getStoredUserInfo() - hooks: remove AuthenticationState.idToken; provider rehydrates from stored profile - ui: update authenticated-user test helper BREAKING CHANGE: the ID token is no longer exposed or persisted. - Removed AuthenticationState.idToken (use userInfo for profile data) - Removed the YouVersionPlatformConfiguration.idToken getter - saveAuthData(accessToken, refreshToken, expiryDate) no longer accepts an idToken arg --- .changeset/id-token-not-persisted.md | 23 ++++ packages/core/src/Users.ts | 35 ++++-- .../src/YouVersionPlatformConfiguration.ts | 47 ++++++-- packages/core/src/YouVersionUserInfo.ts | 9 +- packages/core/src/__tests__/Users.test.ts | 53 ++++----- .../YouVersionPlatformConfiguration.test.ts | 104 +++++++++++------- .../core/src/__tests__/highlights.test.ts | 22 ++-- packages/core/src/schemas/index.ts | 1 + packages/core/src/schemas/user-info.ts | 18 +++ packages/core/src/types/index.ts | 1 - .../src/__tests__/mocks/core-mock-factory.ts | 53 ++++----- .../context/YouVersionAuthProvider.test.tsx | 39 +++---- .../src/context/YouVersionAuthProvider.tsx | 21 ++-- packages/hooks/src/useYVAuth.test.tsx | 4 +- packages/hooks/src/useYVAuth.ts | 6 +- packages/ui/src/test/utils.ts | 18 +-- 16 files changed, 276 insertions(+), 178 deletions(-) create mode 100644 .changeset/id-token-not-persisted.md create mode 100644 packages/core/src/schemas/user-info.ts diff --git a/.changeset/id-token-not-persisted.md b/.changeset/id-token-not-persisted.md new file mode 100644 index 00000000..3d3d7e85 --- /dev/null +++ b/.changeset/id-token-not-persisted.md @@ -0,0 +1,23 @@ +--- +"@youversion/platform-core": major +"@youversion/platform-react-hooks": major +"@youversion/platform-react-ui": major +--- + +Stop persisting the ID token. The ID token is now decoded once at sign-in to +derive the user profile and then discarded — only the decoded profile is +persisted (validated with Zod on read), so it survives reloads without keeping +the signed token in `localStorage`. The stored profile is cleared on sign-out +and when a session expires and cannot be refreshed. + +**Breaking changes:** + +- `AuthenticationState.idToken` has been removed. Components that read + `auth.idToken` from `useYVAuth()` should no longer rely on it; use `userInfo` + for profile data. +- `YouVersionPlatformConfiguration.saveAuthData(accessToken, refreshToken, expiryDate)` + no longer accepts an `idToken` argument. +- `YouVersionPlatformConfiguration.idToken` getter has been removed. The decoded + profile is available via `YouVersionPlatformConfiguration.storedUserInfo` (or + `YouVersionAPIUsers.getStoredUserInfo()`). +- `YouVersionAPIUsers.refreshTokens()` no longer requires a stored ID token. diff --git a/packages/core/src/Users.ts b/packages/core/src/Users.ts index 9134bf85..44caa917 100644 --- a/packages/core/src/Users.ts +++ b/packages/core/src/Users.ts @@ -117,14 +117,23 @@ export class YouVersionAPIUsers { // Extract user info from ID token const result = this.extractSignInResult(tokens); - // Store tokens in configuration + // Store tokens in configuration. The ID token is intentionally not + // persisted — it is only used here to derive the user profile below. YouVersionPlatformConfiguration.saveAuthData( result.accessToken || null, result.refreshToken || null, - result.idToken || null, result.expiryDate || null, ); + // Persist the decoded user profile so it survives reloads without + // retaining the ID token itself. + YouVersionPlatformConfiguration.saveUserInfo({ + id: result.yvpUserId, + name: result.name, + email: result.email, + avatar_url: result.profilePicture, + }); + // Clean up localStorage localStorage.removeItem('youversion-auth-code-verifier'); localStorage.removeItem('youversion-auth-redirect-uri'); @@ -283,6 +292,18 @@ export class YouVersionAPIUsers { } } + /** + * Returns the user profile that was persisted at sign-in, or `null` if the + * user is not signed in (or the stored profile is missing/invalid). + * + * This reads the decoded profile from storage rather than re-decoding the ID + * token, which is never persisted. + */ + static getStoredUserInfo(): YouVersionUserInfo | null { + const stored = YouVersionPlatformConfiguration.storedUserInfo; + return stored ? new YouVersionUserInfo(stored) : null; + } + /** * Refreshes the access token using the stored refresh token. * @@ -292,10 +313,9 @@ export class YouVersionAPIUsers { static async refreshTokens(): Promise { const refreshToken = YouVersionPlatformConfiguration.refreshToken; const appKey = YouVersionPlatformConfiguration.appKey; - const existingIdToken = YouVersionPlatformConfiguration.idToken; - if (!refreshToken || !existingIdToken) { - throw new Error('No refresh token or id token available'); + if (!refreshToken) { + throw new Error('No refresh token available'); } if (!appKey) { @@ -335,19 +355,18 @@ export class YouVersionAPIUsers { token_type: string; }; - // Create result with new tokens but preserve user info + // Create result with new tokens. The persisted user profile is left + // untouched — refreshing only rotates the access/refresh tokens. const result = new SignInWithYouVersionResult({ accessToken: tokens.access_token, expiresIn: tokens.expires_in, refreshToken: tokens.refresh_token, - idToken: existingIdToken, }); // Store updated tokens YouVersionPlatformConfiguration.saveAuthData( result.accessToken || null, result.refreshToken || null, - result.idToken || null, result.expiryDate || null, ); diff --git a/packages/core/src/YouVersionPlatformConfiguration.ts b/packages/core/src/YouVersionPlatformConfiguration.ts index 87843464..1e46f94f 100644 --- a/packages/core/src/YouVersionPlatformConfiguration.ts +++ b/packages/core/src/YouVersionPlatformConfiguration.ts @@ -1,5 +1,9 @@ +import { YouVersionUserInfoJSONSchema, type YouVersionUserInfoJSON } from './schemas/user-info'; + /** - * Security Note: Tokens are stored in localStorage for persistence. + * Security Note: Tokens and the decoded user profile are stored in localStorage + * for persistence. The ID token itself is never persisted — it is decoded once at + * sign-in to derive the user profile and then discarded. * Ensure your application follows XSS prevention best practices: * - Sanitize user input * - Use Content Security Policy headers @@ -33,7 +37,6 @@ export class YouVersionPlatformConfiguration { public static saveAuthData( accessToken: string | null, refreshToken: string | null, - idToken: string | null, expiryDate: Date | null, ): void { if (accessToken !== null) { @@ -48,12 +51,6 @@ export class YouVersionPlatformConfiguration { localStorage.removeItem('refreshToken'); } - if (idToken !== null) { - localStorage.setItem('idToken', idToken); - } else { - localStorage.removeItem('idToken'); - } - if (expiryDate !== null) { localStorage.setItem('expiryDate', expiryDate.toISOString()); } else { @@ -61,8 +58,21 @@ export class YouVersionPlatformConfiguration { } } + /** + * Persists the decoded user profile derived from the ID token at sign-in. + * Pass `null` to remove any stored profile. + */ + public static saveUserInfo(userInfo: YouVersionUserInfoJSON | null): void { + if (userInfo !== null) { + localStorage.setItem('userInfo', JSON.stringify(userInfo)); + } else { + localStorage.removeItem('userInfo'); + } + } + public static clearAuthTokens(): void { - this.saveAuthData(null, null, null, null); + this.saveAuthData(null, null, null); + this.saveUserInfo(null); } public static get accessToken(): string | null { @@ -73,8 +83,23 @@ export class YouVersionPlatformConfiguration { return localStorage.getItem('refreshToken'); } - public static get idToken(): string | null { - return localStorage.getItem('idToken'); + /** + * Returns the persisted user profile, validated against the expected schema. + * Returns `null` when nothing is stored or the stored value is malformed. + */ + public static get storedUserInfo(): YouVersionUserInfoJSON | null { + const raw = localStorage.getItem('userInfo'); + if (!raw) { + return null; + } + + try { + const parsed: unknown = JSON.parse(raw); + const result = YouVersionUserInfoJSONSchema.safeParse(parsed); + return result.success ? result.data : null; + } catch { + return null; + } } public static get tokenExpiryDate(): Date | null { diff --git a/packages/core/src/YouVersionUserInfo.ts b/packages/core/src/YouVersionUserInfo.ts index 1d70f575..27206f04 100644 --- a/packages/core/src/YouVersionUserInfo.ts +++ b/packages/core/src/YouVersionUserInfo.ts @@ -1,9 +1,6 @@ -export interface YouVersionUserInfoJSON { - name?: string; - id?: string; - avatar_url?: string; - email?: string; -} +import type { YouVersionUserInfoJSON } from './schemas/user-info'; + +export type { YouVersionUserInfoJSON }; export class YouVersionUserInfo { readonly name?: string; diff --git a/packages/core/src/__tests__/Users.test.ts b/packages/core/src/__tests__/Users.test.ts index edb7458b..f60d5148 100644 --- a/packages/core/src/__tests__/Users.test.ts +++ b/packages/core/src/__tests__/Users.test.ts @@ -192,8 +192,9 @@ describe('YouVersionAPIUsers', () => { }), ); - // Mock YouVersionPlatformConfiguration.saveAuthData + // Mock YouVersionPlatformConfiguration persistence const saveAuthDataSpy = vi.spyOn(YouVersionPlatformConfiguration, 'saveAuthData'); + const saveUserInfoSpy = vi.spyOn(YouVersionPlatformConfiguration, 'saveUserInfo'); const result = await YouVersionAPIUsers.handleAuthCallback(); @@ -204,8 +205,22 @@ describe('YouVersionAPIUsers', () => { expect(result?.name).toBe('John Doe'); expect(result?.email).toBe('john@example.com'); - // Verify saveAuthData was called - expect(saveAuthDataSpy).toHaveBeenCalled(); + // Verify saveAuthData was called with tokens only (no id token persisted) + expect(saveAuthDataSpy).toHaveBeenCalledWith( + 'access-token-123', + 'refresh-token-456', + expect.any(Date), + ); + + // Verify the decoded profile was persisted instead of the id token + expect(saveUserInfoSpy).toHaveBeenCalledWith({ + id: '1234567890', + name: 'John Doe', + email: 'john@example.com', + avatar_url: 'https://example.com/avatar.jpg', + }); + + saveUserInfoSpy.mockRestore(); // Verify cleanup expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-code-verifier'); @@ -463,24 +478,11 @@ describe('YouVersionAPIUsers', () => { it('should throw error when no refresh token available', async () => { mocks.localStorage.getItem.mockImplementation((key: string) => { if (key === 'refreshToken') return null; - if (key === 'idToken') return 'id-token-123'; - return null; - }); - - await expect(YouVersionAPIUsers.refreshTokens()).rejects.toThrow( - 'No refresh token or id token available', - ); - }); - - it('should throw error when no id token available', async () => { - mocks.localStorage.getItem.mockImplementation((key: string) => { - if (key === 'refreshToken') return 'refresh-token-123'; - if (key === 'idToken') return null; return null; }); await expect(YouVersionAPIUsers.refreshTokens()).rejects.toThrow( - 'No refresh token or id token available', + 'No refresh token available', ); }); @@ -489,7 +491,6 @@ describe('YouVersionAPIUsers', () => { mocks.localStorage.getItem.mockImplementation((key: string) => { if (key === 'refreshToken') return 'refresh-token-123'; - if (key === 'idToken') return 'id-token-123'; return null; }); @@ -498,10 +499,9 @@ describe('YouVersionAPIUsers', () => { ); }); - it('should successfully refresh tokens and preserve existing id_token', async () => { + it('should successfully rotate the access and refresh tokens', async () => { const originalAccessToken = 'old-access-token'; const originalRefreshToken = 'old-refresh-token'; - const existingIdToken = 'existing-id-token'; const mockRefreshResponse = { access_token: 'new-access-token', @@ -520,7 +520,6 @@ describe('YouVersionAPIUsers', () => { mocks.localStorage.getItem.mockImplementation((key: string) => { if (key === 'refreshToken') return originalRefreshToken; - if (key === 'idToken') return existingIdToken; if (key === 'accessToken') return originalAccessToken; return null; }); @@ -528,6 +527,7 @@ describe('YouVersionAPIUsers', () => { mockFetch.mockResolvedValue(mockResponse); const saveAuthDataSpy = vi.spyOn(YouVersionPlatformConfiguration, 'saveAuthData'); + const saveUserInfoSpy = vi.spyOn(YouVersionPlatformConfiguration, 'saveUserInfo'); const result = await YouVersionAPIUsers.refreshTokens(); @@ -539,8 +539,8 @@ describe('YouVersionAPIUsers', () => { expect(result?.refreshToken).toBe('new-refresh-token'); expect(result?.refreshToken).not.toBe(originalRefreshToken); - // Assert that id_token is preserved (same as original) - expect(result?.idToken).toBe(existingIdToken); + // The id token is never carried through a refresh + expect(result?.idToken).toBeUndefined(); // Verify the refresh token request was made correctly expect(mockFetch).toHaveBeenCalledTimes(1); @@ -560,15 +560,18 @@ describe('YouVersionAPIUsers', () => { const body = new URLSearchParams(bodyText); expect(body.get('grant_type')).toBe('refresh_token'); - // Verify saveAuthData was called with new tokens but existing id_token + // Verify saveAuthData was called with the rotated tokens only expect(saveAuthDataSpy).toHaveBeenCalledWith( 'new-access-token', 'new-refresh-token', - existingIdToken, expect.any(Date), ); + // The stored profile is left untouched by a refresh + expect(saveUserInfoSpy).not.toHaveBeenCalled(); + saveAuthDataSpy.mockRestore(); + saveUserInfoSpy.mockRestore(); }); it('should handle refresh token request failure', async () => { diff --git a/packages/core/src/__tests__/YouVersionPlatformConfiguration.test.ts b/packages/core/src/__tests__/YouVersionPlatformConfiguration.test.ts index 3f358edc..9a38cb0c 100644 --- a/packages/core/src/__tests__/YouVersionPlatformConfiguration.test.ts +++ b/packages/core/src/__tests__/YouVersionPlatformConfiguration.test.ts @@ -47,7 +47,7 @@ describe('YouVersionPlatformConfiguration', () => { // Reset all static properties YouVersionPlatformConfiguration.appKey = null; YouVersionPlatformConfiguration.installationId = null; - YouVersionPlatformConfiguration.saveAuthData(null, null, null, null); + YouVersionPlatformConfiguration.clearAuthTokens(); YouVersionPlatformConfiguration.apiHost = envApiHost; }); @@ -115,10 +115,10 @@ describe('YouVersionPlatformConfiguration', () => { expect(YouVersionPlatformConfiguration.accessToken).toBeNull(); const token = 'test-access-token'; - YouVersionPlatformConfiguration.saveAuthData(token, null, null, null); + YouVersionPlatformConfiguration.saveAuthData(token, null, null); expect(YouVersionPlatformConfiguration.accessToken).toBe(token); - YouVersionPlatformConfiguration.saveAuthData(null, null, null, null); + YouVersionPlatformConfiguration.saveAuthData(null, null, null); expect(YouVersionPlatformConfiguration.accessToken).toBeNull(); }); @@ -126,10 +126,10 @@ describe('YouVersionPlatformConfiguration', () => { expect(YouVersionPlatformConfiguration.refreshToken).toBeNull(); const refreshToken = 'test-refresh-token'; - YouVersionPlatformConfiguration.saveAuthData(null, refreshToken, null, null); + YouVersionPlatformConfiguration.saveAuthData(null, refreshToken, null); expect(YouVersionPlatformConfiguration.refreshToken).toBe(refreshToken); - YouVersionPlatformConfiguration.saveAuthData(null, null, null, null); + YouVersionPlatformConfiguration.saveAuthData(null, null, null); expect(YouVersionPlatformConfiguration.refreshToken).toBeNull(); }); @@ -137,53 +137,46 @@ describe('YouVersionPlatformConfiguration', () => { expect(YouVersionPlatformConfiguration.tokenExpiryDate).toBeNull(); const expiryDate = new Date('2024-12-31T23:59:59Z'); - YouVersionPlatformConfiguration.saveAuthData(null, null, null, expiryDate); + YouVersionPlatformConfiguration.saveAuthData(null, null, expiryDate); expect(YouVersionPlatformConfiguration.tokenExpiryDate).toEqual(expiryDate); - YouVersionPlatformConfiguration.saveAuthData(null, null, null, null); + YouVersionPlatformConfiguration.saveAuthData(null, null, null); expect(YouVersionPlatformConfiguration.tokenExpiryDate).toBeNull(); }); - it('should save all auth data together', () => { + it('should never persist the id token', () => { const accessToken = 'test-access-token'; const refreshToken = 'test-refresh-token'; - const idToken = 'test-id-token'; const expiryDate = new Date('2024-12-31T23:59:59Z'); - YouVersionPlatformConfiguration.saveAuthData(accessToken, refreshToken, idToken, expiryDate); + YouVersionPlatformConfiguration.saveAuthData(accessToken, refreshToken, expiryDate); expect(YouVersionPlatformConfiguration.accessToken).toBe(accessToken); expect(YouVersionPlatformConfiguration.refreshToken).toBe(refreshToken); - expect(YouVersionPlatformConfiguration.idToken).toBe(idToken); expect(YouVersionPlatformConfiguration.tokenExpiryDate).toEqual(expiryDate); + expect(mockSetItem).not.toHaveBeenCalledWith('idToken', expect.anything()); + expect(mockStorage.idToken).toBeUndefined(); }); it('should handle partial updates without affecting other tokens', () => { // Set up initial state const initialAccess = 'initial-access'; const initialRefresh = 'initial-refresh'; - const initialIdToken = 'initial-id-token'; const initialExpiry = new Date('2024-01-01T00:00:00Z'); - YouVersionPlatformConfiguration.saveAuthData( - initialAccess, - initialRefresh, - initialIdToken, - initialExpiry, - ); + YouVersionPlatformConfiguration.saveAuthData(initialAccess, initialRefresh, initialExpiry); // Update only access token const newAccess = 'new-access-token'; - YouVersionPlatformConfiguration.saveAuthData(newAccess, null, null, null); + YouVersionPlatformConfiguration.saveAuthData(newAccess, null, null); expect(YouVersionPlatformConfiguration.accessToken).toBe(newAccess); expect(YouVersionPlatformConfiguration.refreshToken).toBeNull(); - expect(YouVersionPlatformConfiguration.idToken).toBeNull(); expect(YouVersionPlatformConfiguration.tokenExpiryDate).toBeNull(); }); it('should properly serialize and deserialize dates', () => { const originalDate = new Date('2024-06-15T14:30:45.123Z'); - YouVersionPlatformConfiguration.saveAuthData(null, null, null, originalDate); + YouVersionPlatformConfiguration.saveAuthData(null, null, originalDate); const retrievedDate = YouVersionPlatformConfiguration.tokenExpiryDate; expect(retrievedDate).toEqual(originalDate); @@ -193,62 +186,95 @@ describe('YouVersionPlatformConfiguration', () => { it('should call localStorage methods correctly', () => { const accessToken = 'test-access'; const refreshToken = 'test-refresh'; - const idToken = 'test-id'; const expiryDate = new Date('2024-12-31T23:59:59Z'); - YouVersionPlatformConfiguration.saveAuthData(accessToken, refreshToken, idToken, expiryDate); + YouVersionPlatformConfiguration.saveAuthData(accessToken, refreshToken, expiryDate); expect(mockSetItem).toHaveBeenCalledWith('accessToken', accessToken); expect(mockSetItem).toHaveBeenCalledWith('refreshToken', refreshToken); - expect(mockSetItem).toHaveBeenCalledWith('idToken', idToken); expect(mockSetItem).toHaveBeenCalledWith('expiryDate', expiryDate.toISOString()); }); it('should call removeItem when tokens are null', () => { // First set some values - YouVersionPlatformConfiguration.saveAuthData('access', 'refresh', 'id-token', new Date()); + YouVersionPlatformConfiguration.saveAuthData('access', 'refresh', new Date()); // Clear the mock calls mockRemoveItem.mockClear(); // Now set to null - YouVersionPlatformConfiguration.saveAuthData(null, null, null, null); + YouVersionPlatformConfiguration.saveAuthData(null, null, null); expect(mockRemoveItem).toHaveBeenCalledWith('accessToken'); expect(mockRemoveItem).toHaveBeenCalledWith('refreshToken'); - expect(mockRemoveItem).toHaveBeenCalledWith('idToken'); expect(mockRemoveItem).toHaveBeenCalledWith('expiryDate'); }); }); + describe('user info persistence', () => { + it('should save and retrieve the decoded user profile', () => { + expect(YouVersionPlatformConfiguration.storedUserInfo).toBeNull(); + + const userInfo = { + id: 'user-123', + name: 'John Doe', + email: 'john@example.com', + avatar_url: 'https://example.com/avatar.jpg', + }; + YouVersionPlatformConfiguration.saveUserInfo(userInfo); + + expect(mockSetItem).toHaveBeenCalledWith('userInfo', JSON.stringify(userInfo)); + expect(YouVersionPlatformConfiguration.storedUserInfo).toEqual(userInfo); + }); + + it('should remove the stored profile when passed null', () => { + YouVersionPlatformConfiguration.saveUserInfo({ id: 'user-123', name: 'John Doe' }); + YouVersionPlatformConfiguration.saveUserInfo(null); + + expect(mockRemoveItem).toHaveBeenCalledWith('userInfo'); + expect(YouVersionPlatformConfiguration.storedUserInfo).toBeNull(); + }); + + it('should return null when the stored profile is malformed JSON', () => { + mockStorage.userInfo = 'not-json{'; + expect(YouVersionPlatformConfiguration.storedUserInfo).toBeNull(); + }); + + it('should return null when the stored profile fails schema validation', () => { + mockStorage.userInfo = JSON.stringify({ id: 42 }); + expect(YouVersionPlatformConfiguration.storedUserInfo).toBeNull(); + }); + }); + describe('clearAuthTokens', () => { - it('should clear all auth tokens', () => { + it('should clear all auth tokens and the stored profile', () => { // Set up initial auth data const accessToken = 'test-access-token'; const refreshToken = 'test-refresh-token'; - const idToken = 'test-id-token'; const expiryDate = new Date('2024-12-31T23:59:59Z'); - YouVersionPlatformConfiguration.saveAuthData(accessToken, refreshToken, idToken, expiryDate); + YouVersionPlatformConfiguration.saveAuthData(accessToken, refreshToken, expiryDate); + YouVersionPlatformConfiguration.saveUserInfo({ id: 'user-123', name: 'John Doe' }); // Verify data is set expect(YouVersionPlatformConfiguration.accessToken).toBe(accessToken); expect(YouVersionPlatformConfiguration.refreshToken).toBe(refreshToken); - expect(YouVersionPlatformConfiguration.idToken).toBe(idToken); expect(YouVersionPlatformConfiguration.tokenExpiryDate).toEqual(expiryDate); + expect(YouVersionPlatformConfiguration.storedUserInfo).not.toBeNull(); // Clear all tokens YouVersionPlatformConfiguration.clearAuthTokens(); - // Verify all tokens are null + // Verify all tokens and the profile are null expect(YouVersionPlatformConfiguration.accessToken).toBeNull(); expect(YouVersionPlatformConfiguration.refreshToken).toBeNull(); - expect(YouVersionPlatformConfiguration.idToken).toBeNull(); expect(YouVersionPlatformConfiguration.tokenExpiryDate).toBeNull(); + expect(YouVersionPlatformConfiguration.storedUserInfo).toBeNull(); }); - it('should call removeItem for all token types', () => { + it('should call removeItem for all token types and the profile', () => { // Set up some auth data first - YouVersionPlatformConfiguration.saveAuthData('access', 'refresh', 'id-token', new Date()); + YouVersionPlatformConfiguration.saveAuthData('access', 'refresh', new Date()); + YouVersionPlatformConfiguration.saveUserInfo({ id: 'user-123' }); // Clear mock calls mockRemoveItem.mockClear(); @@ -256,18 +282,18 @@ describe('YouVersionPlatformConfiguration', () => { // Clear auth tokens YouVersionPlatformConfiguration.clearAuthTokens(); - // Verify removeItem was called for each token type + // Verify removeItem was called for each token type and the profile expect(mockRemoveItem).toHaveBeenCalledWith('accessToken'); expect(mockRemoveItem).toHaveBeenCalledWith('refreshToken'); - expect(mockRemoveItem).toHaveBeenCalledWith('idToken'); expect(mockRemoveItem).toHaveBeenCalledWith('expiryDate'); + expect(mockRemoveItem).toHaveBeenCalledWith('userInfo'); }); it('should work when no tokens are previously set', () => { // Ensure clean state expect(YouVersionPlatformConfiguration.accessToken).toBeNull(); expect(YouVersionPlatformConfiguration.refreshToken).toBeNull(); - expect(YouVersionPlatformConfiguration.idToken).toBeNull(); + expect(YouVersionPlatformConfiguration.storedUserInfo).toBeNull(); expect(YouVersionPlatformConfiguration.tokenExpiryDate).toBeNull(); // Should not throw when clearing empty state @@ -278,7 +304,7 @@ describe('YouVersionPlatformConfiguration', () => { // State should remain null expect(YouVersionPlatformConfiguration.accessToken).toBeNull(); expect(YouVersionPlatformConfiguration.refreshToken).toBeNull(); - expect(YouVersionPlatformConfiguration.idToken).toBeNull(); + expect(YouVersionPlatformConfiguration.storedUserInfo).toBeNull(); expect(YouVersionPlatformConfiguration.tokenExpiryDate).toBeNull(); }); }); diff --git a/packages/core/src/__tests__/highlights.test.ts b/packages/core/src/__tests__/highlights.test.ts index cc1e8a3a..0a42dfbd 100644 --- a/packages/core/src/__tests__/highlights.test.ts +++ b/packages/core/src/__tests__/highlights.test.ts @@ -15,12 +15,12 @@ describe.skip('HighlightsClient', () => { }); highlightsClient = new HighlightsClient(apiClient); // Set a default token for tests that don't explicitly pass one - YouVersionPlatformConfiguration.saveAuthData('test-token', null, null, null); + YouVersionPlatformConfiguration.saveAuthData('test-token', null, null); }); afterEach(() => { // Clean up token after each test - YouVersionPlatformConfiguration.saveAuthData(null, null, null, null); + YouVersionPlatformConfiguration.saveAuthData(null, null, null); vi.clearAllMocks(); // Reset all mocked calls between tests }); @@ -96,7 +96,7 @@ describe.skip('HighlightsClient', () => { }); it('should include lat parameter when token auto-retrieved from config', async () => { - YouVersionPlatformConfiguration.saveAuthData('config-token', null, null, null); + YouVersionPlatformConfiguration.saveAuthData('config-token', null, null); const fetchSpy = vi.spyOn(global, 'fetch'); const highlights = await highlightsClient.getHighlights({ version_id: 1 }); @@ -112,7 +112,7 @@ describe.skip('HighlightsClient', () => { }); it('should throw an error when no token is available', async () => { - YouVersionPlatformConfiguration.saveAuthData(null, null, null, null); + YouVersionPlatformConfiguration.saveAuthData(null, null, null); await expect(highlightsClient.getHighlights({ version_id: 1 })).rejects.toThrow( 'Authentication required. Please provide a token or sign in before accessing highlights.', @@ -120,7 +120,7 @@ describe.skip('HighlightsClient', () => { }); it('should use explicit token over config token', async () => { - YouVersionPlatformConfiguration.saveAuthData('config-token', null, null, null); + YouVersionPlatformConfiguration.saveAuthData('config-token', null, null); const fetchSpy = vi.spyOn(global, 'fetch'); const highlights = await highlightsClient.getHighlights({ version_id: 1 }, 'explicit-token'); @@ -269,7 +269,7 @@ describe.skip('HighlightsClient', () => { }); it('should include lat parameter when token auto-retrieved from config', async () => { - YouVersionPlatformConfiguration.saveAuthData('config-token', null, null, null); + YouVersionPlatformConfiguration.saveAuthData('config-token', null, null); const fetchSpy = vi.spyOn(global, 'fetch'); const highlight = await highlightsClient.createHighlight({ version_id: 111, @@ -291,7 +291,7 @@ describe.skip('HighlightsClient', () => { }); it('should throw an error when no token is available', async () => { - YouVersionPlatformConfiguration.saveAuthData(null, null, null, null); + YouVersionPlatformConfiguration.saveAuthData(null, null, null); await expect( highlightsClient.createHighlight({ @@ -305,7 +305,7 @@ describe.skip('HighlightsClient', () => { }); it('should use explicit token over config token', async () => { - YouVersionPlatformConfiguration.saveAuthData('config-token', null, null, null); + YouVersionPlatformConfiguration.saveAuthData('config-token', null, null); const fetchSpy = vi.spyOn(global, 'fetch'); const highlight = await highlightsClient.createHighlight( { @@ -432,7 +432,7 @@ describe.skip('HighlightsClient', () => { }); it('should include lat parameter when token auto-retrieved from config', async () => { - YouVersionPlatformConfiguration.saveAuthData('config-token', null, null, null); + YouVersionPlatformConfiguration.saveAuthData('config-token', null, null); let capturedStatus: number | undefined; const originalFetch = global.fetch; const fetchSpy = vi.spyOn(global, 'fetch').mockImplementation(async (...args) => { @@ -453,7 +453,7 @@ describe.skip('HighlightsClient', () => { }); it('should throw an error when no token is available', async () => { - YouVersionPlatformConfiguration.saveAuthData(null, null, null, null); + YouVersionPlatformConfiguration.saveAuthData(null, null, null); await expect(highlightsClient.deleteHighlight('MAT.1.1')).rejects.toThrow( 'Authentication required. Please provide a token or sign in before accessing highlights.', @@ -461,7 +461,7 @@ describe.skip('HighlightsClient', () => { }); it('should use explicit token over config token', async () => { - YouVersionPlatformConfiguration.saveAuthData('config-token', null, null, null); + YouVersionPlatformConfiguration.saveAuthData('config-token', null, null); let capturedStatus: number | undefined; const originalFetch = global.fetch; const fetchSpy = vi.spyOn(global, 'fetch').mockImplementation(async (...args) => { diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index dec85e75..759cb365 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -9,3 +9,4 @@ export * from './version'; export * from './verse'; export * from './votd'; export * from './user'; +export * from './user-info'; diff --git a/packages/core/src/schemas/user-info.ts b/packages/core/src/schemas/user-info.ts new file mode 100644 index 00000000..9616bed0 --- /dev/null +++ b/packages/core/src/schemas/user-info.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +/** + * Shape of the decoded user profile that is persisted at sign-in. + * + * The ID token is decoded once to produce this profile and then discarded — the + * token itself is never persisted. This schema validates the profile when it is + * rehydrated from storage so a tampered or malformed entry is rejected rather + * than trusted blindly. + */ +export const YouVersionUserInfoJSONSchema = z.object({ + name: z.string().optional(), + id: z.string().optional(), + avatar_url: z.string().optional(), + email: z.string().optional(), +}); + +export type YouVersionUserInfoJSON = z.infer; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 792bafb4..a8b2a377 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -43,7 +43,6 @@ export interface AuthenticationState { readonly isAuthenticated: boolean; readonly isLoading: boolean; readonly accessToken: string | null; - readonly idToken: string | null; readonly result: SignInWithYouVersionResult | null; readonly error: Error | null; } diff --git a/packages/hooks/src/__tests__/mocks/core-mock-factory.ts b/packages/hooks/src/__tests__/mocks/core-mock-factory.ts index 11da6eed..0a05aa20 100644 --- a/packages/hooks/src/__tests__/mocks/core-mock-factory.ts +++ b/packages/hooks/src/__tests__/mocks/core-mock-factory.ts @@ -78,20 +78,16 @@ class MockSignInWithYouVersionResult { interface MockConfiguration { accessToken: string | null; - idToken: string | null; refreshToken: string | null; + storedUserInfo: MockUserInfoData | null; appKey: string; apiHost: string; installationId: string | null; clearAuthTokens: MockedFunction<() => void>; saveAuthData: MockedFunction< - ( - accessToken: string | null, - refreshToken: string | null, - idToken: string | null, - installationId: string | null, - ) => void + (accessToken: string | null, refreshToken: string | null, expiryDate: Date | null) => void >; + saveUserInfo: MockedFunction<(userInfo: MockUserInfoData | null) => void>; } interface SimpleCoreMockFactory { @@ -99,6 +95,7 @@ interface SimpleCoreMockFactory { signIn: MockedFunction; handleAuthCallback: MockedFunction; userInfo: MockedFunction; + getStoredUserInfo: MockedFunction; refreshTokenIfNeeded: MockedFunction; }; YouVersionPlatformConfiguration: MockConfiguration; @@ -111,19 +108,21 @@ interface GetterCoreMockFactory { YouVersionAPIUsers: { handleAuthCallback: MockedFunction; userInfo: MockedFunction; + getStoredUserInfo: MockedFunction; refreshTokenIfNeeded: MockedFunction; }; YouVersionPlatformConfiguration: { appKey: string; installationId: string; apiHost: string; - readonly idToken: string | null; readonly refreshToken: string | null; readonly accessToken: string | null; + readonly storedUserInfo: MockUserInfoData | null; clearAuthTokens: MockedFunction<() => void>; saveAuthData: MockedFunction< - (accessToken: string | null, refreshToken: string | null, idToken: string | null) => void + (accessToken: string | null, refreshToken: string | null, expiryDate: Date | null) => void >; + saveUserInfo: MockedFunction<(userInfo: MockUserInfoData | null) => void>; }; YouVersionUserInfo: typeof MockYouVersionUserInfo; SignInWithYouVersionResult: typeof MockSignInWithYouVersionResult; @@ -132,29 +131,25 @@ interface GetterCoreMockFactory { export function createSimpleCoreMockFactory(): SimpleCoreMockFactory { const mockConfiguration: MockConfiguration = { accessToken: null, - idToken: null, refreshToken: null, + storedUserInfo: null, appKey: '', apiHost: 'test-api.example.com', installationId: null, clearAuthTokens: vi.fn(() => { mockConfiguration.accessToken = null; - mockConfiguration.idToken = null; mockConfiguration.refreshToken = null; + mockConfiguration.storedUserInfo = null; }), saveAuthData: vi.fn( - ( - accessToken: string | null, - refreshToken: string | null, - idToken: string | null, - installationId: string | null, - ) => { + (accessToken: string | null, refreshToken: string | null, _expiryDate: Date | null) => { mockConfiguration.accessToken = accessToken; mockConfiguration.refreshToken = refreshToken; - mockConfiguration.idToken = idToken; - mockConfiguration.installationId = installationId; }, ), + saveUserInfo: vi.fn((userInfo: MockUserInfoData | null) => { + mockConfiguration.storedUserInfo = userInfo; + }), }; return { @@ -162,6 +157,7 @@ export function createSimpleCoreMockFactory(): SimpleCoreMockFactory { signIn: vi.fn(), handleAuthCallback: vi.fn(), userInfo: vi.fn(), + getStoredUserInfo: vi.fn(), refreshTokenIfNeeded: vi.fn(), }, YouVersionPlatformConfiguration: mockConfiguration, @@ -179,14 +175,17 @@ export function createSimpleCoreMockFactory(): SimpleCoreMockFactory { export function createGetterCoreMockFactory(): GetterCoreMockFactory { let mockInstallationId = 'auto-generated-installation-id'; - let mockIdToken: string | null = null; let mockRefreshToken: string | null = null; let mockAccessToken: string | null = null; + let mockStoredUserInfo: MockUserInfoData | null = null; return { YouVersionAPIUsers: { handleAuthCallback: vi.fn(), userInfo: vi.fn(), + getStoredUserInfo: vi.fn(() => + mockStoredUserInfo ? new MockYouVersionUserInfo(mockStoredUserInfo) : null, + ), refreshTokenIfNeeded: vi.fn(), }, YouVersionPlatformConfiguration: { @@ -198,27 +197,29 @@ export function createGetterCoreMockFactory(): GetterCoreMockFactory { if (value) mockInstallationId = value; }, apiHost: 'test-api.example.com', - get idToken() { - return mockIdToken; - }, get refreshToken() { return mockRefreshToken; }, get accessToken() { return mockAccessToken; }, + get storedUserInfo() { + return mockStoredUserInfo; + }, clearAuthTokens: vi.fn(() => { - mockIdToken = null; mockRefreshToken = null; mockAccessToken = null; + mockStoredUserInfo = null; }), saveAuthData: vi.fn( - (accessToken: string | null, refreshToken: string | null, idToken: string | null) => { + (accessToken: string | null, refreshToken: string | null, _expiryDate: Date | null) => { mockAccessToken = accessToken; mockRefreshToken = refreshToken; - mockIdToken = idToken; }, ), + saveUserInfo: vi.fn((userInfo: MockUserInfoData | null) => { + mockStoredUserInfo = userInfo; + }), }, YouVersionUserInfo: MockYouVersionUserInfo, SignInWithYouVersionResult: MockSignInWithYouVersionResult, diff --git a/packages/hooks/src/context/YouVersionAuthProvider.test.tsx b/packages/hooks/src/context/YouVersionAuthProvider.test.tsx index 3de8f12d..d02d50c2 100644 --- a/packages/hooks/src/context/YouVersionAuthProvider.test.tsx +++ b/packages/hooks/src/context/YouVersionAuthProvider.test.tsx @@ -109,13 +109,8 @@ describe('YouVersionAuthProvider', () => { describe('OAuth callback handling', () => { it('should detect OAuth callback with state parameter', async () => { mockWindow.location.search = '?state=test-state&code=auth-code'; - vi.spyOn(YouVersionAPIUsers, 'userInfo').mockReturnValue(mockUserInfo); - - // Mock the configuration to return the id token after handleAuthCallback - vi.spyOn(YouVersionAPIUsers, 'handleAuthCallback').mockImplementation(() => { - YouVersionPlatformConfiguration.saveAuthData(null, null, 'test-id-token', null); - return Promise.resolve(mockAuthResult as any); - }); + vi.spyOn(YouVersionAPIUsers, 'getStoredUserInfo').mockReturnValue(mockUserInfo); + vi.spyOn(YouVersionAPIUsers, 'handleAuthCallback').mockResolvedValue(mockAuthResult); const { getByTestId } = render( @@ -128,7 +123,7 @@ describe('YouVersionAuthProvider', () => { }); expect(vi.mocked(YouVersionAPIUsers).handleAuthCallback).toHaveBeenCalled(); - expect(vi.mocked(YouVersionAPIUsers).userInfo).toHaveBeenCalledWith('test-id-token'); + expect(vi.mocked(YouVersionAPIUsers).getStoredUserInfo).toHaveBeenCalled(); expect(getByTestId('user-info')).toHaveTextContent(JSON.stringify(mockUserInfo)); }); @@ -167,10 +162,10 @@ describe('YouVersionAuthProvider', () => { }); }); - it('should handle callback with no idToken', async () => { + it('should leave user null when no profile was stored during callback', async () => { mockWindow.location.search = '?state=test-state&code=auth-code'; vi.spyOn(YouVersionAPIUsers, 'handleAuthCallback').mockResolvedValue(mockAuthResult); - YouVersionPlatformConfiguration.saveAuthData(null, null, null, null); + vi.spyOn(YouVersionAPIUsers, 'getStoredUserInfo').mockReturnValue(null); const { getByTestId } = render( @@ -182,22 +177,18 @@ describe('YouVersionAuthProvider', () => { expect(getByTestId('is-loading')).toHaveTextContent('false'); }); - expect(vi.mocked(YouVersionAPIUsers).userInfo).not.toHaveBeenCalled(); + expect(vi.mocked(YouVersionAPIUsers).getStoredUserInfo).toHaveBeenCalled(); expect(getByTestId('user-info')).toHaveTextContent('null'); }); }); describe('existing token handling', () => { - it('should refresh token when refresh token exists', async () => { + it('should rehydrate stored user when refresh succeeds', async () => { // Set up refresh token before mounting component - YouVersionPlatformConfiguration.saveAuthData(null, 'existing-refresh-token', null, null); + YouVersionPlatformConfiguration.saveAuthData(null, 'existing-refresh-token', null); - // Mock refreshTokenIfNeeded to set the id token after successful refresh - vi.spyOn(YouVersionAPIUsers, 'refreshTokenIfNeeded').mockImplementation(() => { - YouVersionPlatformConfiguration.saveAuthData(null, null, 'refreshed-id-token', null); - return Promise.resolve(true); - }); - vi.spyOn(YouVersionAPIUsers, 'userInfo').mockReturnValue(mockUserInfo); + vi.spyOn(YouVersionAPIUsers, 'refreshTokenIfNeeded').mockResolvedValue(true); + vi.spyOn(YouVersionAPIUsers, 'getStoredUserInfo').mockReturnValue(mockUserInfo); const { getByTestId } = render( @@ -210,12 +201,12 @@ describe('YouVersionAuthProvider', () => { }); expect(vi.mocked(YouVersionAPIUsers).refreshTokenIfNeeded).toHaveBeenCalled(); - expect(vi.mocked(YouVersionAPIUsers).userInfo).toHaveBeenCalledWith('refreshed-id-token'); + expect(vi.mocked(YouVersionAPIUsers).getStoredUserInfo).toHaveBeenCalled(); expect(getByTestId('user-info')).toHaveTextContent(JSON.stringify(mockUserInfo)); }); it('should handle refresh token failure', async () => { - YouVersionPlatformConfiguration.saveAuthData(null, 'existing-refresh-token', null, null); + YouVersionPlatformConfiguration.saveAuthData(null, 'existing-refresh-token', null); vi.spyOn(YouVersionAPIUsers, 'refreshTokenIfNeeded').mockRejectedValue( new Error('Refresh failed'), ); @@ -233,9 +224,10 @@ describe('YouVersionAuthProvider', () => { expect(getByTestId('user-info')).toHaveTextContent('null'); }); - it('should clear user when refresh token exists but no idToken after refresh', async () => { - YouVersionPlatformConfiguration.saveAuthData(null, 'existing-refresh-token', null, null); + it('should clear user when the session expires and cannot be refreshed', async () => { + YouVersionPlatformConfiguration.saveAuthData(null, 'existing-refresh-token', null); vi.spyOn(YouVersionAPIUsers, 'refreshTokenIfNeeded').mockResolvedValue(false); + const getStoredUserInfoSpy = vi.spyOn(YouVersionAPIUsers, 'getStoredUserInfo'); const { getByTestId } = render( @@ -247,6 +239,7 @@ describe('YouVersionAuthProvider', () => { expect(getByTestId('is-loading')).toHaveTextContent('false'); }); + expect(getStoredUserInfoSpy).not.toHaveBeenCalled(); expect(getByTestId('user-info')).toHaveTextContent('null'); }); }); diff --git a/packages/hooks/src/context/YouVersionAuthProvider.tsx b/packages/hooks/src/context/YouVersionAuthProvider.tsx index 50d5fefb..44cf9d11 100644 --- a/packages/hooks/src/context/YouVersionAuthProvider.tsx +++ b/packages/hooks/src/context/YouVersionAuthProvider.tsx @@ -37,10 +37,10 @@ export default function YouVersionAuthProvider({ if (isOAuthCallback) { try { const result = await YouVersionAPIUsers.handleAuthCallback(); - if (result && YouVersionPlatformConfiguration.idToken) { - const info = YouVersionAPIUsers.userInfo(YouVersionPlatformConfiguration.idToken); + if (result) { + // handleAuthCallback persists the decoded profile; read it back. if (!mounted) return; - setUserInfo(info); + setUserInfo(YouVersionAPIUsers.getStoredUserInfo()); } } catch (err) { if (!mounted) return; @@ -51,16 +51,11 @@ export default function YouVersionAuthProvider({ const refreshToken = YouVersionPlatformConfiguration.refreshToken; if (refreshToken) { try { - await YouVersionAPIUsers.refreshTokenIfNeeded(); - const idToken = YouVersionPlatformConfiguration.idToken; - if (idToken) { - const info = YouVersionAPIUsers.userInfo(idToken); - if (!mounted) return; - setUserInfo(info); - } else { - if (!mounted) return; - setUserInfo(null); - } + // refreshTokenIfNeeded clears tokens (and the stored profile) + // when the session has expired and cannot be refreshed. + const refreshed = await YouVersionAPIUsers.refreshTokenIfNeeded(); + if (!mounted) return; + setUserInfo(refreshed ? YouVersionAPIUsers.getStoredUserInfo() : null); } catch { if (!mounted) return; setUserInfo(null); diff --git a/packages/hooks/src/useYVAuth.test.tsx b/packages/hooks/src/useYVAuth.test.tsx index 6112eed0..42e3c169 100644 --- a/packages/hooks/src/useYVAuth.test.tsx +++ b/packages/hooks/src/useYVAuth.test.tsx @@ -63,7 +63,6 @@ describe('useYVAuth', () => { expect(result.current).not.toBeNull(); expect(result.current.auth.isAuthenticated).toBe(false); expect(result.current.auth.accessToken).toBe(null); - expect(result.current.auth.idToken).toBe(null); expect(result.current.userInfo).toBe(null); }); @@ -219,11 +218,10 @@ describe('useYVAuth', () => { describe('auth state', () => { it('should derive correct auth state from configuration', async () => { - YouVersionPlatformConfiguration.saveAuthData('access-token', null, 'id-token', null); + YouVersionPlatformConfiguration.saveAuthData('access-token', null, null); const { result } = await renderAuthHook(); expect(result.current.auth.accessToken).toBe('access-token'); - expect(result.current.auth.idToken).toBe('id-token'); }); }); diff --git a/packages/hooks/src/useYVAuth.ts b/packages/hooks/src/useYVAuth.ts index 17e3a2e7..9d4d50f0 100644 --- a/packages/hooks/src/useYVAuth.ts +++ b/packages/hooks/src/useYVAuth.ts @@ -122,10 +122,9 @@ export function useYVAuth(): UseYVAuthReturn { if (typeof window !== 'undefined') { return { accessToken: YouVersionPlatformConfiguration.accessToken, - idToken: YouVersionPlatformConfiguration.idToken, }; } - return { accessToken: null, idToken: null }; + return { accessToken: null }; }, []); // Sign in function @@ -159,11 +158,10 @@ export function useYVAuth(): UseYVAuthReturn { isAuthenticated, isLoading, accessToken: authTokens.accessToken, - idToken: authTokens.idToken, result: null, error, }), - [isAuthenticated, isLoading, authTokens.accessToken, authTokens.idToken, error], + [isAuthenticated, isLoading, authTokens.accessToken, error], ); // Sign out function diff --git a/packages/ui/src/test/utils.ts b/packages/ui/src/test/utils.ts index c428d7b8..7c630ad4 100644 --- a/packages/ui/src/test/utils.ts +++ b/packages/ui/src/test/utils.ts @@ -15,12 +15,14 @@ export async function setupAuthenticatedUser( '@youversion/platform-core' ); - YouVersionPlatformConfiguration.saveAuthData( - 'mock-access-token', - 'mock-refresh-token', - 'mock-id-token', - null, - ); + YouVersionPlatformConfiguration.saveAuthData('mock-access-token', 'mock-refresh-token', null); + + YouVersionPlatformConfiguration.saveUserInfo({ + id: options.id ?? 'mock-user-id', + name: options.name ?? 'Test User', + email: options.email ?? 'test@example.com', + avatar_url: options.avatarUrl ?? undefined, + }); const mockUserInfo = new YouVersionUserInfo({ id: options.id ?? 'mock-user-id', @@ -29,8 +31,8 @@ export async function setupAuthenticatedUser( avatar_url: options.avatarUrl ?? undefined, }); - spyOn(YouVersionAPIUsers, 'refreshTokenIfNeeded').mockResolvedValue(false); - spyOn(YouVersionAPIUsers, 'userInfo').mockReturnValue(mockUserInfo); + spyOn(YouVersionAPIUsers, 'refreshTokenIfNeeded').mockResolvedValue(true); + spyOn(YouVersionAPIUsers, 'getStoredUserInfo').mockReturnValue(mockUserInfo); return mockUserInfo; }