├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── getRefreshTokenLink.ts └── index.tsx ├── tests └── notTested.test.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | .rts2_cache_cjs 6 | .rts2_cache_esm 7 | .rts2_cache_umd 8 | .rts2_cache_system 9 | dist 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Benjamin Leeds 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # apollo-link-refresh-token 2 | 3 | A link to refresh auth tokens on authentication errors 4 | 5 | ## Getting started 6 | 7 | Install the package: 8 | 9 | ``` 10 | yarn add apollo-link-refresh-token 11 | ``` 12 | 13 | Add the link to your apollo client: 14 | 15 | _Note that your implementation will likely change based on your specific parameters._ 16 | 17 | ```typescript 18 | import { ApolloClient } from 'apollo-client'; 19 | import { 20 | getTokenRefreshLink, 21 | FetchNewAccessToken, 22 | } from 'apollo-link-refresh-token'; 23 | import jwtDecode from 'jwt-decode'; 24 | import { authLink, errorLink, httpLink } from './links'; 25 | 26 | const isTokenValid = (token: string): boolean => { 27 | const decodedToken = jwtDecode<{ [key: string]: number }>(token); 28 | 29 | if (!decodedToken) { 30 | return false; 31 | } 32 | 33 | const now = new Date(); 34 | return now.getTime() < decodedToken.exp * 1000; 35 | }; 36 | 37 | const fetchNewAccessToken: FetchNewAccessToken = async refreshToken => { 38 | if (!process.env.REACT_APP_API_URL) { 39 | throw new Error( 40 | '.env.REACT_APP_API_URL must be set to use refresh token link' 41 | ); 42 | } 43 | 44 | try { 45 | const fetchResult = await fetch(process.env.REACT_APP_API_URL, { 46 | method: 'POST', 47 | headers: { 'Content-Type': 'application/json' }, 48 | body: JSON.stringify({ 49 | query: ` 50 | mutation { 51 | refreshTokens(input: { 52 | refreshToken: "${refreshToken}" 53 | }) { 54 | accessToken 55 | refreshToken 56 | errors { 57 | field 58 | message 59 | } 60 | } 61 | } 62 | `, 63 | }), 64 | }); 65 | 66 | const refreshResponse = await fetchResult.json(); 67 | 68 | if ( 69 | !refreshResponse || 70 | !refreshResponse.data || 71 | !refreshResponse.data.refreshTokens || 72 | !refreshResponse.data.refreshTokens.accessToken 73 | ) { 74 | return undefined; 75 | } 76 | 77 | return refreshResponse.data.refreshTokens.accessToken; 78 | } catch (e) { 79 | throw new Error('Failed to fetch fresh access token'); 80 | } 81 | }; 82 | 83 | const refreshTokenLink = getRefreshTokenLink({ 84 | authorizationHeaderKey: 'Authorization', 85 | fetchNewAccessToken, 86 | getAccessToken: () => localStorage.getItem('access_token'), 87 | getRefreshToken: () => localStorage.getItem('refresh_token'), 88 | isAccessTokenValid: accessToken => isTokenValid(accessToken), 89 | isUnauthenticatedError: graphQLError => { 90 | const { extensions } = graphQLError; 91 | if ( 92 | extensions && 93 | extensions.code && 94 | extensions.code === 'UNAUTHENTICATED' 95 | ) { 96 | return true; 97 | } 98 | return false; 99 | }, 100 | }); 101 | 102 | export const client = new ApolloClient({ 103 | link: ApolloLink.from([authLink, refreshTokenLink, errorLink, httpLink]), 104 | cache, 105 | }); 106 | ``` 107 | 108 | ## Options 109 | 110 | | Option | Type | Default | Description | 111 | | ---------------------- | ------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | 112 | | authorizationHeaderKey | string | -- | Name of the authorization header on your requests. Is used to update the headers before retrying the failed request | 113 | | fetchNewAccessToken | (refreshToken: string) => Promise | -- | A function returning a promise to fetch and return the new refresh token string. | 114 | | getAccessToken | () => string \| undefined \| null | -- | A function to return the current access token. Is used to ensure that the user should be logged in, and to pass into isAccessTokenValid. | 115 | | getRefreshToken | () => string \| undefined \| null | -- | A function to return the current refreshToken. Is used to ensure that refresh is possible. It is passed to fetchNewAccessToken(). | 116 | | isAccessTokenValid | (accessToken?: string) => boolean | -- | A function that takes the access token (from getAccessToken) and returns true if the access token is valid. If the token is valid, refresh won't occur. | 117 | | isUnauthenticatedError | (graphQLError: GraphQLError) => boolean | -- | A function that determines whether the error from the current operation warrants a token refresh. Usually looks for an unauthenticated code. | 118 | | onFailedRefresh? | (error: any) => void | -- | A function to handle errors when the refresh fails. | 119 | | onSuccessfulRefresh? | (refreshToken: string) => void | -- | A function to handle successful refresh. | 120 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-link-refresh-token", 3 | "description": "A link to refresh auth tokens on authentication errors", 4 | "repository": { 5 | "url": "https://github.com/baleeds/apollo-link-refresh-token", 6 | "type": "git" 7 | }, 8 | "version": "0.1.2", 9 | "license": "MIT", 10 | "author": { 11 | "name": "Benjamin Leeds", 12 | "email": "baleeds@ncsu.edu" 13 | }, 14 | "keywords": [ 15 | "apollo", 16 | "link", 17 | "refresh", 18 | "access", 19 | "oauth" 20 | ], 21 | "main": "dist/index.js", 22 | "module": "dist/apollo-link-refresh-token.esm.js", 23 | "typings": "dist/index.d.ts", 24 | "files": [ 25 | "dist" 26 | ], 27 | "scripts": { 28 | "start": "tsdx watch", 29 | "build": "tsdx build", 30 | "test": "tsdx test --env=jsdom", 31 | "lint": "tsdx lint" 32 | }, 33 | "peerDependencies": { 34 | "react": ">=16" 35 | }, 36 | "husky": { 37 | "hooks": { 38 | "pre-commit": "tsdx lint" 39 | } 40 | }, 41 | "prettier": { 42 | "printWidth": 80, 43 | "semi": true, 44 | "singleQuote": true, 45 | "trailingComma": "es5" 46 | }, 47 | "devDependencies": { 48 | "@types/jest": "^24.0.23", 49 | "husky": "^3.0.9", 50 | "tsdx": "^0.11.0", 51 | "tslib": "^1.10.0", 52 | "typescript": "^3.7.2" 53 | }, 54 | "dependencies": { 55 | "apollo-link": "^1.2.13", 56 | "apollo-link-error": "^1.1.12", 57 | "graphql": "^14.5.8" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/getRefreshTokenLink.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'apollo-link'; 2 | import { onError } from 'apollo-link-error'; 3 | import { GraphQLError } from 'graphql'; 4 | 5 | type FetchNewAccessToken = ( 6 | refreshToken: string 7 | ) => Promise; 8 | 9 | type IsUnauthenticatedError = (graphQLError: GraphQLError) => boolean; 10 | 11 | type GetToken = () => string | undefined | null; 12 | 13 | type IsAccessTokenValid = (accessToken?: string) => boolean; 14 | 15 | type OnSuccessfulRefetch = (refreshToken?: string) => void; 16 | 17 | type OnFailedRefresh = (error: any) => void; 18 | 19 | interface Options { 20 | isUnauthenticatedError: IsUnauthenticatedError; 21 | getAccessToken: GetToken; 22 | getRefreshToken: GetToken; 23 | isAccessTokenValid: IsAccessTokenValid; 24 | fetchNewAccessToken: FetchNewAccessToken; 25 | authorizationHeaderKey: string; 26 | onSuccessfulRefresh?: OnSuccessfulRefetch; 27 | onFailedRefresh?: OnFailedRefresh; 28 | } 29 | 30 | export const getRefreshTokenLink = ({ 31 | isUnauthenticatedError, 32 | getAccessToken, 33 | getRefreshToken, 34 | isAccessTokenValid, 35 | fetchNewAccessToken, 36 | authorizationHeaderKey, 37 | onSuccessfulRefresh, 38 | onFailedRefresh, 39 | }: Options) => 40 | onError(({ graphQLErrors, operation, forward }) => { 41 | if (graphQLErrors) { 42 | for (let i = 0; i < graphQLErrors.length; i += 1) { 43 | const graphQLError = graphQLErrors[i]; 44 | 45 | if (isUnauthenticatedError(graphQLError)) { 46 | const accessToken = getAccessToken(); 47 | const refreshToken = getRefreshToken(); 48 | 49 | if ( 50 | !accessToken || 51 | !refreshToken || 52 | isAccessTokenValid(accessToken) 53 | ) { 54 | return forward(operation); 55 | } 56 | 57 | return new Observable(observer => { 58 | fetchNewAccessToken(refreshToken) 59 | .then(newAccessToken => { 60 | if (!newAccessToken) { 61 | throw new Error('Unable to fetch new access token'); 62 | } 63 | 64 | operation.setContext(({ headers = {} }: any) => ({ 65 | headers: { 66 | ...headers, 67 | [authorizationHeaderKey]: newAccessToken || undefined, 68 | }, 69 | })); 70 | 71 | onSuccessfulRefresh && onSuccessfulRefresh(refreshToken); 72 | }) 73 | .then(() => { 74 | const subscriber = { 75 | next: observer.next.bind(observer), 76 | error: observer.error.bind(observer), 77 | complete: observer.complete.bind(observer), 78 | }; 79 | 80 | forward(operation).subscribe(subscriber); 81 | }) 82 | .catch(error => { 83 | onFailedRefresh && onFailedRefresh(error); 84 | observer.error(error); 85 | }); 86 | }); 87 | } 88 | } 89 | } 90 | 91 | return forward(operation); 92 | }); 93 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './getRefreshTokenLink'; 2 | -------------------------------------------------------------------------------- /tests/notTested.test.ts: -------------------------------------------------------------------------------- 1 | test('I should write some tests', () => { 2 | expect(true).toBeTruthy(); 3 | }); 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types", "test"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | --------------------------------------------------------------------------------