Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Keychain/Keystore to store refresh token #13

Closed
wants to merge 3 commits into from
Closed
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
74 changes: 33 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+**
Expand All @@ -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

Expand Down Expand Up @@ -91,7 +91,6 @@ export const axiosInstance = axios.create({ baseURL: BASE_URL })

// 2. Define token refresh function.
const requestRefresh: TokenRefreshRequest = async (refreshToken: string): Promise<string> => {

// 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
Expand Down Expand Up @@ -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
Expand All @@ -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
})
```

Expand All @@ -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'

Expand All @@ -166,43 +165,38 @@ 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.
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
Expand All @@ -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) => {})
```
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
76 changes: 40 additions & 36 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -31,49 +33,71 @@ export const isLoggedIn = async (): Promise<boolean> => {
* @param {AuthTokens} tokens - Access and Refresh tokens
* @returns {Promise}
*/
export const setAuthTokens = (tokens: AuthTokens): Promise<void> =>
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(tokens))
export const setAuthTokens = (tokens: AuthTokens): Promise<void> => {
// 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
* @async
* @param {Promise} token - Access token
*/
export const setAccessToken = async (token: Token): Promise<void> => {
const tokens = await getAuthTokens()
if (!tokens) {
const refreshToken = await getRefreshToken()
if (!refreshToken || !accessToken) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should it be !refreshToken || !token?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both tokens need to be checked, like before.

throw new Error('Unable to update access token since there are not tokens currently stored')
}

tokens.accessToken = token
return setAuthTokens(tokens)
accessToken = token
return
}

/**
* Clears both tokens
* @async
* @param {Promise}
*/
export const clearAuthTokens = (): Promise<void> => AsyncStorage.removeItem(STORAGE_KEY)
export const clearAuthTokens = async (): Promise<void> => {
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
* @async
* @returns {Promise<string>} Refresh token
*/
export const getRefreshToken = async (): Promise<Token | undefined> => {
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<string>} Access token
*/
export const getAccessToken = async (): Promise<Token | undefined> => {
const tokens = await getAuthTokens()
return tokens ? tokens.accessToken : undefined
export const getAccessToken = (): Token | undefined => {
return accessToken ?? undefined
}

/**
Expand All @@ -89,9 +113,6 @@ export const getAccessToken = async (): Promise<Token | undefined> => {
* @returns {Promise<string>} Access token
*/
export const refreshTokenIfNeeded = async (requestRefresh: TokenRefreshRequest): Promise<Token | undefined> => {
// use access token (if we have it)
let accessToken = await getAccessToken()

// check if access token is expired
if (!accessToken || isTokenExpired(accessToken)) {
// do refresh
Expand All @@ -114,23 +135,6 @@ export const applyAuthTokenInterceptor = (axios: AxiosInstance, config: AuthToke

// PRIVATE

/**
* Returns the refresh and access tokens
* @async
* @returns {Promise<AuthTokens>} Object containing refresh and access tokens
*/
const getAuthTokens = async (): Promise<AuthTokens | undefined> => {
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
*
Expand Down Expand Up @@ -198,7 +202,7 @@ const refreshToken = async (requestRefresh: TokenRefreshRequest): Promise<Token>
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`
}

Expand Down
8 changes: 3 additions & 5 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'. */
Expand Down Expand Up @@ -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. */

Expand All @@ -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"]
}
Loading