diff --git a/README.md b/README.md index 6c632b0..1a06465 100644 --- a/README.md +++ b/README.md @@ -9,35 +9,35 @@ Looking for a web alternative? Check out [axios-jwt](https://github.com/jetbridg Applies a request interceptor to your axios instance. The interceptor automatically adds an access token header (default: `Authorization`) to all requests. -It stores `accessToken` and `refreshToken` in `AsyncStorage` and reads them when needed. +It stores `accessToken` and `refreshToken` in `Keychain/Keystore` and reads them when needed. It parses the expiration time of your access token and checks to see if it is expired before every request. If it has expired, a request to refresh and store a new access token is automatically performed before the request proceeds. ## Installation -### 1. Install async-storage +### 1. Install required depencies -#### Install package +#### Install packages With npm: ```bash -npm install @react-native-async-storage/async-storage +npm install react-native-keychain react-native-device-info ``` With Yarn: ```bash -yarn add @react-native-async-storage/async-storage +yarn add react-native-keychain react-native-device-info ``` With Expo CLI: ```bash -expo install @react-native-async-storage/async-storage -``` - +expo install react-native-keychain react-native-device-info + ``` + #### Link Android & iOS packages - **React Native 0.60+** @@ -49,10 +49,10 @@ npx pod-install - **React Native <= 0.59** ```bash -react-native link @react-native-async-storage/async-storage +react-native link react-native-keychain react-native-device-info ``` -Please follow the [async-storage installation instructions](https://react-native-async-storage.github.io/async-storage/docs/install/) if you encounter any problems while installing async-storage +Please follow the [react-native-keychain installation instructions](https://github.com/oblador/react-native-keychain#installation) and [react-native-device-info installation instructions](https://github.com/react-native-device-info/react-native-device-info#installation) if you encounter any problems while installing dependencies. ### 2. Install this library @@ -91,7 +91,6 @@ export const axiosInstance = axios.create({ baseURL: BASE_URL }) // 2. Define token refresh function. const requestRefresh: TokenRefreshRequest = async (refreshToken: string): Promise => { - // Important! Do NOT use the axios instance that you supplied to applyAuthTokenInterceptor // because this will result in an infinite loop when trying to refresh the token. // Use the global axios client or a different instance @@ -120,11 +119,11 @@ const login = async (params: ILoginRequest) => { // save tokens to storage await setAuthTokens({ accessToken: response.data.access_token, - refreshToken: response.data.refresh_token + refreshToken: response.data.refresh_token, }) } -// 5. Log out by clearing the auth tokens from AsyncStorage +// 5. Log out by clearing the auth tokens from Keychain/Keystore const logout = () => clearAuthTokens() // Check if refresh token exists @@ -133,17 +132,17 @@ if (isLoggedIn()) { } // Get access to tokens -const accessToken = getAccessToken().then(accessToken => console.log(accessToken)) -const refreshToken = getRefreshToken().then(refreshToken => console.log(refreshToken)) +const accessToken = getAccessToken().then((accessToken) => console.log(accessToken)) +const refreshToken = getRefreshToken().then((refreshToken) => console.log(refreshToken)) ``` ## Configuration ```typescript applyAuthTokenInterceptor(axiosInstance, { - requestRefresh, // async function that takes a refreshToken and returns a promise the resolves in a fresh accessToken - header = "Authorization", // header name - headerPrefix = "Bearer ", // header value prefix + requestRefresh, // async function that takes a refreshToken and returns a promise the resolves in a fresh accessToken + header = 'Authorization', // header name + headerPrefix = 'Bearer ', // header value prefix }) ``` @@ -156,8 +155,8 @@ applyAuthTokenInterceptor(axiosInstance, { ```javascript //api.js -import { applyAuthTokenInterceptor } from 'react-native-axios-jwt'; -import axios from 'axios'; +import { applyAuthTokenInterceptor } from 'react-native-axios-jwt' +import axios from 'axios' const BASE_URL = 'https://api.example.com' @@ -166,29 +165,24 @@ export const axiosInstance = axios.create({ baseURL: BASE_URL }) // 2. Define token refresh function. const requestRefresh = async (refresh) => { - // Notice that this is the global axios instance, not the axiosInstance! - const response = await axios.post(`${BASE_URL}/auth/refresh_token`, { refresh }) + // Notice that this is the global axios instance, not the axiosInstance! + const response = await axios.post(`${BASE_URL}/auth/refresh_token`, { refresh }) - return response.data.access_token -}; + return response.data.access_token +} // 3. Apply interceptor // Notice that this uses the axiosInstance instance. -applyAuthTokenInterceptor(axiosInstance, { requestRefresh }); +applyAuthTokenInterceptor(axiosInstance, { requestRefresh }) ``` + ### Login/logout ```javascript //login.js -import { - isLoggedIn, - setAuthTokens, - clearAuthTokens, - getAccessToken, - getRefreshToken, -} from 'react-native-axios-jwt'; -import { axiosInstance } from '../api'; +import { isLoggedIn, setAuthTokens, clearAuthTokens, getAccessToken, getRefreshToken } from 'react-native-axios-jwt' +import { axiosInstance } from '../api' // 4. Log in by POST-ing the email and password and get tokens in return // and call setAuthTokens with the result. @@ -196,13 +190,13 @@ const login = async (params) => { const response = await axiosInstance.post('/auth/login', params) // save tokens to storage - await setAuthTokens({ + await setAuthTokens({ accessToken: response.data.access_token, - refreshToken: response.data.refresh_token + refreshToken: response.data.refresh_token, }) } -// 5. Log out by clearing the auth tokens from AsyncStorage +// 5. Log out by clearing the auth tokens from KeycKeychain/Keystorehain const logout = () => clearAuthTokens() // Check if refresh token exists @@ -211,11 +205,9 @@ if (isLoggedIn()) { } // Get access to tokens -const accessToken = getAccessToken().then(accessToken => console.log(accessToken)) -const refreshToken = getRefreshToken().then(refreshToken => console.log(refreshToken)) - +const accessToken = getAccessToken().then((accessToken) => console.log(accessToken)) +const refreshToken = getRefreshToken().then((refreshToken) => console.log(refreshToken)) // Now just make all requests using your axiosInstance instance -axiosInstance.get('/api/endpoint/that/requires/login').then(response => { }) - +axiosInstance.get('/api/endpoint/that/requires/login').then((response) => {}) ``` diff --git a/package.json b/package.json index 9c99b5b..e3c717a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "author": "Michiel van Roon", "license": "MIT", "devDependencies": { - "@react-native-async-storage/async-storage": "^1.15.2", "@types/jest": "^26.0.22", "@types/jsonwebtoken": "^8.5.1", "@typescript-eslint/eslint-plugin": "^4.21.0", @@ -23,12 +22,15 @@ "jest": "^26.6.3", "jsonwebtoken": "^8.5.1", "prettier": "^2.2.1", + "react-native-device-info": "^8.4.8", + "react-native-keychain": "^8.0.0", "ts-jest": "^26.5.4", "typescript": "^4.4.4" }, "peerDependencies": { - "@react-native-async-storage/async-storage": "^1.13.2", - "axios": "^0.21.1" + "axios": "^0.21.1", + "react-native-device-info": "^8.4.8", + "react-native-keychain": "^8.0.0" }, "dependencies": { "jwt-decode": "^3.1.2" diff --git a/src/index.ts b/src/index.ts index b907194..f3dd83a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,19 @@ import jwtDecode, { JwtPayload } from 'jwt-decode' import axios, { AxiosInstance, AxiosRequestConfig } from 'axios' -import AsyncStorage from '@react-native-async-storage/async-storage' +import * as Keychain from 'react-native-keychain' +import { getBundleId } from 'react-native-device-info' // a little time before expiration to try refresh (seconds) const EXPIRE_FUDGE = 10 -export const STORAGE_KEY = `auth-tokens-${process.env.NODE_ENV}` +export const STORAGE_KEY = `${getBundleId()}-refresh-token-${process.env.NODE_ENV}` type Token = string export interface AuthTokens { accessToken: Token refreshToken: Token } +let accessToken: Token | null = null // EXPORTS @@ -31,8 +33,20 @@ export const isLoggedIn = async (): Promise => { * @param {AuthTokens} tokens - Access and Refresh tokens * @returns {Promise} */ -export const setAuthTokens = (tokens: AuthTokens): Promise => - AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(tokens)) +export const setAuthTokens = (tokens: AuthTokens): Promise => { + // store accesToken in memory + accessToken = tokens.accessToken + + // store refreshToken securely + return Keychain.setGenericPassword('refreshToken', tokens.refreshToken, { service: STORAGE_KEY }) + .then((result) => { + if (result) return + else throw new Error('Failed to store refresh token') + }) + .catch((error) => { + throw error + }) +} /** * Sets the access token @@ -40,13 +54,13 @@ export const setAuthTokens = (tokens: AuthTokens): Promise => * @param {Promise} token - Access token */ export const setAccessToken = async (token: Token): Promise => { - const tokens = await getAuthTokens() - if (!tokens) { + const refreshToken = await getRefreshToken() + if (!refreshToken || !accessToken) { throw new Error('Unable to update access token since there are not tokens currently stored') } - tokens.accessToken = token - return setAuthTokens(tokens) + accessToken = token + return } /** @@ -54,7 +68,19 @@ export const setAccessToken = async (token: Token): Promise => { * @async * @param {Promise} */ -export const clearAuthTokens = (): Promise => AsyncStorage.removeItem(STORAGE_KEY) +export const clearAuthTokens = async (): Promise => { + accessToken = null + try { + const result = await Keychain.resetGenericPassword({ service: STORAGE_KEY }) + if (result) { + return + } else { + throw new Error('Failed to clear refresh token') + } + } catch (error) { + throw error + } +} /** * Returns the stored refresh token @@ -62,18 +88,16 @@ export const clearAuthTokens = (): Promise => AsyncStorage.removeItem(STOR * @returns {Promise} Refresh token */ export const getRefreshToken = async (): Promise => { - const tokens = await getAuthTokens() - return tokens ? tokens.refreshToken : undefined + const credentials = await Keychain.getGenericPassword({ service: STORAGE_KEY }) + return credentials ? credentials.password : undefined } /** * Returns the stored access token - * @async * @returns {Promise} Access token */ -export const getAccessToken = async (): Promise => { - const tokens = await getAuthTokens() - return tokens ? tokens.accessToken : undefined +export const getAccessToken = (): Token | undefined => { + return accessToken ?? undefined } /** @@ -89,9 +113,6 @@ export const getAccessToken = async (): Promise => { * @returns {Promise} Access token */ export const refreshTokenIfNeeded = async (requestRefresh: TokenRefreshRequest): Promise => { - // use access token (if we have it) - let accessToken = await getAccessToken() - // check if access token is expired if (!accessToken || isTokenExpired(accessToken)) { // do refresh @@ -114,23 +135,6 @@ export const applyAuthTokenInterceptor = (axios: AxiosInstance, config: AuthToke // PRIVATE -/** - * Returns the refresh and access tokens - * @async - * @returns {Promise} Object containing refresh and access tokens - */ -const getAuthTokens = async (): Promise => { - const rawTokens = await AsyncStorage.getItem(STORAGE_KEY) - if (!rawTokens) return - - try { - // parse stored tokens JSON - return JSON.parse(rawTokens) - } catch (error) { - throw new Error(`Failed to parse auth tokens: ${rawTokens}`) - } -} - /** * Checks if the token is undefined, has expired or is about the expire * @@ -198,7 +202,7 @@ const refreshToken = async (requestRefresh: TokenRefreshRequest): Promise const status = error.response?.status if (status === 401 || status === 422) { // The refresh token is invalid so remove the stored tokens - await AsyncStorage.removeItem(STORAGE_KEY) + await clearAuthTokens() error.message = `Got ${status} on token refresh; clearing both auth tokens` } diff --git a/tsconfig.json b/tsconfig.json index 6367a4f..0708f78 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,10 +4,7 @@ // "incremental": true, /* Enable incremental compilation */ "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, - "lib": [ - "es2018", - "dom" - ] /* Specify library files to be included in the compilation. */, + "lib": ["es2018", "dom"] /* Specify library files to be included in the compilation. */, // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ @@ -49,7 +46,7 @@ // "typeRoots": [], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ @@ -62,6 +59,7 @@ /* Experimental Options */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + "skipLibCheck": true }, "exclude": ["node_modules", "dist", "tests"] } diff --git a/yarn.lock b/yarn.lock index ef60696..6bd9881 100644 --- a/yarn.lock +++ b/yarn.lock @@ -547,13 +547,6 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@react-native-async-storage/async-storage@^1.15.2": - version "1.15.9" - resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-1.15.9.tgz#744ecd566f108e86b6b59e617c23fdb4b8d53358" - integrity sha512-LrVPfhKqodRiDWCgZp7J2X55JQqOhdQUxbl17RrktIGCZ0ud2XHdNoTIvyI1VccqHoF/CZK6v+G0IoX5NYZ1JA== - dependencies: - merge-options "^3.0.4" - "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -2161,11 +2154,6 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-plain-obj@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" - integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== - is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -2923,13 +2911,6 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" -merge-options@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/merge-options/-/merge-options-3.0.4.tgz#84709c2aa2a4b24c1981f66c179fe5565cc6dbb7" - integrity sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ== - dependencies: - is-plain-obj "^2.1.0" - merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -3361,6 +3342,16 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +react-native-device-info@^8.4.8: + version "8.4.8" + resolved "https://registry.yarnpkg.com/react-native-device-info/-/react-native-device-info-8.4.8.tgz#fc92ae423e47db6cfbf30c30012e09cee63727fa" + integrity sha512-92676ZWHZHsPM/EW1ulgb2MuVfjYfMWRTWMbLcrCsipkcMaZ9Traz5mpsnCS7KZpsOksnvUinzDIjsct2XGc6Q== + +react-native-keychain@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/react-native-keychain/-/react-native-keychain-8.0.0.tgz#ff708e4dc2a5440df717179bf9b7cd50f78b61d7" + integrity sha512-c7Cs+YQN26UaQsRG1dmlXL7VL2ctnXwH/dl0IOMEQ7ZaL2NdN313YSAI8ZEZZjrVhNmPsyWEuvTFqWrdpItqQg== + read-pkg-up@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"