Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/request-highlights-permission.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Comment thread
Dustin-Kelley marked this conversation as resolved.
Comment thread
Dustin-Kelley marked this conversation as resolved.

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/`).
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,14 +191,19 @@ export default function RootLayout() {

return (
<GestureHandlerRootView style={{ flex: 1 }}>
<YouVersionProvider appKey={appKey} auth={{ redirectUri, scopes: ['profile', 'email'] }}>
<YouVersionProvider
appKey={appKey}
auth={{ redirectUri, scopes: ['profile', 'email'], permissions: ['highlights'] }}
>
{/* your app */}
</YouVersionProvider>
</GestureHandlerRootView>
)
}
```

`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
Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/auth/__tests__/pkce-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,59 @@ describe('signInWithPKCE — authorization URL', () => {
})
})

describe('signInWithPKCE — requested permissions', () => {
async function authorizeParams(
overrides: Partial<Parameters<typeof signInWithPKCE>[0]> = {},
): Promise<URLSearchParams> {
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)
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/auth/auth-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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'
13 changes: 12 additions & 1 deletion packages/core/src/auth/pkce-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
| {
Expand All @@ -20,13 +20,15 @@ type SignInWithPKCEProps = {
appKey: string
redirectUri: string
scopes?: readonly AuthScope[]
permissions?: readonly AuthPermission[]
}

export async function signInWithPKCE({
apiHost,
appKey,
redirectUri,
scopes,
permissions,
}: SignInWithPKCEProps): Promise<SignInResult> {
const [{ codeVerifier, codeChallenge, nonce, state }, installationId] = await Promise.all([
generatePKCEParameters(),
Expand All @@ -42,6 +44,7 @@ export async function signInWithPKCE({
state,
nonce,
scopes: scopes ?? DEFAULT_SCOPES,
permissions,
installationId,
})

Expand Down Expand Up @@ -136,6 +139,7 @@ function buildAuthorizationUrl(args: {
state: string
nonce: string
scopes: readonly AuthScope[]
permissions?: readonly AuthPermission[]
installationId: string
}): string {
const params = new URLSearchParams({
Expand All @@ -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()) {
Comment thread
Dustin-Kelley marked this conversation as resolved.
params.append('requested_permissions[]', permission)
}

return `https://${args.apiHost}/auth/authorize?${params.toString().replace(/\+/g, '%20')}`
}
16 changes: 16 additions & 0 deletions packages/core/src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment thread
Dustin-Kelley marked this conversation as resolved.

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[]
Comment thread
Dustin-Kelley marked this conversation as resolved.
}

export type YVUserInfo = {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'