├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── ci-cd.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .nvmrc ├── .prettierrc.json ├── LICENSE ├── README.md ├── babel.config.js ├── e2e ├── login-authorization-code-flow-with-queryfn.test.ts ├── login-authorization-code-flow.test.ts ├── login-implicit-grant-flow.test.ts └── test-utils.ts ├── example ├── client │ ├── Example.tsx │ ├── LoginAuthorizationCode.tsx │ ├── LoginAuthorizationCodeWithQueryFn.tsx │ ├── LoginImplicitGrant.tsx │ ├── index.html │ └── index.tsx └── server │ └── index.ts ├── jest.config.e2e.ts ├── jest.config.unit.ts ├── package-lock.json ├── package.json ├── rollup.config.example.mjs ├── rollup.config.mjs ├── setup-tests.unit.ts ├── src ├── components │ ├── OAuthPopup.tsx │ ├── constants.ts │ ├── index.ts │ ├── tools.test.ts │ ├── tools.ts │ ├── types.ts │ ├── use-check-props.test.ts │ ├── use-check-props.ts │ ├── use-oauth2.test.ts │ └── use-oauth2.ts ├── index.ts └── react-app-env.d.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.css 2 | *.svg -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-module */ 2 | module.exports = { 3 | extends: ['tasoskakour-typescript-prettier/with-react'], 4 | ignorePatterns: ['dist', 'coverage'], 5 | rules: { 6 | // your overrides 7 | 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | allow: 13 | - dependency-type: "direct" 14 | versioning-strategy: increase 15 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: CI & CD 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: ⬇️ Checkout repo 10 | uses: actions/checkout@v4 11 | 12 | - name: ⎔ Setup node 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | 17 | - name: 📥 Download deps 18 | uses: bahmutov/npm-install@v1 19 | 20 | - name: 🧪 Run test script 21 | run: npm test 22 | 23 | publish-npm: 24 | runs-on: ubuntu-latest 25 | needs: test 26 | if: contains(github.ref, 'refs/tags/') 27 | steps: 28 | - name: ⬇️ Checkout repo 29 | uses: actions/checkout@v4 30 | 31 | - name: ⎔ Setup node 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: 20 35 | registry-url: "https://registry.npmjs.org" 36 | 37 | - name: 📥 Download deps 38 | uses: bahmutov/npm-install@v1 39 | 40 | - name: 🔨 Build it 41 | run: npm run build 42 | 43 | - name: 🚀 Publish to NPM 44 | run: npm publish 45 | env: 46 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | *.lcov 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (http://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # Typescript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | .env.test 61 | .envrc 62 | 63 | # build directory 64 | build 65 | dist 66 | 67 | # vscode 68 | .vscode -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "es5", 4 | "tabWidth": 4, 5 | "semi": true, 6 | "singleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Tasos Kakouris (tasoskakour.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @tasoskakour/react-use-oauth2 2 | 3 | ![gh workflow](https://img.shields.io/github/actions/workflow/status/tasoskakour/react-use-oauth2/ci-cd.yml?branch=master) [![npm](https://img.shields.io/npm/v/@tasoskakour/react-use-oauth2.svg?style=svg&logo=npm&label=)](https://www.npmjs.com/package/@tasoskakour/react-use-oauth2) 4 | 5 | > 💎 A custom React hook that makes OAuth2 authorization simple. Both for **Implicit Grant** and **Authorization Code** flows. 6 | 7 | ## Features 8 | 9 | - Usage with both `Implicit` and `Authorization Code` grant flows. 10 | - Seamlessly **exchanges code for token** via your backend API URL, for authorization code grant flows. 11 | - Works with **Popup** authorization. 12 | - Provides data and loading/error states via a hook. 13 | - **Persists data** to localStorage and automatically syncs auth state between tabs and/or browser windows. 14 | 15 | ## Install 16 | 17 | _Requires `react@18.0.0` or higher_ 18 | 19 | ```console 20 | yarn add @tasoskakour/react-use-oauth2 21 | ``` 22 | 23 | or 24 | 25 | ```console 26 | npm i @tasoskakour/react-use-oauth2 27 | ``` 28 | 29 | ## Usage example 30 | 31 | *For authorization code flow:* 32 | 33 | ```js 34 | import { OAuth2Popup, useOAuth2 } from "@tasoskakour/react-use-oauth2"; 35 | import { BrowserRouter, Routes, Route } from "react-router-dom"; 36 | 37 | const Home = () => { 38 | const { data, loading, error, getAuth, logout } = useOAuth2({ 39 | authorizeUrl: "https://example.com/auth", 40 | clientId: "YOUR_CLIENT_ID", 41 | redirectUri: `${document.location.origin}/callback`, 42 | scope: "YOUR_SCOPES", 43 | responseType: "code", 44 | exchangeCodeForTokenQuery: { 45 | url: "https://your-backend/token", 46 | method: "POST", 47 | }, 48 | onSuccess: (payload) => console.log("Success", payload), 49 | onError: (error_) => console.log("Error", error_) 50 | }); 51 | 52 | const isLoggedIn = Boolean(data?.access_token); // or whatever... 53 | 54 | if (error) { 55 | return
Error
; 56 | } 57 | 58 | if (loading) { 59 | return
Loading...
; 60 | } 61 | 62 | if (isLoggedIn) { 63 | return ( 64 |
65 |
{JSON.stringify(data)}
66 | 67 |
68 | ) 69 | } 70 | 71 | return ( 72 | 75 | ); 76 | }; 77 | 78 | const App = () => { 79 | return ( 80 | 81 | 82 | } path="/callback" /> 83 | } path="/" /> 84 | 85 | 86 | ); 87 | }; 88 | ``` 89 | 90 | ##### Example with `exchangeCodeForTokenQueryFn` 91 | 92 | You can also use `exchangeCodeForTokenQueryFn` if you want full control over your query to your backend, e.g if you must send your data as form-urlencoded: 93 | ```js 94 | 95 | const { ... } = useOAuth2({ 96 | // ... 97 | // Instead of exchangeCodeForTokenQuery (e.g sending form-urlencoded or similar)... 98 | exchangeCodeForTokenQueryFn: async (callbackParameters) => { 99 | const formBody = []; 100 | for (const key in callbackParameters) { 101 | formBody.push( 102 | `${encodeURIComponent(key)}=${encodeURIComponent(callbackParameters[key])}` 103 | ); 104 | } 105 | const response = await fetch(`YOUR_BACKEND_URL`, { 106 | method: 'POST', 107 | body: formBody.join('&'), 108 | headers: { 109 | 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 110 | }, 111 | }); 112 | if (!response.ok) throw new Error('Failed'); 113 | const tokenData = await response.json(); 114 | return tokenData; 115 | },. 116 | // ... 117 | }) 118 | 119 | ``` 120 | 121 | ### What is the purpose of `exchangeCodeForTokenQuery` for Authorization Code flows? 122 | 123 | Generally when we're working with authorization code flows, we need to *immediately* **exchange** the retrieved *code* with an actual *access token*, after a successful authorization. Most of the times this is needed for back-end apps, but there are many use cases this is useful for front-end apps as well. 124 | 125 | In order for the flow to be accomplished, the 3rd party provider we're authorizing against (e.g Google, Facebook etc), will provide an API call (e.g for Google is `https://oauth2.googleapis.com/token`) that we need to hit in order to exchange the code for an access token. However, this call requires the `client_secret` of your 3rd party app as a parameter to work - a secret that you cannot expose to your front-end app. 126 | 127 | That's why you need to proxy this call to your back-end and with `exchangeCodeForTokenQuery` object you can provide the schematics of your call e.g `url`, `method` etc. The request parameters that will get passed along as **query parameters** are `{ code, client_id, grant_type, redirect_uri, state }`. By default this will be a **POST** request but you can change it with the `method` property. 128 | 129 | 130 | You can read more about "Exchanging authorization code for refresh and access tokens" in [Google OAuth2 documentation](https://developers.google.com/identity/protocols/oauth2/web-server#exchange-authorization-code). 131 | 132 | ### What's the alternative option `exchangeCodeForTokenQueryFn`? 133 | 134 | There could be certain cases where `exchangeCodeForTokenQuery` is not enough and you want full control over how you send the request to your backend. For example you may want to send it as a urlencoded form. With this property you can define your callback function which takes `callbackParameters: object` as a parameter (which includes whatever returned from OAuth2 callback e.g `code, scope, state` etc) and must return a promise with a valid object which will contain all the token data state e.g `access_token, expires_in` etc. 135 | 136 | ### What's the case with Implicit Grant flows? 137 | 138 | With an implicit grant flow things are much simpler as the 3rd-party provider immediately returns the `access_token` to the callback request so there's no need to make any action after that. Just set `responseType=token` to use this flow. 139 | 140 | ### Data persistence 141 | 142 | After a successful authorization, data will get persisted to **localStorage** and the state will automatically sync to all tabs/pages of the browser. The storage key the data will be written to will be: `{responseType}-{authorizeUrl}-{clientId}-{scope}`. 143 | 144 | If you want to re-trigger the authorization flow just call `getAuth()` function again. 145 | 146 | **Note**: In case localStorage is throwing an error (e.g user has disabled it) then you can use the `isPersistent` property which - for this case - will be false. Useful if you want to notify the user that the data is only stored in-memory. 147 | 148 | ## API 149 | 150 | - `function useOAuth2(options): {data, loading, error, getAuth}` 151 | 152 | This is the hook that makes this package to work. `Options` is an object that contains the properties below 153 | 154 | - `authorizeUrl` (string): The 3rd party authorization URL (e.g https://accounts.google.com/o/oauth2/v2/auth). 155 | - `clientId` (string): The OAuth2 client id of your application. 156 | - `redirectUri` (string): Determines where the 3rd party API server redirects the user after the user completes the authorization flow. In our [example](#usage-example) the Popup is rendered on that redirectUri. 157 | - `scope` (string - _optional_): A list of scopes depending on your application needs. 158 | - `responseType` (string): Can be either **code** for _code authorization grant_ or **token** for _implicit grant_. 159 | - `extraQueryParameters` (object - _optional_): An object of extra parameters that you'd like to pass to the query part of the authorizeUrl, e.g {audience: "xyz"}. 160 | - `exchangeCodeForTokenQuery` (object): This property is only required when using _code authorization grant_ flow (responseType = code). It's properties are: 161 | - `url` (string - _required_) It specifies the API URL of your server that will get called immediately after the user completes the authorization flow. Read more [here](#what-is-the-purpose-of-exchangecodefortokenserverurl-for-authorization-code-flows). 162 | - `method` (string - _required_): Specifies the HTTP method that will be used for the code-for-token exchange to your server. Defaults to **POST** 163 | - `headers` (object - _optional_): An object of extra parameters that will be used for the code-for-token exchange to your server. 164 | - `exchangeCodeForTokenQueryFn` function(callbackParameters) => Promise\: **Instead of using** `exchangeCodeForTokenQuery` to describe the query, you can take full control and provide query function yourself. `callbackParameters` will contain everything returned from the OAUth2 callback e.g `code, state` etc. You must return a promise with a valid object that will represent your final state - data of the auth procedure. 165 | - **onSuccess** (function): Called after a complete successful authorization flow. 166 | - **onError** (function): Called when an error occurs. 167 | 168 | **Returns**: 169 | 170 | - `data` (object): Consists of the retrieved auth data and generally will have the shape of `{access_token, token_type, expires_in}` (check [Typescript](#typescript) usage for providing custom shape). If you're using `responseType: code` and `exchangeCodeForTokenQueryFn` this object will contain whatever you returnn from your query function. 171 | - `loading` (boolean): Is set to true while the authorization is taking place. 172 | - `error` (string): Is set when an error occurs. 173 | - `getAuth` (function): Call this function to trigger the authorization flow. 174 | - `logout` (function): Call this function to logout and clear all authorization data. 175 | - `isPersistent` (boolean): Property that returns false if localStorage is throwing an error and the data is stored only in-memory. Useful if you want to notify the user. 176 | 177 | --- 178 | 179 | - `function OAuthPopup(props)` 180 | 181 | This is the component that will be rendered as a window Popup for as long as the authorization is taking place. You need to render this in a place where it does not disrupt the user flow. An ideal place is inside a `Route` component of `react-router-dom` as seen in the [usage example](#usage-example). 182 | 183 | Props consists of: 184 | 185 | - `Component` (ReactElement - _optional_): You can optionally set a custom component to be rendered inside the Popup. By default it just displays a "Loading..." message. 186 | 187 | ### Typescript 188 | 189 | The `useOAuth2` function identity is: 190 | 191 | ``` 192 | const useOAuth2: (props: Oauth2Props) => { 193 | data: State; 194 | loading: boolean; 195 | error: null; 196 | getAuth: () => () => void; 197 | } 198 | ``` 199 | 200 | That means that generally the data will have the shape of `AuthTokenPayload` which consists of: 201 | 202 | ``` 203 | token_type: string; 204 | expires_in: number; 205 | access_token: string; 206 | scope: string; 207 | refresh_token: string; 208 | ``` 209 | 210 | And that also means that you can set the data type by using the hook like this: 211 | 212 | ``` 213 | type MyCustomShapeData = { 214 | ... 215 | } 216 | 217 | const {data, ...} = useOAuth2({...}); 218 | ``` 219 | 220 | ### Migrating to v2.0.0 (2024-03-05) 221 | 222 | Please follow the steps below to migrate to `v2.0.0`: 223 | 224 | - **DEPRECATED properties**: `exchangeCodeForTokenServerURL`, `exchangeCodeForTokenMethod`, `exchangeCodeForTokenHeaders` 225 | - **INTRODUCED NEW PROPERTY**: `exchangeCodeForTokenQuery` 226 | - `exchangeCodeForTokenQuery` just combines all the above deprecated properties, e.g you can use it like: `exchangeCodeForTokenQuery: { url:"...", method:"POST", headers:{} }` 227 | 228 | ### Tests 229 | 230 | You can run tests by calling 231 | 232 | ```console 233 | npm test 234 | ``` 235 | 236 | It will start a react-app (:3000) and back-end server (:3001) and then it will run the tests with jest & puppeteer. -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line unicorn/prefer-module 2 | module.exports = { 3 | presets: [ 4 | '@babel/preset-env', 5 | ['@babel/preset-react', { runtime: 'automatic' }], 6 | '@babel/preset-typescript', 7 | ], 8 | env: { 9 | test: { 10 | plugins: ['@babel/plugin-transform-runtime'], 11 | }, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /e2e/login-authorization-code-flow-with-queryfn.test.ts: -------------------------------------------------------------------------------- 1 | import puppeteer, { Browser } from 'puppeteer'; 2 | import { getTextContent, IS_RUNNING_IN_GITHUB_ACTIONS } from './test-utils'; 3 | 4 | const URL = 'http://localhost:3000'; 5 | 6 | let browser: Browser; 7 | afterAll((done) => { 8 | browser.close(); 9 | 10 | done(); 11 | }); 12 | 13 | test('Login with authorization code flow and exchangeCodeForQueryFn works as expected', async () => { 14 | browser = await puppeteer.launch( 15 | IS_RUNNING_IN_GITHUB_ACTIONS ? { args: ['--no-sandbox', '--disable-setuid-sandbox'] } : {} 16 | ); 17 | const page = await browser.newPage(); 18 | 19 | await page.goto(URL); 20 | 21 | const nav = new Promise((response) => { 22 | browser.on('targetcreated', response); 23 | }); 24 | 25 | await page.click('#authorization-code-queryfn-login'); 26 | 27 | // Assess loading 28 | await page.waitForSelector('#authorization-code-queryfn-loading'); 29 | 30 | // Assess popup redirection 31 | await nav; 32 | const pages = await browser.pages(); 33 | expect(pages[2].url()).toMatch( 34 | /http:\/\/localhost:3000\/callback\?code=SOME_CODE&state=.*\S.*/ // any non-empty state 35 | ); 36 | 37 | // Assess network call to exchange code for token 38 | await page.waitForResponse(async (response) => { 39 | if (response.request().method().toUpperCase() === 'OPTIONS') return false; 40 | 41 | const url = decodeURIComponent(response.url()); 42 | const json = await response.json(); 43 | const urlPath = url.split('?')[0]; 44 | 45 | return ( 46 | urlPath === 'http://localhost:3001/mock-token-form-data' && 47 | response.request().method().toUpperCase() === 'POST' && 48 | response.request().postData() === 'code=SOME_CODE&someOtherData=someOtherData' && 49 | json.code === 'SOME_CODE' && 50 | json.access_token === 'SOME_ACCESS_TOKEN' && 51 | json.expires_in === 3600 && 52 | json.refresh_token === 'SOME_REFRESH_TOKEN' && 53 | json.scope === 'SOME_SCOPE' && 54 | json.token_type === 'Bearer' 55 | ); 56 | }); 57 | 58 | // Assess UI 59 | await page.waitForSelector('#authorization-code-queryfn-data'); 60 | expect(await getTextContent(page, '#authorization-code-queryfn-data')).toBe( 61 | '{"code":"SOME_CODE","access_token":"SOME_ACCESS_TOKEN","expires_in":3600,"refresh_token":"SOME_REFRESH_TOKEN","scope":"SOME_SCOPE","token_type":"Bearer"}' 62 | ); 63 | 64 | // Assess localStorage 65 | expect( 66 | await page.evaluate(() => 67 | JSON.parse( 68 | window.localStorage.getItem( 69 | 'code-http://localhost:3001/mock-authorize-SOME_CLIENT_ID_2-SOME_SCOPE' 70 | ) || '' 71 | ) 72 | ) 73 | ).toEqual({ 74 | code: 'SOME_CODE', 75 | access_token: 'SOME_ACCESS_TOKEN', 76 | expires_in: 3600, 77 | refresh_token: 'SOME_REFRESH_TOKEN', 78 | scope: 'SOME_SCOPE', 79 | token_type: 'Bearer', 80 | }); 81 | 82 | // Logout 83 | await page.click('#authorization-code-queryfn-logout'); 84 | expect(await page.$('#authorization-code-queryfn-data')).toBe(null); 85 | expect(await page.$('#authorization-code-queryfn-login')).not.toBe(null); 86 | expect( 87 | await page.evaluate(() => 88 | window.localStorage.getItem( 89 | 'code-http://localhost:3001/mock-authorize-SOME_CLIENT_ID_2-SOME_SCOPE' 90 | ) 91 | ) 92 | ).toEqual('null'); 93 | }); 94 | -------------------------------------------------------------------------------- /e2e/login-authorization-code-flow.test.ts: -------------------------------------------------------------------------------- 1 | import puppeteer, { Browser } from 'puppeteer'; 2 | import { getTextContent, IS_RUNNING_IN_GITHUB_ACTIONS } from './test-utils'; 3 | 4 | const URL = 'http://localhost:3000'; 5 | 6 | let browser: Browser; 7 | afterAll((done) => { 8 | browser.close(); 9 | 10 | done(); 11 | }); 12 | 13 | test('Login with authorization code flow works as expected', async () => { 14 | browser = await puppeteer.launch( 15 | IS_RUNNING_IN_GITHUB_ACTIONS ? { args: ['--no-sandbox', '--disable-setuid-sandbox'] } : {} 16 | ); 17 | const page = await browser.newPage(); 18 | 19 | await page.goto(URL); 20 | 21 | const nav = new Promise((response) => { 22 | browser.on('targetcreated', response); 23 | }); 24 | 25 | await page.click('#authorization-code-login'); 26 | 27 | // Assess loading 28 | await page.waitForSelector('#authorization-code-loading'); 29 | 30 | // Assess popup redirection 31 | await nav; 32 | const pages = await browser.pages(); 33 | expect(pages[2].url()).toMatch( 34 | /http:\/\/localhost:3000\/callback\?code=SOME_CODE&state=.*\S.*/ // any non-empty state 35 | ); 36 | 37 | // Assess network call to exchange code for token 38 | await page.waitForResponse(async (response) => { 39 | if (response.request().method().toUpperCase() === 'OPTIONS') return false; 40 | 41 | const url = decodeURIComponent(response.url()); 42 | const json = await response.json(); 43 | const urlPath = url.split('?')[0]; 44 | const urlQuery = new URLSearchParams(url.replace(urlPath, '')); 45 | 46 | return ( 47 | urlPath === 'http://localhost:3001/mock-token' && 48 | urlQuery.get('client_id') === 'SOME_CLIENT_ID' && 49 | urlQuery.get('grant_type') === 'authorization_code' && 50 | urlQuery.get('code') === 'SOME_CODE' && 51 | urlQuery.get('redirect_uri') === 'http://localhost:3000/callback' && 52 | Boolean(urlQuery.get('state')?.match(/.*\S.*/)) && 53 | json.code === 'SOME_CODE' && 54 | json.access_token === 'SOME_ACCESS_TOKEN' && 55 | json.expires_in === 3600 && 56 | json.refresh_token === 'SOME_REFRESH_TOKEN' && 57 | json.scope === 'SOME_SCOPE' && 58 | json.token_type === 'Bearer' 59 | ); 60 | }); 61 | 62 | // Assess UI 63 | await page.waitForSelector('#authorization-code-data'); 64 | expect(await getTextContent(page, '#authorization-code-data')).toBe( 65 | '{"code":"SOME_CODE","access_token":"SOME_ACCESS_TOKEN","expires_in":3600,"refresh_token":"SOME_REFRESH_TOKEN","scope":"SOME_SCOPE","token_type":"Bearer"}' 66 | ); 67 | 68 | // Assess localStorage 69 | expect( 70 | await page.evaluate(() => 71 | JSON.parse( 72 | window.localStorage.getItem( 73 | 'code-http://localhost:3001/mock-authorize-SOME_CLIENT_ID-SOME_SCOPE' 74 | ) || '' 75 | ) 76 | ) 77 | ).toEqual({ 78 | code: 'SOME_CODE', 79 | access_token: 'SOME_ACCESS_TOKEN', 80 | expires_in: 3600, 81 | refresh_token: 'SOME_REFRESH_TOKEN', 82 | scope: 'SOME_SCOPE', 83 | token_type: 'Bearer', 84 | }); 85 | 86 | // Logout 87 | await page.click('#authorization-code-logout'); 88 | expect(await page.$('#authorization-code-data')).toBe(null); 89 | expect(await page.$('#authorization-code-login')).not.toBe(null); 90 | expect( 91 | await page.evaluate(() => 92 | window.localStorage.getItem( 93 | 'code-http://localhost:3001/mock-authorize-SOME_CLIENT_ID-SOME_SCOPE' 94 | ) 95 | ) 96 | ).toEqual('null'); 97 | }); 98 | -------------------------------------------------------------------------------- /e2e/login-implicit-grant-flow.test.ts: -------------------------------------------------------------------------------- 1 | import puppeteer, { Browser } from 'puppeteer'; 2 | import { getTextContent, IS_RUNNING_IN_GITHUB_ACTIONS } from './test-utils'; 3 | 4 | const URL = 'http://localhost:3000'; 5 | 6 | let browser: Browser; 7 | afterAll((done) => { 8 | browser.close(); 9 | 10 | done(); 11 | }); 12 | 13 | test('Login with implicit grant flow works as expected', async () => { 14 | browser = await puppeteer.launch( 15 | IS_RUNNING_IN_GITHUB_ACTIONS ? { args: ['--no-sandbox', '--disable-setuid-sandbox'] } : {} 16 | ); 17 | const page = await browser.newPage(); 18 | 19 | await page.goto(URL); 20 | 21 | const nav = new Promise((response) => { 22 | browser.on('targetcreated', response); 23 | }); 24 | 25 | await page.click('#implicit-grant-login'); 26 | 27 | // Assess loading 28 | await page.waitForSelector('#implicit-grant-loading'); 29 | 30 | // Assess popup redirection 31 | await nav; 32 | const pages = await browser.pages(); 33 | 34 | expect(pages[2].url()).toMatch( 35 | /http:\/\/localhost:3000\/callback\?access_token=SOME_ACCESS_TOKEN&token_type=Bearer&expires_in=3600&state=.*\S.*/ // any non-empty state 36 | ); 37 | 38 | // Assess UI 39 | await page.waitForSelector('#implicit-grant-data'); 40 | expect(await getTextContent(page, '#implicit-grant-data')).toMatch( 41 | /{"access_token":"SOME_ACCESS_TOKEN","token_type":"Bearer","expires_in":"3600","state":.*\S.*}/ 42 | ); 43 | 44 | // Assess localStorage 45 | expect( 46 | await page.evaluate(() => 47 | JSON.parse( 48 | window.localStorage.getItem( 49 | 'token-http://localhost:3001/mock-authorize-SOME_CLIENT_ID-SOME_SCOPE' 50 | ) || '' 51 | ) 52 | ) 53 | ).toEqual({ 54 | access_token: 'SOME_ACCESS_TOKEN', 55 | expires_in: '3600', 56 | state: expect.anything(), 57 | token_type: 'Bearer', 58 | }); 59 | 60 | // Logout 61 | await page.click('#implicit-grant-logout'); 62 | expect(await page.$('#implicit-grant-data')).toBe(null); 63 | expect(await page.$('#implicit-grant-login')).not.toBe(null); 64 | expect( 65 | await page.evaluate(() => 66 | window.localStorage.getItem( 67 | 'token-http://localhost:3001/mock-authorize-SOME_CLIENT_ID-SOME_SCOPE' 68 | ) 69 | ) 70 | ).toEqual('null'); 71 | }); 72 | -------------------------------------------------------------------------------- /e2e/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'puppeteer'; 2 | 3 | export const getTextContent = (page: Page, selector: string) => 4 | page.$eval(selector, (element) => element.textContent); 5 | 6 | export const IS_RUNNING_IN_GITHUB_ACTIONS = process.env.GITHUB_ACTIONS === 'true'; 7 | -------------------------------------------------------------------------------- /example/client/Example.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Route, Routes } from 'react-router-dom'; 2 | 3 | import { OAuthPopup } from '../../src/components'; 4 | import LoginAuthorizationCode from './LoginAuthorizationCode'; 5 | import LoginAuthorizationCodeWithQueryFn from './LoginAuthorizationCodeWithQueryFn'; 6 | import LoginImplicitGrant from './LoginImplicitGrant'; 7 | 8 | const Home = () => { 9 | return ( 10 |
11 | 12 |
13 | 14 |
15 | 16 |
17 | ); 18 | }; 19 | 20 | const Example = () => ( 21 | 22 | 23 | } path="/callback" /> 24 | } path="/" /> 25 | 26 | 27 | ); 28 | 29 | export default Example; 30 | -------------------------------------------------------------------------------- /example/client/LoginAuthorizationCode.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { useOAuth2 } from '../../src/components'; 3 | 4 | const LoginCode = () => { 5 | const { data, loading, error, getAuth, logout } = useOAuth2({ 6 | authorizeUrl: 'http://localhost:3001/mock-authorize', 7 | clientId: 'SOME_CLIENT_ID', 8 | redirectUri: `${document.location.origin}/callback`, 9 | scope: 'SOME_SCOPE', 10 | responseType: 'code', 11 | exchangeCodeForTokenQuery: { 12 | method: 'GET', 13 | url: 'http://localhost:3001/mock-token', 14 | headers: { 15 | someHeader: 'someHeader', 16 | }, 17 | }, 18 | onSuccess: (payload) => console.log('Success', payload), 19 | onError: (error_) => console.log('Error', error_), 20 | }); 21 | 22 | const isLoggedIn = Boolean(data?.access_token); // or whatever... 23 | 24 | let ui = ( 25 | 28 | ); 29 | 30 | if (error) { 31 | ui =
Error
; 32 | } 33 | 34 | if (loading) { 35 | ui =
Loading...
; 36 | } 37 | 38 | if (isLoggedIn) { 39 | ui = ( 40 |
41 |
{JSON.stringify(data)}
42 | 45 |
46 | ); 47 | } 48 | 49 | return ( 50 |
51 |

Authorization Code Flow

52 | {ui} 53 |
54 | ); 55 | }; 56 | 57 | export default LoginCode; 58 | -------------------------------------------------------------------------------- /example/client/LoginAuthorizationCodeWithQueryFn.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { useOAuth2 } from '../../src/components'; 3 | 4 | type TMyAuthData = { 5 | access_token: string; 6 | }; 7 | 8 | const LoginCode = () => { 9 | const { data, loading, error, getAuth, logout } = useOAuth2({ 10 | authorizeUrl: 'http://localhost:3001/mock-authorize', 11 | clientId: 'SOME_CLIENT_ID_2', 12 | redirectUri: `${document.location.origin}/callback`, 13 | scope: 'SOME_SCOPE', 14 | responseType: 'code', 15 | exchangeCodeForTokenQueryFn: async (callbackParameters: { code: string }) => { 16 | const jsonObject = { 17 | code: callbackParameters.code, 18 | someOtherData: 'someOtherData', 19 | }; 20 | const formBody = []; 21 | // eslint-disable-next-line no-restricted-syntax, guard-for-in 22 | for (const key in jsonObject) { 23 | formBody.push( 24 | `${encodeURIComponent(key)}=${encodeURIComponent(jsonObject[key as keyof typeof jsonObject])}` 25 | ); 26 | } 27 | const response = await fetch(`http://localhost:3001/mock-token-form-data`, { 28 | method: 'POST', 29 | body: formBody.join('&'), 30 | headers: { 31 | 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 32 | }, 33 | }); 34 | if (!response.ok) throw new Error('exchangeCodeForTokenQueryFn fail at example'); 35 | const tokenData = await response.json(); 36 | return tokenData; 37 | }, 38 | onSuccess: (payload) => console.log('Success', payload), 39 | onError: (error_) => console.log('Error', error_), 40 | }); 41 | 42 | const isLoggedIn = Boolean(data?.access_token); // or whatever... 43 | 44 | let ui = ( 45 | 48 | ); 49 | 50 | if (error) { 51 | ui =
Error
; 52 | } 53 | 54 | if (loading) { 55 | ui =
Loading...
; 56 | } 57 | 58 | if (isLoggedIn) { 59 | ui = ( 60 |
61 |
{JSON.stringify(data)}
62 | 69 |
70 | ); 71 | } 72 | 73 | return ( 74 |
75 |

Login with Authorization Code with QueryFn

76 | {ui} 77 |
78 | ); 79 | }; 80 | 81 | export default LoginCode; 82 | -------------------------------------------------------------------------------- /example/client/LoginImplicitGrant.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { useOAuth2 } from '../../src/components'; 3 | 4 | const LoginToken = () => { 5 | const { data, loading, error, getAuth, logout } = useOAuth2({ 6 | authorizeUrl: 'http://localhost:3001/mock-authorize', 7 | clientId: 'SOME_CLIENT_ID', 8 | redirectUri: `${document.location.origin}/callback`, 9 | scope: 'SOME_SCOPE', 10 | responseType: 'token', 11 | onSuccess: (payload) => console.log('Success', payload), 12 | onError: (error_) => console.log('Error', error_), 13 | }); 14 | 15 | const isLoggedIn = Boolean(data?.access_token); // or whatever... 16 | 17 | let ui = ( 18 | 21 | ); 22 | 23 | if (error) { 24 | ui =
Error
; 25 | } 26 | 27 | if (loading) { 28 | ui =
Loading...
; 29 | } 30 | 31 | if (isLoggedIn) { 32 | ui = ( 33 |
34 |
{JSON.stringify(data)}
35 | 38 |
39 | ); 40 | } 41 | 42 | return ( 43 |
44 |

Implicit Grant Flow

45 | {ui} 46 |
47 | ); 48 | }; 49 | 50 | export default LoginToken; 51 | -------------------------------------------------------------------------------- /example/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | React App 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /example/client/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import { StrictMode } from 'react'; 3 | import Example from './Example'; 4 | 5 | const rootElement = document.querySelector('#root') as Element; 6 | const root = createRoot(rootElement); 7 | 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /example/server/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import Fastify from 'fastify'; 3 | import delay from 'delay'; 4 | import formBody from '@fastify/formbody'; 5 | import cors from '@fastify/cors'; 6 | 7 | const fastify = Fastify({ 8 | logger: true, 9 | exposeHeadRoutes: true, 10 | }); 11 | 12 | // eslint-disable-next-line import/no-extraneous-dependencies, unicorn/prefer-module, @typescript-eslint/no-var-requires 13 | fastify.register(cors, { 14 | // put your options here 15 | }); 16 | fastify.register(formBody); 17 | 18 | fastify.head('/', async (request, reply) => { 19 | reply.send('OK'); 20 | }); 21 | 22 | fastify.get('/mock-authorize', async (request, reply) => { 23 | const { redirect_uri, state, response_type } = request.query as any; 24 | 25 | await delay(500); 26 | 27 | if (response_type === 'code') { 28 | reply.redirect(`${redirect_uri}?code=SOME_CODE&state=${state}`); 29 | } else { 30 | reply.redirect( 31 | `${redirect_uri}?access_token=SOME_ACCESS_TOKEN&token_type=Bearer&expires_in=3600&state=${state}` 32 | ); 33 | } 34 | }); 35 | 36 | fastify.get('/mock-token', async (request, reply) => { 37 | await delay(1000); 38 | 39 | const { code } = request.query as any; 40 | 41 | reply.send({ 42 | code, 43 | access_token: `SOME_ACCESS_TOKEN`, 44 | expires_in: 3600, 45 | refresh_token: 'SOME_REFRESH_TOKEN', 46 | scope: 'SOME_SCOPE', 47 | token_type: 'Bearer', 48 | }); 49 | }); 50 | 51 | fastify.post('/mock-token-form-data', async (request, reply) => { 52 | await delay(1000); 53 | 54 | reply.send({ 55 | code: (request.body as any).code, 56 | access_token: `SOME_ACCESS_TOKEN`, 57 | expires_in: 3600, 58 | refresh_token: 'SOME_REFRESH_TOKEN', 59 | scope: 'SOME_SCOPE', 60 | token_type: 'Bearer', 61 | }); 62 | }); 63 | 64 | fastify.listen({ port: 3001, host: 'localhost' }, (error) => { 65 | if (error) throw error; 66 | }); 67 | -------------------------------------------------------------------------------- /jest.config.e2e.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prevent-abbreviations */ 2 | import type { Config } from '@jest/types'; 3 | 4 | export default { 5 | preset: 'jest-puppeteer', 6 | testMatch: ['/e2e/**/*.test.{js,jsx,ts,tsx}'], 7 | } as Config.InitialOptions; 8 | -------------------------------------------------------------------------------- /jest.config.unit.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | export default { 4 | preset: 'ts-jest/presets/js-with-ts', 5 | testEnvironment: 'jsdom', 6 | testMatch: ['/src/**/*.test.{js,jsx,ts,tsx}'], 7 | setupFiles: ['./setup-tests.unit.ts'], 8 | automock: false, 9 | } as Config.InitialOptions; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tasoskakour/react-use-oauth2", 3 | "author": { 4 | "name": "Tasos Kakouris", 5 | "email": "tasoskakour@gmail.com", 6 | "website": "https://tasoskakour.com" 7 | }, 8 | "keywords": [ 9 | "react", 10 | "hooks", 11 | "typescript", 12 | "nodejs", 13 | "oauth2" 14 | ], 15 | "version": "2.0.2", 16 | "description": "A React hook that handles OAuth2 authorization flow.", 17 | "license": "MIT", 18 | "homepage": "https://github.com/tasoskakour/react-use-oauth2#readme", 19 | "bugs": { 20 | "url": "https://github.com/tasoskakour/react-use-oauth2/issues" 21 | }, 22 | "repository": "github:tasoskakour/react-use-oauth2", 23 | "main": "dist/cjs/index.js", 24 | "module": "dist/esm/index.js", 25 | "dependencies": { 26 | "use-local-storage-state": "^18.3.3" 27 | }, 28 | "scripts": { 29 | "build": "rollup -c", 30 | "example": "rollup -c rollup.config.example.mjs -w --silent", 31 | "lint": "eslint . --cache", 32 | "test-typeCheck": "tsc --emitDeclarationOnly false --noEmit", 33 | "test-unit": "jest --detectOpenHandles --runInBand --config jest.config.unit.ts", 34 | "test-e2e": "start-server-and-test example \"http://localhost:3000|http://localhost:3001\" \"jest --detectOpenHandles --runInBand --config jest.config.e2e.ts\"", 35 | "test": "npm run lint && npm run test-typeCheck && npm run test-unit && npm run test-e2e", 36 | "prepare": "husky install" 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">1%", 41 | "not dead", 42 | "not op_mini all", 43 | "not ie > 0", 44 | "not ie_mob > 0" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | }, 52 | "peerDependencies": { 53 | "react": "^18.0.0 || ^19.0.0" 54 | }, 55 | "devDependencies": { 56 | "@babel/core": "^7.21.4", 57 | "@babel/plugin-transform-runtime": "^7.21.4", 58 | "@babel/preset-env": "^7.22.14", 59 | "@babel/preset-react": "^7.23.3", 60 | "@babel/preset-typescript": "^7.21.4", 61 | "@fastify/cors": "^9.0.1", 62 | "@fastify/formbody": "^7.4.0", 63 | "@rollup/plugin-commonjs": "^25.0.0", 64 | "@rollup/plugin-json": "^6.0.0", 65 | "@rollup/plugin-node-resolve": "^15.2.3", 66 | "@rollup/plugin-replace": "^5.0.2", 67 | "@rollup/plugin-run": "^3.0.1", 68 | "@rollup/plugin-terser": "^0.4.0", 69 | "@rollup/plugin-typescript": "^11.1.1", 70 | "@testing-library/react": "^14.0.0", 71 | "@types/jest": "^29.5.1", 72 | "@types/node": "^20.2.3", 73 | "@types/react": "^18.2.8", 74 | "@types/react-dom": "^18.2.4", 75 | "babel-jest": "^29.5.0", 76 | "babel-loader": "^9.1.2", 77 | "builtin-modules": "^3.3.0", 78 | "delay": "^5.0.0", 79 | "eslint": "^8.57.0", 80 | "eslint-config-tasoskakour-typescript-prettier": "^3.0.0", 81 | "fastify": "^4.17.0", 82 | "husky": "^8.0.3", 83 | "jest": "^29.7.0", 84 | "jest-environment-jsdom": "^29.7.0", 85 | "jest-fetch-mock": "^3.0.3", 86 | "jest-puppeteer": "^11.0.0", 87 | "lint-staged": "^13.2.2", 88 | "puppeteer": "^24.0.0", 89 | "react-dom": "^18.2.0", 90 | "react-router-dom": "^6.11.2", 91 | "rollup": "^3.29.4", 92 | "rollup-plugin-delete": "^2.0.0", 93 | "rollup-plugin-dts": "^5.3.0", 94 | "rollup-plugin-livereload": "^2.0.5", 95 | "rollup-plugin-peer-deps-external": "^2.2.4", 96 | "rollup-plugin-serve": "^2.0.2", 97 | "start-server-and-test": "^2.0.0", 98 | "ts-jest": "^29.1.1", 99 | "ts-jest-resolver": "^2.0.1", 100 | "ts-node": "^10.9.1", 101 | "typescript": "^5.0.3" 102 | }, 103 | "engines": { 104 | "node": ">=16" 105 | }, 106 | "files": [ 107 | "dist" 108 | ], 109 | "types": "dist/index.d.ts", 110 | "lint-staged": { 111 | "*.{js,ts,tsx}": "eslint --cache --fix" 112 | }, 113 | "publishConfig": { 114 | "access": "public" 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /rollup.config.example.mjs: -------------------------------------------------------------------------------- 1 | import serve from 'rollup-plugin-serve'; 2 | import livereload from 'rollup-plugin-livereload'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | import commonjs from '@rollup/plugin-commonjs'; 5 | import typescript from '@rollup/plugin-typescript'; 6 | import json from '@rollup/plugin-json'; 7 | import run from '@rollup/plugin-run'; 8 | import replace from '@rollup/plugin-replace'; 9 | import builtins from 'builtin-modules'; 10 | 11 | const commonPlugins = [ 12 | replace({ 13 | values: { 14 | 'process.env.NODE_ENV': `'${process.env.NODE_ENV}'`, 15 | }, 16 | preventAssignment: true, 17 | }), 18 | commonjs({ 19 | ignoreDynamicRequires: true, 20 | }), 21 | resolve(), 22 | json(), 23 | typescript({ tsconfig: './tsconfig.json' }), 24 | ]; 25 | 26 | export default [ 27 | { 28 | input: 'example/client/index.tsx', 29 | output: { 30 | file: 'dist/browser.js', 31 | format: 'iife', 32 | sourcemap: true, 33 | }, 34 | plugins: [ 35 | ...commonPlugins, 36 | serve({ 37 | open: false, 38 | verbose: true, 39 | contentBase: ['.', './example/client'], 40 | host: 'localhost', 41 | port: 3000, 42 | historyApiFallback: true, 43 | }), 44 | livereload({ watch: 'dist' }), 45 | ], 46 | external: builtins, 47 | }, 48 | { 49 | input: 'example/server/index.ts', 50 | output: { 51 | inlineDynamicImports: true, 52 | file: 'dist/server.js', 53 | format: 'cjs', 54 | sourcemap: true, 55 | }, 56 | plugins: [...commonPlugins, run()], 57 | external: builtins, 58 | }, 59 | ]; 60 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import typescript from '@rollup/plugin-typescript'; 4 | import dts from 'rollup-plugin-dts'; 5 | import terser from '@rollup/plugin-terser'; 6 | import peerDepsExternal from 'rollup-plugin-peer-deps-external'; 7 | import del from 'rollup-plugin-delete'; 8 | import packageJson from './package.json' assert { type: 'json' }; 9 | 10 | export default [ 11 | { 12 | input: 'src/index.ts', 13 | output: [ 14 | { 15 | file: packageJson.main, 16 | format: 'cjs', 17 | sourcemap: false, 18 | }, 19 | { 20 | file: packageJson.module, 21 | format: 'esm', 22 | sourcemap: false, 23 | }, 24 | ], 25 | plugins: [ 26 | del({ targets: 'dist/*' }), 27 | peerDepsExternal(), 28 | resolve(), 29 | commonjs(), 30 | typescript({ tsconfig: './tsconfig.json', include: ['./src/components/**'] }), 31 | terser(), 32 | ], 33 | }, 34 | { 35 | input: 'dist/esm/types/index.d.ts', 36 | output: [{ file: 'dist/index.d.ts', format: 'esm', sourcemap: false }], 37 | plugins: [dts()], 38 | }, 39 | ]; 40 | -------------------------------------------------------------------------------- /setup-tests.unit.ts: -------------------------------------------------------------------------------- 1 | import fetchMock from 'jest-fetch-mock'; 2 | 3 | fetchMock.enableMocks(); 4 | -------------------------------------------------------------------------------- /src/components/OAuthPopup.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { OAUTH_RESPONSE } from './constants'; 3 | import { checkState, isWindowOpener, openerPostMessage, queryToObject } from './tools'; 4 | 5 | type Props = { 6 | Component?: React.ReactElement; 7 | }; 8 | 9 | let didInit = false; 10 | 11 | export const OAuthPopup = ({ 12 | Component = ( 13 |
14 | Loading... 15 |
16 | ), 17 | }: Props) => { 18 | useEffect(() => { 19 | if (didInit) return; 20 | didInit = true; 21 | 22 | const payload = { 23 | ...queryToObject(window.location.search.split('?')[1]), 24 | ...queryToObject(window.location.hash.split('#')[1]), 25 | }; 26 | const state = payload?.state; 27 | const error = payload?.error; 28 | const opener = window?.opener; 29 | 30 | if (isWindowOpener(opener)) { 31 | const stateOk = state && checkState(opener.sessionStorage, state); 32 | 33 | if (!error && stateOk) { 34 | openerPostMessage(opener, { 35 | type: OAUTH_RESPONSE, 36 | payload, 37 | }); 38 | } else { 39 | const errorMessage = error 40 | ? decodeURI(error) 41 | : `${ 42 | stateOk 43 | ? 'OAuth error: An error has occured.' 44 | : 'OAuth error: State mismatch.' 45 | }`; 46 | 47 | openerPostMessage(opener, { 48 | type: OAUTH_RESPONSE, 49 | error: errorMessage, 50 | }); 51 | } 52 | } else { 53 | throw new Error('No window opener'); 54 | } 55 | }, []); 56 | 57 | return Component; 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/constants.ts: -------------------------------------------------------------------------------- 1 | export const POPUP_HEIGHT = 700; 2 | export const POPUP_WIDTH = 600; 3 | export const OAUTH_STATE_KEY = 'react-use-oauth2-state-key'; 4 | export const OAUTH_RESPONSE = 'react-use-oauth2-response'; 5 | export const EXCHANGE_CODE_FOR_TOKEN_METHODS = ['GET', 'POST', 'PUT', 'PATCH'] as const; 6 | export const DEFAULT_EXCHANGE_CODE_FOR_TOKEN_METHOD = 'POST'; 7 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './OAuthPopup'; 2 | export * from './use-oauth2'; 3 | export * from './types'; 4 | -------------------------------------------------------------------------------- /src/components/tools.test.ts: -------------------------------------------------------------------------------- 1 | import { OAUTH_RESPONSE, OAUTH_STATE_KEY, POPUP_HEIGHT, POPUP_WIDTH } from './constants'; 2 | import { 3 | objectToQuery, 4 | queryToObject, 5 | formatAuthorizeUrl, 6 | generateState, 7 | saveState, 8 | removeState, 9 | checkState, 10 | openPopup, 11 | closePopup, 12 | isWindowOpener, 13 | openerPostMessage, 14 | cleanup, 15 | formatExchangeCodeForTokenServerURL, 16 | } from './tools'; 17 | 18 | describe('objectToQuery', () => { 19 | it('should convert an object to a URL query string', () => { 20 | const object = { key1: 'value1', key2: 'value2' }; 21 | expect(objectToQuery(object)).toEqual('key1=value1&key2=value2'); 22 | }); 23 | }); 24 | 25 | describe('queryToObject', () => { 26 | it('should convert a URL query string to an object', () => { 27 | const query = 'key1=value1&key2=value2'; 28 | const expectedObject = { key1: 'value1', key2: 'value2' }; 29 | expect(queryToObject(query)).toEqual(expectedObject); 30 | }); 31 | }); 32 | 33 | describe('formatAuthorizeUrl', () => { 34 | it('should format the authorization URL correctly', () => { 35 | const authorizeUrl = 'https://example.com/oauth2/authorize'; 36 | const clientId = '1234'; 37 | const redirectUri = 'https://example.com/callback'; 38 | const scope = 'read'; 39 | const state = 'abc123'; 40 | const responseType = 'code'; 41 | const extraQueryParameters = { custom: 'value' }; 42 | const expectedUrl = 43 | 'https://example.com/oauth2/authorize?response_type=code&client_id=1234&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&scope=read&state=abc123&custom=value'; 44 | expect( 45 | formatAuthorizeUrl( 46 | authorizeUrl, 47 | clientId, 48 | redirectUri, 49 | scope, 50 | state, 51 | responseType, 52 | extraQueryParameters 53 | ) 54 | ).toEqual(expectedUrl); 55 | }); 56 | }); 57 | 58 | describe('generateState', () => { 59 | it('should generate a random state', () => { 60 | const state = generateState(); 61 | expect(typeof state).toEqual('string'); 62 | expect(state.length).toBeGreaterThan(0); 63 | }); 64 | }); 65 | 66 | describe('saveState', () => { 67 | it('should save the state to sessionStorage', () => { 68 | saveState(sessionStorage, 'abc123'); 69 | expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toEqual('abc123'); 70 | }); 71 | }); 72 | 73 | describe('removeState', () => { 74 | it('should remove the state from sessionStorage', () => { 75 | sessionStorage.setItem(OAUTH_STATE_KEY, 'abc123'); 76 | removeState(sessionStorage); 77 | expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBeNull(); 78 | }); 79 | }); 80 | 81 | describe('checkState', () => { 82 | it('should return true if the received state matches the saved state', () => { 83 | sessionStorage.setItem(OAUTH_STATE_KEY, 'abc123'); 84 | expect(checkState(sessionStorage, 'abc123')).toBe(true); 85 | }); 86 | 87 | it('should return false if the received state does not match the saved state', () => { 88 | sessionStorage.setItem(OAUTH_STATE_KEY, 'abc123'); 89 | expect(checkState(sessionStorage, 'def456')).toBe(false); 90 | }); 91 | }); 92 | 93 | describe('openPopup', () => { 94 | it('should open a popup window with the correct dimensions and position', () => { 95 | const url = 'https://example.com'; 96 | const mockWindowOpen = jest.fn(); 97 | window.open = mockWindowOpen; 98 | window.outerHeight = 1000; 99 | window.screenY = 1000; 100 | window.outerWidth = 500; 101 | window.screenX = 500; 102 | openPopup(url); 103 | expect(mockWindowOpen).toHaveBeenCalledWith( 104 | url, 105 | 'OAuth2 Popup', 106 | `height=${POPUP_HEIGHT},width=${POPUP_WIDTH},top=1150,left=450` 107 | ); 108 | }); 109 | }); 110 | 111 | describe('closePopup', () => { 112 | it('should close the popup window', () => { 113 | const closeMock = jest.fn(); 114 | const popupRef = { 115 | current: { close: closeMock }, 116 | } as unknown as React.MutableRefObject; 117 | closePopup(popupRef); 118 | expect(closeMock).toHaveBeenCalled(); 119 | }); 120 | }); 121 | 122 | describe('isWindowOpener', () => { 123 | it('should return true when the opener is a Window object', () => { 124 | expect(isWindowOpener(window)).toBe(true); 125 | }); 126 | 127 | it('should return false when the opener is null', () => { 128 | const opener = null; 129 | expect(isWindowOpener(opener)).toBe(false); 130 | }); 131 | }); 132 | 133 | describe('openerPostMessage', () => { 134 | it('should call postMessage on the opener window', () => { 135 | const postMessageMock = jest.fn(); 136 | const opener = { postMessage: postMessageMock } as unknown as Window; 137 | openerPostMessage(opener as Window, { 138 | type: OAUTH_RESPONSE, 139 | payload: 'some-payload', 140 | }); 141 | expect(postMessageMock).toHaveBeenCalledWith({ 142 | type: OAUTH_RESPONSE, 143 | payload: 'some-payload', 144 | }); 145 | }); 146 | }); 147 | 148 | describe('cleanup', () => { 149 | it('should clear the interval and close the popup window', () => { 150 | jest.useFakeTimers(); 151 | jest.spyOn(global, 'clearInterval'); 152 | 153 | const closePopupMock = jest.fn(); 154 | const removeEventListenerMock = jest.fn(); 155 | const intervalRef = { current: 123 } as unknown as React.MutableRefObject< 156 | string | number | NodeJS.Timeout | undefined 157 | >; 158 | const popupRef = { 159 | current: { close: closePopupMock }, 160 | } as unknown as React.MutableRefObject; 161 | const handleMessageListener = jest.fn(); 162 | window.removeEventListener = removeEventListenerMock; 163 | 164 | cleanup(intervalRef, popupRef, handleMessageListener); 165 | 166 | expect(clearInterval).toHaveBeenCalledWith(intervalRef.current); 167 | expect(closePopupMock).toHaveBeenCalled(); 168 | expect(removeEventListenerMock).toHaveBeenCalledWith('message', handleMessageListener); 169 | jest.clearAllTimers(); 170 | }); 171 | }); 172 | 173 | describe('formatExchangeCodeForTokenServerURL', () => { 174 | it('should return the URL with query parameters', () => { 175 | const exchangeCodeForTokenServerURL = 'https://example.com/oauth/token'; 176 | const clientId = '123'; 177 | const code = '456'; 178 | const redirectUri = 'https://example.com/callback'; 179 | const state = '789'; 180 | 181 | const expectedURL = 182 | 'https://example.com/oauth/token?client_id=123&grant_type=authorization_code&code=456&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=789'; 183 | 184 | expect( 185 | formatExchangeCodeForTokenServerURL( 186 | exchangeCodeForTokenServerURL, 187 | clientId, 188 | code, 189 | redirectUri, 190 | state 191 | ) 192 | ).toBe(expectedURL); 193 | }); 194 | }); 195 | -------------------------------------------------------------------------------- /src/components/tools.ts: -------------------------------------------------------------------------------- 1 | import { OAUTH_STATE_KEY, POPUP_HEIGHT, POPUP_WIDTH } from './constants'; 2 | import { TMessageData, TOauth2Props } from './types'; 3 | 4 | export const objectToQuery = (object: Record) => { 5 | return new URLSearchParams(object).toString(); 6 | }; 7 | 8 | export const queryToObject = (query: string) => { 9 | const parameters = new URLSearchParams(query); 10 | return Object.fromEntries(parameters.entries()); 11 | }; 12 | 13 | export const formatAuthorizeUrl = ( 14 | authorizeUrl: string, 15 | clientId: string, 16 | redirectUri: string, 17 | scope: string, 18 | state: string, 19 | responseType: TOauth2Props['responseType'], 20 | extraQueryParameters: TOauth2Props['extraQueryParameters'] = {} 21 | ) => { 22 | const query = objectToQuery({ 23 | response_type: responseType, 24 | client_id: clientId, 25 | redirect_uri: redirectUri, 26 | scope, 27 | state, 28 | ...extraQueryParameters, 29 | }); 30 | 31 | return `${authorizeUrl}?${query}`; 32 | }; 33 | 34 | // https://medium.com/@dazcyril/generating-cryptographic-random-state-in-javascript-in-the-browser-c538b3daae50 35 | export const generateState = () => { 36 | const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 37 | let array = new Uint8Array(40) as any; 38 | window.crypto.getRandomValues(array); 39 | array = array.map((x: number) => validChars.codePointAt(x % validChars.length)); 40 | const randomState = String.fromCharCode.apply(null, array); 41 | return randomState; 42 | }; 43 | 44 | export const saveState = (storage: Storage, state: string) => { 45 | storage.setItem(OAUTH_STATE_KEY, state); 46 | }; 47 | 48 | export const removeState = (storage: Storage) => { 49 | storage.removeItem(OAUTH_STATE_KEY); 50 | }; 51 | 52 | export const checkState = (storage: Storage, receivedState: string) => { 53 | const state = storage.getItem(OAUTH_STATE_KEY); 54 | return state === receivedState; 55 | }; 56 | 57 | export const openPopup = (url: string) => { 58 | // To fix issues with window.screen in multi-monitor setups, the easier option is to 59 | // center the pop-up over the parent window. 60 | const top = window.outerHeight / 2 + window.screenY - POPUP_HEIGHT / 2; 61 | const left = window.outerWidth / 2 + window.screenX - POPUP_WIDTH / 2; 62 | return window.open( 63 | url, 64 | 'OAuth2 Popup', 65 | `height=${POPUP_HEIGHT},width=${POPUP_WIDTH},top=${top},left=${left}` 66 | ); 67 | }; 68 | 69 | export const closePopup = (popupRef: React.MutableRefObject) => { 70 | popupRef.current?.close(); 71 | }; 72 | 73 | export const isWindowOpener = (opener: Window | null): opener is Window => 74 | opener !== null && opener !== undefined; 75 | 76 | export const openerPostMessage = (opener: Window, message: TMessageData) => 77 | opener.postMessage(message); 78 | 79 | export const cleanup = ( 80 | intervalRef: React.MutableRefObject, 81 | popupRef: React.MutableRefObject, 82 | handleMessageListener: any 83 | ) => { 84 | clearInterval(intervalRef.current); 85 | if (popupRef.current && typeof popupRef.current.close === 'function') closePopup(popupRef); 86 | removeState(sessionStorage); 87 | window.removeEventListener('message', handleMessageListener); 88 | }; 89 | 90 | export const formatExchangeCodeForTokenServerURL = ( 91 | serverUrl: string, 92 | clientId: string, 93 | code: string, 94 | redirectUri: string, 95 | state: string 96 | ) => { 97 | const url = serverUrl.split('?')[0]; 98 | const anySearchParameters = queryToObject(serverUrl.split('?')[1]); 99 | return `${url}?${objectToQuery({ 100 | ...anySearchParameters, 101 | client_id: clientId, 102 | grant_type: 'authorization_code', 103 | code, 104 | redirect_uri: redirectUri, 105 | state, 106 | })}`; 107 | }; 108 | -------------------------------------------------------------------------------- /src/components/types.ts: -------------------------------------------------------------------------------- 1 | import { OAUTH_RESPONSE, EXCHANGE_CODE_FOR_TOKEN_METHODS } from './constants'; 2 | 3 | export type TAuthTokenPayload = { 4 | token_type: string; 5 | expires_in: number; 6 | access_token: string; 7 | scope: string; 8 | refresh_token: string; 9 | }; 10 | 11 | type TExchangeCodeForTokenQuery = { 12 | url: string; 13 | method: (typeof EXCHANGE_CODE_FOR_TOKEN_METHODS)[number]; 14 | headers?: Record; 15 | }; 16 | 17 | type TExchangeCodeForTokenQueryFn = ( 18 | callbackParameters: any 19 | ) => Promise; 20 | 21 | export type TResponseTypeBasedProps = 22 | | RequireOnlyOne< 23 | { 24 | responseType: 'code'; 25 | exchangeCodeForTokenQuery: TExchangeCodeForTokenQuery; 26 | exchangeCodeForTokenQueryFn: TExchangeCodeForTokenQueryFn; 27 | onSuccess?: (payload: TData) => void; 28 | }, 29 | 'exchangeCodeForTokenQuery' | 'exchangeCodeForTokenQueryFn' 30 | > 31 | | { 32 | responseType: 'token'; 33 | onSuccess?: (payload: TData) => void; 34 | }; 35 | 36 | export type TOauth2Props = { 37 | authorizeUrl: string; 38 | clientId: string; 39 | redirectUri: string; 40 | scope?: string; 41 | extraQueryParameters?: Record; 42 | onError?: (error: string) => void; 43 | } & TResponseTypeBasedProps; 44 | 45 | export type TState = TData | null; 46 | 47 | export type TMessageData = 48 | | { 49 | type: typeof OAUTH_RESPONSE; 50 | error: string; 51 | } 52 | | { 53 | type: typeof OAUTH_RESPONSE; 54 | payload: any; 55 | }; 56 | 57 | type RequireOnlyOne = Pick> & 58 | { 59 | [K in Keys]-?: Required> & Partial, undefined>>; 60 | }[Keys]; 61 | -------------------------------------------------------------------------------- /src/components/use-check-props.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | import { useCheckProps } from './use-check-props'; 3 | import { TOauth2Props } from './types'; 4 | import { EXCHANGE_CODE_FOR_TOKEN_METHODS } from './constants'; 5 | 6 | // Silence react-test-library intentional error logs 7 | beforeAll(() => { 8 | const spy = jest.spyOn(console, 'error'); 9 | // eslint-disable-next-line @typescript-eslint/no-empty-function 10 | spy.mockImplementation(() => {}); 11 | }); 12 | 13 | afterAll(() => { 14 | jest.resetAllMocks(); 15 | }); 16 | 17 | describe('useCheckProps', () => { 18 | test('throws error if required props are missing', () => { 19 | const props = { responseType: 'token' } as TOauth2Props; 20 | expect(() => renderHook(() => useCheckProps(props))).toThrow( 21 | new Error( 22 | 'Missing required props for useOAuth2. Required props are: {authorizeUrl, clientId, redirectUri, responseType}' 23 | ) 24 | ); 25 | }); 26 | 27 | test('throws error if exchangeCodeForTokenQuery or exchangeCodeForTokenQueryFn is missing for responseType of "code"', () => { 28 | const props = { 29 | authorizeUrl: 'https://example.com', 30 | clientId: 'test-client-id', 31 | redirectUri: 'https://example.com/callback', 32 | responseType: 'code', 33 | } as TOauth2Props; 34 | expect(() => renderHook(() => useCheckProps(props))).toThrow( 35 | new Error( 36 | 'Either `exchangeCodeForTokenQuery` or `exchangeCodeForTokenQueryFn` is required for responseType of "code" for useOAuth2.' 37 | ) 38 | ); 39 | }); 40 | 41 | test('throws error if invalid exchangeCodeForTokenQuery.method value is provided', () => { 42 | const props = { 43 | authorizeUrl: 'https://example.com', 44 | clientId: 'test-client-id', 45 | redirectUri: 'https://example.com/callback', 46 | responseType: 'code', 47 | exchangeCodeForTokenQuery: { 48 | url: 'https://some-url', 49 | method: 'invalid-method', 50 | }, 51 | } as unknown as TOauth2Props; 52 | expect(() => renderHook(() => useCheckProps(props))).toThrow( 53 | new Error( 54 | `Invalid \`exchangeCodeForTokenQuery.method\` value. It can be one of ${EXCHANGE_CODE_FOR_TOKEN_METHODS.join(', ')}.` 55 | ) 56 | ); 57 | }); 58 | 59 | test('throws error if extraQueryParameters is not an object', () => { 60 | const props = { 61 | authorizeUrl: 'https://example.com', 62 | clientId: 'test-client-id', 63 | redirectUri: 'https://example.com/callback', 64 | responseType: 'token', 65 | extraQueryParameters: 'invalid', 66 | } as unknown as TOauth2Props; 67 | expect(() => renderHook(() => useCheckProps(props))).toThrow( 68 | new TypeError('extraQueryParameters must be an object for useOAuth2.') 69 | ); 70 | }); 71 | 72 | test('throws error if onSuccess callback is not a function', () => { 73 | const props = { 74 | authorizeUrl: 'https://example.com', 75 | clientId: 'test-client-id', 76 | redirectUri: 'https://example.com/callback', 77 | responseType: 'token', 78 | onSuccess: 'invalid-callback', 79 | } as unknown as TOauth2Props; 80 | expect(() => renderHook(() => useCheckProps(props))).toThrow( 81 | new TypeError('onSuccess callback must be a function for useOAuth2.') 82 | ); 83 | }); 84 | 85 | test('throws error if onError callback is not a function', () => { 86 | const props = { 87 | authorizeUrl: 'https://example.com', 88 | clientId: 'test-client-id', 89 | redirectUri: 'https://example.com/callback', 90 | responseType: 'token', 91 | onError: 'invalid-callback', 92 | } as unknown as TOauth2Props; 93 | expect(() => renderHook(() => useCheckProps(props))).toThrow( 94 | new TypeError('onError callback must be a function for useOAuth2.') 95 | ); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/components/use-check-props.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/consistent-destructuring */ 2 | import { EXCHANGE_CODE_FOR_TOKEN_METHODS } from './constants'; 3 | import { TAuthTokenPayload, TOauth2Props } from './types'; 4 | 5 | export const useCheckProps = (props: TOauth2Props) => { 6 | const { 7 | authorizeUrl, 8 | clientId, 9 | redirectUri, 10 | // scope = '', 11 | responseType, 12 | extraQueryParameters = {}, 13 | onSuccess, 14 | onError, 15 | } = props; 16 | 17 | if (!authorizeUrl || !clientId || !redirectUri || !responseType) { 18 | throw new Error( 19 | 'Missing required props for useOAuth2. Required props are: {authorizeUrl, clientId, redirectUri, responseType}' 20 | ); 21 | } 22 | 23 | if ( 24 | responseType === 'code' && 25 | !props.exchangeCodeForTokenQuery && 26 | !props.exchangeCodeForTokenQueryFn 27 | ) { 28 | throw new Error( 29 | 'Either `exchangeCodeForTokenQuery` or `exchangeCodeForTokenQueryFn` is required for responseType of "code" for useOAuth2.' 30 | ); 31 | } 32 | 33 | if ( 34 | responseType === 'code' && 35 | props.exchangeCodeForTokenQuery && 36 | !props.exchangeCodeForTokenQuery.url 37 | ) { 38 | throw new Error('Value `exchangeCodeForTokenQuery.url` is missing.'); 39 | } 40 | 41 | if ( 42 | responseType === 'code' && 43 | props.exchangeCodeForTokenQuery && 44 | !['GET', 'POST', 'PUT', 'PATCH'].includes(props.exchangeCodeForTokenQuery.method) 45 | ) { 46 | throw new Error( 47 | `Invalid \`exchangeCodeForTokenQuery.method\` value. It can be one of ${EXCHANGE_CODE_FOR_TOKEN_METHODS.join(', ')}.` 48 | ); 49 | } 50 | 51 | if (typeof extraQueryParameters !== 'object') { 52 | throw new TypeError('extraQueryParameters must be an object for useOAuth2.'); 53 | } 54 | 55 | if (onSuccess && typeof onSuccess !== 'function') { 56 | throw new TypeError('onSuccess callback must be a function for useOAuth2.'); 57 | } 58 | 59 | if (onError && typeof onError !== 'function') { 60 | throw new TypeError('onError callback must be a function for useOAuth2.'); 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/use-oauth2.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act, waitFor } from '@testing-library/react'; 2 | import fetchMock from 'jest-fetch-mock'; 3 | import { useOAuth2 } from './use-oauth2'; 4 | import { OAUTH_RESPONSE, OAUTH_STATE_KEY } from './constants'; 5 | import { 6 | openPopup, 7 | cleanup, 8 | formatAuthorizeUrl, 9 | formatExchangeCodeForTokenServerURL, 10 | } from './tools'; 11 | 12 | const AUTHORIZE_URL = 'http://mockAuthorizeUrl'; 13 | const CLIENT_ID = 'mockClientId'; 14 | const REDIRECT_URI = 'http://mockRedirectUri'; 15 | const SCOPE = 'some-scope'; 16 | const EXTRA_QUERY_PARAMETERS = { a: 1, b: 2 }; 17 | const EXCHANGE_CODE_FOR_TOKEN_SERVER_URL = 'http://mockExchangeCodeForTokenServerURL'; 18 | const EXCHANGE_CODE_FOR_TOKEN_SERVER_HEADERS = { 19 | Authorization: 20 | 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', 21 | }; 22 | 23 | jest.mock('./tools', () => { 24 | const originalModule = jest.requireActual('./tools'); 25 | return { 26 | ...originalModule, 27 | openPopup: jest.fn(() => ({ 28 | window: { closed: false }, 29 | close: jest.fn(), 30 | postMessage: jest.fn(), 31 | })), 32 | cleanup: jest.fn(), 33 | }; 34 | }); 35 | 36 | afterAll(() => jest.resetAllMocks()); 37 | 38 | const fetchMockPayload = { 39 | access_token: 'SOME_ACCESS_TOKEN', 40 | expires_in: 3600, 41 | refresh_token: 'SOME_REFRESH_TOKEN', 42 | scope: 'SOME_SCOPE', 43 | token_type: 'Bearer', 44 | }; 45 | fetchMock.mockResponse(JSON.stringify(fetchMockPayload), { status: 200 }); 46 | 47 | describe('useOAuth2', () => { 48 | beforeEach(() => { 49 | jest.useFakeTimers(); 50 | }); 51 | 52 | afterEach(() => { 53 | jest.clearAllTimers(); 54 | localStorage.clear(); 55 | }); 56 | 57 | it('For responseType=token, should call onSuccess with payload and set data on successful authorization', async () => { 58 | const onSuccess = jest.fn(); 59 | 60 | const { result } = renderHook(() => 61 | useOAuth2({ 62 | authorizeUrl: AUTHORIZE_URL, 63 | clientId: CLIENT_ID, 64 | redirectUri: REDIRECT_URI, 65 | responseType: 'token', 66 | scope: SCOPE, 67 | extraQueryParameters: EXTRA_QUERY_PARAMETERS, 68 | onSuccess, 69 | }) 70 | ); 71 | expect(result.current.loading).toBe(false); 72 | expect(result.current.error).toBe(null); 73 | expect(result.current.data).toBe(null); 74 | 75 | // Trigger auth 76 | await act(() => result.current.getAuth()); 77 | expect(result.current.loading).toBe(true); 78 | 79 | // Check that a state is generated 80 | const generatedState = sessionStorage.getItem(OAUTH_STATE_KEY); 81 | expect(generatedState).toEqual(expect.any(String)); 82 | 83 | // Check openPopup fn has been called 84 | const formattedAuthorizeUrl = formatAuthorizeUrl( 85 | AUTHORIZE_URL, 86 | CLIENT_ID, 87 | REDIRECT_URI, 88 | 'some-scope', 89 | generatedState as string, 90 | 'token', 91 | EXTRA_QUERY_PARAMETERS 92 | ); 93 | expect(openPopup).toHaveBeenCalledWith(formattedAuthorizeUrl); 94 | 95 | // Simulate message from popup 96 | window.postMessage( 97 | { 98 | type: OAUTH_RESPONSE, 99 | payload: fetchMockPayload, 100 | }, 101 | '*' 102 | ); 103 | 104 | await waitFor(() => { 105 | expect(result.current.loading).toBe(false); 106 | expect(result.current.error).toBe(null); 107 | expect(result.current.data).toEqual(fetchMockPayload); 108 | expect(onSuccess).toHaveBeenCalledWith(fetchMockPayload); 109 | expect(cleanup).toHaveBeenCalled(); 110 | }); 111 | }); 112 | 113 | it('For responseType=code and exchangeCodeForTokenQuery, should exchange code for token and then run onSuccess with payload and set data on successful authorization', async () => { 114 | const onSuccess = jest.fn(); 115 | 116 | fetchMock.mockResponseOnce(JSON.stringify(fetchMockPayload), { 117 | status: 200, 118 | headers: { 'content-type': 'application/json' }, 119 | }); 120 | 121 | const { result } = renderHook(() => 122 | useOAuth2({ 123 | authorizeUrl: AUTHORIZE_URL, 124 | clientId: CLIENT_ID, 125 | redirectUri: REDIRECT_URI, 126 | responseType: 'code', 127 | scope: SCOPE, 128 | exchangeCodeForTokenQuery: { 129 | method: 'GET', 130 | url: EXCHANGE_CODE_FOR_TOKEN_SERVER_URL, 131 | headers: EXCHANGE_CODE_FOR_TOKEN_SERVER_HEADERS, 132 | }, 133 | extraQueryParameters: EXTRA_QUERY_PARAMETERS, 134 | onSuccess, 135 | }) 136 | ); 137 | expect(result.current.loading).toBe(false); 138 | expect(result.current.error).toBe(null); 139 | expect(result.current.data).toBe(null); 140 | 141 | await act(() => result.current.getAuth()); 142 | expect(result.current.loading).toBe(true); 143 | 144 | const generatedState = sessionStorage.getItem(OAUTH_STATE_KEY); 145 | expect(generatedState).toEqual(expect.any(String)); 146 | 147 | const formattedAuthorizeUrl = formatAuthorizeUrl( 148 | AUTHORIZE_URL, 149 | CLIENT_ID, 150 | REDIRECT_URI, 151 | 'some-scope', 152 | generatedState as string, 153 | 'code', 154 | EXTRA_QUERY_PARAMETERS 155 | ); 156 | expect(openPopup).toHaveBeenCalledWith(formattedAuthorizeUrl); 157 | 158 | window.postMessage( 159 | { 160 | type: OAUTH_RESPONSE, 161 | payload: { code: 'some-code' }, 162 | }, 163 | '*' 164 | ); 165 | 166 | await waitFor(() => { 167 | expect(fetchMock.mock.lastCall).toEqual([ 168 | formatExchangeCodeForTokenServerURL( 169 | EXCHANGE_CODE_FOR_TOKEN_SERVER_URL, 170 | CLIENT_ID, 171 | 'some-code', 172 | REDIRECT_URI, 173 | generatedState as string 174 | ), 175 | { 176 | method: 'GET', 177 | headers: EXCHANGE_CODE_FOR_TOKEN_SERVER_HEADERS, 178 | }, 179 | ]); 180 | expect(result.current.loading).toBe(false); 181 | expect(result.current.error).toBe(null); 182 | expect(result.current.data).toEqual(fetchMockPayload); 183 | expect(onSuccess).toHaveBeenCalledWith(fetchMockPayload); 184 | expect(cleanup).toHaveBeenCalled(); 185 | }); 186 | }); 187 | 188 | it('For responseType=code and exchangeCodeForTokenQueryFn, should exchange code for token and then run onSuccess with payload and set data on successful authorization', async () => { 189 | const onSuccess = jest.fn(); 190 | 191 | const { result } = renderHook(() => 192 | useOAuth2({ 193 | authorizeUrl: AUTHORIZE_URL, 194 | clientId: CLIENT_ID, 195 | redirectUri: REDIRECT_URI, 196 | responseType: 'code', 197 | scope: SCOPE, 198 | exchangeCodeForTokenQueryFn: async (callbackParameters: { code: string }) => { 199 | const response = await fetch(EXCHANGE_CODE_FOR_TOKEN_SERVER_URL, { 200 | method: 'POST', 201 | body: JSON.stringify({ code: callbackParameters.code }), 202 | headers: { Accept: 'application/json', 'content-type': 'application/json' }, 203 | }); 204 | if (!response.ok) throw new Error('exchangeCodeForTokenQueryFn fail at test'); 205 | const tokenData = await response.json(); 206 | return tokenData; 207 | }, 208 | extraQueryParameters: EXTRA_QUERY_PARAMETERS, 209 | onSuccess, 210 | }) 211 | ); 212 | expect(result.current.loading).toBe(false); 213 | expect(result.current.error).toBe(null); 214 | expect(result.current.data).toBe(null); 215 | 216 | await act(() => result.current.getAuth()); 217 | expect(result.current.loading).toBe(true); 218 | 219 | const generatedState = sessionStorage.getItem(OAUTH_STATE_KEY); 220 | expect(generatedState).toEqual(expect.any(String)); 221 | 222 | const formattedAuthorizeUrl = formatAuthorizeUrl( 223 | AUTHORIZE_URL, 224 | CLIENT_ID, 225 | REDIRECT_URI, 226 | 'some-scope', 227 | generatedState as string, 228 | 'code', 229 | EXTRA_QUERY_PARAMETERS 230 | ); 231 | expect(openPopup).toHaveBeenCalledWith(formattedAuthorizeUrl); 232 | 233 | window.postMessage( 234 | { 235 | type: OAUTH_RESPONSE, 236 | payload: { code: 'some-code' }, 237 | }, 238 | '*' 239 | ); 240 | 241 | await waitFor(async () => { 242 | expect(fetchMock.mock.lastCall).toEqual([ 243 | EXCHANGE_CODE_FOR_TOKEN_SERVER_URL, 244 | { 245 | method: 'POST', 246 | body: '{"code":"some-code"}', 247 | headers: { Accept: 'application/json', 'content-type': 'application/json' }, 248 | }, 249 | ]); 250 | expect(result.current.loading).toBe(false); 251 | expect(result.current.error).toBe(null); 252 | expect(result.current.data).toEqual(fetchMockPayload); 253 | expect(onSuccess).toHaveBeenCalledWith(fetchMockPayload); 254 | expect(cleanup).toHaveBeenCalled(); 255 | }); 256 | }); 257 | 258 | it('Should call onError with error message on authorization error', async () => { 259 | const onError = jest.fn(); 260 | 261 | const { result } = renderHook(() => 262 | useOAuth2({ 263 | authorizeUrl: AUTHORIZE_URL, 264 | clientId: CLIENT_ID, 265 | redirectUri: REDIRECT_URI, 266 | responseType: 'token', 267 | scope: SCOPE, 268 | extraQueryParameters: EXTRA_QUERY_PARAMETERS, 269 | onError, 270 | }) 271 | ); 272 | expect(result.current.loading).toBe(false); 273 | expect(result.current.error).toBe(null); 274 | expect(result.current.data).toBe(null); 275 | 276 | await act(() => result.current.getAuth()); 277 | expect(result.current.loading).toBe(true); 278 | 279 | const generatedState = sessionStorage.getItem(OAUTH_STATE_KEY); 280 | expect(generatedState).toEqual(expect.any(String)); 281 | 282 | const formattedAuthorizeUrl = formatAuthorizeUrl( 283 | AUTHORIZE_URL, 284 | CLIENT_ID, 285 | REDIRECT_URI, 286 | 'some-scope', 287 | generatedState as string, 288 | 'token', 289 | EXTRA_QUERY_PARAMETERS 290 | ); 291 | expect(openPopup).toHaveBeenCalledWith(formattedAuthorizeUrl); 292 | 293 | // Simulate error message from popup 294 | window.postMessage( 295 | { 296 | type: OAUTH_RESPONSE, 297 | error: 'Ooops', 298 | }, 299 | '*' 300 | ); 301 | 302 | await waitFor(() => { 303 | expect(result.current.loading).toBe(false); 304 | expect(result.current.error).toBe('Ooops'); 305 | expect(result.current.data).toBe(null); 306 | expect(onError).toHaveBeenCalledWith('Ooops'); 307 | expect(cleanup).toHaveBeenCalled(); 308 | }); 309 | }); 310 | }); 311 | -------------------------------------------------------------------------------- /src/components/use-oauth2.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from 'react'; 2 | import useLocalStorageState from 'use-local-storage-state'; 3 | import { DEFAULT_EXCHANGE_CODE_FOR_TOKEN_METHOD, OAUTH_RESPONSE } from './constants'; 4 | import { 5 | cleanup, 6 | formatAuthorizeUrl, 7 | formatExchangeCodeForTokenServerURL, 8 | generateState, 9 | openPopup, 10 | saveState, 11 | } from './tools'; 12 | import { TAuthTokenPayload, TMessageData, TOauth2Props, TState } from './types'; 13 | import { useCheckProps } from './use-check-props'; 14 | 15 | export const useOAuth2 = (props: TOauth2Props) => { 16 | const { 17 | authorizeUrl, 18 | clientId, 19 | redirectUri, 20 | scope = '', 21 | responseType, 22 | extraQueryParameters = {}, 23 | onSuccess, 24 | onError, 25 | } = props; 26 | 27 | useCheckProps(props); 28 | const extraQueryParametersRef = useRef(extraQueryParameters); 29 | const popupRef = useRef(); 30 | const intervalRef = useRef(); 31 | const exchangeCodeForTokenQueryRef = useRef( 32 | responseType === 'code' && props.exchangeCodeForTokenQuery 33 | ); 34 | const exchangeCodeForTokenQueryFnRef = useRef( 35 | responseType === 'code' && props.exchangeCodeForTokenQueryFn 36 | ); 37 | const [{ loading, error }, setUI] = useState<{ loading: boolean; error: string | null }>({ 38 | loading: false, 39 | error: null, 40 | }); 41 | const [data, setData, { removeItem, isPersistent }] = useLocalStorageState>( 42 | `${responseType}-${authorizeUrl}-${clientId}-${scope}`, 43 | { 44 | defaultValue: null, 45 | } 46 | ); 47 | 48 | const getAuth = useCallback(() => { 49 | // 1. Init 50 | setUI({ 51 | loading: true, 52 | error: null, 53 | }); 54 | 55 | // 2. Generate and save state 56 | const state = generateState(); 57 | saveState(sessionStorage, state); 58 | 59 | // 3. Open popup 60 | popupRef.current = openPopup( 61 | formatAuthorizeUrl( 62 | authorizeUrl, 63 | clientId, 64 | redirectUri, 65 | scope, 66 | state, 67 | responseType, 68 | extraQueryParametersRef.current 69 | ) 70 | ); 71 | 72 | // 4. Register message listener 73 | async function handleMessageListener(message: MessageEvent) { 74 | const type = message?.data?.type; 75 | if (type !== OAUTH_RESPONSE) { 76 | return; 77 | } 78 | try { 79 | if ('error' in message.data) { 80 | const errorMessage = message.data?.error || 'Unknown Error occured.'; 81 | setUI({ 82 | loading: false, 83 | error: errorMessage, 84 | }); 85 | if (onError) await onError(errorMessage); 86 | } else { 87 | let payload = message?.data?.payload; 88 | 89 | if (responseType === 'code') { 90 | const exchangeCodeForTokenQueryFn = exchangeCodeForTokenQueryFnRef.current; 91 | const exchangeCodeForTokenQuery = exchangeCodeForTokenQueryRef.current; 92 | if ( 93 | exchangeCodeForTokenQueryFn && 94 | typeof exchangeCodeForTokenQueryFn === 'function' 95 | ) { 96 | payload = await exchangeCodeForTokenQueryFn(message.data?.payload); 97 | } else if (exchangeCodeForTokenQuery) { 98 | const response = await fetch( 99 | formatExchangeCodeForTokenServerURL( 100 | exchangeCodeForTokenQuery.url, 101 | clientId, 102 | payload?.code, 103 | redirectUri, 104 | state 105 | ), 106 | { 107 | method: 108 | exchangeCodeForTokenQuery.method ?? 109 | DEFAULT_EXCHANGE_CODE_FOR_TOKEN_METHOD, 110 | headers: exchangeCodeForTokenQuery.headers || {}, 111 | } 112 | ); 113 | payload = await response.json(); 114 | } else { 115 | throw new Error( 116 | 'useOAuth2: You must provide `exchangeCodeForTokenQuery` or `exchangeCodeForTokenQueryFn`' 117 | ); 118 | } 119 | } 120 | 121 | setUI({ 122 | loading: false, 123 | error: null, 124 | }); 125 | setData(payload); 126 | if (onSuccess) { 127 | await onSuccess(payload); 128 | } 129 | } 130 | } catch (genericError: any) { 131 | console.error(genericError); 132 | setUI({ 133 | loading: false, 134 | error: genericError.toString(), 135 | }); 136 | if (onError) await onError(genericError.toString()); 137 | } finally { 138 | // Clear stuff ... 139 | cleanup(intervalRef, popupRef, handleMessageListener); 140 | } 141 | } 142 | window.addEventListener('message', handleMessageListener); 143 | 144 | // 4. Begin interval to check if popup was closed forcefully by the user 145 | intervalRef.current = setInterval(() => { 146 | const popupClosed = !popupRef.current?.window || popupRef.current?.window?.closed; 147 | if (popupClosed) { 148 | // Popup was closed before completing auth... 149 | setUI((ui) => ({ 150 | ...ui, 151 | loading: false, 152 | })); 153 | console.warn('Warning: Popup was closed before completing authentication.'); 154 | cleanup(intervalRef, popupRef, handleMessageListener); 155 | } 156 | }, 250); 157 | 158 | // 5. Remove listener(s) on unmount 159 | return () => { 160 | window.removeEventListener('message', handleMessageListener); 161 | if (intervalRef.current) clearInterval(intervalRef.current); 162 | }; 163 | }, [ 164 | authorizeUrl, 165 | clientId, 166 | redirectUri, 167 | scope, 168 | responseType, 169 | onSuccess, 170 | onError, 171 | setUI, 172 | setData, 173 | ]); 174 | 175 | const logout = useCallback(() => { 176 | removeItem(); 177 | setUI({ loading: false, error: null }); 178 | }, [removeItem]); 179 | 180 | return { data, loading, error, getAuth, logout, isPersistent }; 181 | }; 182 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prevent-abbreviations */ 2 | /// 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Default 4 | "target": "es5", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | 10 | // Added 11 | "jsx": "react-jsx", 12 | "module": "ESNext", 13 | "declaration": true, 14 | "declarationDir": "types", 15 | "sourceMap": true, 16 | "outDir": "dist", 17 | "moduleResolution": "node", 18 | "allowSyntheticDefaultImports": true, 19 | "emitDeclarationOnly": true, 20 | "lib": [ 21 | "dom" 22 | ] 23 | } 24 | } --------------------------------------------------------------------------------