diff --git a/README.md b/README.md index f63ed5b..39d69ac 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,10 @@ const Home = () => { redirectUri: `${document.location.origin}/callback`, scope: "YOUR_SCOPES", responseType: "code", - exchangeCodeForTokenServerURL: "https://your-backend/token", - exchangeCodeForTokenMethod: "POST", + exchangeCodeForTokenQuery: { + url: "https://your-backend/token", + method: "POST", + }, onSuccess: (payload) => console.log("Success", payload), onError: (error_) => console.log("Error", error_) }); @@ -85,17 +87,52 @@ const App = () => { }; ``` -### What is the purpose of `exchangeCodeForTokenServerURL` for Authorization Code flows? +##### Example with `exchangeCodeForTokenQueryFn` + +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: +```js + + const { ... } = useOAuth2({ + // ... + // Instead of exchangeCodeForTokenQuery (e.g sending form-urlencoded or similar)... + exchangeCodeForTokenQueryFn: async (callbackParameters) => { + const formBody = []; + for (const key in callbackParameters) { + formBody.push( + `${encodeURIComponent(key)}=${encodeURIComponent(callbackParameters[key])}` + ); + } + const response = await fetch(`YOUR_BACKEND_URL`, { + method: 'POST', + body: formBody.join('&'), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + }); + if (!response.ok) throw new Error('Failed'); + const tokenData = await response.json(); + return tokenData; + },. + // ... + }) + +``` + +### What is the purpose of `exchangeCodeForTokenQuery` for Authorization Code flows? 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. 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. -That's why you need to proxy this call to your back-end and `exchangeCodeForTokenServerURL` is the API URL of your back-end route that will take care of this. 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 `exchangeCodeForTokenMethod` property. +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. 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). +### What's the alternative option `exchangeCodeForTokenQueryFn`? + +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. + ### What's the case with Implicit Grant flows? 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. @@ -106,7 +143,7 @@ After a successful authorization, data will get persisted to **localStorage** an If you want to re-trigger the authorization flow just call `getAuth()` function again. -**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. +**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. ## API @@ -114,26 +151,28 @@ If you want to re-trigger the authorization flow just call `getAuth()` function This is the hook that makes this package to work. `Options` is an object that contains the properties below -- **authorizeUrl** (string): The 3rd party authorization URL (e.g https://accounts.google.com/o/oauth2/v2/auth). -- **clientId** (string): The OAuth2 client id of your application. -- **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. -- **scope** (string - _optional_): A list of scopes depending on your application needs. -- **responseType** (string): Can be either **code** for _code authorization grant_ or **token** for _implicit grant_. -- **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"}. -- **exchangeCodeForTokenServerURL** (string): This property is only used when using _code authorization grant_ flow (responseType = code). 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). -- **exchangeCodeForTokenMethod** (string - _optional_): Specifies the HTTP method that will be used for the code-for-token exchange to your server. Defaults to **POST**. -- **exchangeCodeForTokenHeaders** (object - _optional_): An object of extra parameters that will be used for the code-for-token exchange to your server. +- `authorizeUrl` (string): The 3rd party authorization URL (e.g https://accounts.google.com/o/oauth2/v2/auth). +- `clientId` (string): The OAuth2 client id of your application. +- `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. +- `scope` (string - _optional_): A list of scopes depending on your application needs. +- `responseType` (string): Can be either **code** for _code authorization grant_ or **token** for _implicit grant_. +- `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"}. +- `exchangeCodeForTokenQuery` (object): This property is only required when using _code authorization grant_ flow (responseType = code). It's properties are: + - `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). + - `method` (string - _required_): Specifies the HTTP method that will be used for the code-for-token exchange to your server. Defaults to **POST** + - `headers` (object - _optional_): An object of extra parameters that will be used for the code-for-token exchange to your server. +- `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. - **onSuccess** (function): Called after a complete successful authorization flow. - **onError** (function): Called when an error occurs. **Returns**: -- **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). -- **loading** (boolean): Is set to true while the authorization is taking place. -- **error** (string): Is set when an error occurs. -- **getAuth** (function): Call this function to trigger the authorization flow. -- **logout** (function): Call this function to logout and clear all authorization data. -- **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. +- `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. +- `loading` (boolean): Is set to true while the authorization is taking place. +- `error` (string): Is set when an error occurs. +- `getAuth` (function): Call this function to trigger the authorization flow. +- `logout` (function): Call this function to logout and clear all authorization data. +- `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. --- @@ -143,7 +182,7 @@ This is the component that will be rendered as a window Popup for as long as the Props consists of: -- **Component** (ReactElement - _optional_): You can optionally set a custom component to be rendered inside the Popup. By default it just displays a "Loading..." message. +- `Component` (ReactElement - _optional_): You can optionally set a custom component to be rendered inside the Popup. By default it just displays a "Loading..." message. ### Typescript @@ -178,6 +217,14 @@ type MyCustomShapeData = { const {data, ...} = useOAuth2({...}); ``` +### Migrating to v2.0.0 (2024-03-05) + +Please follow the steps below to migrate to `v2.0.0`: + +- **DEPRECATED properties**: `exchangeCodeForTokenServerURL`, `exchangeCodeForTokenMethod`, `exchangeCodeForTokenHeaders` +- **INTRODUCED NEW PROPERTY**: `exchangeCodeForTokenQuery` + - `exchangeCodeForTokenQuery` just combines all the above deprecated properties, e.g you can use it like: `exchangeCodeForTokenQuery: { url:"...", method:"POST", headers:{} }` + ### Tests You can run tests by calling diff --git a/e2e/login-authorization-code-flow-with-queryfn.test.ts b/e2e/login-authorization-code-flow-with-queryfn.test.ts new file mode 100644 index 0000000..40bfd5a --- /dev/null +++ b/e2e/login-authorization-code-flow-with-queryfn.test.ts @@ -0,0 +1,91 @@ +import puppeteer, { Browser } from 'puppeteer'; +import { getTextContent } from './test-utils'; + +const URL = 'http://localhost:3000'; + +let browser: Browser; +afterAll((done) => { + browser.close(); + + done(); +}); + +test('Login with authorization code flow and exchangeCodeForQueryFn works as expected', async () => { + browser = await puppeteer.launch({ headless: 'new' }); + const page = await browser.newPage(); + + await page.goto(URL); + + const nav = new Promise((response) => { + browser.on('targetcreated', response); + }); + + await page.click('#authorization-code-queryfn-login'); + + // Assess loading + await page.waitForSelector('#authorization-code-queryfn-loading'); + + // Assess popup redirection + await nav; + const pages = await browser.pages(); + expect(pages[2].url()).toMatch( + /http:\/\/localhost:3000\/callback\?code=SOME_CODE&state=.*\S.*/ // any non-empty state + ); + + // Assess network call to exchange code for token + await page.waitForResponse(async (response) => { + if (response.request().method().toUpperCase() === 'OPTIONS') return false; + + const url = decodeURIComponent(response.url()); + const json = await response.json(); + const urlPath = url.split('?')[0]; + + return ( + urlPath === 'http://localhost:3001/mock-token-form-data' && + response.request().method().toUpperCase() === 'POST' && + response.request().postData() === 'code=SOME_CODE&someOtherData=someOtherData' && + json.code === 'SOME_CODE' && + json.access_token === 'SOME_ACCESS_TOKEN' && + json.expires_in === 3600 && + json.refresh_token === 'SOME_REFRESH_TOKEN' && + json.scope === 'SOME_SCOPE' && + json.token_type === 'Bearer' + ); + }); + + // Assess UI + await page.waitForSelector('#authorization-code-queryfn-data'); + expect(await getTextContent(page, '#authorization-code-queryfn-data')).toBe( + '{"code":"SOME_CODE","access_token":"SOME_ACCESS_TOKEN","expires_in":3600,"refresh_token":"SOME_REFRESH_TOKEN","scope":"SOME_SCOPE","token_type":"Bearer"}' + ); + + // Assess localStorage + expect( + await page.evaluate(() => + JSON.parse( + window.localStorage.getItem( + 'code-http://localhost:3001/mock-authorize-SOME_CLIENT_ID_2-SOME_SCOPE' + ) || '' + ) + ) + ).toEqual({ + code: 'SOME_CODE', + access_token: 'SOME_ACCESS_TOKEN', + expires_in: 3600, + refresh_token: 'SOME_REFRESH_TOKEN', + scope: 'SOME_SCOPE', + token_type: 'Bearer', + }); + + // Logout + await page.click('#authorization-code-queryfn-logout'); + expect(await page.$('#authorization-code-queryfn-data')).toBe(null); + expect(await page.$('#authorization-code-queryfn-login')).not.toBe(null); + expect( + await page.evaluate(() => + window.localStorage.getItem( + 'code-http://localhost:3001/mock-authorize-SOME_CLIENT_ID_2-SOME_SCOPE' + ) + ) + ).toEqual('null'); +}); diff --git a/e2e/login-authorization-code-flow.test.ts b/e2e/login-authorization-code-flow.test.ts index 11241cf..2a7b91f 100644 --- a/e2e/login-authorization-code-flow.test.ts +++ b/e2e/login-authorization-code-flow.test.ts @@ -34,6 +34,8 @@ test('Login with authorization code flow works as expected', async () => { // Assess network call to exchange code for token await page.waitForResponse(async (response) => { + if (response.request().method().toUpperCase() === 'OPTIONS') return false; + const url = decodeURIComponent(response.url()); const json = await response.json(); const urlPath = url.split('?')[0]; @@ -46,6 +48,7 @@ test('Login with authorization code flow works as expected', async () => { urlQuery.get('code') === 'SOME_CODE' && urlQuery.get('redirect_uri') === 'http://localhost:3000/callback' && Boolean(urlQuery.get('state')?.match(/.*\S.*/)) && + json.code === 'SOME_CODE' && json.access_token === 'SOME_ACCESS_TOKEN' && json.expires_in === 3600 && json.refresh_token === 'SOME_REFRESH_TOKEN' && @@ -57,7 +60,7 @@ test('Login with authorization code flow works as expected', async () => { // Assess UI await page.waitForSelector('#authorization-code-data'); expect(await getTextContent(page, '#authorization-code-data')).toBe( - '{"access_token":"SOME_ACCESS_TOKEN","expires_in":3600,"refresh_token":"SOME_REFRESH_TOKEN","scope":"SOME_SCOPE","token_type":"Bearer"}' + '{"code":"SOME_CODE","access_token":"SOME_ACCESS_TOKEN","expires_in":3600,"refresh_token":"SOME_REFRESH_TOKEN","scope":"SOME_SCOPE","token_type":"Bearer"}' ); // Assess localStorage @@ -70,6 +73,7 @@ test('Login with authorization code flow works as expected', async () => { ) ) ).toEqual({ + code: 'SOME_CODE', access_token: 'SOME_ACCESS_TOKEN', expires_in: 3600, refresh_token: 'SOME_REFRESH_TOKEN', diff --git a/example/client/Example.tsx b/example/client/Example.tsx index 969834f..81efc07 100644 --- a/example/client/Example.tsx +++ b/example/client/Example.tsx @@ -2,14 +2,17 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { OAuthPopup } from '../../src/components'; import LoginAuthorizationCode from './LoginAuthorizationCode'; +import LoginAuthorizationCodeWithQueryFn from './LoginAuthorizationCodeWithQueryFn'; import LoginImplicitGrant from './LoginImplicitGrant'; const Home = () => { return (
+ +

- +
); }; diff --git a/example/client/LoginAuthorizationCode.tsx b/example/client/LoginAuthorizationCode.tsx index 2e4f217..5137af7 100644 --- a/example/client/LoginAuthorizationCode.tsx +++ b/example/client/LoginAuthorizationCode.tsx @@ -8,8 +8,13 @@ const LoginCode = () => { redirectUri: `${document.location.origin}/callback`, scope: 'SOME_SCOPE', responseType: 'code', - exchangeCodeForTokenServerURL: 'http://localhost:3001/mock-token', - exchangeCodeForTokenMethod: 'POST', + exchangeCodeForTokenQuery: { + method: 'GET', + url: 'http://localhost:3001/mock-token', + headers: { + someHeader: 'someHeader', + }, + }, onSuccess: (payload) => console.log('Success', payload), onError: (error_) => console.log('Error', error_), }); diff --git a/example/client/LoginAuthorizationCodeWithQueryFn.tsx b/example/client/LoginAuthorizationCodeWithQueryFn.tsx new file mode 100644 index 0000000..ad1bbec --- /dev/null +++ b/example/client/LoginAuthorizationCodeWithQueryFn.tsx @@ -0,0 +1,81 @@ +/* eslint-disable no-console */ +import { useOAuth2 } from '../../src/components'; + +type TMyAuthData = { + access_token: string; +}; + +const LoginCode = () => { + const { data, loading, error, getAuth, logout } = useOAuth2({ + authorizeUrl: 'http://localhost:3001/mock-authorize', + clientId: 'SOME_CLIENT_ID_2', + redirectUri: `${document.location.origin}/callback`, + scope: 'SOME_SCOPE', + responseType: 'code', + exchangeCodeForTokenQueryFn: async (callbackParameters: { code: string }) => { + const jsonObject = { + code: callbackParameters.code, + someOtherData: 'someOtherData', + }; + const formBody = []; + // eslint-disable-next-line no-restricted-syntax, guard-for-in + for (const key in jsonObject) { + formBody.push( + `${encodeURIComponent(key)}=${encodeURIComponent(jsonObject[key as keyof typeof jsonObject])}` + ); + } + const response = await fetch(`http://localhost:3001/mock-token-form-data`, { + method: 'POST', + body: formBody.join('&'), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + }); + if (!response.ok) throw new Error('exchangeCodeForTokenQueryFn fail at example'); + const tokenData = await response.json(); + return tokenData; + }, + onSuccess: (payload) => console.log('Success', payload), + onError: (error_) => console.log('Error', error_), + }); + + const isLoggedIn = Boolean(data?.access_token); // or whatever... + + let ui = ( + + ); + + if (error) { + ui =
Error
; + } + + if (loading) { + ui =
Loading...
; + } + + if (isLoggedIn) { + ui = ( +
+
{JSON.stringify(data)}
+ +
+ ); + } + + return ( +
+

Login with Authorization Code with QueryFn

+ {ui} +
+ ); +}; + +export default LoginCode; diff --git a/example/server/index.ts b/example/server/index.ts index 439823b..d4e547c 100644 --- a/example/server/index.ts +++ b/example/server/index.ts @@ -1,17 +1,19 @@ /* eslint-disable camelcase */ import Fastify from 'fastify'; import delay from 'delay'; +import formBody from '@fastify/formbody'; +import cors from '@fastify/cors'; const fastify = Fastify({ logger: true, + exposeHeadRoutes: true, }); -fastify.addHook('preHandler', (request, reply, done) => { - reply.headers({ - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET,OPTIONS,PATCH,DELETE,POST,PUT', - }); - done(); + +// eslint-disable-next-line import/no-extraneous-dependencies, unicorn/prefer-module, @typescript-eslint/no-var-requires +fastify.register(cors, { + // put your options here }); +fastify.register(formBody); fastify.head('/', async (request, reply) => { reply.send('OK'); @@ -31,11 +33,27 @@ fastify.get('/mock-authorize', async (request, reply) => { } }); -fastify.post('/mock-token', async (request, reply) => { +fastify.get('/mock-token', async (request, reply) => { + await delay(1000); + + const { code } = request.query as any; + + reply.send({ + code, + access_token: `SOME_ACCESS_TOKEN`, + expires_in: 3600, + refresh_token: 'SOME_REFRESH_TOKEN', + scope: 'SOME_SCOPE', + token_type: 'Bearer', + }); +}); + +fastify.post('/mock-token-form-data', async (request, reply) => { await delay(1000); reply.send({ - access_token: 'SOME_ACCESS_TOKEN', + code: (request.body as any).code, + access_token: `SOME_ACCESS_TOKEN`, expires_in: 3600, refresh_token: 'SOME_REFRESH_TOKEN', scope: 'SOME_SCOPE', diff --git a/jest.config.unit.ts b/jest.config.unit.ts index b6e47c9..9736ef0 100644 --- a/jest.config.unit.ts +++ b/jest.config.unit.ts @@ -5,4 +5,5 @@ export default { testEnvironment: 'jsdom', testMatch: ['/src/**/*.test.{js,jsx,ts,tsx}'], setupFiles: ['./setup-tests.unit.ts'], + automock: false, } as Config.InitialOptions; diff --git a/package-lock.json b/package-lock.json index 0d69f01..9dd1fb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "@babel/preset-env": "^7.22.14", "@babel/preset-react": "^7.23.3", "@babel/preset-typescript": "^7.21.4", + "@fastify/cors": "^9.0.1", + "@fastify/formbody": "^7.4.0", "@rollup/plugin-commonjs": "^25.0.0", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^15.2.3", @@ -2086,6 +2088,16 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, + "node_modules/@fastify/cors": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz", + "integrity": "sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==", + "dev": true, + "dependencies": { + "fastify-plugin": "^4.0.0", + "mnemonist": "0.39.6" + } + }, "node_modules/@fastify/error": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", @@ -2101,6 +2113,16 @@ "fast-json-stringify": "^5.7.0" } }, + "node_modules/@fastify/formbody": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-7.4.0.tgz", + "integrity": "sha512-H3C6h1GN56/SMrZS8N2vCT2cZr7mIHzBHzOBa5OPpjfB/D6FzP9mMpE02ZzrFX0ANeh0BAJdoXKOF2e7IbV+Og==", + "dev": true, + "dependencies": { + "fast-querystring": "^1.0.0", + "fastify-plugin": "^4.0.0" + } + }, "node_modules/@fastify/merge-json-schemas": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", @@ -7477,6 +7499,12 @@ "toad-cache": "^3.3.0" } }, + "node_modules/fastify-plugin": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", + "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "dev": true + }, "node_modules/fastify/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -12034,6 +12062,15 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, + "node_modules/mnemonist": { + "version": "0.39.6", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", + "integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==", + "dev": true, + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -12299,6 +12336,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", + "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==", + "dev": true + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", diff --git a/package.json b/package.json index 158c325..c18271e 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "@babel/preset-env": "^7.22.14", "@babel/preset-react": "^7.23.3", "@babel/preset-typescript": "^7.21.4", + "@fastify/cors": "^9.0.1", + "@fastify/formbody": "^7.4.0", "@rollup/plugin-commonjs": "^25.0.0", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^15.2.3", diff --git a/src/components/constants.ts b/src/components/constants.ts index 2b838bc..93c7914 100644 --- a/src/components/constants.ts +++ b/src/components/constants.ts @@ -2,4 +2,5 @@ export const POPUP_HEIGHT = 700; export const POPUP_WIDTH = 600; export const OAUTH_STATE_KEY = 'react-use-oauth2-state-key'; export const OAUTH_RESPONSE = 'react-use-oauth2-response'; +export const EXCHANGE_CODE_FOR_TOKEN_METHODS = ['GET', 'POST', 'PUT', 'PATCH'] as const; export const DEFAULT_EXCHANGE_CODE_FOR_TOKEN_METHOD = 'POST'; diff --git a/src/components/tools.ts b/src/components/tools.ts index 69f0f06..6afec8d 100644 --- a/src/components/tools.ts +++ b/src/components/tools.ts @@ -88,14 +88,14 @@ export const cleanup = ( }; export const formatExchangeCodeForTokenServerURL = ( - exchangeCodeForTokenServerURL: string, + serverUrl: string, clientId: string, code: string, redirectUri: string, state: string ) => { - const url = exchangeCodeForTokenServerURL.split('?')[0]; - const anySearchParameters = queryToObject(exchangeCodeForTokenServerURL.split('?')[1]); + const url = serverUrl.split('?')[0]; + const anySearchParameters = queryToObject(serverUrl.split('?')[1]); return `${url}?${objectToQuery({ ...anySearchParameters, client_id: clientId, diff --git a/src/components/types.ts b/src/components/types.ts index 653c0af..fb9f7c5 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -1,4 +1,4 @@ -import { OAUTH_RESPONSE } from './constants'; +import { OAUTH_RESPONSE, EXCHANGE_CODE_FOR_TOKEN_METHODS } from './constants'; export type TAuthTokenPayload = { token_type: string; @@ -8,18 +8,29 @@ export type TAuthTokenPayload = { refresh_token: string; }; -export type TResponseTypeBasedProps = - | { - responseType: 'code'; - exchangeCodeForTokenServerURL: string; - exchangeCodeForTokenMethod?: 'POST' | 'GET'; - exchangeCodeForTokenHeaders?: Record; - onSuccess?: (payload: TData) => void; // TODO as this payload will be custom - // TODO Adjust payload type - } +type TExchangeCodeForTokenQuery = { + url: string; + method: (typeof EXCHANGE_CODE_FOR_TOKEN_METHODS)[number]; + headers?: Record; +}; + +type TExchangeCodeForTokenQueryFn = ( + callbackParameters: any +) => Promise; + +export type TResponseTypeBasedProps = + | RequireOnlyOne< + { + responseType: 'code'; + exchangeCodeForTokenQuery: TExchangeCodeForTokenQuery; + exchangeCodeForTokenQueryFn: TExchangeCodeForTokenQueryFn; + onSuccess?: (payload: TData) => void; + }, + 'exchangeCodeForTokenQuery' | 'exchangeCodeForTokenQueryFn' + > | { responseType: 'token'; - onSuccess?: (payload: TData) => void; // TODO Adjust payload type + onSuccess?: (payload: TData) => void; }; export type TOauth2Props = { @@ -42,3 +53,8 @@ export type TMessageData = type: typeof OAUTH_RESPONSE; payload: any; }; + +type RequireOnlyOne = Pick> & + { + [K in Keys]-?: Required> & Partial, undefined>>; + }[Keys]; diff --git a/src/components/use-check-props.test.ts b/src/components/use-check-props.test.ts index 020040c..04c10d7 100644 --- a/src/components/use-check-props.test.ts +++ b/src/components/use-check-props.test.ts @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react'; import { useCheckProps } from './use-check-props'; import { TOauth2Props } from './types'; +import { EXCHANGE_CODE_FOR_TOKEN_METHODS } from './constants'; // Silence react-test-library intentional error logs beforeAll(() => { @@ -23,7 +24,7 @@ describe('useCheckProps', () => { ); }); - test('throws error if exchangeCodeForTokenServerURL is missing for responseType of "code"', () => { + test('throws error if exchangeCodeForTokenQuery or exchangeCodeForTokenQueryFn is missing for responseType of "code"', () => { const props = { authorizeUrl: 'https://example.com', clientId: 'test-client-id', @@ -32,23 +33,25 @@ describe('useCheckProps', () => { } as TOauth2Props; expect(() => renderHook(() => useCheckProps(props))).toThrow( new Error( - 'exchangeCodeForTokenServerURL is required for responseType of "code" for useOAuth2.' + 'Either `exchangeCodeForTokenQuery` or `exchangeCodeForTokenQueryFn` is required for responseType of "code" for useOAuth2.' ) ); }); - test('throws error if invalid exchangeCodeForTokenServerURL value is provided', () => { + test('throws error if invalid exchangeCodeForTokenQuery.method value is provided', () => { const props = { authorizeUrl: 'https://example.com', clientId: 'test-client-id', redirectUri: 'https://example.com/callback', responseType: 'code', - exchangeCodeForTokenServerURL: 'invalid-url', - exchangeCodeForTokenMethod: 'invalid-method', + exchangeCodeForTokenQuery: { + url: 'https://some-url', + method: 'invalid-method', + }, } as unknown as TOauth2Props; expect(() => renderHook(() => useCheckProps(props))).toThrow( new Error( - 'Invalid exchangeCodeForTokenServerURL value. It can be one of "POST" or "GET".' + `Invalid \`exchangeCodeForTokenQuery.method\` value. It can be one of ${EXCHANGE_CODE_FOR_TOKEN_METHODS.join(', ')}.` ) ); }); diff --git a/src/components/use-check-props.ts b/src/components/use-check-props.ts index c0deaac..a3d0f4f 100644 --- a/src/components/use-check-props.ts +++ b/src/components/use-check-props.ts @@ -1,4 +1,5 @@ /* eslint-disable unicorn/consistent-destructuring */ +import { EXCHANGE_CODE_FOR_TOKEN_METHODS } from './constants'; import { TAuthTokenPayload, TOauth2Props } from './types'; export const useCheckProps = (props: TOauth2Props) => { @@ -19,19 +20,31 @@ export const useCheckProps = (props: TOauth2Props { afterAll(() => jest.resetAllMocks()); +const fetchMockPayload = { + access_token: 'SOME_ACCESS_TOKEN', + expires_in: 3600, + refresh_token: 'SOME_REFRESH_TOKEN', + scope: 'SOME_SCOPE', + token_type: 'Bearer', +}; +fetchMock.mockResponse(JSON.stringify(fetchMockPayload), { status: 200 }); + describe('useOAuth2', () => { beforeEach(() => { jest.useFakeTimers(); @@ -47,13 +56,6 @@ describe('useOAuth2', () => { it('For responseType=token, should call onSuccess with payload and set data on successful authorization', async () => { const onSuccess = jest.fn(); - const mockPayload = { - access_token: 'SOME_ACCESS_TOKEN', - expires_in: 3600, - refresh_token: 'SOME_REFRESH_TOKEN', - scope: 'SOME_SCOPE', - token_type: 'Bearer', - }; const { result } = renderHook(() => useOAuth2({ @@ -94,7 +96,7 @@ describe('useOAuth2', () => { window.postMessage( { type: OAUTH_RESPONSE, - payload: mockPayload, + payload: fetchMockPayload, }, '*' ); @@ -102,21 +104,14 @@ describe('useOAuth2', () => { await waitFor(() => { expect(result.current.loading).toBe(false); expect(result.current.error).toBe(null); - expect(result.current.data).toEqual(mockPayload); - expect(onSuccess).toHaveBeenCalledWith(mockPayload); + expect(result.current.data).toEqual(fetchMockPayload); + expect(onSuccess).toHaveBeenCalledWith(fetchMockPayload); expect(cleanup).toHaveBeenCalled(); }); }); - it('For responseType=code, should exchange code for token and then run onSuccess with payload and set data on successful authorization', async () => { + it('For responseType=code and exchangeCodeForTokenQuery, should exchange code for token and then run onSuccess with payload and set data on successful authorization', async () => { const onSuccess = jest.fn(); - const fetchMockPayload = { - access_token: 'SOME_ACCESS_TOKEN', - expires_in: 3600, - refresh_token: 'SOME_REFRESH_TOKEN', - scope: 'SOME_SCOPE', - token_type: 'Bearer', - }; fetchMock.mockResponseOnce(JSON.stringify(fetchMockPayload), { status: 200, @@ -130,8 +125,11 @@ describe('useOAuth2', () => { redirectUri: REDIRECT_URI, responseType: 'code', scope: SCOPE, - exchangeCodeForTokenServerURL: EXCHANGE_CODE_FOR_TOKEN_SERVER_URL, - exchangeCodeForTokenHeaders: EXCHANGE_CODE_FOR_TOKEN_SERVER_HEADERS, + exchangeCodeForTokenQuery: { + method: 'GET', + url: EXCHANGE_CODE_FOR_TOKEN_SERVER_URL, + headers: EXCHANGE_CODE_FOR_TOKEN_SERVER_HEADERS, + }, extraQueryParameters: EXTRA_QUERY_PARAMETERS, onSuccess, }) @@ -175,7 +173,7 @@ describe('useOAuth2', () => { generatedState as string ), { - method: 'POST', + method: 'GET', headers: EXCHANGE_CODE_FOR_TOKEN_SERVER_HEADERS, }, ]); @@ -187,6 +185,76 @@ describe('useOAuth2', () => { }); }); + it('For responseType=code and exchangeCodeForTokenQueryFn, should exchange code for token and then run onSuccess with payload and set data on successful authorization', async () => { + const onSuccess = jest.fn(); + + const { result } = renderHook(() => + useOAuth2({ + authorizeUrl: AUTHORIZE_URL, + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + responseType: 'code', + scope: SCOPE, + exchangeCodeForTokenQueryFn: async (callbackParameters: { code: string }) => { + const response = await fetch(EXCHANGE_CODE_FOR_TOKEN_SERVER_URL, { + method: 'POST', + body: JSON.stringify({ code: callbackParameters.code }), + headers: { Accept: 'application/json', 'content-type': 'application/json' }, + }); + if (!response.ok) throw new Error('exchangeCodeForTokenQueryFn fail at test'); + const tokenData = await response.json(); + return tokenData; + }, + extraQueryParameters: EXTRA_QUERY_PARAMETERS, + onSuccess, + }) + ); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.data).toBe(null); + + await act(() => result.current.getAuth()); + expect(result.current.loading).toBe(true); + + const generatedState = sessionStorage.getItem(OAUTH_STATE_KEY); + expect(generatedState).toEqual(expect.any(String)); + + const formattedAuthorizeUrl = formatAuthorizeUrl( + AUTHORIZE_URL, + CLIENT_ID, + REDIRECT_URI, + 'some-scope', + generatedState as string, + 'code', + EXTRA_QUERY_PARAMETERS + ); + expect(openPopup).toHaveBeenCalledWith(formattedAuthorizeUrl); + + window.postMessage( + { + type: OAUTH_RESPONSE, + payload: { code: 'some-code' }, + }, + '*' + ); + + await waitFor(async () => { + expect(fetchMock.mock.lastCall).toEqual([ + EXCHANGE_CODE_FOR_TOKEN_SERVER_URL, + { + method: 'POST', + body: '{"code":"some-code"}', + headers: { Accept: 'application/json', 'content-type': 'application/json' }, + }, + ]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.data).toEqual(fetchMockPayload); + expect(onSuccess).toHaveBeenCalledWith(fetchMockPayload); + expect(cleanup).toHaveBeenCalled(); + }); + }); + it('Should call onError with error message on authorization error', async () => { const onError = jest.fn(); diff --git a/src/components/use-oauth2.ts b/src/components/use-oauth2.ts index ea4cfb8..b52a517 100644 --- a/src/components/use-oauth2.ts +++ b/src/components/use-oauth2.ts @@ -28,23 +28,23 @@ export const useOAuth2 = (props: TOauth2Props) const extraQueryParametersRef = useRef(extraQueryParameters); const popupRef = useRef(); const intervalRef = useRef(); + const exchangeCodeForTokenQueryRef = useRef( + responseType === 'code' && props.exchangeCodeForTokenQuery + ); + const exchangeCodeForTokenQueryFnRef = useRef( + responseType === 'code' && props.exchangeCodeForTokenQueryFn + ); const [{ loading, error }, setUI] = useState<{ loading: boolean; error: string | null }>({ loading: false, error: null, }); - const [data, setData, { removeItem, isPersistent }] = useLocalStorageState( + const [data, setData, { removeItem, isPersistent }] = useLocalStorageState>( `${responseType}-${authorizeUrl}-${clientId}-${scope}`, { defaultValue: null, } ); - const exchangeCodeForTokenServerURL = - responseType === 'code' && props.exchangeCodeForTokenServerURL; - const exchangeCodeForTokenMethod = responseType === 'code' && props.exchangeCodeForTokenMethod; - const exchangeCodeForTokenHeaders = - responseType === 'code' && props.exchangeCodeForTokenHeaders; - const getAuth = useCallback(() => { // 1. Init setUI({ @@ -85,24 +85,39 @@ export const useOAuth2 = (props: TOauth2Props) if (onError) await onError(errorMessage); } else { let payload = message?.data?.payload; - if (responseType === 'code' && exchangeCodeForTokenServerURL) { - const response = await fetch( - formatExchangeCodeForTokenServerURL( - exchangeCodeForTokenServerURL, - clientId, - payload?.code, - redirectUri, - state - ), - { - method: - exchangeCodeForTokenMethod || - DEFAULT_EXCHANGE_CODE_FOR_TOKEN_METHOD, - headers: exchangeCodeForTokenHeaders || {}, - } - ); - payload = await response.json(); + + if (responseType === 'code') { + const exchangeCodeForTokenQueryFn = exchangeCodeForTokenQueryFnRef.current; + const exchangeCodeForTokenQuery = exchangeCodeForTokenQueryRef.current; + if ( + exchangeCodeForTokenQueryFn && + typeof exchangeCodeForTokenQueryFn === 'function' + ) { + payload = await exchangeCodeForTokenQueryFn(message.data?.payload); + } else if (exchangeCodeForTokenQuery) { + const response = await fetch( + formatExchangeCodeForTokenServerURL( + exchangeCodeForTokenQuery.url, + clientId, + payload?.code, + redirectUri, + state + ), + { + method: + exchangeCodeForTokenQuery.method ?? + DEFAULT_EXCHANGE_CODE_FOR_TOKEN_METHOD, + headers: exchangeCodeForTokenQuery.headers || {}, + } + ); + payload = await response.json(); + } else { + throw new Error( + 'useOAuth2: You must provide `exchangeCodeForTokenQuery` or `exchangeCodeForTokenQueryFn`' + ); + } } + setUI({ loading: false, error: null, @@ -150,9 +165,6 @@ export const useOAuth2 = (props: TOauth2Props) redirectUri, scope, responseType, - exchangeCodeForTokenServerURL, - exchangeCodeForTokenMethod, - exchangeCodeForTokenHeaders, onSuccess, onError, setUI,