From b42efef35b944d29e08fa6417a07212b374801e8 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 21 Sep 2026 13:20:13 +0100 Subject: [PATCH] feat(react): add useOAuthTokens hook --- .changeset/oauth-usetokens.md | 6 + .../core/src/auth/oauth/oauthActions.test.ts | 23 +- packages/core/src/auth/oauth/oauthActions.ts | 29 +- packages/react/src/_exports/sdk-react.ts | 1 + .../src/hooks/auth/useOAuthTokens.test.tsx | 267 ++++++++++++++++++ .../react/src/hooks/auth/useOAuthTokens.tsx | 104 +++++++ 6 files changed, 420 insertions(+), 10 deletions(-) create mode 100644 .changeset/oauth-usetokens.md create mode 100644 packages/react/src/hooks/auth/useOAuthTokens.test.tsx create mode 100644 packages/react/src/hooks/auth/useOAuthTokens.tsx diff --git a/.changeset/oauth-usetokens.md b/.changeset/oauth-usetokens.md new file mode 100644 index 000000000..19745cb9e --- /dev/null +++ b/.changeset/oauth-usetokens.md @@ -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)) diff --git a/packages/core/src/auth/oauth/oauthActions.test.ts b/packages/core/src/auth/oauth/oauthActions.test.ts index 335cf6acf..d92793c66 100644 --- a/packages/core/src/auth/oauth/oauthActions.test.ts +++ b/packages/core/src/auth/oauth/oauthActions.test.ts @@ -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 () => { @@ -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', () => { diff --git a/packages/core/src/auth/oauth/oauthActions.ts b/packages/core/src/auth/oauth/oauthActions.ts index def5330d6..495fbb705 100644 --- a/packages/core/src/auth/oauth/oauthActions.ts +++ b/packages/core/src/auth/oauth/oauthActions.ts @@ -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' @@ -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 | null = null +let refreshInFlight: Promise | 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 */ @@ -275,7 +277,7 @@ export const refreshOAuthTokens = bindActionGlobally(authStore, (context) => { async function doRefreshOAuthTokens({ state, instance, -}: StoreContext): Promise { +}: StoreContext): Promise | null> { const logger = getAuthLogger(instance) const options = getOAuthOptions(state.get()) @@ -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 @@ -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 | 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. */ diff --git a/packages/react/src/_exports/sdk-react.ts b/packages/react/src/_exports/sdk-react.ts index a16f8d1f0..5140ef249 100644 --- a/packages/react/src/_exports/sdk-react.ts +++ b/packages/react/src/_exports/sdk-react.ts @@ -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 { diff --git a/packages/react/src/hooks/auth/useOAuthTokens.test.tsx b/packages/react/src/hooks/auth/useOAuthTokens.test.tsx new file mode 100644 index 000000000..cdaec5537 --- /dev/null +++ b/packages/react/src/hooks/auth/useOAuthTokens.test.tsx @@ -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() + 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 | null) { + let current = initial + const listeners = new Set<() => void>() + const source: StateSource | null> & { + set: (next: Omit | 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 => ({ + accessToken: 'access-token', + tokenType: 'bearer', + expiresIn: 3600, + expiresAt: new Date(Date.now() + 3600_000), + ...overrides, +}) + +const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + +) + +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 | 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 | 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) + }) +}) diff --git a/packages/react/src/hooks/auth/useOAuthTokens.tsx b/packages/react/src/hooks/auth/useOAuthTokens.tsx new file mode 100644 index 000000000..977565f93 --- /dev/null +++ b/packages/react/src/hooks/auth/useOAuthTokens.tsx @@ -0,0 +1,104 @@ +import { + getOAuthTokensState, + type OAuthTokens, + refreshOAuthTokens, + revokeOAuthTokens, + type SanityInstance, +} from '@sanity/sdk' + +import {createCallbackHook} from '../helpers/createCallbackHook' +import {createStateSourceHook} from '../helpers/createStateSourceHook' + +/** + * The current OAuth token state, plus actions to refresh and revoke it. + * + * @public + */ +export interface UseOAuthTokensResult { + /** + * The stored OAuth tokens, or `null` when not logged in via OAuth. The + * refresh token is omitted — core retains it internally for `refresh`. + */ + tokens: Omit | null + /** + * Returns whether the access token has expired, comparing the latest stored + * `expiresAt` against the current time at the moment it is called. Both the + * tokens and the clock are read at call time, so a reference captured in an + * earlier render stays accurate after a `refresh`. Reading the clock does not + * trigger a re-render, so call this in an event handler or effect rather than + * during render. Returns `false` when there are no tokens. + */ + isExpired: () => boolean + /** + * Refresh via the OAuth `refresh_token` grant. When there is no refresh token, + * core clears the stored tokens, logs the user out, and this resolves `null`. + * Rejects on transient failures (network, 5xx, 408, 429), leaving tokens + * unchanged so the call can be retried. Also rejects when the server rejects + * the refresh token itself (other 4xx); core clears the tokens and logs out + * first, so check `tokens` before retrying. + */ + refresh: () => Promise | null> + /** Revoke the tokens at the OAuth server, clear them locally, and log out. */ + revoke: () => Promise +} + +const useOAuthTokensState = createStateSourceHook(getOAuthTokensState) +const useRefreshOAuthTokens = createCallbackHook(refreshOAuthTokens) +const useRevokeOAuthTokens = createCallbackHook(revokeOAuthTokens) + +// Reads core's state source directly rather than the render-time `tokens` +// snapshot, so a reference captured in an earlier render (e.g. in an event +// handler that awaits `refresh()`) still reflects the latest tokens. +const useIsOAuthTokenExpired = createCallbackHook((instance: SanityInstance): boolean => { + const tokens = getOAuthTokensState(instance).getCurrent() + return tokens ? tokens.expiresAt.getTime() <= Date.now() : false +}) + +/** + * A React hook that exposes the stored OAuth token state along with `refresh` + * and `revoke` actions. + * + * @remarks + * The token view is a synchronous read over core's token state source, so the + * hook re-renders whenever tokens change — including changes made in another + * tab, which core propagates via `storage` events. + * + * @returns The current {@link UseOAuthTokensResult} + * + * @example + * ```tsx + * function TokenStatus() { + * const {tokens, isExpired, refresh, revoke} = useOAuthTokens() + * + * if (!tokens) return
Not signed in
+ * + * const handleRefresh = async () => { + * if (!isExpired()) return + * try { + * await refresh() + * } catch { + * // Transient failure (tokens unchanged, retry later) or the refresh + * // token was rejected (tokens now null, user is logged out). + * } + * } + * + * return ( + *
+ *

Expires at {tokens.expiresAt.toLocaleTimeString()}

+ * + * + *
+ * ) + * } + * ``` + * + * @public + */ +export function useOAuthTokens(): UseOAuthTokensResult { + return { + tokens: useOAuthTokensState(), + isExpired: useIsOAuthTokenExpired(), + refresh: useRefreshOAuthTokens(), + revoke: useRevokeOAuthTokens(), + } +}