Skip to content
Closed
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
17 changes: 17 additions & 0 deletions .changeset/highlight-auth-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@youversion/platform-core': patch
'@youversion/platform-react-hooks': patch
'@youversion/platform-react-ui': patch
---

Add the highlight auth flow: a color tap in BibleReader without a session or the `highlights` permission now stashes the intent and runs the two-path grant flow (YPE-1034, still behind the internal `HIGHLIGHTS_LIVE` flag).

- **Core**: new `DataExchangeClient.updateToken` (`POST /data-exchange/token`, 201 → `{ token }`, Zod-validated) plus `buildDataExchangeUrl` / `parseDataExchangeCallback` / `handleDataExchangeCallback` for the hosted just-in-time grant. Sign-in and data-exchange callbacks now parse `granted_permissions` and seed an optimistic permission cache on `YouVersionPlatformConfiguration` (`grantedPermissions`, `hasPermission`, `saveGrantedPermissions`, `removeGrantedPermission`); the cache is cleared on sign-out and a 401/403 invalidates it (server truth wins). `SignInWithYouVersionResult` gains a `permissions` field.
- **Hooks**: new `useHighlightAuthActions` exposing the one-fell-swoop sign-in (requesting `highlights`), the just-in-time data-exchange redirect, the permission-cache reads/invalidation, and the data-exchange return handler.
- **UI**: `useBibleReaderHighlights` now runs the state machine — pending highlights persist to `sessionStorage` (~10-minute expiry) to survive the redirect round-trip and apply automatically on a granted return; a just-in-time permission confirm dialog (`HighlightPermissionDialog`, copy matched to the native SDK) gates the data-exchange grant. Write failures route by status: 401/403 invalidates the cache, keeps the pending highlight, and re-prompts; 5xx/network reverts the optimistic overlay and discards. Apply/remove writes are serialized through a FIFO queue with per-verse ownership so overlapping operations settle to the last-issued state. Copy/share-only behavior when no auth provider is configured is unchanged.

Deferred follow-ups (documented at each code site in `use-bible-reader-highlights.ts`, accepted for dark launch):

1. A 401 from an expired token (not a missing permission) misroutes to the permission re-prompt; follow-up is distinguishing auth-expiry from permission-denied at the `isPermissionError` boundary.
2. A failure while applying a resumed pending highlight only logs + reverts (the pending intent was cleared before the write), so a 401 there loses the highlight instead of keep-pending + re-prompt; follow-up is routing resume-write failures through the standard apply failure handling.
3. A 401/403 on `remove` opens the permission dialog with no pending highlight, so the post-grant resume is a no-op; follow-up is not re-prompting on remove failures.
9 changes: 9 additions & 0 deletions packages/core/src/SignInWithYouVersionResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type SignInWithYouVersionResultProps = {
name?: string;
profilePicture?: string;
email?: string;
permissions?: string[];
};
export class SignInWithYouVersionResult {
public readonly accessToken: string | undefined;
Expand All @@ -23,6 +24,12 @@ export class SignInWithYouVersionResult {
public readonly name: string | undefined;
public readonly profilePicture: string | undefined;
public readonly email: string | undefined;
/**
* Data-exchange permissions the server reported as granted for this sign-in
* (parsed from `granted_permissions` on the callback). Empty when the callback
* carried none. Additive: existing consumers can ignore it.
*/
public permissions: string[];

constructor({
accessToken,
Expand All @@ -32,6 +39,7 @@ export class SignInWithYouVersionResult {
name,
profilePicture,
email,
permissions,
}: SignInWithYouVersionResultProps) {
this.accessToken = accessToken;
this.expiryDate = expiresIn ? new Date(Date.now() + expiresIn * 1000) : new Date();
Expand All @@ -40,5 +48,6 @@ export class SignInWithYouVersionResult {
this.name = name;
this.profilePicture = profilePicture;
this.email = email;
this.permissions = permissions ?? [];
}
}
12 changes: 12 additions & 0 deletions packages/core/src/Users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { YouVersionUserInfo } from './YouVersionUserInfo';
import { YouVersionPlatformConfiguration } from './YouVersionPlatformConfiguration';
import { SignInWithYouVersionPKCEAuthorizationRequestBuilder } from './SignInWithYouVersionPKCE';
import { SignInWithYouVersionResult } from './SignInWithYouVersionResult';
import { parseGrantedPermissions } from './permissions';

export class YouVersionAPIUsers {
/**
Expand Down Expand Up @@ -124,6 +125,17 @@ export class YouVersionAPIUsers {
// Extract user info from ID token
const result = this.extractSignInResult(tokens);

// Surface + persist the data-exchange permissions the server granted. The
// server echoes them as `granted_permissions` on the callback URL (comma-
// or space-separated, param may repeat). This seeds the optimistic
// permission cache so a one-fell-swoop sign-in that requested `highlights`
// can apply a pending highlight on return without a probe round-trip.
const grantedPermissions = parseGrantedPermissions(urlParams);
result.permissions = grantedPermissions;
if (grantedPermissions.length > 0) {
YouVersionPlatformConfiguration.saveGrantedPermissions(grantedPermissions);
}

// Store tokens in configuration. The ID token is intentionally not
// persisted — it is only used here to derive the user profile below.
YouVersionPlatformConfiguration.saveAuthData(
Expand Down
56 changes: 56 additions & 0 deletions packages/core/src/YouVersionPlatformConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,62 @@ export class YouVersionPlatformConfiguration {
public static clearAuthTokens(): void {
this.saveAuthData(null, null, null);
this.saveUserInfo(null);
this.clearGrantedPermissions();
}

/**
* Optimistic cache of the data-exchange permissions the server told us are
* granted (seeded from `granted_permissions` on the sign-in / data-exchange
* callbacks). It is optimistic only: the server is the source of truth, and a
* 401/403 on a permissioned request invalidates the relevant entry via
* {@link removeGrantedPermission}. Stored as a JSON string array.
*/
private static readonly grantedPermissionsKey = 'youversion-platform:granted-permissions';

public static get grantedPermissions(): string[] {
if (typeof localStorage === 'undefined') return [];
const raw = localStorage.getItem(this.grantedPermissionsKey);
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((entry): entry is string => typeof entry === 'string');
} catch {
return [];
}
}

/** Merges `permissions` into the cache (union), preserving existing entries. */
public static saveGrantedPermissions(permissions: string[]): void {
if (typeof localStorage === 'undefined') return;
const merged = new Set([...this.grantedPermissions, ...permissions]);
localStorage.setItem(this.grantedPermissionsKey, JSON.stringify([...merged]));
}

/**
* Replaces the cache with exactly `permissions` (used to reconcile the cache
* with the authoritative set the server returns on a data-exchange grant).
*/
public static setGrantedPermissions(permissions: string[]): void {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(this.grantedPermissionsKey, JSON.stringify([...new Set(permissions)]));
}

/** Drops a single permission from the cache — used to honor a server 401/403. */
public static removeGrantedPermission(permission: string): void {
if (typeof localStorage === 'undefined') return;
const next = this.grantedPermissions.filter((entry) => entry !== permission);
localStorage.setItem(this.grantedPermissionsKey, JSON.stringify(next));
}

public static clearGrantedPermissions(): void {
if (typeof localStorage === 'undefined') return;
localStorage.removeItem(this.grantedPermissionsKey);
}

/** Optimistic check against the permission cache. Server 401/403 still wins. */
public static hasPermission(permission: string): boolean {
return this.grantedPermissions.includes(permission);
}

public static get accessToken(): string | null {
Expand Down
108 changes: 108 additions & 0 deletions packages/core/src/__tests__/data-exchange.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { ApiClient } from '../client';
import {
DataExchangeClient,
buildDataExchangeUrl,
parseDataExchangeCallback,
} from '../data-exchange';
import { server } from './setup';

const apiHost = process.env.YVP_API_HOST;

describe('DataExchangeClient.updateToken', () => {
let client: DataExchangeClient;

beforeEach(() => {
client = new DataExchangeClient(
new ApiClient({ apiHost, appKey: 'test-app', installationId: 'test-installation' }),
);
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it('POSTs requested_permissions with app-key query + Bearer auth and returns the token', async () => {
let seenAuth: string | null = null;
let seenBody: unknown = null;
let seenUrl = '';
server.use(
http.post(`https://${apiHost}/data-exchange/token`, async ({ request }) => {
seenAuth = request.headers.get('Authorization');
seenBody = await request.json();
seenUrl = request.url;
return HttpResponse.json({ token: 'dx-token-123' }, { status: 201 });
}),
);

const token = await client.updateToken(['highlights'], 'my-access-token');

expect(token).toBe('dx-token-123');
expect(seenAuth).toBe('Bearer my-access-token');
expect(seenBody).toEqual({ requested_permissions: ['highlights'] });
expect(seenUrl).toContain('app-key=test-app');
});

it('throws (401 = not permitted) when the server rejects', async () => {
server.use(
http.post(
`https://${apiHost}/data-exchange/token`,
() => new HttpResponse(null, { status: 401 }),
),
);

await expect(client.updateToken(['highlights'], 'tok')).rejects.toThrow();
});

it('throws when the response fails schema validation', async () => {
server.use(
http.post(`https://${apiHost}/data-exchange/token`, () =>
HttpResponse.json({ not_a_token: true }, { status: 201 }),
),
);

await expect(client.updateToken(['highlights'], 'tok')).rejects.toThrow(
/Unexpected data exchange token response/,
);
});
});

describe('buildDataExchangeUrl', () => {
it('builds the hosted consent URL with token + both app-key params', () => {
const url = new URL(buildDataExchangeUrl('tok-9', 'app-42', 'api.example.com'));
expect(url.origin + url.pathname).toBe('https://api.example.com/data-exchange');
expect(url.searchParams.get('token')).toBe('tok-9');
expect(url.searchParams.get('app_key')).toBe('app-42');
expect(url.searchParams.get('x-yvp-app-key')).toBe('app-42');
});
});

describe('parseDataExchangeCallback', () => {
it('returns null when there is no data_exchange_status', () => {
expect(parseDataExchangeCallback('?state=abc&code=1')).toBeNull();
});

it('parses a granted return with granted_permissions', () => {
expect(
parseDataExchangeCallback('?data_exchange_status=granted&granted_permissions=highlights'),
).toEqual({ status: 'granted', grantedPermissions: ['highlights'] });
});

it('maps cancel verbatim and treats anything else as failure', () => {
expect(parseDataExchangeCallback('?data_exchange_status=cancel')).toEqual({
status: 'cancel',
grantedPermissions: [],
});
expect(parseDataExchangeCallback('?data_exchange_status=weird')).toEqual({
status: 'failure',
grantedPermissions: [],
});
// Present but empty value → failure.
expect(parseDataExchangeCallback('?data_exchange_status=')).toEqual({
status: 'failure',
grantedPermissions: [],
});
});
});
73 changes: 73 additions & 0 deletions packages/core/src/__tests__/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { parseGrantedPermissions } from '../permissions';
import { YouVersionPlatformConfiguration } from '../YouVersionPlatformConfiguration';

describe('parseGrantedPermissions', () => {
it('parses a single value', () => {
const params = new URLSearchParams('granted_permissions=highlights');
expect(parseGrantedPermissions(params)).toEqual(['highlights']);
});

it('splits comma- and space-separated values and de-duplicates', () => {
const params = new URLSearchParams('granted_permissions=highlights,votd%20bibles');
expect(parseGrantedPermissions(params)).toEqual(['highlights', 'votd', 'bibles']);
});

it('unions repeated params', () => {
const params = new URLSearchParams(
'granted_permissions=highlights&granted_permissions=votd,highlights',
);
expect(parseGrantedPermissions(params)).toEqual(['highlights', 'votd']);
});

it('returns [] when the param is absent', () => {
expect(parseGrantedPermissions(new URLSearchParams('state=x'))).toEqual([]);
});
});

describe('YouVersionPlatformConfiguration permission cache', () => {
beforeEach(() => {
localStorage.clear();
});

it('starts empty and merges granted permissions without duplicates', () => {
expect(YouVersionPlatformConfiguration.grantedPermissions).toEqual([]);
expect(YouVersionPlatformConfiguration.hasPermission('highlights')).toBe(false);

YouVersionPlatformConfiguration.saveGrantedPermissions(['highlights']);
YouVersionPlatformConfiguration.saveGrantedPermissions(['highlights', 'votd']);

expect(YouVersionPlatformConfiguration.grantedPermissions.sort()).toEqual([
'highlights',
'votd',
]);
expect(YouVersionPlatformConfiguration.hasPermission('highlights')).toBe(true);
});

it('setGrantedPermissions overwrites the cache (reconcile)', () => {
YouVersionPlatformConfiguration.saveGrantedPermissions(['highlights', 'votd']);
YouVersionPlatformConfiguration.setGrantedPermissions(['highlights']);
expect(YouVersionPlatformConfiguration.grantedPermissions).toEqual(['highlights']);
});

it('removeGrantedPermission honors a server 401/403 invalidation', () => {
YouVersionPlatformConfiguration.saveGrantedPermissions(['highlights', 'votd']);
YouVersionPlatformConfiguration.removeGrantedPermission('highlights');
expect(YouVersionPlatformConfiguration.hasPermission('highlights')).toBe(false);
expect(YouVersionPlatformConfiguration.hasPermission('votd')).toBe(true);
});

it('clearAuthTokens also clears the permission cache', () => {
YouVersionPlatformConfiguration.saveGrantedPermissions(['highlights']);
YouVersionPlatformConfiguration.clearAuthTokens();
expect(YouVersionPlatformConfiguration.grantedPermissions).toEqual([]);
});

it('tolerates malformed stored JSON', () => {
localStorage.setItem('youversion-platform:granted-permissions', '{not json');
expect(YouVersionPlatformConfiguration.grantedPermissions).toEqual([]);
});
});
Loading
Loading