Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/oauth-usetokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sanity/sdk': minor
'@sanity/sdk-react': minor
---

Add the `useOAuthTokens` hook exposing stored OAuth token state with `refresh` and `revoke`. The public OAuth token surface (`getOAuthTokensState`, `refreshOAuthTokens`, and the hook) now omits the refresh token, which core retains internally for refreshing. ([#1213](https://github.com/sanity-io/sdk/pull/1213))
23 changes: 19 additions & 4 deletions packages/core/src/auth/oauth/oauthActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,8 +299,13 @@ describe('refreshOAuthTokens', () => {
expect(body.get('client_id')).toBe('client-abc')
expect(body.get('resource')).toBe('urn:io.sanity:organization:org123')

expect(result).toMatchObject({accessToken: 'new-access', refreshToken: 'new-refresh'})
expect(readStored(storageArea)).toMatchObject({accessToken: 'new-access'})
expect(result).toMatchObject({accessToken: 'new-access'})
expect(result).not.toHaveProperty('refreshToken')
// Storage retains the refresh token so core can refresh again later.
expect(readStored(storageArea)).toMatchObject({
accessToken: 'new-access',
refreshToken: 'new-refresh',
})
})

it('shares a single in-flight request across concurrent callers', async () => {
Expand Down Expand Up @@ -445,9 +450,19 @@ describe('revokeOAuthTokens', () => {
})

describe('getOAuthTokensState', () => {
it('exposes the current OAuth tokens', () => {
it('exposes the current OAuth tokens without the refresh token', () => {
setup({storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)}})
const current = getOAuthTokensState(instance!).getCurrent()
const {refreshToken: _refreshToken, ...expected} = seededTokens
expect(current).toEqual(expected)
expect(current).not.toHaveProperty('refreshToken')
})

it('returns the same reference while the tokens are unchanged', () => {
// useSyncExternalStore loops forever on a snapshot that changes identity every read
setup({storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)}})
expect(getOAuthTokensState(instance!).getCurrent()).toEqual(seededTokens)
const source = getOAuthTokensState(instance!)
expect(source.getCurrent()).toBe(source.getCurrent())
})

it('returns null when there are no OAuth tokens', () => {
Expand Down
29 changes: 23 additions & 6 deletions packages/core/src/auth/oauth/oauthActions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {ClientError, type SanityClient} from '@sanity/client'
import {createSelector} from 'reselect'

import {bindActionGlobally} from '../../store/createActionBinder'
import {createStateSourceAction} from '../../store/createStateSourceAction'
Expand Down Expand Up @@ -253,14 +254,15 @@ function isUnrecoverableRefreshError(error: unknown): boolean {
// singleton because `authStore` is a global store (one shared state).
// ponytail: module-level single-flight; upgrade to per-store keying only if
// the auth store ever stops being global.
let refreshInFlight: Promise<OAuthTokens | null> | null = null
let refreshInFlight: Promise<Omit<OAuthTokens, 'refreshToken'> | null> | null = null

/**
* Refreshes the OAuth tokens using the `refresh_token` grant. Concurrent
* callers share a single in-flight request. An unrecoverable failure (a 4xx
* rejecting the refresh token) clears the tokens and transitions to
* `LOGGED_OUT`; transient failures (network, 5xx, rate limits) leave the
* session intact and rethrow so the caller can retry.
* session intact and rethrow so the caller can retry. The resolved tokens omit
* the refresh token, which core retains internally for subsequent refreshes.
*
* @public
*/
Expand All @@ -275,7 +277,7 @@ export const refreshOAuthTokens = bindActionGlobally(authStore, (context) => {
async function doRefreshOAuthTokens({
state,
instance,
}: StoreContext<AuthStoreState>): Promise<OAuthTokens | null> {
}: StoreContext<AuthStoreState>): Promise<Omit<OAuthTokens, 'refreshToken'> | null> {
const logger = getAuthLogger(instance)
const options = getOAuthOptions(state.get())

Expand Down Expand Up @@ -315,7 +317,8 @@ async function doRefreshOAuthTokens({
authState: createLoggedInAuthState(tokens.accessToken, null),
oauthTokens: tokens,
})
return tokens
const {refreshToken: _refreshToken, ...publicTokens} = tokens
return publicTokens
} catch (error) {
if (!isUnrecoverableRefreshError(error)) {
// Transient (network / 5xx / rate limit) — keep the session so the
Expand Down Expand Up @@ -371,15 +374,29 @@ export const revokeOAuthTokens = bindActionGlobally(authStore, async ({state, in
}
})

// Memoised so `getCurrent()` keeps returning the same object while
// `oauthTokens` is unchanged; `useSyncExternalStore` loops on a snapshot
// that changes identity every read. One slot suffices: no params, global store.
const selectPublicOAuthTokens = createSelector(
(state: AuthStoreState) => state.oauthTokens,
(oauthTokens): Omit<OAuthTokens, 'refreshToken'> | null => {
if (!oauthTokens) return null
const {refreshToken: _refreshToken, ...tokens} = oauthTokens
return tokens
},
)

/**
* A state source exposing the current OAuth tokens (including expiry), or
* `null` when not logged in via OAuth.
* `null` when not logged in via OAuth. The refresh token is omitted — it is a
* long-lived credential held internally by core for `refreshOAuthTokens`, not
* part of the app-facing token view.
*
* @public
*/
export const getOAuthTokensState = bindActionGlobally(
authStore,
createStateSourceAction(({state}) => state.oauthTokens ?? null),
createStateSourceAction(({state}) => selectPublicOAuthTokens(state)),
)

/** Removes the transient PKCE artifacts from session storage. */
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/_exports/sdk-react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export {useCurrentUser} from '../hooks/auth/useCurrentUser'
export {useHandleAuthCallback} from '../hooks/auth/useHandleAuthCallback'
export {useLoginUrl} from '../hooks/auth/useLoginUrl'
export {useLogOut} from '../hooks/auth/useLogOut'
export {useOAuthTokens, type UseOAuthTokensResult} from '../hooks/auth/useOAuthTokens'
export {useVerifyOrgProjects} from '../hooks/auth/useVerifyOrgProjects'
export {useClient} from '../hooks/client/useClient'
export {
Expand Down
267 changes: 267 additions & 0 deletions packages/react/src/hooks/auth/useOAuthTokens.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import {
getOAuthTokensState,
type OAuthTokens,
refreshOAuthTokens,
revokeOAuthTokens,
type StateSource,
} from '@sanity/sdk'
import {act, renderHook} from '@testing-library/react'
import {throwError} from 'rxjs'
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'

import {ResourceProvider} from '../../context/ResourceProvider'
import {useOAuthTokens} from './useOAuthTokens'

vi.mock('@sanity/sdk', async (importOriginal) => {
const original = await importOriginal<typeof import('@sanity/sdk')>()
return {
...original,
getOAuthTokensState: vi.fn(),
refreshOAuthTokens: vi.fn(),
revokeOAuthTokens: vi.fn(),
}
})

/**
* A controllable stand-in for core's token state source. `set` mimics core
* updating the store (refresh/revoke or a cross-tab `storage` event) and
* notifies subscribers so the hook re-renders.
*/
function createFakeTokenSource(initial: Omit<OAuthTokens, 'refreshToken'> | null) {
let current = initial
const listeners = new Set<() => void>()
const source: StateSource<Omit<OAuthTokens, 'refreshToken'> | null> & {
set: (next: Omit<OAuthTokens, 'refreshToken'> | null) => void
} = {
subscribe: (onStoreChanged?: () => void) => {
if (onStoreChanged) listeners.add(onStoreChanged)
return () => {
if (onStoreChanged) listeners.delete(onStoreChanged)
}
},
getCurrent: () => current,
observable: throwError(() => new Error('unexpected usage of observable')),
set: (next) => {
current = next
for (const listener of listeners) listener()
},
}
return source
}

const makeTokens = (
overrides: Partial<Omit<OAuthTokens, 'refreshToken'>> = {},
): Omit<OAuthTokens, 'refreshToken'> => ({
accessToken: 'access-token',
tokenType: 'bearer',
expiresIn: 3600,
expiresAt: new Date(Date.now() + 3600_000),
...overrides,
})

const wrapper = ({children}: {children: React.ReactNode}) => (
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
{children}
</ResourceProvider>
)

describe('useOAuthTokens', () => {
const mockGetState = vi.mocked(getOAuthTokensState)
const mockRefresh = vi.mocked(refreshOAuthTokens)
const mockRevoke = vi.mocked(revokeOAuthTokens)

beforeEach(() => {
vi.clearAllMocks()
})

afterEach(() => {
vi.useRealTimers()
})

it('returns the stored tokens with isExpired=false when expiresAt is in the future', () => {
const tokens = makeTokens()
mockGetState.mockReturnValue(createFakeTokenSource(tokens))

const {result} = renderHook(() => useOAuthTokens(), {wrapper})

expect(result.current.tokens).toEqual(tokens)
expect(result.current.isExpired()).toBe(false)
})

it('derives isExpired=true when expiresAt is in the past', () => {
mockGetState.mockReturnValue(
createFakeTokenSource(makeTokens({expiresAt: new Date(Date.now() - 1000)})),
)

const {result} = renderHook(() => useOAuthTokens(), {wrapper})

expect(result.current.isExpired()).toBe(true)
})

it('treats expiresAt exactly equal to now as expired (<= boundary)', () => {
vi.useFakeTimers()
const now = new Date('2030-01-01T00:00:00.000Z')
vi.setSystemTime(now)
mockGetState.mockReturnValue(createFakeTokenSource(makeTokens({expiresAt: new Date(now)})))

const {result} = renderHook(() => useOAuthTokens(), {wrapper})

expect(result.current.isExpired()).toBe(true)
})

it('re-evaluates isExpired against the clock at call time, with no re-render or token change', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2030-01-01T00:00:00.000Z'))
const expiresAt = new Date(Date.now() + 10_000)
mockGetState.mockReturnValue(createFakeTokenSource(makeTokens({expiresAt})))

const {result} = renderHook(() => useOAuthTokens(), {wrapper})
const {isExpired} = result.current
expect(isExpired()).toBe(false)

// Advance past expiry. The same function reference must now report expired,
// proving the read happens at call time, not render time.
vi.setSystemTime(new Date(expiresAt.getTime() + 1000))
expect(isExpired()).toBe(true)
})

it('isExpired reads the current tokens at call time, not the render-time snapshot', async () => {
const source = createFakeTokenSource(makeTokens({expiresAt: new Date(Date.now() - 1000)}))
mockGetState.mockReturnValue(source)
const fresh = makeTokens({accessToken: 'new'})
mockRefresh.mockImplementation(() => {
source.set(fresh)
return Promise.resolve(fresh)
})

const {result} = renderHook(() => useOAuthTokens(), {wrapper})
// Capture the references from the first render, as a consumer's event
// handler would when it destructures the hook result.
const {isExpired, refresh} = result.current
expect(isExpired()).toBe(true)

await act(async () => {
await refresh()
})

expect(result.current.isExpired()).toBe(false)
// The captured reference must agree: it should not "cache" the stale tokens.
expect(isExpired()).toBe(false)
})

it('returns tokens=null and isExpired=false when there are no tokens', () => {
mockGetState.mockReturnValue(createFakeTokenSource(null))

const {result} = renderHook(() => useOAuthTokens(), {wrapper})

expect(result.current.tokens).toBeNull()
expect(result.current.isExpired()).toBe(false)
})

it('calls core refreshOAuthTokens and re-renders with the new tokens', async () => {
const source = createFakeTokenSource(makeTokens())
mockGetState.mockReturnValue(source)
const refreshed = makeTokens({accessToken: 'refreshed-token'})
mockRefresh.mockImplementation(() => {
source.set(refreshed)
return Promise.resolve(refreshed)
})

const {result} = renderHook(() => useOAuthTokens(), {wrapper})

let returned: Omit<OAuthTokens, 'refreshToken'> | null = null
await act(async () => {
returned = await result.current.refresh()
})

expect(mockRefresh).toHaveBeenCalledTimes(1)
expect(returned).toEqual(refreshed)
expect(result.current.tokens).toEqual(refreshed)
})

it('resolves null from refresh when core has no refresh token, and tokens become null', async () => {
const existing = makeTokens()
const source = createFakeTokenSource(existing)
mockGetState.mockReturnValue(source)
// Core clears stored tokens and logs out on the no-refresh-token path.
mockRefresh.mockImplementation(() => {
source.set(null)
return Promise.resolve(null)
})

const {result} = renderHook(() => useOAuthTokens(), {wrapper})

let returned: Omit<OAuthTokens, 'refreshToken'> | null = existing
await act(async () => {
returned = await result.current.refresh()
})

expect(returned).toBeNull()
expect(result.current.tokens).toBeNull()
})

it('propagates a rejected refresh and leaves tokens unchanged', async () => {
const existing = makeTokens()
mockGetState.mockReturnValue(createFakeTokenSource(existing))
mockRefresh.mockRejectedValue(new Error('network'))

const {result} = renderHook(() => useOAuthTokens(), {wrapper})

await act(async () => {
await expect(result.current.refresh()).rejects.toThrow('network')
})

expect(result.current.tokens).toEqual(existing)
})

it('propagates an unrecoverable refresh rejection after core has cleared tokens', async () => {
const source = createFakeTokenSource(makeTokens())
mockGetState.mockReturnValue(source)
// Core clears stored tokens and logs out before rethrowing a 4xx.
mockRefresh.mockImplementation(() => {
source.set(null)
return Promise.reject(new Error('invalid_grant'))
})

const {result} = renderHook(() => useOAuthTokens(), {wrapper})

await act(async () => {
await expect(result.current.refresh()).rejects.toThrow('invalid_grant')
})

expect(result.current.tokens).toBeNull()
})

it('calls core revokeOAuthTokens and re-renders with tokens=null', async () => {
const source = createFakeTokenSource(makeTokens())
mockGetState.mockReturnValue(source)
mockRevoke.mockImplementation(() => {
source.set(null)
return Promise.resolve()
})

const {result} = renderHook(() => useOAuthTokens(), {wrapper})

await act(async () => {
await result.current.revoke()
})

expect(mockRevoke).toHaveBeenCalledTimes(1)
expect(result.current.tokens).toBeNull()
})

it('re-renders when tokens change externally (e.g. another tab)', () => {
const source = createFakeTokenSource(null)
mockGetState.mockReturnValue(source)

const {result} = renderHook(() => useOAuthTokens(), {wrapper})
expect(result.current.tokens).toBeNull()

const otherTabTokens = makeTokens({accessToken: 'other-tab-token'})
act(() => {
source.set(otherTabTokens)
})

expect(result.current.tokens).toEqual(otherTabTokens)
})
})
Loading
Loading