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..c11390b1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -144,13 +144,14 @@ 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.
## 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 2d9d962f..15d15005 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; 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:
```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..1267bb93 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,12 @@ function buildAuthorizationUrl(args: {
'x-yvp-installation-id': args.installationId,
})
+ // 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)
+ }
+
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..c0ad04e2 100644
--- a/packages/core/src/auth/types.ts
+++ b/packages/core/src/auth/types.ts
@@ -2,9 +2,25 @@ 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[]
+ /**
+ * {@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[]
}
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'