Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/id-token-not-persisted.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 27 additions & 8 deletions packages/core/src/Users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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.
*
Expand All @@ -292,10 +313,9 @@ export class YouVersionAPIUsers {
static async refreshTokens(): Promise<SignInWithYouVersionResult | null> {
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) {
Expand Down Expand Up @@ -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,
);

Expand Down
47 changes: 36 additions & 11 deletions packages/core/src/YouVersionPlatformConfiguration.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -48,21 +51,28 @@ 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 {
localStorage.removeItem('expiryDate');
}
}

/**
* 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 {
Expand All @@ -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 {
Expand Down
9 changes: 3 additions & 6 deletions packages/core/src/YouVersionUserInfo.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
53 changes: 28 additions & 25 deletions packages/core/src/__tests__/Users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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');
Expand Down Expand Up @@ -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',
);
});

Expand All @@ -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;
});

Expand All @@ -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',
Expand All @@ -520,14 +520,14 @@ 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;
});

mockFetch.mockResolvedValue(mockResponse);

const saveAuthDataSpy = vi.spyOn(YouVersionPlatformConfiguration, 'saveAuthData');
const saveUserInfoSpy = vi.spyOn(YouVersionPlatformConfiguration, 'saveUserInfo');

const result = await YouVersionAPIUsers.refreshTokens();

Expand All @@ -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);
Expand All @@ -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 () => {
Expand Down
Loading
Loading