Skip to content

Commit

Permalink
Merge pull request #23 from lifeomic/PHC-4072-auth-token-load
Browse files Browse the repository at this point in the history
Refactor awkward username param, and allow for loading stored auth token
  • Loading branch information
markdlv authored Feb 10, 2023
2 parents cbe6184 + 2a66836 commit 257e561
Show file tree
Hide file tree
Showing 6 changed files with 111 additions and 43 deletions.
9 changes: 4 additions & 5 deletions src/common/SecureStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SecureStore } from './SecureStore';
import { SecureStore, unusedUsername } from './SecureStore';

import Keychain from 'react-native-keychain';

Expand Down Expand Up @@ -47,9 +47,9 @@ describe('SecureStore', () => {
});

it('sets a generic password with the expected options', async () => {
await store.setObject('some-user', { item: 'item-1' });
await store.setObject({ item: 'item-1' });
expect(keychainMock.setGenericPassword).toHaveBeenCalledWith(
'some-user',
unusedUsername,
'{"item":"item-1"}',
expectedOptions,
);
Expand All @@ -58,7 +58,7 @@ describe('SecureStore', () => {
it('returns a generic password with the username and password when exists', async () => {
keychainMock.getGenericPassword.mockResolvedValueOnce({
service: '',
username: 'bob',
username: 'unused',
password: '{"item":"item-1"}',
storage: '',
});
Expand All @@ -68,7 +68,6 @@ describe('SecureStore', () => {
expectedOptions,
);
expect(result).toMatchObject({
username: 'bob',
item: 'item-1',
});
});
Expand Down
10 changes: 6 additions & 4 deletions src/common/SecureStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ export class SecureStore<Stored> {
* Stores an object as { username, password } at key com.lifeomic.securestore/<instanceIdentifier>
* @param object
*/
async setObject(itemId: string, object: Stored) {
async setObject(object: Stored) {
return Keychain.setGenericPassword(
itemId,
unusedUsername,
JSON.stringify(object),
this.keychainOptions,
);
Expand All @@ -49,13 +49,15 @@ export class SecureStore<Stored> {
if (result === false) {
return null;
} else {
const { username, password } = result;
const { password } = result;
const secureData: Stored = JSON.parse(password);
return { username, ...secureData };
return secureData;
}
}

async clear() {
return Keychain.resetGenericPassword(this.keychainOptions);
}
}

export const unusedUsername = 'unused-username';
82 changes: 61 additions & 21 deletions src/hooks/useAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import React from 'react';
import { renderHook, act } from '@testing-library/react-native';
import { AuthContextProvider, AuthResult, useAuth } from './useAuth';
import Keychain from 'react-native-keychain';
import { unusedUsername } from '../common/SecureStore';

jest.mock('react-native-keychain', () => ({
ACCESSIBLE: { AFTER_FIRST_UNLOCK: 'AFTER_FIRST_UNLOCK' },
SECURITY_LEVEL: { SECURE_SOFTWARE: 'SECURE_SOFTWARE' },
setGenericPassword: jest.fn(),
resetGenericPassword: jest.fn(),
getGenericPassword: jest.fn(),
}));

const keychainMock = Keychain as jest.Mocked<typeof Keychain>;
Expand All @@ -20,59 +22,97 @@ const renderHookInContext = async () => {
});
};

const exampleAuthResult: AuthResult = {
tokenType: 'bearer',
accessTokenExpirationDate: new Date().toISOString(),
accessToken: 'accessToken',
idToken: 'idToken',
refreshToken: 'refreshToken',
};

beforeEach(() => {
keychainMock.getGenericPassword.mockResolvedValue(false);
});

test('initial state test', async () => {
const { result } = await renderHookInContext();

expect(result.current.loading).toBe(false);
expect(result.current.loading).toBe(true);
expect(result.current.authResult).toBeUndefined();
expect(result.current.isLoggedIn).toBe(false);
});

test('stores token and updates state', async () => {
const { result } = await renderHookInContext();
const authResult: AuthResult = {
tokenType: 'bearer',
accessTokenExpirationDate: new Date().toISOString(),
accessToken: 'accessToken',
idToken: 'idToken',
refreshToken: 'refreshToken',
};

await act(async () => {
await result.current.storeAuthResult(authResult);
await result.current.storeAuthResult(exampleAuthResult);
});
expect(result.current.loading).toBe(false);
expect(result.current.authResult).toEqual(authResult);
expect(result.current.authResult).toEqual(exampleAuthResult);
expect(result.current.isLoggedIn).toBe(true);
expect(keychainMock.setGenericPassword).toHaveBeenCalledWith(
'auth-result',
JSON.stringify(authResult),
unusedUsername,
JSON.stringify(exampleAuthResult),
expect.anything(),
);
});

test('clears token and updates state', async () => {
const { result } = await renderHookInContext();
const authResult: AuthResult = {
tokenType: 'bearer',
accessTokenExpirationDate: new Date().toISOString(),
accessToken: 'accessToken',
idToken: 'idToken',
refreshToken: 'refreshToken',
await act(async () => {
await result.current.storeAuthResult(exampleAuthResult);
});
expect(result.current.loading).toBe(false);
expect(result.current.authResult).toEqual(exampleAuthResult);
expect(result.current.isLoggedIn).toBe(true);

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

expect(result.current.loading).toBe(false);
expect(result.current.authResult).toBeUndefined();
expect(result.current.isLoggedIn).toBe(false);
expect(keychainMock.resetGenericPassword).toHaveBeenCalled();
});

test('initialize loads token and updates state', async () => {
const authResult = {
...exampleAuthResult,
accessTokenExpirationDate: new Date(
Date.now() + 1 * 60 * 60 * 1000,
).toISOString(),
};
keychainMock.getGenericPassword.mockResolvedValue({
username: 'auth-result',
password: JSON.stringify(authResult),
service: 'any',
storage: 'any',
});
const { result } = await renderHookInContext();
expect(result.current.loading).toBe(true);

await act(async () => {
await result.current.storeAuthResult(authResult);
await result.current.initialize();
});

expect(result.current.loading).toBe(false);
expect(result.current.authResult).toEqual(authResult);
expect(result.current.isLoggedIn).toBe(true);
});

test('initialize can handle password retrieval error', async () => {
const error = new Error('uh oh');
keychainMock.getGenericPassword.mockRejectedValue(error);
const { result } = await renderHookInContext();
expect(result.current.loading).toBe(true);

await act(async () => {
await await result.current.clearAuthResult();
await result.current.initialize();
});

expect(result.current.loading).toBe(false);
expect(result.current.authResult).toBeUndefined();
expect(result.current.isLoggedIn).toBe(false);
expect(keychainMock.resetGenericPassword).toHaveBeenCalled();
});
31 changes: 21 additions & 10 deletions src/hooks/useAuth.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import React, { createContext, useCallback, useContext, useState } from 'react';
import { RefreshResult } from 'react-native-app-auth';
import { SecureStore } from '../common/SecureStore';

Expand All @@ -14,6 +8,7 @@ export interface AuthStatus {
authResult?: AuthResult;
storeAuthResult: (params: AuthResult) => Promise<void>;
clearAuthResult: () => Promise<void>;
initialize: () => Promise<void>;
}

export type AuthResult = Omit<RefreshResult, 'additionalParameters'>;
Expand All @@ -23,6 +18,7 @@ const AuthContext = createContext<AuthStatus>({
isLoggedIn: false,
storeAuthResult: (_) => Promise.reject(),
clearAuthResult: () => Promise.reject(),
initialize: () => Promise.reject(),
});

const secureStorage = new SecureStore<AuthResult>('auth-hook');
Expand All @@ -37,7 +33,7 @@ export const AuthContextProvider = ({
const [loading, setLoading] = useState<boolean>(true);

const storeAuthResult = useCallback(async (result: AuthResult) => {
await secureStorage.setObject('auth-result', result);
await secureStorage.setObject(result);
setAuthResult(result);
setIsLoggedIn(true);
setLoading(false);
Expand All @@ -50,8 +46,22 @@ export const AuthContextProvider = ({
setLoading(false);
}, []);

// TODO: load saved token and evaluated expiration
useEffect(() => {
const initialize = useCallback(async () => {
try {
const storedResult = await secureStorage.getObject();
if (storedResult) {
const fifteenMinInMs = 15 * 60 * 1000;
if (
new Date(storedResult.accessTokenExpirationDate).getTime() >
new Date().getTime() + fifteenMinInMs
) {
setAuthResult(storedResult);
setIsLoggedIn(true);
}
}
} catch (error) {
console.warn(error, 'Error occurred loading auth token');
}
setLoading(false);
}, []);

Expand All @@ -61,6 +71,7 @@ export const AuthContextProvider = ({
authResult,
storeAuthResult,
clearAuthResult,
initialize,
};

return (
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/useOAuthFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ beforeEach(() => {
authResult: authResult,
storeAuthResult: storeAuthResultMock,
clearAuthResult: clearAuthResultMock,
initialize: jest.fn(),
});

authorizeMock.mockResolvedValue(authResult);
Expand Down Expand Up @@ -147,6 +148,7 @@ describe('logout', () => {
authResult: undefined,
storeAuthResult: storeAuthResultMock,
clearAuthResult: clearAuthResultMock,
initialize: jest.fn(),
});
const { result } = await renderHookInContext();
const onSuccess = jest.fn();
Expand Down
20 changes: 17 additions & 3 deletions src/hooks/useOAuthFlow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import React, { createContext, useCallback, useContext } from 'react';
import React, {
createContext,
useCallback,
useContext,
useEffect,
} from 'react';
import {
authorize,
AuthConfiguration,
Expand Down Expand Up @@ -35,8 +40,13 @@ export const OAuthContextProvider = ({
authConfig: AuthConfiguration;
children?: React.ReactNode;
}) => {
const { isLoggedIn, authResult, storeAuthResult, clearAuthResult } =
useAuth();
const {
isLoggedIn,
initialize,
authResult,
storeAuthResult,
clearAuthResult,
} = useAuth();

// PKCE is required
if (!authConfig.usePKCE) {
Expand Down Expand Up @@ -83,6 +93,10 @@ export const OAuthContextProvider = ({
[authConfig, clearAuthResult, storeAuthResult],
);

useEffect(() => {
initialize();
}, [initialize]);

const context = {
login,
logout,
Expand Down

0 comments on commit 257e561

Please sign in to comment.