diff --git a/src/hooks/__tests__/use-auth0.spec.jsx b/src/hooks/__tests__/use-auth0.spec.jsx index a3884d2c..24c73727 100644 --- a/src/hooks/__tests__/use-auth0.spec.jsx +++ b/src/hooks/__tests__/use-auth0.spec.jsx @@ -31,6 +31,7 @@ const mockIdToken = makeJwt(); const mockCredentials = { idToken: mockIdToken, accessToken: 'ACCESS TOKEN', + refreshToken: 'REFRESH TOKEN', }; const mockAuthError = new Auth0Error({ json: { error: 'mock error' } }); @@ -38,10 +39,12 @@ const mockAuthError = new Auth0Error({ json: { error: 'mock error' } }); const updatedMockCredentialsWithIdToken = { idToken: makeJwt({ name: 'Different User' }), accessToken: 'ACCESS TOKEN', + refreshToken: 'REFRESH TOKEN', }; const updatedMockCredentialsWithoutIdToken = { accessToken: 'ACCESS TOKEN', + refreshToken: 'REFRESH TOKEN', }; const wrapper = ({ children }) => ( @@ -65,6 +68,9 @@ const mockAuth0 = { loginWithOTP: jest.fn().mockResolvedValue(mockCredentials), loginWithRecoveryCode: jest.fn().mockResolvedValue(mockCredentials), hasValidCredentials: jest.fn().mockResolvedValue(), + refreshToken: jest + .fn() + .mockResolvedValue(updatedMockCredentialsWithIdToken), }, credentialsManager: { getCredentials: jest.fn().mockResolvedValue(mockCredentials), @@ -212,6 +218,7 @@ describe('The useAuth0 hook', () => { idToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cHM6Ly9hdXRoMC5jb20iLCJhdWQiOiJjbGllbnQxMjMiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwicGljdHVyZSI6Imh0dHBzOi8vaW1hZ2VzL3BpYy5wbmcifQ==.c2lnbmF0dXJl', accessToken: 'ACCESS TOKEN', + refreshToken: 'REFRESH TOKEN', }); }); @@ -251,6 +258,63 @@ describe('The useAuth0 hook', () => { idToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cHM6Ly9hdXRoMC5jb20iLCJhdWQiOiJjbGllbnQxMjMiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwicGljdHVyZSI6Imh0dHBzOi8vaW1hZ2VzL3BpYy5wbmcifQ==.c2lnbmF0dXJl', accessToken: 'ACCESS TOKEN', + refreshToken: 'REFRESH TOKEN', + }); + }); + + it("can refresh the user's credentials using refreshToken method", async () => { + const { result } = renderHook(() => useAuth0(), { + wrapper, + }); + let credentials; + let newCredentials; + await act(async () => { + credentials = await result.current.getCredentials(); + await result.current.refreshToken({ + refreshToken: credentials.refreshToken, + scope: 'openid profile email offline_access', + }); + newCredentials = await result.current.getCredentials(); + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockAuth0.auth.refreshToken).toHaveBeenCalledWith({ + refreshToken: credentials.refreshToken, + scope: 'openid profile email offline_access', + }); + expect(newCredentials).toEqual({ + idToken: + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cHM6Ly9hdXRoMC5jb20iLCJhdWQiOiJjbGllbnQxMjMiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwicGljdHVyZSI6Imh0dHBzOi8vaW1hZ2VzL3BpYy5wbmcifQ==.c2lnbmF0dXJl', + accessToken: 'ACCESS TOKEN', + refreshToken: 'REFRESH TOKEN', + }); + }); + + it("can save the user's credentials using saveCredentials method", async () => { + const { result } = renderHook(() => useAuth0(), { + wrapper, + }); + let credentials; + let newCredentials; + + await act(async () => { + const { refreshToken } = await result.current.getCredentials(); + newCredentials = await result.current.refreshToken({ + refreshToken, + scope: 'openid profile email offline_access', + }); + result.current.saveCredentials(newCredentials); + credentials = await result.current.getCredentials(); + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(newCredentials).toEqual({ + idToken: + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cHM6Ly9hdXRoMC5jb20iLCJhdWQiOiJjbGllbnQxMjMiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwicGljdHVyZSI6Imh0dHBzOi8vaW1hZ2VzL3BpYy5wbmcifQ==.c2lnbmF0dXJl', + accessToken: 'ACCESS TOKEN', + refreshToken: 'REFRESH TOKEN', }); }); @@ -407,6 +471,7 @@ describe('The useAuth0 hook', () => { idToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cHM6Ly9hdXRoMC5jb20iLCJhdWQiOiJjbGllbnQxMjMiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwicGljdHVyZSI6Imh0dHBzOi8vaW1hZ2VzL3BpYy5wbmcifQ==.c2lnbmF0dXJl', accessToken: 'ACCESS TOKEN', + refreshToken: 'REFRESH TOKEN', }); }); @@ -524,6 +589,7 @@ describe('The useAuth0 hook', () => { idToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cHM6Ly9hdXRoMC5jb20iLCJhdWQiOiJjbGllbnQxMjMiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwicGljdHVyZSI6Imh0dHBzOi8vaW1hZ2VzL3BpYy5wbmcifQ==.c2lnbmF0dXJl', accessToken: 'ACCESS TOKEN', + refreshToken: 'REFRESH TOKEN', }); }); @@ -639,6 +705,7 @@ describe('The useAuth0 hook', () => { idToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cHM6Ly9hdXRoMC5jb20iLCJhdWQiOiJjbGllbnQxMjMiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwicGljdHVyZSI6Imh0dHBzOi8vaW1hZ2VzL3BpYy5wbmcifQ==.c2lnbmF0dXJl', accessToken: 'ACCESS TOKEN', + refreshToken: 'REFRESH TOKEN', }); }); @@ -695,6 +762,7 @@ describe('The useAuth0 hook', () => { idToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cHM6Ly9hdXRoMC5jb20iLCJhdWQiOiJjbGllbnQxMjMiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwicGljdHVyZSI6Imh0dHBzOi8vaW1hZ2VzL3BpYy5wbmcifQ==.c2lnbmF0dXJl', accessToken: 'ACCESS TOKEN', + refreshToken: 'REFRESH TOKEN', }); }); @@ -751,6 +819,7 @@ describe('The useAuth0 hook', () => { idToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cHM6Ly9hdXRoMC5jb20iLCJhdWQiOiJjbGllbnQxMjMiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwicGljdHVyZSI6Imh0dHBzOi8vaW1hZ2VzL3BpYy5wbmcifQ==.c2lnbmF0dXJl', accessToken: 'ACCESS TOKEN', + refreshToken: 'REFRESH TOKEN', }); }); diff --git a/src/hooks/auth0-context.ts b/src/hooks/auth0-context.ts index 5c77aa88..2c701d62 100644 --- a/src/hooks/auth0-context.ts +++ b/src/hooks/auth0-context.ts @@ -14,6 +14,7 @@ import { WebAuthorizeParameters, PasswordlessWithSMSOptions, ClearSessionOptions, + RefreshTokenOptions, } from '../types'; import LocalAuthenticationStrategy from '../credentials-manager/localAuthenticationStrategy'; @@ -102,6 +103,23 @@ export interface Auth0ContextInterface parameters?: Record, forceRefresh?: boolean ) => Promise; + /** + * Manually saves the user's credentials to the native credential store. + * by default. See {@link CredentialsManager#getCredentials} + * @returns + */ + saveCredentials: ( + credentials: Credentials + ) => Promise; + /** + * Obtain new tokens using the Refresh Token obtained during Auth (requesting `offline_access` scope) + * + * @returns A populated instance of {@link Credentials}. + * @see https://auth0.com/docs/tokens/refresh-token/current#use-a-refresh-token + */ + refreshToken: ( + refreshTokenOptions: RefreshTokenOptions + ) => Promise; /** * Clears the user's credentials without clearing their web session and logs them out. */ @@ -160,6 +178,8 @@ const initialContext = { getCredentials: stub, clearCredentials: stub, requireLocalAuthentication: stub, + saveCredentials: stub, + refreshToken: stub, }; const Auth0Context = createContext(initialContext); diff --git a/src/hooks/auth0-provider.tsx b/src/hooks/auth0-provider.tsx index 10c354ab..c7477ca0 100644 --- a/src/hooks/auth0-provider.tsx +++ b/src/hooks/auth0-provider.tsx @@ -22,6 +22,7 @@ import { MultifactorChallengeOptions, PasswordlessWithEmailOptions, PasswordlessWithSMSOptions, + RefreshTokenOptions, User, WebAuthorizeOptions, WebAuthorizeParameters, @@ -163,6 +164,40 @@ const Auth0Provider = ({ [client] ); + const saveCredentials = useCallback( + async ( + credentials: Credentials + ): Promise => { + try { + await client.credentialsManager.saveCredentials( + credentials + ); + const newCredentials = await getCredentials() + return newCredentials; + } catch (error) { + dispatch({ type: 'ERROR', error }); + return; + } + }, + [client] + ); + + const refreshToken = useCallback( + async ( + refreshTokenOptions: RefreshTokenOptions + ): Promise => { + try { + const credentials = await client.auth.refreshToken(refreshTokenOptions); + const newCredentials = saveCredentials(credentials) + return newCredentials; + } catch (error) { + dispatch({ type: 'ERROR', error }); + return; + } + }, + [client] + ); + const sendSMSCode = useCallback( async (parameters: PasswordlessWithSMSOptions) => { try { @@ -351,6 +386,8 @@ const Auth0Provider = ({ getCredentials, clearCredentials, requireLocalAuthentication, + refreshToken, + saveCredentials }), [ state, @@ -368,6 +405,8 @@ const Auth0Provider = ({ getCredentials, clearCredentials, requireLocalAuthentication, + refreshToken, + saveCredentials ] ); diff --git a/src/hooks/use-auth0.ts b/src/hooks/use-auth0.ts index 0e9db951..85215b71 100644 --- a/src/hooks/use-auth0.ts +++ b/src/hooks/use-auth0.ts @@ -25,7 +25,9 @@ import Auth0Context, { Auth0ContextInterface } from './auth0-context'; * clearSession, * getCredentials, * clearCredentials, - * requireLocalAuthentication + * requireLocalAuthentication, + * refreshToken, + * saveCredentials * } = useAuth0(); * ``` *