From f13418a7de61e1f449f7d7fb2f3d2b33243edf67 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 20 Jul 2026 17:19:00 -0500 Subject: [PATCH 1/4] feat(auth): add permissions request for YouVersion Platform at sign-in --- .changeset/request-highlights-permission.md | 5 ++ AGENTS.md | 2 +- README.md | 7 ++- .../core/src/auth/__tests__/pkce-flow.test.ts | 53 +++++++++++++++++++ packages/core/src/auth/auth-provider.tsx | 3 +- packages/core/src/auth/index.ts | 2 +- packages/core/src/auth/pkce-flow.ts | 15 +++++- packages/core/src/auth/types.ts | 22 ++++++++ packages/core/src/index.ts | 2 +- 9 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 .changeset/request-highlights-permission.md diff --git a/.changeset/request-highlights-permission.md b/.changeset/request-highlights-permission.md new file mode 100644 index 00000000..53b039f4 --- /dev/null +++ b/.changeset/request-highlights-permission.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-native-expo-core': minor +--- + +Partners can now request YouVersion Platform permissions at sign-in. `AuthConfig` gains an optional `permissions` field typed by the new exported `AuthPermission` union (`'bibles' | 'highlights' | 'votd' | 'demographics' | 'bible_activity'`), and the PKCE flow appends each requested value to `/auth/authorize` as a repeated `requested_permissions[]` param — deduped and sorted, and omitted entirely when no permissions are configured. Permissions are deliberately kept separate from `scopes`: they are not OIDC scopes, and the auth server silently drops unknown values from `scope`, so requesting one there would grant nothing. This ships the request side only — reading back which permissions the user actually granted arrives in a later release. diff --git a/AGENTS.md b/AGENTS.md index 7495780a..7f5d9ccd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ Keep `apps/example/metro.config.js` minimal — just `getDefaultConfig(__dirname **UI** (`@youversion/platform-react-native-expo-ui`): `YouVersionProvider`, `BibleCard`, `BibleChapterPickerSheet`, `BibleReader`, `BibleReaderSettingsSheet`, `BibleTextView`, `BibleVersionPickerSheet`, `VerseOfTheDay`, and `YouVersionAuthButton` -**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth`, `mmkvStorage`, and auth types (`AuthConfig`, `AuthScope`, `YVUserInfo`) +**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth`, `mmkvStorage`, and auth types (`AuthConfig`, `AuthPermission`, `AuthScope`, `YVUserInfo`) UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider`. Import Bible components from UI; import `useYVAuth` from core. diff --git a/README.md b/README.md index 2d9d962f..6558103c 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,10 @@ export default function RootLayout() { return ( - + {/* your app */} @@ -199,6 +202,8 @@ export default function RootLayout() { } ``` +`permissions` lists YouVersion Platform permissions (`'bibles'`, `'highlights'`, `'votd'`, `'demographics'`, `'bible_activity'`) to ask for on the consent screen — these are not OIDC scopes, so keep them out of `scopes`. Today this only _requests_ the permission: the SDK does not yet report whether the user granted it, and the highlights APIs and the just-in-time grant flow land in a later release. + For sign-in UI, drop in `YouVersionAuthButton` — it renders the branded Sign in with YouVersion button and handles sign-in/sign-out for you: ```tsx diff --git a/packages/core/src/auth/__tests__/pkce-flow.test.ts b/packages/core/src/auth/__tests__/pkce-flow.test.ts index 66184e2b..4288e065 100644 --- a/packages/core/src/auth/__tests__/pkce-flow.test.ts +++ b/packages/core/src/auth/__tests__/pkce-flow.test.ts @@ -121,6 +121,59 @@ describe('signInWithPKCE — authorization URL', () => { }) }) +describe('signInWithPKCE — requested permissions', () => { + async function authorizeParams( + overrides: Partial[0]> = {}, + ): Promise { + mockGeneratePkce.mockResolvedValue(PKCE_FIXTURE) + mockOpenAuthSession.mockResolvedValue({ type: 'dismiss' }) + + await signInWithPKCE(defaultProps(overrides)) + + const [url] = mockOpenAuthSession.mock.calls[0] + return new URL(url).searchParams + } + + it('appends a requested_permissions[] param per configured permission', async () => { + const params = await authorizeParams({ permissions: ['highlights', 'bibles'] }) + expect(params.getAll('requested_permissions[]')).toEqual(['bibles', 'highlights']) + }) + + it('omits requested_permissions[] when permissions are not configured', async () => { + const params = await authorizeParams() + expect(params.getAll('requested_permissions[]')).toEqual([]) + expect(params.has('requested_permissions[]')).toBe(false) + }) + + it('omits requested_permissions[] when permissions is an empty array', async () => { + const params = await authorizeParams({ permissions: [] }) + expect(params.getAll('requested_permissions[]')).toEqual([]) + expect(params.has('requested_permissions[]')).toBe(false) + }) + + it('dedupes and sorts requested permissions', async () => { + const params = await authorizeParams({ + permissions: ['votd', 'highlights', 'votd', 'bible_activity'], + }) + expect(params.getAll('requested_permissions[]')).toEqual([ + 'bible_activity', + 'highlights', + 'votd', + ]) + }) + + it('never leaks a permission value into the scope param', async () => { + const params = await authorizeParams({ + scopes: ['profile', 'email'], + permissions: ['highlights', 'bibles', 'votd', 'demographics', 'bible_activity'], + }) + expect(params.get('scope')).toBe('email openid profile') + for (const permission of ['highlights', 'bibles', 'votd', 'demographics', 'bible_activity']) { + expect(params.get('scope')).not.toContain(permission) + } + }) +}) + describe('signInWithPKCE — callback error + state CSRF', () => { it('returns { kind: "cancel" } when the callback carries error=access_denied (Cancel button)', async () => { mockGeneratePkce.mockResolvedValue(PKCE_FIXTURE) diff --git a/packages/core/src/auth/auth-provider.tsx b/packages/core/src/auth/auth-provider.tsx index 68051945..03faae51 100644 --- a/packages/core/src/auth/auth-provider.tsx +++ b/packages/core/src/auth/auth-provider.tsx @@ -148,6 +148,7 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth appKey, redirectUri: config.redirectUri, scopes: config.scopes, + permissions: config.permissions, }) if (result.kind === 'cancel') { @@ -166,7 +167,7 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth setError(err) throw err } - }, [apiHost, appKey, config.redirectUri, config.scopes, setAuthState]) + }, [apiHost, appKey, config.redirectUri, config.scopes, config.permissions, setAuthState]) const signOut = useCallback(async () => { await clearAuthState() diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index d7b84bee..246ff5e4 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -1,2 +1,2 @@ -export type { AuthConfig, AuthScope, YVUserInfo } from './types' +export type { AuthConfig, AuthPermission, AuthScope, YVUserInfo } from './types' export { useYVAuth, useYVAuthOptional } from './use-yv-auth' diff --git a/packages/core/src/auth/pkce-flow.ts b/packages/core/src/auth/pkce-flow.ts index 14b4ed96..88a488f2 100644 --- a/packages/core/src/auth/pkce-flow.ts +++ b/packages/core/src/auth/pkce-flow.ts @@ -5,7 +5,7 @@ import { DEFAULT_SCOPES } from './constants' import { exchangeCodeForTokens, type TokenResponse } from './http' import { decodeIdToken, deriveUserInfo } from './id-token' import { generatePKCEParameters } from './pkce' -import type { AuthScope, YVUserInfo } from './types' +import type { AuthPermission, AuthScope, YVUserInfo } from './types' export type SignInResult = | { @@ -20,6 +20,7 @@ type SignInWithPKCEProps = { appKey: string redirectUri: string scopes?: readonly AuthScope[] + permissions?: readonly AuthPermission[] } export async function signInWithPKCE({ @@ -27,6 +28,7 @@ export async function signInWithPKCE({ appKey, redirectUri, scopes, + permissions, }: SignInWithPKCEProps): Promise { const [{ codeVerifier, codeChallenge, nonce, state }, installationId] = await Promise.all([ generatePKCEParameters(), @@ -42,6 +44,7 @@ export async function signInWithPKCE({ state, nonce, scopes: scopes ?? DEFAULT_SCOPES, + permissions, installationId, }) @@ -136,6 +139,7 @@ function buildAuthorizationUrl(args: { state: string nonce: string scopes: readonly AuthScope[] + permissions?: readonly AuthPermission[] installationId: string }): string { const params = new URLSearchParams({ @@ -151,5 +155,14 @@ function buildAuthorizationUrl(args: { 'x-yvp-installation-id': args.installationId, }) + // Permissions are NOT OIDC scopes: they ride alongside `scope` as a repeatable + // `requested_permissions[]` param. Deduped and sorted (order is wire-irrelevant) + // to match how `scope` is built. Omitted entirely when there are none — on the + // callback an absent `granted_permissions` means "none requested" while an empty + // one means "requested and denied", so we don't blur that signal. + for (const permission of [...new Set(args.permissions ?? [])].sort()) { + params.append('requested_permissions[]', permission) + } + return `https://${args.apiHost}/auth/authorize?${params.toString().replace(/\+/g, '%20')}` } diff --git a/packages/core/src/auth/types.ts b/packages/core/src/auth/types.ts index 44e756e8..85f4601f 100644 --- a/packages/core/src/auth/types.ts +++ b/packages/core/src/auth/types.ts @@ -2,9 +2,31 @@ import { DEFAULT_SCOPES } from './constants' export type AuthScope = (typeof DEFAULT_SCOPES)[number] +/** + * A YouVersion Platform permission. + * + * These are **not** OIDC scopes. They travel on `/auth/authorize` as a repeatable + * `requested_permissions[]` query param, separate from `scope` — the auth server + * silently drops unknown values from `scope`, so passing a permission there grants + * nothing. + */ +export type AuthPermission = 'bibles' | 'highlights' | 'votd' | 'demographics' | 'bible_activity' + export type AuthConfig = { redirectUri: string scopes?: readonly AuthScope[] + /** + * YouVersion Platform permissions to request at sign-in, sent as repeated + * `requested_permissions[]` params on `/auth/authorize`. + * + * These are **not** OIDC scopes — keep them out of `scopes`, which stays + * `'profile' | 'email'`. + * + * Requesting a permission is not the same as being granted it: the user can + * deny it on the consent screen and sign-in still succeeds. Reading back what + * was actually granted is not yet supported. + */ + permissions?: readonly AuthPermission[] } export type YVUserInfo = { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c6ee1a58..98987bec 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,6 @@ export type { YouVersionContextValue } from './youversion-context' export { default as YouVersionProvider } from './youversion-provider' export { useYVAuth, useYVAuthOptional } from './auth' -export type { AuthConfig, AuthScope, YVUserInfo } from './auth' +export type { AuthConfig, AuthPermission, AuthScope, YVUserInfo } from './auth' export { mmkvStorage } from './storage' From a73733083af3c03c4bc14d20721f79d8d607b6d1 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Tue, 21 Jul 2026 08:42:41 -0500 Subject: [PATCH 2/4] refactor(auth): simplify permissions request documentation in AuthConfig type --- packages/core/src/auth/types.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/core/src/auth/types.ts b/packages/core/src/auth/types.ts index 85f4601f..c0ad04e2 100644 --- a/packages/core/src/auth/types.ts +++ b/packages/core/src/auth/types.ts @@ -16,15 +16,9 @@ export type AuthConfig = { redirectUri: string scopes?: readonly AuthScope[] /** - * YouVersion Platform permissions to request at sign-in, sent as repeated - * `requested_permissions[]` params on `/auth/authorize`. - * - * These are **not** OIDC scopes — keep them out of `scopes`, which stays - * `'profile' | 'email'`. - * - * Requesting a permission is not the same as being granted it: the user can - * deny it on the consent screen and sign-in still succeeds. Reading back what - * was actually granted is not yet supported. + * {@link AuthPermission}s to request at sign-in. Requesting one is not the same + * as being granted it — the user can deny on the consent screen and sign-in + * still succeeds; reading back what was granted is not yet supported. */ permissions?: readonly AuthPermission[] } From 614def1c9f3c194823f592664e8e488184e2d118 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Tue, 21 Jul 2026 08:46:32 -0500 Subject: [PATCH 3/4] refactor(auth): clarify permissions handling in authorization URL construction --- packages/core/src/auth/pkce-flow.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/src/auth/pkce-flow.ts b/packages/core/src/auth/pkce-flow.ts index 88a488f2..1267bb93 100644 --- a/packages/core/src/auth/pkce-flow.ts +++ b/packages/core/src/auth/pkce-flow.ts @@ -155,11 +155,9 @@ function buildAuthorizationUrl(args: { 'x-yvp-installation-id': args.installationId, }) - // Permissions are NOT OIDC scopes: they ride alongside `scope` as a repeatable - // `requested_permissions[]` param. Deduped and sorted (order is wire-irrelevant) - // to match how `scope` is built. Omitted entirely when there are none — on the - // callback an absent `granted_permissions` means "none requested" while an empty - // one means "requested and denied", so we don't blur that signal. + // Omit the param entirely when there are none — don't emit an empty one. On the + // callback, absent `granted_permissions` means "none requested" while an empty + // value means "requested and denied"; emitting an empty param blurs the two. for (const permission of [...new Set(args.permissions ?? [])].sort()) { params.append('requested_permissions[]', permission) } From aabd0d367d72a95ca1efc897b6a5c033e28c66e3 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Wed, 22 Jul 2026 09:15:11 -0500 Subject: [PATCH 4/4] docs(auth): document provider-level permissions in AGENTS Address review feedback: Auth bullet includes permissions?, notes RN provider config vs web AuthButton, and softens the README grant caveat. Co-authored-by: Cursor --- AGENTS.md | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7f5d9ccd..c11390b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,8 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider ## Auth (core) -- Optional PKCE OAuth when `auth: { redirectUri, scopes? }` is passed to core `YouVersionProvider` (forwarded by UI provider). +- Optional PKCE OAuth when `auth: { redirectUri, scopes?, permissions? }` is passed to core `YouVersionProvider` (forwarded by UI provider). +- On RN, `permissions` is configured on `YouVersionProvider`'s `auth` config (not on `YouVersionAuthButton` / `signIn()`), unlike web. The example app stays scopes-only until grant reporting lands (C3). - `useYVAuth()` throws if `auth` was not configured on the provider. - `YouVersionAuthButton` (UI package) is the drop-in sign-in/sign-out button built on `useYVAuth`; use it for standard sign-in UI instead of hand-rolling a button. - Tokens in `expo-secure-store`; expiry and cached user info in MMKV (`packages/core/src/storage/`). diff --git a/README.md b/README.md index 6558103c..15d15005 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ export default function RootLayout() { } ``` -`permissions` lists YouVersion Platform permissions (`'bibles'`, `'highlights'`, `'votd'`, `'demographics'`, `'bible_activity'`) to ask for on the consent screen — these are not OIDC scopes, so keep them out of `scopes`. Today this only _requests_ the permission: the SDK does not yet report whether the user granted it, and the highlights APIs and the just-in-time grant flow land in a later release. +`permissions` lists YouVersion Platform permissions (`'bibles'`, `'highlights'`, `'votd'`, `'demographics'`, `'bible_activity'`) to ask for on the consent screen — these are not OIDC scopes, so keep them out of `scopes`. Today this only _requests_ the permission; whether it was granted is not exposed yet (coming in a follow-up). For sign-in UI, drop in `YouVersionAuthButton` — it renders the branded Sign in with YouVersion button and handles sign-in/sign-out for you: