├── .travis.yml ├── src ├── react-app-env.d.ts ├── .eslintrc ├── AuthForward.tsx ├── index.tsx ├── util.ts ├── AuthProvider.tsx ├── typings.d.ts ├── pkce.ts ├── codeFromLocation.ts ├── AuthContext.tsx ├── AuthService.test.ts └── AuthService.ts ├── .eslintignore ├── example ├── src │ ├── react-app-env.d.ts │ ├── index.js │ ├── setupTest.ts │ ├── App.test.tsx │ ├── Routes.tsx │ ├── index.css │ ├── App.tsx │ └── Home.tsx ├── public │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── README.md ├── tsconfig.json └── package.json ├── tsconfig.test.json ├── .editorconfig ├── .prettierrc ├── .gitignore ├── .eslintrc ├── tsconfig.json ├── .github └── workflows │ ├── npm-publish.yml │ └── node.js.yml ├── README.md └── package.json /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | - 10 5 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | node_modules/ 4 | .snapshots/ 5 | *.min.js -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gardner/react-oauth2-pkce/HEAD/example/public/favicon.ico -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /src/AuthForward.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useAuth } from './AuthContext' 3 | 4 | export const AuthForward = () => { 5 | const authService = useAuth() 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import './index.css' 2 | 3 | import React from 'react' 4 | import ReactDOM from 'react-dom' 5 | import App from './App' 6 | 7 | ReactDOM.render(, document.getElementById('root')) 8 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export { AuthContext, withAuth, useAuth } from './AuthContext' 2 | export type { AuthContextProps } from './AuthContext' 3 | export { AuthProvider } from './AuthProvider' 4 | export { AuthService } from './AuthService' 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": false, 5 | "tabWidth": 2, 6 | "bracketSpacing": true, 7 | "jsxBracketSameLine": false, 8 | "arrowParens": "always", 9 | "trailingComma": "none" 10 | } 11 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This example was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | It is linked to the react-oauth2-pkce package in the parent directory for development purposes. 4 | 5 | You can run `npm install` and then `npm start` to test your package. 6 | -------------------------------------------------------------------------------- /example/src/setupTest.ts: -------------------------------------------------------------------------------- 1 | 2 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 3 | // allows you to do things like: 4 | // expect(element).toHaveTextContent(/react/i) 5 | // learn more: https://github.com/testing-library/jest-dom 6 | import '@testing-library/jest-dom/extend-expect'; 7 | -------------------------------------------------------------------------------- /example/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div') 7 | ReactDOM.render(, div) 8 | ReactDOM.unmountComponentAtNode(div) 9 | }) 10 | -------------------------------------------------------------------------------- /example/src/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter as Router, Route } from 'react-router-dom' 3 | 4 | import { Home } from './Home' 5 | 6 | export const Routes = () => { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-oauth2-pkce", 3 | "name": "react-oauth2-pkce", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export const toSnakeCase = (str: string): string => { 2 | return str 3 | .split(/(?=[A-Z])/) 4 | .join('_') 5 | .toLowerCase() 6 | } 7 | 8 | export const toUrlEncoded = (obj: {}): string => { 9 | return Object.keys(obj) 10 | .map( 11 | (k) => 12 | encodeURIComponent(toSnakeCase(k)) + '=' + encodeURIComponent(obj[k]) 13 | ) 14 | .join('&') 15 | } 16 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /src/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, ReactNode } from 'react' 2 | 3 | import { AuthService } from './AuthService' 4 | import { AuthContext } from './AuthContext' 5 | 6 | interface AuthProviderProps { 7 | children: ReactNode 8 | authService: AuthService 9 | } 10 | 11 | export const AuthProvider = (props: AuthProviderProps): ReactElement => { 12 | const { authService, children } = props 13 | 14 | return ( 15 | 16 | {children} 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Default CSS definition for typescript, 3 | * will be overridden with file-specific definitions by rollup 4 | */ 5 | declare module '*.css' { 6 | const content: { [className: string]: string } 7 | export default content 8 | } 9 | 10 | interface SvgrComponent 11 | extends React.StatelessComponent> {} 12 | 13 | declare module '*.svg' { 14 | const svgUrl: string 15 | const svgComponent: SvgrComponent 16 | export default svgUrl 17 | export { svgComponent as ReactComponent } 18 | } 19 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { AuthProvider, AuthService } from 'react-oauth2-pkce' 3 | 4 | import { Routes } from './Routes'; 5 | 6 | const authService = new AuthService({ 7 | clientId: process.env.REACT_APP_CLIENT_ID || 'CHANGEME', 8 | location: window.location, 9 | provider: process.env.REACT_APP_PROVIDER || 'https://sandbox.auth.ap-southeast-2.amazoncognito.com/oauth2', 10 | redirectUri: process.env.REACT_APP_REDIRECT_URI || window.location.origin, 11 | scopes: ['openid', 'profile'] 12 | }); 13 | 14 | const App = () => { 15 | return ( 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | export default App 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier/@typescript-eslint", 7 | "plugin:prettier/recommended" 8 | ], 9 | "env": { 10 | "node": true 11 | }, 12 | "parserOptions": { 13 | "ecmaVersion": 2020, 14 | "ecmaFeatures": { 15 | "legacyDecorators": true, 16 | "jsx": true 17 | } 18 | }, 19 | "settings": { 20 | "react": { 21 | "version": "16" 22 | } 23 | }, 24 | "rules": { 25 | "no-unused-vars": 0, 26 | "space-before-function-paren": 0, 27 | "react/prop-types": 0, 28 | "react/jsx-handler-names": 0, 29 | "react/jsx-fragments": 0, 30 | "react/no-unused-prop-types": 0, 31 | "import/export": 0 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /example/src/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useAuth } from 'react-oauth2-pkce' 3 | 4 | export const Home = () => { 5 | const { authService, authTokens } = useAuth() 6 | 7 | const login = async () => { 8 | authService.authorize() 9 | } 10 | const logout = async () => { 11 | authService.logout() 12 | } 13 | 14 | if (authService.isPending()) { 15 | return Loading... 16 | } 17 | 18 | if (!authService.isAuthenticated()) { 19 | return ( 20 | 21 | Not Logged in yet: {authTokens.idToken} 22 | Login 23 | 24 | ) 25 | } 26 | 27 | return ( 28 | 29 | Logged in! {authTokens.idToken} 30 | Logout 31 | 32 | ) 33 | } 34 | 35 | export default Home 36 | -------------------------------------------------------------------------------- /src/pkce.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes, createHash } from 'crypto' 2 | 3 | export type PKCECodePair = { 4 | codeVerifier: string 5 | codeChallenge: string 6 | createdAt: Date 7 | } 8 | 9 | export const base64URLEncode = (str: Buffer): string => { 10 | return str 11 | .toString('base64') 12 | .replace(/\+/g, '-') 13 | .replace(/\//g, '_') 14 | .replace(/=/g, '') 15 | } 16 | 17 | export const sha256 = (buffer: Buffer): Buffer => { 18 | return createHash('sha256').update(buffer).digest() 19 | } 20 | 21 | export const createPKCECodes = (): PKCECodePair => { 22 | const codeVerifier = base64URLEncode(randomBytes(64)) 23 | const codeChallenge = base64URLEncode(sha256(Buffer.from(codeVerifier))) 24 | const createdAt = new Date() 25 | const codePair = { 26 | codeVerifier, 27 | codeChallenge, 28 | createdAt 29 | } 30 | return codePair 31 | } 32 | -------------------------------------------------------------------------------- /src/codeFromLocation.ts: -------------------------------------------------------------------------------- 1 | export const getCodeFromLocation = (location: Location): string => { 2 | const split = location.toString().split('?') 3 | if (split.length < 2) { 4 | return '' 5 | } 6 | const pairs = split[1].split('&') 7 | for (const pair of pairs) { 8 | const [key, value] = pair.split('=') 9 | if (key === 'code') { 10 | return decodeURIComponent(value || '') 11 | } 12 | } 13 | return '' 14 | } 15 | 16 | export const removeCodeFromLocation = (location: Location): void => { 17 | const [base, search] = location.href.split('?') 18 | if (!search) { 19 | return 20 | } 21 | const newSearch = search 22 | .split('&') 23 | .map((param) => param.split('=')) 24 | .filter(([key]) => key !== 'code') 25 | .map((keyAndVal) => keyAndVal.join('=')) 26 | .join('&') 27 | window.history.replaceState( 28 | window.history.state, 29 | '', 30 | base + (newSearch.length ? `?${newSearch}` : '') 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "esnext", 5 | "lib": [ 6 | "dom", 7 | "esnext" 8 | ], 9 | "moduleResolution": "node", 10 | "jsx": "react", 11 | "sourceMap": true, 12 | "declaration": true, 13 | "esModuleInterop": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "allowSyntheticDefaultImports": true, 22 | "target": "es5", 23 | "allowJs": true, 24 | "skipLibCheck": true, 25 | "strict": true, 26 | "forceConsistentCasingInFileNames": true, 27 | "resolveJsonModule": true, 28 | "isolatedModules": true, 29 | "noEmit": true 30 | }, 31 | "include": [ 32 | "src" 33 | ], 34 | "exclude": [ 35 | "node_modules", 36 | "build" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "esnext", 5 | "lib": [ 6 | "dom", 7 | "esnext" 8 | ], 9 | "moduleResolution": "node", 10 | "jsx": "react", 11 | "sourceMap": true, 12 | "declaration": true, 13 | "esModuleInterop": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "allowSyntheticDefaultImports": true, 22 | "target": "es5", 23 | "allowJs": true, 24 | "skipLibCheck": true, 25 | "strict": true, 26 | "forceConsistentCasingInFileNames": true, 27 | "resolveJsonModule": true, 28 | "isolatedModules": true, 29 | "noEmit": true 30 | }, 31 | "include": [ 32 | "src" 33 | ], 34 | "exclude": [ 35 | "node_modules", 36 | "dist", 37 | "example" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | - run: npm ci 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-node@v1 27 | with: 28 | node-version: 12 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm ci 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 34 | 35 | -------------------------------------------------------------------------------- /src/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, ReactElement } from 'react' 2 | 3 | import { AuthServiceProps, AuthService } from './AuthService' 4 | 5 | export type AuthContextProps = { 6 | authService: AuthService 7 | } 8 | 9 | export type AuthContextType = AuthContextProps | undefined 10 | 11 | export const AuthContext = React.createContext( 12 | undefined 13 | ) 14 | 15 | export const useAuth = (): AuthContextProps => { 16 | const context = useContext(AuthContext) 17 | if (context === undefined) { 18 | throw new Error('useAuth must be used within a AuthProvider') 19 | } 20 | return context 21 | } 22 | 23 | export function withAuth( 24 | ComponentToWrap: React.ComponentType 25 | ): React.FC { 26 | const WrappedComponent = (props: T & AuthServiceProps): ReactElement => { 27 | const authProps = useAuth() 28 | return 29 | } 30 | WrappedComponent.displayName = 31 | 'withAuth_' + (ComponentToWrap.displayName || ComponentToWrap.name) 32 | return WrappedComponent 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run build --if-present 31 | - run: npm test 32 | 33 | publish: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v1 37 | - uses: actions/setup-node@v1 38 | with: 39 | node-version: 10 40 | - run: npm install 41 | - run: npm test 42 | - uses: JS-DevTools/npm-publish@v1 43 | with: 44 | token: ${{ secrets.NPM_TOKEN }} 45 | -------------------------------------------------------------------------------- /src/AuthService.test.ts: -------------------------------------------------------------------------------- 1 | import { AuthService, AuthTokens, AuthServiceProps } from './AuthService' 2 | 3 | // import tokens from './__fixtures__/tokens.json' 4 | 5 | const props: AuthServiceProps = { 6 | clientId: 'testClientID', 7 | clientSecret: undefined, 8 | location, 9 | contentType: undefined, 10 | provider: 'http://oauth2provider/', 11 | redirectUri: 'http://localhost/', 12 | scopes: ['openid', 'profile'] 13 | } 14 | 15 | const stubTokens: AuthTokens = { 16 | accessToken: 'accessToken', 17 | idToken: 'idToken', 18 | refreshToken: 'refreshToken', 19 | expiresIn: 3600, 20 | tokenType: 'Bearer' 21 | } 22 | 23 | // const stubToken = 24 | // '{"id_token":"id_token","access_token":"access_token","refresh_token":"refresh_token","expires_in":3600,"token_type":"Bearer"}' 25 | 26 | const authService = new AuthService(props) 27 | 28 | describe('AuthService', () => { 29 | it('is truthy', () => { 30 | expect(AuthService).toBeTruthy() 31 | }) 32 | 33 | it('should add requestId to headers', () => { 34 | const fakeFetch = jest.fn() 35 | window.fetch = fakeFetch 36 | const authorizationCode = 'authorizationCode' 37 | authService.fetchToken(authorizationCode).then((tokens) => { 38 | console.log(tokens) 39 | expect(fakeFetch.mock.calls[0][1]).toHaveProperty('headers') 40 | expect(fakeFetch.mock.calls[0][1].headers).toHaveProperty('requestId') 41 | }) 42 | }) 43 | 44 | // it('it parses a token', () => { 45 | // window.localStorage.setItem('auth', tokens) 46 | // authService.getUser() 47 | // }) 48 | }) 49 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-oauth2-pkce-example", 3 | "homepage": "https://gardner.github.io/react-oauth2-pkce", 4 | "version": "0.0.0", 5 | "private": true, 6 | "dependencies": { 7 | "@testing-library/jest-dom": "file:../node_modules/@testing-library/jest-dom", 8 | "@testing-library/react": "file:../node_modules/@testing-library/react", 9 | "@testing-library/user-event": "file:../node_modules/@testing-library/user-event", 10 | "@types/node": "file:../node_modules/@types/node", 11 | "@types/react": "file:../node_modules/@types/react", 12 | "@types/react-dom": "file:../node_modules/@types/react-dom", 13 | "@types/react-router": "^5.1.7", 14 | "@types/react-router-dom": "^5.1.5", 15 | "react": "file:../node_modules/react", 16 | "react-dom": "file:../node_modules/react-dom", 17 | "react-oauth2-pkce": "file:..", 18 | "react-router-dom": "^5.2.0", 19 | "react-scripts": "file:../node_modules/react-scripts", 20 | "typescript": "file:../node_modules/typescript" 21 | }, 22 | "scripts": { 23 | "start": "PORT=8100 node ../node_modules/react-scripts/bin/react-scripts.js start", 24 | "build": "node ../node_modules/react-scripts/bin/react-scripts.js build", 25 | "test": "node ../node_modules/react-scripts/bin/react-scripts.js test", 26 | "eject": "node ../node_modules/react-scripts/bin/react-scripts.js eject" 27 | }, 28 | "eslintConfig": { 29 | "extends": "react-app" 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.2%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-oauth2-pkce 2 | 3 | > Authenticate against generic OAuth2 using PKCE 4 | 5 | [](https://www.npmjs.com/package/react-oauth2-pkce) [](https://standardjs.com) 6 | 7 | ## Install 8 | 9 | ```bash 10 | npm install --save react-oauth2-pkce 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```tsx 16 | import React from 'react' 17 | import { AuthProvider, AuthService } from 'react-oauth2-pkce' 18 | 19 | import { Routes } from './Routes'; 20 | 21 | const authService = new AuthService({ 22 | clientId: process.env.REACT_APP_CLIENT_ID || 'CHANGEME', 23 | location: window.location, 24 | provider: process.env.REACT_APP_PROVIDER || 'https://sandbox.auth.ap-southeast-2.amazoncognito.com/oauth2', 25 | redirectUri: process.env.REACT_APP_REDIRECT_URI || window.location.origin, 26 | scopes: ['openid', 'profile'] 27 | }); 28 | 29 | const App = () => { 30 | return ( 31 | 32 | 33 | 34 | ) 35 | } 36 | 37 | export default App 38 | ``` 39 | 40 | ### Custom Provider/Endpoint 41 | 42 | After https://github.com/gardner/react-oauth2-pkce/pull/16 it is possible to pass in just `provider` or `authorizeEndpoint`, `tokenEndpoint` and `logoutEndpoint`. These two parameters were added to maintain backwards compatibility while enabling callers to customize the endpoint. 43 | 44 | ### End User Session on "Single Application Logout" 45 | You can end user session when calling `logout(true)`. A custom endpoint can configured by passing `logoutEndpoint` as props. The user will be redirected to the `redirectUri`. 46 | 47 | ## License 48 | 49 | MIT © [Gardner Bickford](https://github.com/gardner) 50 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 16 | 17 | 18 | 27 | react-oauth2-pkce 28 | 29 | 30 | 31 | 32 | You need to enable JavaScript to run this app. 33 | 34 | 35 | 36 | 37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-oauth2-pkce", 3 | "version": "2.0.7", 4 | "description": "Authenticate against generic OAuth2 using PKCE", 5 | "author": "Gardner Bickford ", 6 | "license": "MIT", 7 | "repository": "gardner/react-oauth2-pkce", 8 | "main": "dist/index.js", 9 | "module": "dist/index.modern.js", 10 | "source": "src/index.tsx", 11 | "keywords": [ 12 | "oauth2", 13 | "pkce", 14 | "aws", 15 | "cognito", 16 | "gatsby" 17 | ], 18 | "engines": { 19 | "node": ">=10" 20 | }, 21 | "scripts": { 22 | "build": "microbundle-crl --no-compress --format modern,cjs", 23 | "start": "microbundle-crl watch --no-compress --format modern,cjs", 24 | "prepublish": "run-s build", 25 | "postversion": "git push && git push --tags", 26 | "test": "run-s test:unit test:lint test:build", 27 | "test:build": "run-s build", 28 | "test:lint": "eslint .", 29 | "test:unit": "cross-env CI=1 react-scripts test --env=jsdom", 30 | "test:watch": "react-scripts test --env=jsdom", 31 | "predeploy": "cd example && npm install && npm run build", 32 | "deploy": "gh-pages -d example/build" 33 | }, 34 | "peerDependencies": { 35 | "react": ">=16.8.0", 36 | "react-dom": ">=16.8.0" 37 | }, 38 | "devDependencies": { 39 | "@testing-library/jest-dom": "^4.2.4", 40 | "@testing-library/react": "^9.5.0", 41 | "@testing-library/user-event": "^7.2.1", 42 | "@types/jest": "^25.1.4", 43 | "@types/jwt-decode": "^2.2.1", 44 | "@types/mocha": "^8.0.1", 45 | "@types/node": "^12.12.38", 46 | "@types/react": "^16.9.27", 47 | "@types/react-dom": "^16.9.7", 48 | "@typescript-eslint/eslint-plugin": "^2.26.0", 49 | "@typescript-eslint/parser": "^2.26.0", 50 | "babel-eslint": "^10.0.3", 51 | "color-string": ">=1.5.5", 52 | "cross-env": "^7.0.2", 53 | "enzyme": "^3.11.0", 54 | "eslint": "^6.8.0", 55 | "eslint-config-prettier": "^6.7.0", 56 | "eslint-config-standard": "^14.1.0", 57 | "eslint-config-standard-react": "^9.2.0", 58 | "eslint-plugin-import": "^2.18.2", 59 | "eslint-plugin-node": "^11.0.0", 60 | "eslint-plugin-prettier": "^3.1.1", 61 | "eslint-plugin-promise": "^4.2.1", 62 | "eslint-plugin-react": "^7.17.0", 63 | "eslint-plugin-standard": "^4.0.1", 64 | "gh-pages": "^2.2.0", 65 | "immer": ">=8.0.1", 66 | "is-svg": ">=4.2.2", 67 | "microbundle-crl": "^0.13.10", 68 | "node-notifier": ">=8.0.1", 69 | "npm-run-all": "^4.1.5", 70 | "prettier": "^2.0.4", 71 | "react": "^16.13.1", 72 | "react-dev-utils": ">=11.0.4", 73 | "react-dom": "^16.13.1", 74 | "react-scripts": "^3.4.1", 75 | "typescript": "^3.7.5" 76 | }, 77 | "files": [ 78 | "dist" 79 | ], 80 | "dependencies": { 81 | "jwt-decode": "^2.2.0" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/AuthService.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | import { createPKCECodes, PKCECodePair } from './pkce' 3 | import { toUrlEncoded } from './util' 4 | 5 | import jwtDecode from 'jwt-decode' 6 | 7 | export interface AuthServiceProps { 8 | clientId: string 9 | clientSecret?: string 10 | contentType?: string 11 | location: Location 12 | provider: string 13 | authorizeEndpoint?: string 14 | tokenEndpoint?: string 15 | logoutEndpoint?: string 16 | audience?: string 17 | redirectUri?: string 18 | scopes: string[] 19 | autoRefresh?: boolean 20 | refreshSlack?: number 21 | } 22 | 23 | export interface AuthTokens { 24 | id_token: string 25 | access_token: string 26 | refresh_token: string 27 | expires_in: number 28 | expires_at?: number // calculated on login 29 | token_type: string 30 | } 31 | 32 | export interface JWTIDToken { 33 | given_name: string 34 | family_name: string 35 | name: string 36 | email: string 37 | } 38 | 39 | export interface TokenRequestBody { 40 | clientId: string 41 | grantType: string 42 | redirectUri?: string 43 | refresh_token?: string 44 | clientSecret?: string 45 | code?: string 46 | codeVerifier?: string 47 | } 48 | 49 | export class AuthService { 50 | props: AuthServiceProps 51 | timeout?: number 52 | 53 | constructor(props: AuthServiceProps) { 54 | this.props = props 55 | const code = this.getCodeFromLocation(window.location) 56 | if (code !== null) { 57 | this.fetchToken(code) 58 | .then(() => { 59 | this.restoreUri() 60 | }) 61 | .catch((e) => { 62 | this.removeItem('pkce') 63 | this.removeItem('auth') 64 | this.removeCodeFromLocation() 65 | console.warn({ e }) 66 | }) 67 | } else if (this.props.autoRefresh) { 68 | this.startTimer() 69 | } 70 | } 71 | 72 | getUser(): {} { 73 | const t = this.getAuthTokens() 74 | if (null === t) return {} 75 | const decoded = jwtDecode(t.id_token) as TIDToken 76 | return decoded 77 | } 78 | 79 | getCodeFromLocation(location: Location): string | null { 80 | const split = location.toString().split('?') 81 | if (split.length < 2) { 82 | return null 83 | } 84 | const pairs = split[1].split('&') 85 | for (const pair of pairs) { 86 | const [key, value] = pair.split('=') 87 | if (key === 'code') { 88 | return decodeURIComponent(value || '') 89 | } 90 | } 91 | return null 92 | } 93 | 94 | removeCodeFromLocation(): void { 95 | const [base, search] = window.location.href.split('?') 96 | if (!search) { 97 | return 98 | } 99 | const newSearch = search 100 | .split('&') 101 | .map((param) => param.split('=')) 102 | .filter(([key]) => key !== 'code') 103 | .map((keyAndVal) => keyAndVal.join('=')) 104 | .join('&') 105 | window.history.replaceState( 106 | window.history.state, 107 | 'null', 108 | base + (newSearch.length ? `?${newSearch}` : '') 109 | ) 110 | } 111 | 112 | getItem(key: string): string | null { 113 | return window.localStorage.getItem(key) 114 | } 115 | removeItem(key: string): void { 116 | window.localStorage.removeItem(key) 117 | } 118 | 119 | getPkce(): PKCECodePair { 120 | const pkce = window.localStorage.getItem('pkce') 121 | if (null === pkce) { 122 | throw new Error('PKCE pair not found in local storage') 123 | } else { 124 | return JSON.parse(pkce) 125 | } 126 | } 127 | 128 | setAuthTokens(auth: AuthTokens): void { 129 | const { refreshSlack = 5 } = this.props 130 | const now = new Date().getTime() 131 | auth.expires_at = now + (auth.expires_in + refreshSlack) * 1000 132 | window.localStorage.setItem('auth', JSON.stringify(auth)) 133 | } 134 | 135 | getAuthTokens(): AuthTokens { 136 | return JSON.parse(window.localStorage.getItem('auth') || '{}') 137 | } 138 | 139 | isPending(): boolean { 140 | return ( 141 | window.localStorage.getItem('pkce') !== null && 142 | window.localStorage.getItem('auth') === null 143 | ) 144 | } 145 | 146 | isAuthenticated(): boolean { 147 | return window.localStorage.getItem('auth') !== null 148 | } 149 | 150 | async logout(shouldEndSession: boolean = false): Promise { 151 | this.removeItem('pkce') 152 | this.removeItem('auth') 153 | if (shouldEndSession) { 154 | const { clientId, provider, logoutEndpoint, redirectUri } = this.props; 155 | const query = { 156 | client_id: clientId, 157 | post_logout_redirect_uri: redirectUri 158 | } 159 | const url = `${logoutEndpoint || `${provider}/logout`}?${toUrlEncoded(query)}` 160 | window.location.replace(url) 161 | return true; 162 | } else { 163 | window.location.reload() 164 | return true 165 | } 166 | } 167 | 168 | async login(): Promise { 169 | this.authorize() 170 | } 171 | 172 | // this will do a full page reload and to to the OAuth2 provider's login page and then redirect back to redirectUri 173 | authorize(): boolean { 174 | const { clientId, provider, authorizeEndpoint, redirectUri, scopes, audience } = this.props 175 | 176 | const pkce = createPKCECodes() 177 | window.localStorage.setItem('pkce', JSON.stringify(pkce)) 178 | window.localStorage.setItem('preAuthUri', location.href) 179 | window.localStorage.removeItem('auth') 180 | const codeChallenge = pkce.codeChallenge 181 | 182 | const query = { 183 | clientId, 184 | scope: scopes.join(' '), 185 | responseType: 'code', 186 | redirectUri, 187 | ...(audience && { audience }), 188 | codeChallenge, 189 | codeChallengeMethod: 'S256' 190 | } 191 | // Responds with a 302 redirect 192 | const url = `${authorizeEndpoint || `${provider}/authorize`}?${toUrlEncoded(query)}` 193 | window.location.replace(url) 194 | return true 195 | } 196 | 197 | // this happens after a full page reload. Read the code from localstorage 198 | async fetchToken(code: string, isRefresh = false): Promise { 199 | const { 200 | clientId, 201 | clientSecret, 202 | contentType, 203 | provider, 204 | tokenEndpoint, 205 | redirectUri, 206 | autoRefresh = true 207 | } = this.props 208 | const grantType = 'authorization_code' 209 | 210 | let payload: TokenRequestBody = { 211 | clientId, 212 | ...(clientSecret ? { clientSecret } : {}), 213 | redirectUri, 214 | grantType 215 | } 216 | if (isRefresh) { 217 | payload = { 218 | ...payload, 219 | grantType: 'refresh_token', 220 | refresh_token: code 221 | } 222 | } else { 223 | const pkce: PKCECodePair = this.getPkce() 224 | const codeVerifier = pkce.codeVerifier 225 | payload = { 226 | ...payload, 227 | code, 228 | codeVerifier 229 | } 230 | } 231 | 232 | const response = await fetch(`${tokenEndpoint || `${provider}/token`}`, { 233 | headers: { 234 | 'Content-Type': contentType || 'application/x-www-form-urlencoded' 235 | }, 236 | method: 'POST', 237 | body: toUrlEncoded(payload) 238 | }) 239 | this.removeItem('pkce') 240 | let json = await response.json() 241 | if (isRefresh && !json.refresh_token) { 242 | json.refresh_token = payload.refresh_token 243 | } 244 | this.setAuthTokens(json as AuthTokens) 245 | if (autoRefresh) { 246 | this.startTimer() 247 | } 248 | return this.getAuthTokens() 249 | } 250 | 251 | armRefreshTimer(refreshToken: string, timeoutDuration: number): void { 252 | if (this.timeout) { 253 | clearTimeout(this.timeout) 254 | } 255 | this.timeout = window.setTimeout(() => { 256 | this.fetchToken(refreshToken, true) 257 | .then(({ refresh_token: newRefreshToken, expires_at: expiresAt }) => { 258 | if (!expiresAt) return 259 | const now = new Date().getTime() 260 | const timeout = expiresAt - now 261 | if (timeout > 0) { 262 | this.armRefreshTimer(newRefreshToken, timeout) 263 | } else { 264 | this.removeItem('auth') 265 | this.removeCodeFromLocation() 266 | } 267 | }) 268 | .catch((e) => { 269 | this.removeItem('auth') 270 | this.removeCodeFromLocation() 271 | console.warn({ e }) 272 | }) 273 | }, timeoutDuration) 274 | } 275 | 276 | startTimer(): void { 277 | const authTokens = this.getAuthTokens() 278 | if (!authTokens) { 279 | return 280 | } 281 | const { refresh_token: refreshToken, expires_at: expiresAt } = authTokens 282 | if (!expiresAt || !refreshToken) { 283 | return 284 | } 285 | const now = new Date().getTime() 286 | const timeout = expiresAt - now 287 | if (timeout > 0) { 288 | this.armRefreshTimer(refreshToken, timeout) 289 | } else { 290 | this.removeItem('auth') 291 | this.removeCodeFromLocation() 292 | } 293 | } 294 | 295 | restoreUri(): void { 296 | const uri = window.localStorage.getItem('preAuthUri') 297 | window.localStorage.removeItem('preAuthUri') 298 | console.log({ uri }) 299 | if (uri !== null) { 300 | window.location.replace(uri) 301 | } 302 | this.removeCodeFromLocation() 303 | } 304 | } 305 | --------------------------------------------------------------------------------
Not Logged in yet: {authTokens.idToken}
Logged in! {authTokens.idToken}