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
8 changes: 8 additions & 0 deletions .changeset/auth-signed-in-requires-user-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@youversion/platform-react-native-expo-core': major
---

Signed-in sessions now always carry a non-empty YouVersion user id.

- **`YVUserInfo.id`** is required (was optional). A session without a valid non-empty `sub` in the id_token is rejected at sign-in and cleared on cold start, including malformed stored id_tokens.
- **`getAccessToken()`** `{ status: 'ok', userId }` is now `string` (was `string | null`). When status is `'ok'`, the token and user id were read in the same synchronous block and both belong to the signed-in user.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider
- The grant rides only on the **app redirect** — the `/auth/callback` `Location` hop drops it — so `pkce-flow.ts` parses it from `result.url` before that hop, and a test in `__tests__/pkce-flow.test.ts` pins the ordering. It is then cached per user in MMKV (redirect parsing in `auth/granted-permissions.ts`, the cache in `auth/granted-permissions-cache.ts`), seeded synchronously in a `useState` initializer so it is correct on the first render, and purged in `clearAuthState`. `AuthPermission` is an open union and cached values are kept verbatim, not filtered — filtering would turn a server-side addition into a silent denial.
- `useYVAuth().requestPermissions(permissions)` is the **just-in-time grant** (data exchange): a signed-in user grants a permission on the spot, no sign-out. Mint (`POST /data-exchange/token`, 201) → hosted consent in an auth session → parse the return → merge into the grant cache. Resolves to a `DataExchangeOutcome` (`granted` / `cancel` / `failure` with `reason: 'not-signed-in' | 'not-permitted' | 'user-changed' | 'in-progress' | 'transient'`) and never throws. Permission-generic — nothing highlights-specific lives in `auth/data-exchange.ts`.
- The grant **merges**, never replaces: a `highlights`-only consent must not erase a previously granted `votd`. `cancel` and `failure` never touch the cache.
- An **initiator guard** fails closed: an `AuthIdentity` (`{ sessionId, userId }`) is captured before minting and re-read after the browser returns; any difference discards the grant (`reason: 'user-changed'`). `sessionId` is a local counter compared only for equality, not a server-issued value; it moves only in `setIdentity` (sign-in and sign-out), so a token-only `setAuthState` leaves it alone and a mid-flow refresh passes. `userId` alone cannot carry the guard because `null` means both "signed out" and "signed in with no `sub`". A same-session id-less user passes deliberatelyfailing closed there locks those users out of the flow entirely.
- An **initiator guard** fails closed: an `AuthIdentity` (`{ sessionId, userId }`) is captured before minting and re-read after the browser returns; any difference discards the grant (`reason: 'user-changed'`). `sessionId` is a local counter compared only for equality, not a server-issued value; it moves only in `setIdentity` (sign-in and sign-out), so a token-only `setAuthState` leaves it alone and a mid-flow refresh passes. A signed-in session always carries a non-empty string `userId` from the id_token's `sub` — sign-in and cold-start bootstrap both reject tokens without one and clear orphan storageso `userId: null` on the guard means signed out, not an id-less signed-in user.
- **The guard is a backstop, not a defence against user action** — worth knowing before you either delete it as dead weight or trust it as a security boundary. Neither platform lets the user reach the app while the consent page is up (iOS is a modal sheet; on Android foregrounding resolves the auth session as `dismiss` first, ending the flow). The paths that _can_ land mid-flow are not user-driven — a revoked token tripping `clearAuthState`, or app code calling `signOut` from async work — and all of them end signed out, where `saveGrantedPermissions` already refuses the null `userId`. What the guard actually buys: a truthful **outcome** (never `granted` for a user who has left, which is what consumers branch on) and a `requestDataExchange` that is correct on its own terms instead of depending on a null check in `granted-permissions-cache.ts` that nothing links to it.
- `status: 'granted'` reports what the server granted, which may not be everything asked for. Check the returned list (or `hasPermission`) for the permission you needed.
- **Never throws is load-bearing and easy to break.** Every doc for this flow tells consumers not to `try`/`catch`, so each `await` that can reject needs a guard returning a `transient` failure: the mint (in `data-exchange-api.ts`), `WebBrowser.openAuthSessionAsync` (which rejects on a session already open, a missing native module, or no Android activity for the intent), and `getOrSetInstallationId()` in the provider. Tests pin all three.
Expand Down
Loading