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'