├── .all-contributorsrc ├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .lintstagedrc ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples └── oauth-dropbox │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json │ ├── src │ ├── App.css │ ├── App.js │ ├── index.css │ ├── index.js │ └── logo.svg │ └── yarn.lock ├── jest.config.js ├── package.json ├── src ├── OauthReceiver │ ├── OauthReceiver.test.js │ └── index.js ├── OauthSender │ ├── OauthSender.test.js │ └── index.js ├── createOauthFlow │ └── index.js ├── index.js └── utils │ ├── fetch.js │ ├── index.js │ └── utils.test.js ├── tests ├── raf-polyfill.js ├── setup-framework.js └── setup.js └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "react-oauth-flow", 3 | "projectOwner": "adambrgmn", 4 | "files": [ 5 | "README.md" 6 | ], 7 | "imageSize": 100, 8 | "commit": false, 9 | "contributors": [ 10 | { 11 | "login": "adambrgmn", 12 | "name": "Adam Bergman", 13 | "avatar_url": "https://avatars1.githubusercontent.com/u/13746650?v=4", 14 | "profile": "http://fransvilhelm.com", 15 | "contributions": [ 16 | "code", 17 | "doc" 18 | ] 19 | }, 20 | { 21 | "login": "jwright", 22 | "name": "Jamie Wright", 23 | "avatar_url": "https://avatars2.githubusercontent.com/u/35017?v=4", 24 | "profile": "http://tatsu.io", 25 | "contributions": [ 26 | "code", 27 | "doc" 28 | ] 29 | } 30 | ], 31 | "repoType": "github" 32 | } 33 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react"], 3 | "plugins": ["transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /dist -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["airbnb", "prettier"], 4 | "env": { 5 | "browser": true, 6 | "node": true, 7 | "commonjs": true, 8 | "shared-node-browser": true, 9 | "es6": true, 10 | "jest": true 11 | }, 12 | "rules": { 13 | "import/prefer-default-export": "off", 14 | "react/jsx-filename-extension": "off", 15 | "react/sort-comp": "off", 16 | "react/jsx-one-expression-per-line": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /dist 11 | 12 | # misc 13 | .env.local 14 | .opt-in 15 | .eslintcache 16 | .DS_Store 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Editor 23 | .vscode 24 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "concurrent": false, 3 | "linters": { 4 | "README.md": ["doctoc --maxlevel 3 --notitle", "git add"], 5 | ".all-contributorsrc": ["all-contributors generate", "git add README.md"], 6 | "*.js": [ 7 | "eslint --fix", 8 | "git add", 9 | "jest --findRelatedTests --passWithNoTests" 10 | ], 11 | "*.{js,json,md}": ["prettier --write", "git add"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false 10 | } 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | cache: yarn 4 | 5 | notifications: 6 | email: false 7 | 8 | node_js: 9 | - '9' 10 | - '8' 11 | 12 | script: yarn test --coverage && yarn build 13 | 14 | deploy: 15 | provider: script 16 | skip_cleanup: true 17 | script: 18 | - npx travis-deploy-once "npx semantic-release" 19 | 20 | branches: 21 | only: 22 | - master 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | The changelog is automatically updated using 4 | [semantic-release](https://github.com/semantic-release/semantic-release). You 5 | can see it on the [releases page](../../releases). 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Adam Bergman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React OAuth Flow 2 | 3 | ## Note to users 4 | 5 | > As the popularity of this module has grown (modestly) I've realized that all 6 | > OAuth implementations doesn't look the same. I was naive in thinking that there 7 | > was some form of standardized simple way of doing this. 8 | > 9 | > So just a heads up – during the winter and spring I will take another look at 10 | > this and hopefully come up with a library that has some sensible defaults 11 | > together with the ability to customize it to your needs. 12 | > 13 | > As of now I recommend anyone who wants some customization to run your own fork. 14 | > And if you have any ideas fow how to rewrite the api of this module – please 15 | > reach out! 16 | > 17 | > – Adam Bergman 18 | 19 | 20 | 21 | 22 | 23 | * [What is `react-oauth-flow`](#what-is-react-oauth-flow) 24 | * [Installation](#installation) 25 | * [Requirements](#requirements) 26 | * [Usage](#usage) 27 | * [``](#oauthsender-) 28 | * [``](#oauthreceiver-) 29 | * [`createOauthFlow`](#createoauthflow) 30 | * [License](#license) 31 | * [Contributors](#contributors) 32 | 33 | 34 | 35 | ## What is `react-oauth-flow` 36 | 37 | `react-oauth-flow` is a small library to simplify the use of OAuth2 38 | authentication inside your react applications. 39 | 40 | It will bring you a simple component to generate the necessary link to send your 41 | users to the correct location and it will give you a component to perform the 42 | authorization process once the user is back on your site. 43 | 44 | ## Installation 45 | 46 | ```sh 47 | npm install react-oauth-flow 48 | yarn add react-oauth-flow 49 | ``` 50 | 51 | There is also a umd-build available for usage directly inside a browser, via 52 | `https://unkpg.com/react-oauth-flow/dist/react-oauth-flow.umd.min.js`. 53 | 54 | ```html 55 | 56 | 60 | ``` 61 | 62 | ## Requirements 63 | 64 | `react-oauth-flow` requires 65 | [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to be 66 | available on the `window`-object. In modern browsers it's there by default. But 67 | for older browsers you might need to provide it yourself as a polyfill. 68 | 69 | If you are using 70 | [`create-react-app`](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#supported-language-features-and-polyfills) 71 | it's already included as a polyfill. Otherwise I recommend 72 | [`whatwg-fetch`](https://github.com/github/fetch) (which `create-react-app` also 73 | uses). 74 | 75 | ## Usage 76 | 77 | `react-oauth-flow` exports three functions: 78 | 79 | * [`OauthSender`](#oauthsender-) 80 | * [`OauthReceiver`](#oauthreceiver-) 81 | * [`createOauthFlow`](#createoauthflow) 82 | 83 | ### `` 84 | 85 | ```js 86 | import React, { Component } from 'react'; 87 | import { OauthSender } from 'react-oauth-flow'; 88 | 89 | export default class SendToDropbox extends Component { 90 | render() { 91 | return ( 92 | Connect to Dropbox} 98 | /> 99 | ); 100 | } 101 | } 102 | ``` 103 | 104 | Use `` to send your users to the correct endpoints at your OAuth2 105 | service. 106 | 107 | #### Props 108 | 109 | | Prop | Type | Required | Default | Description | 110 | | :------------- | :------- | :------- | :------ | :------------------------------------------------------------------------------------------------------------------ | 111 | | `authorizeUrl` | `string` | yes | - | The full url to the authorize endpoint, provided by the service | 112 | | `clientId` | `string` | yes | - | Your client id from the service provider (remember to keep it secret!) | 113 | | `redirectUri` | `string` | yes | - | The URL where the provider should redirect your users back | 114 | | `state` | `object` | no | - | Additional state to get back from the service provider [(read more below)](#state) | 115 | | `args` | `object` | no | - | Additional args to send to service provider, e.g. `scope`. Will be serialized by [qs](https://github.com/ljharb/qs) | 116 | 117 | #### Render 118 | 119 | `` can be used in three ways, either by a render-prop, 120 | children-function or component-prop. In either way they will recieve the 121 | generated `url` as a prop/arg. 122 | 123 | ```js 124 | const RenderProp = props => ( 125 | Connect} /> 126 | ); 127 | 128 | const ChildrenFunction = props => ( 129 | {({ url }) => Connect} 130 | ); 131 | 132 | const Link = ({ url }) => Connect; 133 | const ComponentProp = props => ; 134 | ``` 135 | 136 | #### State 137 | 138 | You can pass some state along with the auth process. This state will be sent 139 | back by the OAuth-provider once the process is done. This state can for example 140 | then be used to redirect the user back to where they started the auth process. 141 | 142 | ### `` 143 | 144 | ```js 145 | import React, { Component } from 'react'; 146 | import { OauthReceiver } from 'react-oauth-flow'; 147 | 148 | export default class ReceiveFromDropbox extends Component { 149 | handleSuccess = async (accessToken, { response, state }) => { 150 | console.log('Successfully authorized'); 151 | await setProfileFromDropbox(accessToken); 152 | await redirect(state.from); 153 | }; 154 | 155 | handleError = error => { 156 | console.error('An error occurred'); 157 | console.error(error.message); 158 | }; 159 | 160 | render() { 161 | return ( 162 | ( 170 |
171 | {processing &&

Authorizing now...

} 172 | {error && ( 173 |

An error occurred: {error.message}

174 | )} 175 |
176 | )} 177 | /> 178 | ); 179 | } 180 | } 181 | ``` 182 | 183 | Use `` to handle authorization when the user is being 184 | redirected from the OAuth2-provider. 185 | 186 | #### Props 187 | 188 | | Prop | Type | Required | Default | Description | 189 | | :--------------- | :------------------- | :------- | :------ | :-------------------------------------------------------------------------------------- | 190 | | `tokenUrl` | `string` | yes | - | The full url to the token endpoint, provided by the service | 191 | | `clientId` | `string` | yes | - | Your client id from the service provider (remember to keep it secret!) | 192 | | `clientSecret` | `string` | yes | - | Your client secret from the service provider (remember to keep it secret!) | 193 | | `redirectUri` | `string` | yes | - | The URL where the provider has redirected your user (used to verify auth) | 194 | | `args` | `object` | no | - | Args will be attatched to the request to the token endpoint. Will be serialized by `qz` | 195 | | `location` | `{ search: string }` | no | - | Used to extract info from querystring [(read more below)](#location-and-querystring) | 196 | | `querystring` | `string` | no | - | Used to extract info from querystring [(read more below)](#location-and-querystring) | 197 | | `tokenFetchArgs` | `object` | no | `{}` | Used to fetch the token endpoint [(read more below)](#tokenfetchargs) | 198 | | `tokenFn` | `func` | no | `null` | Used to bypass default fetch function to fetch the token [(read more below)](#tokenfn) | 199 | 200 | 201 | #### Events 202 | 203 | * `onAuthSuccess(accessToken, result)` 204 | 205 | | Arg | Type | Description | 206 | | :---------------- | :------- | :----------------------------------------------------------- | 207 | | `accessToken` | `string` | Access token recieved from OAuth2 provider | 208 | | `result` | `object` | | 209 | | `result.response` | `object` | The full response from the call to the token-endpoint | 210 | | `result.state` | `object` | The state recieved from provider, if it was provided earlier | 211 | 212 | * `onAuthError(error)` 213 | 214 | | Arg | Type | Description | 215 | | :------ | :------ | :------------------------------------------------- | 216 | | `error` | `Error` | Error with message as description of what happened | 217 | 218 | #### Render 219 | 220 | `` can be used in three ways, either by a render-prop, 221 | children-function or component-prop. Either way they will recieve three 222 | props/args: 223 | 224 | * `processing: boolean`: True if authorization is in progress 225 | * `state: object`: The state received from provider (might be null) 226 | * `error: Error`: An error object if an error occurred 227 | 228 | ```js 229 | const RenderProp = props => ( 230 | ( 233 |
234 | {processing &&

Authorization in progress

} 235 | {state &&

Will redirect you to {state.from}

} 236 | {error &&

Error: {error.message}

} 237 |
238 | )} 239 | /> 240 | ); 241 | 242 | const ChildrenFunction = props => ( 243 | 244 | {({ processing, state, error }) => ( 245 |
246 | {processing &&

Authorization in progress

} 247 | {state &&

Will redirect you to {state.from}

} 248 | {error &&

Error: {error.message}

} 249 |
250 | )} 251 |
252 | ); 253 | 254 | const View = ({ processing, state, error }) => ( 255 |
256 | {processing &&

Authorization in progress

} 257 | {state &&

Will redirect you to {state.from}

} 258 | {error &&

Error: {error.message}

} 259 |
260 | ); 261 | const ComponentProp = props => ; 262 | ``` 263 | 264 | #### `location` and `querystring` 265 | 266 | The props `location` and `querystring` actually do the same thing but both can 267 | be ommitted. But what they do is still important. When the OAuth2-provider 268 | redirects your users back to your app they do so with a querystring attatched to 269 | the call. `` parses this string to extract information that it 270 | needs to request an access token. 271 | 272 | `location` is especially useful if you're using 273 | [`react-router`](https://github.com/ReactTraining/react-router). Because it 274 | provides you with a `location`-prop with all the information that 275 | `` needs. 276 | 277 | `querystring` can be used if you want some control over the process, but 278 | basically it's `window.location.search`. So if it is not provided 279 | `` will fetch the information from `window.location.search`. 280 | 281 | #### `tokenFetchArgs` 282 | 283 | The prop `tokenFetchArgs` can be used to change how the token is received from 284 | the service. For example, the token service for Facebook requires a `GET` 285 | request but the token service for Dropbox requires a `POST` request. You can 286 | change `tokenFetchArgs` to make this necessary change. 287 | 288 | The following are the default fetch args used to fetch the token but they can be 289 | merged and overriden with the `tokenFetchArgs`: 290 | 291 | ``` 292 | { method: 'GET', headers: { 'Content-Type': 'application/json' }} 293 | ``` 294 | 295 | #### `tokenFn` 296 | 297 | The prop `tokenFn` can be used to change how the token is fetched and received from 298 | the service. It's a way to bypass the default fetch all together and use your own. 299 | For example, if your `access-token` comes in the headers instead of the response body 300 | you will have to use your own fetch function to get those. Or perhaps you already 301 | have a custom built fetch function that communicates with your backend and you want 302 | to make use of it. 303 | 304 | Your function will receive the `url` from the OauthReceiver, it takes the 305 | `tokenUrl` and builds it up with all the other needed parameters so you don't have to. 306 | It will also receive the `tokenFetchArgs` parameter just in case you need it. if you don't, 307 | just ignore it. 308 | 309 | ### `createOauthFlow` 310 | 311 | ```js 312 | import { createOauthFlow } from 'react-oauth-flow'; 313 | 314 | const { Sender, Receiver } = createOauthFlow({ 315 | authorizeUrl: 'https://www.dropbox.com/oauth2/authorize', 316 | tokenUrl: 'https://api.dropbox.com/oauth2/token', 317 | clientId: process.env.CLIENT_ID, 318 | clientSecret: process.env.CLIENT_SECRET, 319 | redirectUri: 'https://www.yourapp.com/auth/dropbox', 320 | }); 321 | 322 | export { Sender, Receiver }; 323 | ``` 324 | 325 | `createOauthFlow` is a shorthand to create instances of both `` 326 | and `` with equal settings to keep things DRY. 327 | 328 | These instances can then be used as described above. All arguments can also be 329 | overridden when you use the created components. 330 | 331 | #### Args 332 | 333 | | Arg | Type | Required | Default | Description | 334 | | :--------------------- | :------- | :------- | :------ | :------------------------------------------------------------------------- | 335 | | `options` | `object` | yes | - | Options object | 336 | | `options.authorizeUrl` | `string` | yes | - | The full url to the authorize endpoint, provided by the service | 337 | | `options.tokenUrl` | `string` | yes | - | The full url to the token endpoint, provided by the service | 338 | | `options.clientId` | `string` | yes | - | Your client id from the service provider (remember to keep it secret!) | 339 | | `options.clientSecret` | `string` | yes | - | Your client secret from the service provider (remember to keep it secret!) | 340 | | `options.redirectUri` | `string` | yes | - | The URL where the provider should redirect your users back | 341 | 342 | ## License 343 | 344 | MIT 345 | 346 | ## Contributors 347 | 348 | 349 | 350 | | [
Adam Bergman](http://fransvilhelm.com)
[💻](https://github.com/adambrgmn/react-oauth-flow/commits?author=adambrgmn "Code") [📖](https://github.com/adambrgmn/react-oauth-flow/commits?author=adambrgmn "Documentation") | [
Jamie Wright](http://tatsu.io)
[💻](https://github.com/adambrgmn/react-oauth-flow/commits?author=jwright "Code") [📖](https://github.com/adambrgmn/react-oauth-flow/commits?author=jwright "Documentation") | 351 | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | 352 | 353 | -------------------------------------------------------------------------------- /examples/oauth-dropbox/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oauth-dropbox", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^16.2.0", 7 | "react-dom": "^16.2.0", 8 | "react-router-dom": "^4.2.2", 9 | "react-scripts": "1.0.17" 10 | }, 11 | "scripts": { 12 | "start": "react-scripts start", 13 | "build": "react-scripts build", 14 | "test": "react-scripts test --env=jsdom", 15 | "eject": "react-scripts eject" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/oauth-dropbox/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adambrgmn/react-oauth-flow/89f224b8a0ec5ce03b42110ad331c025e6220180/examples/oauth-dropbox/public/favicon.ico -------------------------------------------------------------------------------- /examples/oauth-dropbox/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /examples/oauth-dropbox/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /examples/oauth-dropbox/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .App-header { 11 | background-color: #222; 12 | height: 150px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .App-title { 18 | font-size: 1.5em; 19 | } 20 | 21 | .App-intro { 22 | font-size: large; 23 | } 24 | 25 | @keyframes App-logo-spin { 26 | from { 27 | transform: rotate(0deg); 28 | } 29 | to { 30 | transform: rotate(360deg); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/oauth-dropbox/src/App.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React, { Component } from 'react'; 3 | import { BrowserRouter, Route, Redirect } from 'react-router-dom'; // eslint-disable-line 4 | import { createOauthFlow } from 'react-oauth-flow'; // eslint-disable-line 5 | import logo from './logo.svg'; 6 | import './App.css'; 7 | 8 | const { Sender, Receiver } = createOauthFlow({ 9 | authorizeUrl: 'https://www.dropbox.com/oauth2/authorize', 10 | tokenUrl: 'https://api.dropboxapi.com/oauth2/token', 11 | clientId: process.env.REACT_APP_DB_KEY, 12 | clientSecret: process.env.REACT_APP_DB_SECRET, 13 | redirectUri: 'http://localhost:3000/auth/dropbox', 14 | }); 15 | 16 | class App extends Component { 17 | handleSuccess = (accessToken, { response, state }) => { 18 | console.log('Success!'); 19 | console.log('AccessToken: ', accessToken); 20 | console.log('Response: ', response); 21 | console.log('State: ', state); 22 | }; 23 | 24 | handleError = async error => { 25 | console.error('Error: ', error.message); 26 | 27 | const text = await error.response.text(); 28 | console.log(text); 29 | }; 30 | 31 | render() { 32 | return ( 33 | 34 |
35 |
36 | logo 37 |

Welcome to React

38 |
39 | 40 | ( 44 |
45 | Connect to Dropbox} 48 | /> 49 |
50 | )} 51 | /> 52 | 53 | ( 57 | { 62 | if (processing) { 63 | return

Processing!

; 64 | } 65 | 66 | if (error) { 67 | return

{error.message}

; 68 | } 69 | 70 | return ; 71 | }} 72 | /> 73 | )} 74 | /> 75 | 76 |
Successfully authorized Dropbox!
} 80 | /> 81 |
82 |
83 | ); 84 | } 85 | } 86 | 87 | export default App; 88 | -------------------------------------------------------------------------------- /examples/oauth-dropbox/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /examples/oauth-dropbox/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | -------------------------------------------------------------------------------- /examples/oauth-dropbox/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFiles: ['/tests/setup.js'], 3 | setupTestFrameworkScriptFile: '/tests/setup-framework.js', 4 | collectCoverageFrom: [ 5 | 'src/**/*.{js,jsx}', 6 | '!**/node_modules/**', 7 | ], 8 | coverageThreshold: { 9 | global: { 10 | branches: 30, 11 | functions: 30, 12 | lines: 30, 13 | statements: 30, 14 | }, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-oauth-flow", 3 | "version": "0.0.0-development", 4 | "repository": "git@github.com:adambrgmn/react-oauth-flow.git", 5 | "author": "Adam Bergman ", 6 | "license": "MIT", 7 | "main": "dist/react-oauth-flow.js", 8 | "umd:main": "dist/react-oauth-flow.umd.js", 9 | "module": "dist/react-oauth-flow.m.js", 10 | "source": "src/index.js", 11 | "scripts": { 12 | "build": "microbundle", 13 | "test": "jest", 14 | "lint": "eslint", 15 | "format": "prettier", 16 | "contributors": "all-contributors" 17 | }, 18 | "devDependencies": { 19 | "all-contributors-cli": "^5.4.0", 20 | "babel-eslint": "^8.2.6", 21 | "babel-jest": "^23.4.2", 22 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 23 | "babel-preset-env": "^1.7.0", 24 | "babel-preset-react": "^6.24.1", 25 | "cz-conventional-changelog": "^2.1.0", 26 | "doctoc": "^1.3.1", 27 | "eslint": "^5.3.0", 28 | "eslint-config-airbnb": "^17.0.0", 29 | "eslint-config-prettier": "^3.0.1", 30 | "eslint-plugin-import": "^2.14.0", 31 | "eslint-plugin-jsx-a11y": "^6.1.1", 32 | "eslint-plugin-react": "^7.10.0", 33 | "husky": "^1.0.0-rc.13", 34 | "isomorphic-fetch": "^2.2.1", 35 | "jest": "^23.5.0", 36 | "jest-dom": "^1.12.0", 37 | "lint-staged": "^7.2.2", 38 | "microbundle": "^0.6.0", 39 | "prettier": "^1.14.2", 40 | "prop-types": "^15.6.0", 41 | "react": "^16.1.1", 42 | "react-dom": "^16.1.1", 43 | "react-testing-library": "^5.0.0" 44 | }, 45 | "dependencies": { 46 | "qs": "^6.5.1" 47 | }, 48 | "peerDependencies": { 49 | "prop-types": ">=15", 50 | "react": ">=15" 51 | }, 52 | "config": { 53 | "commitizen": { 54 | "path": "./node_modules/cz-conventional-changelog" 55 | } 56 | }, 57 | "hooks": { 58 | "pre-commit": "lint-staged" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/OauthReceiver/OauthReceiver.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cleanup, render, waitForElement } from 'react-testing-library'; 3 | import qs from 'qs'; 4 | import { OauthReceiver } from './index'; 5 | import { fetch2 } from '../utils/fetch'; 6 | 7 | jest.mock('../utils/fetch.js', () => ({ 8 | fetch2: jest.fn(() => Promise.resolve({ access_token: 'foo' })), 9 | })); 10 | 11 | afterEach(cleanup); 12 | 13 | describe('Component ', () => { 14 | test('with default fetch args', async () => { 15 | const onAuthSuccess = jest.fn(); 16 | const onAuthError = jest.fn(); 17 | 18 | const props = { 19 | tokenUrl: 'https://api.service.com/oauth2/token', 20 | clientId: 'abc', 21 | clientSecret: 'abcdef', 22 | redirectUri: 'https://www.test.com/redirect', 23 | querystring: `?${qs.stringify({ 24 | code: 'abc', 25 | state: JSON.stringify({ from: '/success' }), 26 | })}`, 27 | onAuthSuccess, 28 | onAuthError, 29 | }; 30 | 31 | const { getByTestId } = render( 32 | ( 35 |
36 | {processing && done} 37 | {state && state.from} 38 |
39 | )} 40 | />, 41 | ); 42 | 43 | await waitForElement(() => getByTestId('done')); 44 | 45 | expect(onAuthSuccess).toHaveBeenCalledTimes(1); 46 | expect(onAuthError).not.toHaveBeenCalled(); 47 | 48 | expect(getByTestId('state')).toHaveTextContent('/success'); 49 | }); 50 | 51 | test('with custom token uri fetch args', async () => { 52 | fetch2.mockClear(); 53 | 54 | const props = { 55 | tokenUrl: 'https://api.service.com/oauth2/token', 56 | tokenFetchArgs: { 57 | cache: 'no-cache', 58 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 59 | }, 60 | clientId: 'abc', 61 | clientSecret: 'abcdef', 62 | redirectUri: 'https://www.test.com/redirect', 63 | querystring: `?${qs.stringify({ 64 | code: 'abc', 65 | state: JSON.stringify({ from: '/settings' }), 66 | })}`, 67 | }; 68 | 69 | const { getByTestId } = render( 70 | ( 73 |
{processing && done}
74 | )} 75 | />, 76 | ); 77 | 78 | await waitForElement(() => getByTestId('done')); 79 | 80 | expect(fetch2).toHaveBeenCalledWith( 81 | expect.stringContaining(props.tokenUrl), 82 | expect.objectContaining({ 83 | method: expect.stringMatching('POST'), 84 | cache: expect.stringMatching('no-cache'), 85 | headers: expect.objectContaining({ 86 | 'Content-Type': expect.stringMatching( 87 | 'application/x-www-form-urlencoded', 88 | ), 89 | }), 90 | }), 91 | ); 92 | }); 93 | test('with custom fetch function, default args', async () => { 94 | const onAuthSuccess = jest.fn(); 95 | const onAuthError = jest.fn(); 96 | 97 | const props = { 98 | tokenUrl: 'https://api.service.com/oauth2/token', 99 | tokenFn: fetch2, 100 | clientId: 'abc', 101 | clientSecret: 'abcdef', 102 | redirectUri: 'https://www.test.com/redirect', 103 | querystring: `?${qs.stringify({ 104 | code: 'abc', 105 | state: JSON.stringify({ from: '/success' }), 106 | })}`, 107 | onAuthSuccess, 108 | onAuthError, 109 | }; 110 | 111 | const { getByTestId } = render( 112 | ( 115 |
116 | {processing && done} 117 | {state && state.from} 118 |
119 | )} 120 | />, 121 | ); 122 | 123 | await waitForElement(() => getByTestId('done')); 124 | 125 | expect(onAuthSuccess).toHaveBeenCalledTimes(1); 126 | expect(onAuthError).not.toHaveBeenCalled(); 127 | 128 | expect(getByTestId('state')).toHaveTextContent('/success'); 129 | }); 130 | 131 | test('with custom token function and token uri fetch args', async () => { 132 | fetch2.mockClear(); 133 | 134 | const props = { 135 | tokenUrl: 'https://api.service.com/oauth2/token', 136 | tokenFn: fetch2, 137 | tokenFetchArgs: { 138 | cache: 'no-cache', 139 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 140 | }, 141 | clientId: 'abc', 142 | clientSecret: 'abcdef', 143 | redirectUri: 'https://www.test.com/redirect', 144 | querystring: `?${qs.stringify({ 145 | code: 'abc', 146 | state: JSON.stringify({ from: '/settings' }), 147 | })}`, 148 | }; 149 | 150 | const { getByTestId } = render( 151 | ( 154 |
{processing && done}
155 | )} 156 | />, 157 | ); 158 | 159 | await waitForElement(() => getByTestId('done')); 160 | 161 | expect(fetch2).toHaveBeenCalledWith( 162 | expect.stringContaining(props.tokenUrl), 163 | expect.objectContaining({ 164 | method: expect.stringMatching('POST'), 165 | cache: expect.stringMatching('no-cache'), 166 | headers: expect.objectContaining({ 167 | 'Content-Type': expect.stringMatching( 168 | 'application/x-www-form-urlencoded', 169 | ), 170 | }), 171 | }), 172 | ); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /src/OauthReceiver/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import qs from 'qs'; 5 | import { buildURL, fetch2 } from '../utils'; 6 | 7 | class OauthReceiver extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | this.state = { 12 | processing: true, 13 | state: null, 14 | error: null, 15 | }; 16 | 17 | this.getAuthorizationCode = this.getAuthorizationCode.bind(this); 18 | this.handleError = this.handleError.bind(this); 19 | this.parseQuerystring = this.parseQuerystring.bind(this); 20 | } 21 | 22 | componentDidMount() { 23 | this.getAuthorizationCode(); 24 | } 25 | 26 | getAuthorizationCode() { 27 | try { 28 | const { 29 | tokenUrl, 30 | tokenFetchArgs, 31 | clientId, 32 | clientSecret, 33 | redirectUri, 34 | args, 35 | tokenFn, 36 | onAuthSuccess, 37 | } = this.props; 38 | 39 | const queryResult = this.parseQuerystring(); 40 | const { error, error_description: errorDescription, code } = queryResult; 41 | const state = JSON.parse(queryResult.state || null); 42 | if (state) { 43 | this.setState(() => ({ state })); 44 | } 45 | 46 | if (error != null) { 47 | const err = new Error(errorDescription); 48 | throw err; 49 | } 50 | 51 | const url = buildURL(`${tokenUrl}`, { 52 | code, 53 | grant_type: 'authorization_code', 54 | client_id: clientId, 55 | client_secret: clientSecret, 56 | redirect_uri: redirectUri, 57 | ...args, 58 | }); 59 | 60 | const headers = new Headers({ 'Content-Type': 'application/json' }); 61 | const defaultFetchArgs = { method: 'POST', headers }; 62 | const fetchArgs = Object.assign(defaultFetchArgs, tokenFetchArgs); 63 | 64 | (typeof tokenFn === 'function' ? 65 | tokenFn(url, fetchArgs) : 66 | fetch2(url, fetchArgs) 67 | ).then(response => { 68 | const accessToken = response.access_token; 69 | 70 | if (typeof onAuthSuccess === 'function') { 71 | onAuthSuccess(accessToken, { response, state }); 72 | } 73 | 74 | this.setState(() => ({ processing: false })); 75 | }) 76 | .catch(err => { 77 | this.handleError(err); 78 | this.setState(() => ({ processing: false })); 79 | }); 80 | } catch (error) { 81 | this.handleError(error); 82 | this.setState(() => ({ processing: false })); 83 | } 84 | } 85 | 86 | handleError(error) { 87 | const { onAuthError } = this.props; 88 | 89 | this.setState(() => ({ error })); 90 | if (typeof onAuthError === 'function') { 91 | onAuthError(error); 92 | } 93 | } 94 | 95 | parseQuerystring() { 96 | const { location, querystring } = this.props; 97 | let search; 98 | 99 | if (location != null) { 100 | search = location.search; // eslint-disable-line 101 | } else if (querystring != null) { 102 | search = querystring; 103 | } else { 104 | search = window.location.search; // eslint-disable-line 105 | } 106 | 107 | return qs.parse(search, { ignoreQueryPrefix: true }); 108 | } 109 | 110 | render() { 111 | const { component, render, children } = this.props; 112 | const { processing, state, error } = this.state; 113 | 114 | if (component != null) { 115 | return React.createElement(component, { processing, state, error }); 116 | } 117 | 118 | if (render != null) { 119 | return render({ processing, state, error }); 120 | } 121 | 122 | if (children != null) { 123 | React.Children.only(children); 124 | return children({ processing, state, error }); 125 | } 126 | 127 | return null; 128 | } 129 | } 130 | 131 | OauthReceiver.propTypes = { 132 | tokenUrl: PropTypes.string.isRequired, 133 | clientId: PropTypes.string.isRequired, 134 | clientSecret: PropTypes.string.isRequired, 135 | redirectUri: PropTypes.string.isRequired, 136 | args: PropTypes.objectOf( 137 | PropTypes.oneOfType([ 138 | PropTypes.string, 139 | PropTypes.number, 140 | PropTypes.bool, 141 | PropTypes.object, 142 | ]), 143 | ), 144 | location: PropTypes.shape({ search: PropTypes.string.isRequired }), 145 | querystring: PropTypes.string, 146 | tokenFn: PropTypes.func, 147 | onAuthSuccess: PropTypes.func, 148 | onAuthError: PropTypes.func, 149 | render: PropTypes.func, 150 | tokenFetchArgs: PropTypes.shape({ 151 | method: PropTypes.string, 152 | }), 153 | component: PropTypes.element, 154 | children: PropTypes.func, 155 | }; 156 | 157 | OauthReceiver.defaultProps = { 158 | args: {}, 159 | location: null, 160 | querystring: null, 161 | tokenFn: null, 162 | onAuthSuccess: null, 163 | onAuthError: null, 164 | render: null, 165 | tokenFetchArgs: {}, 166 | component: null, 167 | children: null, 168 | }; 169 | 170 | export { OauthReceiver }; 171 | -------------------------------------------------------------------------------- /src/OauthSender/OauthSender.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React from 'react'; 3 | import { render, cleanup } from 'react-testing-library'; 4 | import { OauthSender } from './index'; 5 | import { buildURL } from '../utils'; 6 | 7 | afterEach(cleanup); 8 | 9 | test('Component: ', () => { 10 | const props = { 11 | authorizeUrl: 'https://www.service.com/oauth2/authorize', 12 | clientId: 'abc', 13 | redirectUri: 'https://www.test.com/redirect', 14 | render: ({ url }) => ( 15 | 16 | Connect 17 | 18 | ), 19 | }; 20 | 21 | const { getByTestId } = render(); 22 | 23 | const expectedUrl = buildURL(props.authorizeUrl, { 24 | client_id: props.clientId, 25 | redirect_uri: props.redirectUri, 26 | response_type: 'code', 27 | }); 28 | 29 | expect(getByTestId('link')).toHaveAttribute('href', expectedUrl); 30 | }); 31 | -------------------------------------------------------------------------------- /src/OauthSender/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { buildURL } from '../utils'; 5 | 6 | class OauthSender extends React.Component { 7 | render() { 8 | const { 9 | authorizeUrl, 10 | clientId, 11 | redirectUri, 12 | state, 13 | args, 14 | render, 15 | component, 16 | children, 17 | } = this.props; 18 | 19 | const url = buildURL(`${authorizeUrl}`, { 20 | client_id: clientId, 21 | redirect_uri: redirectUri, 22 | response_type: 'code', 23 | state: state ? JSON.stringify(state) : undefined, 24 | ...(args || {}), 25 | }); 26 | 27 | if (component != null) { 28 | return React.createElement(component, { url }); 29 | } 30 | 31 | if (render != null) { 32 | return render({ url }); 33 | } 34 | 35 | if (children != null) { 36 | React.Children.only(children); 37 | return children({ url }); 38 | } 39 | 40 | return null; 41 | } 42 | } 43 | 44 | OauthSender.propTypes = { 45 | authorizeUrl: PropTypes.string.isRequired, 46 | clientId: PropTypes.string.isRequired, 47 | redirectUri: PropTypes.string.isRequired, 48 | state: PropTypes.objectOf( 49 | PropTypes.oneOfType([ 50 | PropTypes.string, 51 | PropTypes.number, 52 | PropTypes.bool, 53 | PropTypes.object, 54 | ]), 55 | ), 56 | args: PropTypes.objectOf( 57 | PropTypes.oneOfType([ 58 | PropTypes.string, 59 | PropTypes.number, 60 | PropTypes.bool, 61 | PropTypes.object, 62 | ]), 63 | ), 64 | render: PropTypes.func, 65 | component: PropTypes.element, 66 | children: PropTypes.func, 67 | }; 68 | 69 | OauthSender.defaultProps = { 70 | state: null, 71 | args: null, 72 | render: null, 73 | component: null, 74 | children: null, 75 | }; 76 | 77 | export { OauthSender }; 78 | -------------------------------------------------------------------------------- /src/createOauthFlow/index.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { OauthReceiver } from '../OauthReceiver'; 3 | import { OauthSender } from '../OauthSender'; 4 | 5 | export function createOauthFlow( 6 | { authorizeUrl, tokenUrl, clientId, clientSecret, redirectUri, appName } = {}, 7 | ) { 8 | const Sender = props => ( 9 | 15 | ); 16 | 17 | const Receiver = props => ( 18 | 26 | ); 27 | 28 | return { 29 | Sender, 30 | Receiver, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { createOauthFlow } from './createOauthFlow'; 2 | import { OauthSender } from './OauthSender'; 3 | import { OauthReceiver } from './OauthReceiver'; 4 | 5 | export { createOauthFlow, OauthSender, OauthReceiver }; 6 | -------------------------------------------------------------------------------- /src/utils/fetch.js: -------------------------------------------------------------------------------- 1 | const defineStaticProp = (obj, key, value) => { 2 | Object.defineProperty(obj, key, { 3 | enumerable: false, 4 | configurable: false, 5 | writable: false, 6 | value, 7 | }); 8 | 9 | return obj; 10 | }; 11 | 12 | export function fetch2(url, opts) { 13 | const request = fetch(url, opts); 14 | return request 15 | .then(response => { 16 | if (!response.ok) throw response; 17 | return response.json(); 18 | }) 19 | .catch(err => 20 | err.json().then(errJSON => { 21 | const error = new Error(err.statusText); 22 | defineStaticProp(error, 'response', err); 23 | defineStaticProp(error.response, 'data', errJSON); 24 | defineStaticProp(error, 'request', request); 25 | 26 | throw error; 27 | }), 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | import qs from 'qs'; 2 | import { fetch2 } from './fetch'; 3 | 4 | function buildURL(url, params) { 5 | if (params == null) return url; 6 | 7 | const serializedParams = qs.stringify(params) 8 | if (!serializedParams) return url; 9 | 10 | return `${url}${url.indexOf('?') < 0 ? '?' : '&'}${serializedParams}`; 11 | } 12 | 13 | export { buildURL, fetch2 }; 14 | -------------------------------------------------------------------------------- /src/utils/utils.test.js: -------------------------------------------------------------------------------- 1 | import * as utils from './index'; 2 | 3 | test('utils.buildURL', () => { 4 | const baseUrl = 'https://www.test.com'; 5 | 6 | expect(utils.buildURL(baseUrl, { 7 | a: 'hello', 8 | b: 'world', 9 | })).toEqual(`${baseUrl}?a=hello&b=world`); 10 | 11 | expect(utils.buildURL(baseUrl)).toBe(baseUrl); 12 | expect(utils.buildURL(`${baseUrl}?a=hello`, { b: 'world' })).toBe(`${baseUrl}?a=hello&b=world`); 13 | expect(utils.buildURL(baseUrl, {})).toBe(baseUrl); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/raf-polyfill.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * requestAnimationFrame polyfill from https://gist.github.com/paulirish/1579671 4 | * http://paulirish.com/2011/requestanimationframe-for-smart-animating/ 5 | * http://my.opera.com/emoller/blog/2011/12/20/requestanimationframe-for-smart-er-animating 6 | * requestAnimationFrame polyfill by Erik Möller. fixes from Paul Irish and Tino Zijdel 7 | * MIT license 8 | */ 9 | 10 | var lastTime = 0; 11 | var vendors = ['ms', 'moz', 'webkit', 'o']; 12 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 13 | window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame']; 14 | window.cancelAnimationFrame = 15 | window[vendors[x] + 'CancelAnimationFrame'] || 16 | window[vendors[x] + 'CancelRequestAnimationFrame']; 17 | } 18 | 19 | if (!window.requestAnimationFrame) { 20 | window.requestAnimationFrame = function(callback, element) { 21 | var currTime = new Date().getTime(); 22 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 23 | var id = window.setTimeout(function() { 24 | // eslint-disable-next-line consumerweb/no-callback-literal 25 | callback(currTime + timeToCall); 26 | }, timeToCall); 27 | lastTime = currTime + timeToCall; 28 | return id; 29 | }; 30 | global.requestAnimationFrame = window.requestAnimationFrame; 31 | } 32 | 33 | if (!window.cancelAnimationFrame) { 34 | window.cancelAnimationFrame = function(id) { 35 | clearTimeout(id); 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /tests/setup-framework.js: -------------------------------------------------------------------------------- 1 | import 'jest-dom/extend-expect'; 2 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | import './raf-polyfill'; 2 | import 'isomorphic-fetch'; 3 | --------------------------------------------------------------------------------