├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.yml ├── pull_request_template.md └── workflows │ ├── codeql-analysis.yml │ ├── publish.yaml │ ├── stale.yml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── SECURITY.md ├── biome.json ├── examples ├── github-auth-provider │ ├── docker-compose.yaml │ ├── github-auth-proxy │ │ ├── Dockerfile │ │ ├── api.py │ │ ├── poetry.lock │ │ └── pyproject.toml │ └── web-app │ │ ├── .gitignore │ │ ├── Dockerfile │ │ ├── package.json │ │ ├── public │ │ └── index.html │ │ └── src │ │ └── index.tsx ├── keycloak-auth-provider │ ├── docker-compose.yaml │ └── web-app │ │ ├── .gitignore │ │ ├── Dockerfile │ │ ├── package.json │ │ ├── public │ │ └── index.html │ │ └── src │ │ └── index.tsx └── microsoft-auth-provider │ ├── docker-compose.yaml │ └── web-app │ ├── Dockerfile │ ├── package.json │ ├── public │ └── index.html │ └── src │ └── index.tsx ├── index.html ├── jest.config.js ├── package.json ├── publish.md ├── src ├── AuthContext.tsx ├── Hooks.tsx ├── authConfig.ts ├── authentication.ts ├── decodeJWT.ts ├── errors.ts ├── httpUtils.ts ├── index.jsx ├── index.ts ├── package.json ├── pkceUtils.ts ├── popupUtils.ts ├── timeUtils.ts ├── tsconfig.json └── types.ts ├── tests ├── auth-util.test.ts ├── get_token.test.tsx ├── jestSetup.js ├── login.test.tsx ├── logout.test.tsx └── test-utils.tsx ├── tsconfig.json └── vite.config.ts /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Each line is a file pattern followed by one or more owners. 2 | # Order matter. If two rules match the same file, only the last one is used 3 | 4 | # Repo owners 5 | * @soofstad 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐛 Bug Report" 3 | about: Report a bug. 4 | title: 'Bug: ' 5 | labels: 'Status: Unconfirmed' 6 | 7 | --- 8 | 9 | 13 | 14 | ## Steps To Reproduce 15 | 16 | 1. 17 | 2. 18 | 19 | ## The current behavior 20 | 21 | ## The expected behavior 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "💡 Feature Request" 2 | description: Create a new ticket for a new feature request 3 | title: "💡 [REQUEST] - " 4 | labels: [ 5 | "question" 6 | ] 7 | body: 8 | - type: textarea 9 | id: summary 10 | attributes: 11 | label: "Summary" 12 | description: Provide a brief explanation of the feature 13 | placeholder: Describe in a few lines your feature request 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: basic_example 18 | attributes: 19 | label: "Basic Example" 20 | description: Indicate here some basic examples of your feature. 21 | placeholder: A few specific words about your feature request. 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: drawbacks 26 | attributes: 27 | label: "Drawbacks" 28 | description: What are the drawbacks/impacts of your feature request ? 29 | placeholder: Identify the drawbacks and impacts while being neutral on your feature request 30 | validations: 31 | required: true 32 | - type: textarea 33 | id: unresolved_question 34 | attributes: 35 | label: "Unresolved questions" 36 | description: What questions still remain unresolved ? 37 | placeholder: Identify any unresolved issues. 38 | validations: 39 | required: false 40 | - type: textarea 41 | id: implementation_pr 42 | attributes: 43 | label: "Implementation PR" 44 | description: Pull request used 45 | placeholder: "#Pull Request ID" 46 | validations: 47 | required: false 48 | - type: textarea 49 | id: reference_issues 50 | attributes: 51 | label: "Reference Issues" 52 | description: Common issues 53 | placeholder: "#Issues IDs" 54 | validations: 55 | required: false 56 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What does this pull request change? 2 | 3 | ## Why is this pull request needed? 4 | 5 | ## Issues related to this change 6 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '26 5 * * 0' 22 | 23 | permissions: 24 | contents: read 25 | 26 | jobs: 27 | analyze: 28 | name: Analyze 29 | runs-on: ubuntu-latest 30 | permissions: 31 | actions: read 32 | contents: read 33 | security-events: write 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | language: [ 'javascript' ] 39 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 40 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 41 | 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v3 45 | 46 | # Initializes the CodeQL tools for scanning. 47 | - name: Initialize CodeQL 48 | uses: github/codeql-action/init@v2 49 | with: 50 | languages: ${{ matrix.language }} 51 | # If you wish to specify custom queries, you can do so here or in a config file. 52 | # By default, queries listed here will override any specified in a config file. 53 | # Prefix the list here with "+" to use these queries and those in the config file. 54 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 https://git.io/JvXDl 63 | 64 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 65 | # and modify them (or add more) to build your code if your project 66 | # uses a compiled language 67 | 68 | #- run: | 69 | # make bootstrap 70 | # make release 71 | 72 | - name: Perform CodeQL Analysis 73 | uses: github/codeql-action/analyze@v2 74 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | tests: 12 | uses: ./.github/workflows/tests.yaml 13 | 14 | publish: 15 | needs: tests 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: '18.x' 22 | registry-url: 'https://registry.npmjs.org' 23 | - run: yarn install 24 | - run: cd src && tsc 25 | - run: cp ./README.md ./src/README.md 26 | - run: cd src && npm publish 27 | env: 28 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: Mark stale issues and pull requests 7 | 8 | on: 9 | schedule: 10 | - cron: '45 20 * * *' 11 | 12 | permissions: 13 | issues: write 14 | pull-requests: write 15 | 16 | jobs: 17 | stale: 18 | runs-on: ubuntu-latest 19 | permissions: 20 | issues: write 21 | pull-requests: write 22 | 23 | steps: 24 | - uses: actions/stale@v7 25 | with: 26 | repo-token: ${{ secrets.GITHUB_TOKEN }} 27 | stale-issue-message: 'Stale issue message' 28 | stale-pr-message: 'Stale pull request message' 29 | stale-issue-label: 'no-issue-activity' 30 | stale-pr-label: 'no-pr-activity' 31 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | push: 4 | workflow_call: 5 | pull_request: 6 | # The branches below must be a subset of the branches above 7 | branches: [ main ] 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | linting: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-python@v4 17 | with: 18 | python-version: '3.11' 19 | - name: Install pre-commit 20 | run: pip install pre-commit 21 | - name: Run pre-commit 22 | run: pre-commit run --all-files 23 | 24 | unittests: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v3 28 | - uses: actions/setup-node@v3 29 | with: 30 | node-version: '18.x' 31 | registry-url: 'https://registry.npmjs.org' 32 | - run: yarn install 33 | - run: yarn test 34 | - run: cd src && tsc 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .venv 3 | dist 4 | .idea 5 | log.txt 6 | /src/react-app-env.d.ts 7 | .env 8 | __pycache__ 9 | yarn.lock 10 | package-lock.json 11 | yarn-error.log 12 | .yarnrc.yml 13 | .pnp.* 14 | .yarn/* 15 | !.yarn/patches 16 | !.yarn/plugins 17 | !.yarn/releases 18 | !.yarn/sdks 19 | !.yarn/versions -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-merge-conflict 6 | - id: check-case-conflict 7 | - id: check-toml 8 | - id: check-yaml 9 | - id: trailing-whitespace 10 | exclude: ^.*\.(lock)$||^docs\/ 11 | - id: mixed-line-ending 12 | exclude: ^.*\.(lock)$||^docs\/ 13 | 14 | - repo: https://github.com/biomejs/pre-commit 15 | rev: v0.4.0 16 | hooks: 17 | - id: biome-check 18 | additional_dependencies: ["@biomejs/biome@1.8.3"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Stig Oskar Ofstad 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-oauth2-code-pkce 2 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/soofstad/react-oauth2-pkce/blob/main/LICENSE) ![NPM Version](https://img.shields.io/npm/v/react-oauth2-code-pkce?logo=npm&label=version) ![NPM Downloads](https://img.shields.io/npm/d18m/react-oauth2-code-pkce?logo=npm) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/react-oauth2-code-pkce?label=size) ![CI](https://github.com/soofstad/react-oauth2-pkce/actions/workflows/tests.yaml/badge.svg) 3 | 4 | React package for OAuth2 Authorization Code flow with PKCE 5 | 6 | Adhering to the RFCs recommendations, cryptographically sound, and with __zero__ dependencies! 7 | 8 | ## What is OAuth2 Authorization Code Flow with Proof Key for Code Exchange? 9 | 10 | Short version; 11 | The modern and secure way to do authentication for mobile and web applications! 12 | 13 | Long version; 14 | <https://www.rfc-editor.org/rfc/rfc6749.html> 15 | <https://datatracker.ietf.org/doc/html/rfc7636> 16 | <https://oauth.net/2/pkce/> 17 | 18 | ## Features 19 | 20 | - Authorization provider-agnostic. Works equally well with all OAuth2 authentication servers following the OAuth2 spec 21 | - Supports OpenID Connect (idTokens) 22 | - Pre- and Post-login callbacks 23 | - Session expired callback 24 | - Silently refreshes short-lived access tokens in the background 25 | - Decodes JWT's 26 | - A total of ~440 lines of code, easy for anyone to audit and understand 27 | 28 | ## Example 29 | 30 | ```tsx 31 | import { AuthContext, AuthProvider, TAuthConfig, TRefreshTokenExpiredEvent } from "react-oauth2-code-pkce" 32 | 33 | const authConfig: TAuthConfig = { 34 | clientId: 'myClientID', 35 | authorizationEndpoint: 'https://myAuthProvider.com/auth', 36 | tokenEndpoint: 'https://myAuthProvider.com/token', 37 | redirectUri: 'http://localhost:3000/', 38 | scope: 'someScope openid', 39 | onRefreshTokenExpire: (event: TRefreshTokenExpiredEvent) => event.logIn(undefined, undefined, "popup"), 40 | } 41 | 42 | const UserInfo = (): JSX.Element => { 43 | const {token, tokenData} = useContext<IAuthContext>(AuthContext) 44 | 45 | return <> 46 | <h4>Access Token</h4> 47 | <pre>{token}</pre> 48 | <h4>User Information from JWT</h4> 49 | <pre>{JSON.stringify(tokenData, null, 2)}</pre> 50 | </> 51 | } 52 | 53 | ReactDOM.render(<AuthProvider authConfig={authConfig}> 54 | <UserInfo/> 55 | </AuthProvider> 56 | , document.getElementById('root'), 57 | ) 58 | ``` 59 | 60 | For more advanced examples, see `./examples/`. 61 | 62 | ## Install 63 | 64 | The package is available on npmjs.com here; https://www.npmjs.com/package/react-oauth2-code-pkce 65 | 66 | ```bash 67 | npm install react-oauth2-code-pkce 68 | ``` 69 | 70 | ## API 71 | 72 | ### IAuthContext values 73 | 74 | The object that's returned by `useContext(AuthContext)` provides these values; 75 | 76 | ```typescript 77 | interface IAuthContext { 78 | // The access token. This is what you will use for authentication against protected Web API's 79 | token: string 80 | // An object with all the properties encoded in the token (username, email, etc.), if the token is a JWT 81 | tokenData?: TTokenData 82 | // Function to trigger login. 83 | // If you want to use 'state', you might want to set 'clearURL' configuration parameter to 'false'. 84 | // Note that most browsers block popups by default. The library will print a warning and fallback to redirect if the popup is blocked 85 | logIn: (state?: string, additionalParameters?: { [key: string]: string | boolean | number }, method: TLoginMethod = 'redirect') => void 86 | // Function to trigger logout from authentication provider. You may provide optional 'state', and 'logout_hint' values. 87 | // See https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout for details. 88 | logOut: (state?: string, logoutHint?: string, additionalParameters?: { [key: string]: string | boolean | number }) => void 89 | // Keeps any errors that occured during login, token fetching/refreshing, decoding, etc.. 90 | error: string | null 91 | // The idToken, if it was returned along with the access token 92 | idToken?: string 93 | // An object with all the properties encoded in the ID-token (username, groups, etc.) 94 | idTokenData?: TTokenData 95 | // If the <AuthProvider> is done fetching tokens or not. Usefull for controlling page rendering 96 | loginInProgress: boolean 97 | } 98 | ``` 99 | 100 | ### Configuration parameters 101 | 102 | __react-oauth2-code-pkce__'s goal is to "just work" with any authentication provider that either 103 | supports the [OAuth2](https://datatracker.ietf.org/doc/html/rfc7636) or [OpenID Connect](https://openid.net/developers/specs/) (OIDC) standards. 104 | However, many authentication providers are not following these standards, or have extended them. 105 | With this in mind, if you are experiencing any problems, a good place to start is to see if the provider expects some custom parameters. 106 | If they do, these can be injected into the different calls with these configuration options; 107 | 108 | - `extraAuthParameters` 109 | - `extraTokenParameters` 110 | - `extraLogoutParameters` 111 | 112 | The `<AuthProvider>` takes a `config` object that supports these parameters; 113 | 114 | ```typescript 115 | type TAuthConfig = { 116 | // ID of your app at the authentication provider 117 | clientId: string // Required 118 | // URL for the authentication endpoint at the authentication provider 119 | authorizationEndpoint: string // Required 120 | // URL for the token endpoint at the authentication provider 121 | tokenEndpoint: string // Required 122 | // Which URL the auth provider should redirect the user to after successful authentication/login 123 | redirectUri: string // Required 124 | // Which scopes to request for the auth token 125 | scope?: string // default: '' 126 | // Optional state value. Will often make more sense to provide the state in a call to the 'logIn()' function 127 | state?: string // default: null 128 | // Which URL to call for logging out of the auth provider 129 | logoutEndpoint?: string // default: null 130 | // Which URL the auth provider should redirect the user to after logout 131 | logoutRedirect?: string // default: null 132 | // Optionally provide a callback function to run _before_ the 133 | // user is redirected to the auth server for login 134 | preLogin?: () => void // default: () => null 135 | // Optionally provide a callback function to run _after_ the 136 | // user has been redirected back from the auth server 137 | postLogin?: () => void // default: () => null 138 | // Which method to use for login. Can be 'redirect', 'replace', or 'popup' 139 | // Note that most browsers block popups by default. The library will print a warning and fallback to redirect if the popup is blocked 140 | loginMethod: 'redirect' | 'replace' | 'popup' // default: 'redirect' 141 | // Optional callback function for the 'refreshTokenExpired' event. 142 | // You likely want to display a message saying the user need to log in again. A page refresh is enough. 143 | onRefreshTokenExpire?: (event: TRefreshTokenExpiredEvent) => void // default: undefined 144 | // Whether or not to decode the access token (should be set to 'false' if the access token is not a JWT (e.g. from Github)) 145 | // If `false`, 'tokenData' will be 'undefined' from the <AuthContext> 146 | decodeToken?: boolean // default: true 147 | // By default, the package will automatically redirect the user to the login server if not already logged in. 148 | // If set to false, you need to call the "logIn()" function to log in (e.g. with a "Log in" button) 149 | autoLogin?: boolean // default: true 150 | // Store login state in 'localStorage' or 'sessionStorage' 151 | // If set to 'session', no login state is persisted by 'react-oauth2-code-pkce` when the browser closes. 152 | // NOTE: Many authentication servers will keep the client logged in by cookies. You should therefore use 153 | // the logOut() function to properly log out the client. Or configure your server not to issue cookies. 154 | storage?: 'local' | 'session' // default: 'local' 155 | // Sets the prefix used when storing login state 156 | storageKeyPrefix?: string // default: 'ROCP_' 157 | // Set to false if you need to access the urlParameters sent back from the login server. 158 | clearURL?: boolean // default: true 159 | // Can be used to provide any non-standard parameters to the authentication request 160 | extraAuthParameters?: { [key: string]: string | boolean | number } // default: null 161 | // Can be used to provide any non-standard parameters to the token request 162 | extraTokenParameters?: { [key: string]: string | boolean | number } // default: null 163 | // Can be used to provide any non-standard parameters to the logout request 164 | extraLogoutParameters?: { [key: string]: string | boolean | number } // default: null 165 | // Superseded by 'extraTokenParameters' options. Will be deprecated in 2.0 166 | extraAuthParams?: { [key: string]: string | boolean | number } // default: null 167 | // Can be used if auth provider doesn't return access token expiration time in token response 168 | tokenExpiresIn?: number // default: null 169 | // Can be used if auth provider doesn't return refresh token expiration time in token response 170 | refreshTokenExpiresIn?: number // default: null 171 | // Defines the expiration strategy for the refresh token. 172 | // - 'renewable': The refresh token's expiration time is renewed each time it is used, getting a new validity period. 173 | // - 'absolute': The refresh token's expiration time is fixed from its initial issuance and does not change, regardless of how many times it is used. 174 | refreshTokenExpiryStrategy?: 'renewable' | 'absolute' // default: renewable 175 | // Whether or not to post 'scope' when refreshing the access token 176 | refreshWithScope?: boolean // default: true 177 | // Controls whether browser credentials (cookies, TLS client certificates, or authentication headers containing a username and password) are sent when requesting tokens. 178 | // Warning: Including browser credentials deviates from the standard protocol and can introduce unforeseen security issues. Only set this to 'include' if you know what 179 | // you are doing and CSRF protection is present. Setting this to 'include' is required when the token endpoint requires client certificate authentication, but likely is 180 | // not needed in any other case. Use with caution. 181 | tokenRequestCredentials?: 'same-origin' | 'include' | 'omit' // default: 'same-origin' 182 | } 183 | 184 | ``` 185 | 186 | ## Common issues 187 | 188 | ### Sessions expire too quickly 189 | 190 | A session expire happens when the `refresh_token` is no longer valid and can't be used to fetch a new valid `access_token`. 191 | This is governed by the `expires_in`, and `refresh_expires_in | refresh_token_expires_in`, in the token response. 192 | If the response does not contain these values, the library assumes a quite conservative value. 193 | You should configure your IDP (Identity Provider) to send these, but if that is not possible, you can set them explicitly 194 | with the config parameters `tokenExpiresIn` and `refreshTokenExpiresIn`. 195 | 196 | ### Fails to compile with Next.js 197 | The library's main componet `AuthProvider` is _client side only_. Meaning it must be rendered in a web browser, and can not be pre-rendered server-side (which is default in newer versions of NextJS and similar frameworks). 198 | 199 | This can be solved by marking the module with `use client` and importing the component in the client only (`"ssr": false`). 200 | 201 | ```tsx 202 | 'use client' 203 | import {useContext} from "react"; 204 | import dynamic from 'next/dynamic' 205 | import {TAuthConfig,TRefreshTokenExpiredEvent, AuthContext} from 'react-oauth2-code-pkce' 206 | 207 | const AuthProvider = dynamic( 208 | ()=> import("react-oauth2-code-pkce") 209 | .then((mod) => mod.AuthProvider), 210 | {ssr: false} 211 | ) 212 | 213 | const authConfig: TAuthConfig = {...for you to fill inn} 214 | 215 | export default function Authenticated() { 216 | (<AuthProvider authConfig={authConfig}> 217 | <LoginInfo/> 218 | </AuthProvider>) 219 | } 220 | ``` 221 | 222 | ### Error `Bad authorization state...` 223 | 224 | This is most likely to happen if the authentication at the identity provider got aborted in some way. 225 | You might also see the error `Expected to find a '?code=' parameter in the URL by now. Did the authentication get aborted or interrupted?` in the console. 226 | 227 | First of all, you should handle any errors the library throws. Usually, hinting at the user reload the page is enough. 228 | 229 | Some known causes for this is that instead of logging in at the auth provider, the user "Registers" or "Reset password" or 230 | something similar instead. Any such functions should be handled outside of this library, with separate buttons/links than the "Log in" button. 231 | 232 | ### After redirect back from auth provider with `?code`, no token request is made 233 | 234 | If you are using libraries that intercept any `fetch()`-requests made. For example `@tanstack/react-query`. That can cause 235 | issues for the _AuthProviders_ token fetching. This can be solved by _not_ wrapping the `<AuthProvider>` in any such library. 236 | 237 | This could also happen if some routes in your app are not wrapped by the `<AuthProvider>`. 238 | 239 | ### The page randomly refreshes in the middle of a session 240 | 241 | This will happen if you haven't provided a callback-function for the `onRefreshTokenExpire` config parameter, and the refresh token expires. 242 | You probably want to implement some kind of "alert/message/banner", saying that the session has expired and that the user needs to log in again. 243 | Either by refreshing the page, or clicking a "Log in" button. 244 | 245 | ## Develop 246 | 247 | 1. Update the 'authConfig' object in `src/index.js` with config from your authorization server and application 248 | 2. Install node_modules -> `$ yarn install` 249 | 3. Run -> `$ yarn start` 250 | 251 | ## Contribute 252 | 253 | You are most welcome to create issues and pull requests :) 254 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the latest major version receives updates 6 | 7 | ## Reporting a Vulnerability 8 | 9 | If you think you might have found a vulnerability in this project, please report it using the "Report a vulnerability" Github feature found 10 | here: [./security/advisories](https://github.com/soofstad/react-oauth2-pkce/security/advisories) 11 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "javascript": { 3 | "formatter": { 4 | "indentStyle": "space", 5 | "indentWidth": 2, 6 | "jsxQuoteStyle": "single", 7 | "quoteStyle": "single", 8 | "trailingCommas": "es5", 9 | "lineWidth": 119, 10 | "semicolons": "asNeeded" 11 | } 12 | }, 13 | "json": { 14 | "formatter": { 15 | "indentStyle": "space", 16 | "indentWidth": 2 17 | } 18 | }, 19 | "linter": { 20 | "rules": { 21 | "a11y": { 22 | "useButtonType": "off" 23 | }, 24 | "correctness": { 25 | "useExhaustiveDependencies": "off", 26 | "noVoidTypeReturn": "off" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/github-auth-provider/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | api: 5 | build: ./github-auth-proxy 6 | restart: unless-stopped 7 | volumes: 8 | - ./github-auth-proxy:/code 9 | environment: 10 | CLIENT_ID: c43524cc7d3c82b05a47 11 | CLIENT_SECRET: ${CLIENT_SECRET} 12 | ports: 13 | - "5000:5000" 14 | web: 15 | build: ./web-app 16 | restart: unless-stopped 17 | volumes: 18 | - ./web-app/src:/app/src 19 | ports: 20 | - "3000:3000" 21 | -------------------------------------------------------------------------------- /examples/github-auth-provider/github-auth-proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | WORKDIR /code 3 | EXPOSE 5000 4 | 5 | ENV PYTHONUNBUFFERED=1 6 | ENV PYTHONPATH=/code 7 | 8 | RUN pip install --upgrade pip && \ 9 | pip install poetry && \ 10 | poetry config virtualenvs.create false 11 | 12 | COPY pyproject.toml pyproject.toml 13 | COPY poetry.lock poetry.lock 14 | 15 | RUN poetry install 16 | COPY . . 17 | USER 1000 18 | CMD ["python", "api.py"] 19 | 20 | -------------------------------------------------------------------------------- /examples/github-auth-provider/github-auth-proxy/api.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import requests 4 | import uvicorn 5 | from fastapi import FastAPI, Form 6 | from fastapi.middleware.cors import CORSMiddleware 7 | 8 | origins = [ 9 | "http://localhost:3000", 10 | ] 11 | 12 | 13 | def create_app() -> FastAPI: 14 | app = FastAPI() 15 | 16 | app.add_middleware( 17 | CORSMiddleware, 18 | allow_origins=origins, 19 | allow_credentials=True, 20 | allow_methods=["*"], 21 | allow_headers=["*"], 22 | ) 23 | 24 | @app.post("/api/token") 25 | def get_github_token(code: str = Form()): 26 | parameters = { 27 | "client_id": os.getenv("CLIENT_ID"), 28 | "client_secret": os.getenv("CLIENT_SECRET"), 29 | "code": code 30 | } 31 | response = requests.post( 32 | "https://github.com/login/oauth/access_token", 33 | params=parameters, 34 | headers={"Accept": "application/json"} 35 | ) 36 | response.raise_for_status() 37 | return response.json() 38 | 39 | return app 40 | 41 | 42 | if __name__ == "__main__": 43 | uvicorn.run( 44 | "api:create_app", 45 | host="0.0.0.0", 46 | port=5000, 47 | reload=True, 48 | log_level='debug', 49 | ) 50 | -------------------------------------------------------------------------------- /examples/github-auth-provider/github-auth-proxy/poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "anyio" 3 | version = "3.6.1" 4 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 5 | category = "main" 6 | optional = false 7 | python-versions = ">=3.6.2" 8 | 9 | [package.dependencies] 10 | idna = ">=2.8" 11 | sniffio = ">=1.1" 12 | 13 | [package.extras] 14 | doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] 15 | test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] 16 | trio = ["trio (>=0.16)"] 17 | 18 | [[package]] 19 | name = "certifi" 20 | version = "2022.6.15" 21 | description = "Python package for providing Mozilla's CA Bundle." 22 | category = "main" 23 | optional = false 24 | python-versions = ">=3.6" 25 | 26 | [[package]] 27 | name = "charset-normalizer" 28 | version = "2.1.0" 29 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 30 | category = "main" 31 | optional = false 32 | python-versions = ">=3.6.0" 33 | 34 | [package.extras] 35 | unicode_backport = ["unicodedata2"] 36 | 37 | [[package]] 38 | name = "click" 39 | version = "8.1.3" 40 | description = "Composable command line interface toolkit" 41 | category = "main" 42 | optional = false 43 | python-versions = ">=3.7" 44 | 45 | [package.dependencies] 46 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 47 | 48 | [[package]] 49 | name = "colorama" 50 | version = "0.4.5" 51 | description = "Cross-platform colored terminal text." 52 | category = "main" 53 | optional = false 54 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 55 | 56 | [[package]] 57 | name = "fastapi" 58 | version = "0.78.0" 59 | description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" 60 | category = "main" 61 | optional = false 62 | python-versions = ">=3.6.1" 63 | 64 | [package.dependencies] 65 | pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" 66 | starlette = "0.19.1" 67 | 68 | [package.extras] 69 | all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)"] 70 | dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)", "pre-commit (>=2.17.0,<3.0.0)"] 71 | doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer (>=0.4.1,<0.5.0)", "pyyaml (>=5.3.1,<7.0.0)"] 72 | test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==22.3.0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==4.2.1)", "types-orjson (==3.6.2)", "types-dataclasses (==0.6.5)"] 73 | 74 | [[package]] 75 | name = "h11" 76 | version = "0.13.0" 77 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 78 | category = "main" 79 | optional = false 80 | python-versions = ">=3.6" 81 | 82 | [[package]] 83 | name = "idna" 84 | version = "3.3" 85 | description = "Internationalized Domain Names in Applications (IDNA)" 86 | category = "main" 87 | optional = false 88 | python-versions = ">=3.5" 89 | 90 | [[package]] 91 | name = "pydantic" 92 | version = "1.9.1" 93 | description = "Data validation and settings management using python type hints" 94 | category = "main" 95 | optional = false 96 | python-versions = ">=3.6.1" 97 | 98 | [package.dependencies] 99 | typing-extensions = ">=3.7.4.3" 100 | 101 | [package.extras] 102 | dotenv = ["python-dotenv (>=0.10.4)"] 103 | email = ["email-validator (>=1.0.3)"] 104 | 105 | [[package]] 106 | name = "python-multipart" 107 | version = "0.0.5" 108 | description = "A streaming multipart parser for Python" 109 | category = "main" 110 | optional = false 111 | python-versions = "*" 112 | 113 | [package.dependencies] 114 | six = ">=1.4.0" 115 | 116 | [[package]] 117 | name = "requests" 118 | version = "2.28.1" 119 | description = "Python HTTP for Humans." 120 | category = "main" 121 | optional = false 122 | python-versions = ">=3.7, <4" 123 | 124 | [package.dependencies] 125 | certifi = ">=2017.4.17" 126 | charset-normalizer = ">=2,<3" 127 | idna = ">=2.5,<4" 128 | urllib3 = ">=1.21.1,<1.27" 129 | 130 | [package.extras] 131 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 132 | use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] 133 | 134 | [[package]] 135 | name = "six" 136 | version = "1.16.0" 137 | description = "Python 2 and 3 compatibility utilities" 138 | category = "main" 139 | optional = false 140 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 141 | 142 | [[package]] 143 | name = "sniffio" 144 | version = "1.2.0" 145 | description = "Sniff out which async library your code is running under" 146 | category = "main" 147 | optional = false 148 | python-versions = ">=3.5" 149 | 150 | [[package]] 151 | name = "starlette" 152 | version = "0.19.1" 153 | description = "The little ASGI library that shines." 154 | category = "main" 155 | optional = false 156 | python-versions = ">=3.6" 157 | 158 | [package.dependencies] 159 | anyio = ">=3.4.0,<5" 160 | 161 | [package.extras] 162 | full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] 163 | 164 | [[package]] 165 | name = "typing-extensions" 166 | version = "4.3.0" 167 | description = "Backported and Experimental Type Hints for Python 3.7+" 168 | category = "main" 169 | optional = false 170 | python-versions = ">=3.7" 171 | 172 | [[package]] 173 | name = "urllib3" 174 | version = "1.26.10" 175 | description = "HTTP library with thread-safe connection pooling, file post, and more." 176 | category = "main" 177 | optional = false 178 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" 179 | 180 | [package.extras] 181 | brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] 182 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 183 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 184 | 185 | [[package]] 186 | name = "uvicorn" 187 | version = "0.18.2" 188 | description = "The lightning-fast ASGI server." 189 | category = "main" 190 | optional = false 191 | python-versions = ">=3.7" 192 | 193 | [package.dependencies] 194 | click = ">=7.0" 195 | h11 = ">=0.8" 196 | 197 | [package.extras] 198 | standard = ["websockets (>=10.0)", "httptools (>=0.4.0)", "watchfiles (>=0.13)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] 199 | 200 | [metadata] 201 | lock-version = "1.1" 202 | python-versions = "^3.10" 203 | content-hash = "e9d8373fb081d533580f23cbb046fbabbfb7459badbbcb2408bcc0fec123cab1" 204 | 205 | [metadata.files] 206 | anyio = [ 207 | {file = "anyio-3.6.1-py3-none-any.whl", hash = "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be"}, 208 | {file = "anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b"}, 209 | ] 210 | certifi = [ 211 | {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, 212 | {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, 213 | ] 214 | charset-normalizer = [ 215 | {file = "charset-normalizer-2.1.0.tar.gz", hash = "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"}, 216 | {file = "charset_normalizer-2.1.0-py3-none-any.whl", hash = "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5"}, 217 | ] 218 | click = [ 219 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 220 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 221 | ] 222 | colorama = [ 223 | {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, 224 | {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, 225 | ] 226 | fastapi = [ 227 | {file = "fastapi-0.78.0-py3-none-any.whl", hash = "sha256:15fcabd5c78c266fa7ae7d8de9b384bfc2375ee0503463a6febbe3bab69d6f65"}, 228 | {file = "fastapi-0.78.0.tar.gz", hash = "sha256:3233d4a789ba018578658e2af1a4bb5e38bdd122ff722b313666a9b2c6786a83"}, 229 | ] 230 | h11 = [ 231 | {file = "h11-0.13.0-py3-none-any.whl", hash = "sha256:8ddd78563b633ca55346c8cd41ec0af27d3c79931828beffb46ce70a379e7442"}, 232 | {file = "h11-0.13.0.tar.gz", hash = "sha256:70813c1135087a248a4d38cc0e1a0181ffab2188141a93eaf567940c3957ff06"}, 233 | ] 234 | idna = [ 235 | {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, 236 | {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, 237 | ] 238 | pydantic = [ 239 | {file = "pydantic-1.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8098a724c2784bf03e8070993f6d46aa2eeca031f8d8a048dff277703e6e193"}, 240 | {file = "pydantic-1.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c320c64dd876e45254bdd350f0179da737463eea41c43bacbee9d8c9d1021f11"}, 241 | {file = "pydantic-1.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18f3e912f9ad1bdec27fb06b8198a2ccc32f201e24174cec1b3424dda605a310"}, 242 | {file = "pydantic-1.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c11951b404e08b01b151222a1cb1a9f0a860a8153ce8334149ab9199cd198131"}, 243 | {file = "pydantic-1.9.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8bc541a405423ce0e51c19f637050acdbdf8feca34150e0d17f675e72d119580"}, 244 | {file = "pydantic-1.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e565a785233c2d03724c4dc55464559639b1ba9ecf091288dd47ad9c629433bd"}, 245 | {file = "pydantic-1.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a4a88dcd6ff8fd47c18b3a3709a89adb39a6373f4482e04c1b765045c7e282fd"}, 246 | {file = "pydantic-1.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:447d5521575f18e18240906beadc58551e97ec98142266e521c34968c76c8761"}, 247 | {file = "pydantic-1.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:985ceb5d0a86fcaa61e45781e567a59baa0da292d5ed2e490d612d0de5796918"}, 248 | {file = "pydantic-1.9.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059b6c1795170809103a1538255883e1983e5b831faea6558ef873d4955b4a74"}, 249 | {file = "pydantic-1.9.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:d12f96b5b64bec3f43c8e82b4aab7599d0157f11c798c9f9c528a72b9e0b339a"}, 250 | {file = "pydantic-1.9.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:ae72f8098acb368d877b210ebe02ba12585e77bd0db78ac04a1ee9b9f5dd2166"}, 251 | {file = "pydantic-1.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:79b485767c13788ee314669008d01f9ef3bc05db9ea3298f6a50d3ef596a154b"}, 252 | {file = "pydantic-1.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:494f7c8537f0c02b740c229af4cb47c0d39840b829ecdcfc93d91dcbb0779892"}, 253 | {file = "pydantic-1.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0f047e11febe5c3198ed346b507e1d010330d56ad615a7e0a89fae604065a0e"}, 254 | {file = "pydantic-1.9.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:969dd06110cb780da01336b281f53e2e7eb3a482831df441fb65dd30403f4608"}, 255 | {file = "pydantic-1.9.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:177071dfc0df6248fd22b43036f936cfe2508077a72af0933d0c1fa269b18537"}, 256 | {file = "pydantic-1.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:9bcf8b6e011be08fb729d110f3e22e654a50f8a826b0575c7196616780683380"}, 257 | {file = "pydantic-1.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a955260d47f03df08acf45689bd163ed9df82c0e0124beb4251b1290fa7ae728"}, 258 | {file = "pydantic-1.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9ce157d979f742a915b75f792dbd6aa63b8eccaf46a1005ba03aa8a986bde34a"}, 259 | {file = "pydantic-1.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0bf07cab5b279859c253d26a9194a8906e6f4a210063b84b433cf90a569de0c1"}, 260 | {file = "pydantic-1.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d93d4e95eacd313d2c765ebe40d49ca9dd2ed90e5b37d0d421c597af830c195"}, 261 | {file = "pydantic-1.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1542636a39c4892c4f4fa6270696902acb186a9aaeac6f6cf92ce6ae2e88564b"}, 262 | {file = "pydantic-1.9.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a9af62e9b5b9bc67b2a195ebc2c2662fdf498a822d62f902bf27cccb52dbbf49"}, 263 | {file = "pydantic-1.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fe4670cb32ea98ffbf5a1262f14c3e102cccd92b1869df3bb09538158ba90fe6"}, 264 | {file = "pydantic-1.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:9f659a5ee95c8baa2436d392267988fd0f43eb774e5eb8739252e5a7e9cf07e0"}, 265 | {file = "pydantic-1.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b83ba3825bc91dfa989d4eed76865e71aea3a6ca1388b59fc801ee04c4d8d0d6"}, 266 | {file = "pydantic-1.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1dd8fecbad028cd89d04a46688d2fcc14423e8a196d5b0a5c65105664901f810"}, 267 | {file = "pydantic-1.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02eefd7087268b711a3ff4db528e9916ac9aa18616da7bca69c1871d0b7a091f"}, 268 | {file = "pydantic-1.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7eb57ba90929bac0b6cc2af2373893d80ac559adda6933e562dcfb375029acee"}, 269 | {file = "pydantic-1.9.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4ce9ae9e91f46c344bec3b03d6ee9612802682c1551aaf627ad24045ce090761"}, 270 | {file = "pydantic-1.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:72ccb318bf0c9ab97fc04c10c37683d9eea952ed526707fabf9ac5ae59b701fd"}, 271 | {file = "pydantic-1.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:61b6760b08b7c395975d893e0b814a11cf011ebb24f7d869e7118f5a339a82e1"}, 272 | {file = "pydantic-1.9.1-py3-none-any.whl", hash = "sha256:4988c0f13c42bfa9ddd2fe2f569c9d54646ce84adc5de84228cfe83396f3bd58"}, 273 | {file = "pydantic-1.9.1.tar.gz", hash = "sha256:1ed987c3ff29fff7fd8c3ea3a3ea877ad310aae2ef9889a119e22d3f2db0691a"}, 274 | ] 275 | python-multipart = [ 276 | {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"}, 277 | ] 278 | requests = [ 279 | {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, 280 | {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, 281 | ] 282 | six = [ 283 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 284 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 285 | ] 286 | sniffio = [ 287 | {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, 288 | {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, 289 | ] 290 | starlette = [ 291 | {file = "starlette-0.19.1-py3-none-any.whl", hash = "sha256:5a60c5c2d051f3a8eb546136aa0c9399773a689595e099e0877704d5888279bf"}, 292 | {file = "starlette-0.19.1.tar.gz", hash = "sha256:c6d21096774ecb9639acad41b86b7706e52ba3bf1dc13ea4ed9ad593d47e24c7"}, 293 | ] 294 | typing-extensions = [ 295 | {file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"}, 296 | {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, 297 | ] 298 | urllib3 = [] 299 | uvicorn = [] 300 | -------------------------------------------------------------------------------- /examples/github-auth-provider/github-auth-proxy/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "github-auth-proxy" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Stig Ofstad <stigofstad@gmail.com>"] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | fastapi = "^0.78.0" 11 | uvicorn = "^0.18.2" 12 | requests = "^2.28.1" 13 | python-multipart = "^0.0.5" 14 | 15 | [tool.poetry.dev-dependencies] 16 | 17 | [build-system] 18 | requires = ["poetry-core>=1.0.0"] 19 | build-backend = "poetry.core.masonry.api" 20 | -------------------------------------------------------------------------------- /examples/github-auth-provider/web-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | yarn.lock 25 | -------------------------------------------------------------------------------- /examples/github-auth-provider/web-app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine as base 2 | WORKDIR /app 3 | ADD package.json ./ 4 | RUN yarn install 5 | ADD ./public ./public 6 | ADD ./src ./src 7 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /examples/github-auth-provider/web-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gh-example-app", 3 | "version": "0.1.0", 4 | "dependencies": { 5 | "react": "^18.1.0", 6 | "react-dom": "^18.1.0", 7 | "react-oauth2-code-pkce": ">=1.8.4", 8 | "react-scripts": "^5.0.1" 9 | }, 10 | "scripts": { 11 | "start": "react-scripts start", 12 | "build": "react-scripts build" 13 | }, 14 | "devDependencies": { 15 | "@types/react": "^18.0.9", 16 | "typescript": "^4.4.3" 17 | }, 18 | "browserslist": { 19 | "production": [">0.2%", "not dead", "not op_mini all"], 20 | "development": [ 21 | "last 1 chrome version", 22 | "last 1 firefox version", 23 | "last 1 safari version" 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/github-auth-provider/web-app/public/index.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="utf-8" /> 5 | <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 | <meta name="theme-color" content="#000000" /> 7 | <meta name="description" content="Web site created using create-react-app" /> 8 | <!-- 9 | manifest.json provides metadata used when your web app is installed on a 10 | user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ 11 | --> 12 | <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> 13 | <!-- 14 | Notice the use of %PUBLIC_URL% in the tags above. 15 | It will be replaced with the URL of the `public` folder during the build. 16 | Only files inside the `public` folder can be referenced from the HTML. 17 | 18 | Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will 19 | work correctly both with client-side routing and a non-root public URL. 20 | Learn how to configure a non-root public URL by running `npm run build`. 21 | --> 22 | <title>React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /examples/github-auth-provider/web-app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | // @ts-ignore 3 | import ReactDOM from 'react-dom' 4 | import { AuthContext, AuthProvider, type IAuthContext, type TAuthConfig } from 'react-oauth2-code-pkce' 5 | 6 | const authConfig: TAuthConfig = { 7 | clientId: 'c43524cc7d3c82b05a47', 8 | authorizationEndpoint: 'https://github.com/login/oauth/authorize', 9 | logoutEndpoint: 'https://github.com/login/oauth/logout', 10 | tokenEndpoint: 'http://localhost:5000/api/token', 11 | redirectUri: 'http://localhost:3000/', 12 | // Example to redirect back to original path after login has completed 13 | preLogin: () => localStorage.setItem('preLoginPath', window.location.pathname), 14 | postLogin: () => window.location.replace(localStorage.getItem('preLoginPath') || ''), 15 | decodeToken: false, 16 | autoLogin: false, 17 | } 18 | 19 | function LoginInfo(): JSX.Element { 20 | const { tokenData, token, logIn, logOut, idToken, error }: IAuthContext = useContext(AuthContext) 21 | 22 | if (error) { 23 | return ( 24 | <> 25 |
An error occurred during authentication: {error}
26 | 27 | 28 | ) 29 | } 30 | 31 | return ( 32 | <> 33 | {token ? ( 34 | <> 35 |
36 |

Access Token (JWT)

37 |
47 |               {token}
48 |             
49 |
50 |
51 |

Login Information from Access Token (Base64 decoded JWT)

52 |
62 |               {JSON.stringify(tokenData, null, 2)}
63 |             
64 |
65 | 66 | 67 | ) : ( 68 | <> 69 |
You are not logged in.
70 | 71 | 72 | )} 73 | 74 | ) 75 | } 76 | 77 | ReactDOM.render( 78 |
79 |
80 |

Demo using the 'react-oauth2-code-pkce' package

81 |

82 | Github:{' '} 83 | https://github.com/soofstad/react-oauth2-pkce 84 |

85 |

86 | NPM:{' '} 87 | 88 | https://www.npmjs.com/package/react-oauth2-code-pkce 89 | 90 |

91 |
92 | 93 | {/* @ts-ignore*/} 94 | 95 | 96 |
, 97 | document.getElementById('root') 98 | ) 99 | -------------------------------------------------------------------------------- /examples/keycloak-auth-provider/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | keycloak: 5 | image: quay.io/keycloak/keycloak:latest 6 | restart: unless-stopped 7 | volumes: 8 | - ./data:/opt/keycloak/data 9 | environment: 10 | KEYCLOAK_ADMIN: admin 11 | KEYCLOAK_ADMIN_PASSWORD: password 12 | ports: 13 | - "8080:8080" 14 | command: ["start-dev"] 15 | web: 16 | build: ./web-app 17 | restart: unless-stopped 18 | volumes: 19 | - ./web-app/src:/app/src 20 | ports: 21 | - "3000:3000" 22 | -------------------------------------------------------------------------------- /examples/keycloak-auth-provider/web-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | yarn.lock 25 | -------------------------------------------------------------------------------- /examples/keycloak-auth-provider/web-app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine as base 2 | WORKDIR /app 3 | COPY package.json ./ 4 | RUN yarn install 5 | COPY ./public ./public 6 | COPY ./src ./src 7 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /examples/keycloak-auth-provider/web-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keycloak-example-app", 3 | "version": "0.1.0", 4 | "dependencies": { 5 | "react": "^18.1.0", 6 | "react-dom": "^18.1.0", 7 | "react-oauth2-code-pkce": ">=1.10.1", 8 | "react-scripts": "^5.0.1" 9 | }, 10 | "scripts": { 11 | "start": "react-scripts start", 12 | "build": "react-scripts build" 13 | }, 14 | "devDependencies": { 15 | "@types/react": "^18.0.9", 16 | "typescript": "^4.4.3" 17 | }, 18 | "browserslist": { 19 | "production": [">0.2%", "not dead", "not op_mini all"], 20 | "development": [ 21 | "last 1 chrome version", 22 | "last 1 firefox version", 23 | "last 1 safari version" 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/keycloak-auth-provider/web-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /examples/keycloak-auth-provider/web-app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | // @ts-ignore 3 | import ReactDOM from 'react-dom' 4 | import { createRoot } from 'react-dom/client' 5 | import { AuthContext, AuthProvider, type IAuthContext, type TAuthConfig } from 'react-oauth2-code-pkce' 6 | 7 | // Get info from http://localhost:8080/realms/test/.well-known/openid-configuration 8 | 9 | const authConfig: TAuthConfig = { 10 | clientId: 'account', 11 | authorizationEndpoint: 'http://localhost:8080/realms/test/protocol/openid-connect/auth', 12 | logoutEndpoint: 'http://localhost:8080/realms/test/protocol/openid-connect/logout', 13 | tokenEndpoint: 'http://localhost:8080/realms/test/protocol/openid-connect/token', 14 | redirectUri: 'http://localhost:3000/', 15 | scope: 'profile openid', 16 | // Example to redirect back to original path after login has completed 17 | // preLogin: () => localStorage.setItem('preLoginPath', window.location.pathname), 18 | // postLogin: () => window.location.replace(localStorage.getItem('preLoginPath') || ''), 19 | decodeToken: true, 20 | autoLogin: false, 21 | } 22 | 23 | function LoginInfo(): JSX.Element { 24 | const { tokenData, token, logIn, logOut, idToken, error }: IAuthContext = useContext(AuthContext) 25 | 26 | if (error) { 27 | return ( 28 | <> 29 |
An error occurred during authentication: {error}
30 | 31 | 32 | ) 33 | } 34 | 35 | return ( 36 | <> 37 | {token ? ( 38 | <> 39 |
40 |

Access Token (JWT)

41 |
 51 |               {token}
 52 |             
53 |
54 |
55 |

Login Information from Access Token (Base64 decoded JWT)

56 |
 66 |               {JSON.stringify(tokenData, null, 2)}
 67 |             
68 |
69 | 70 | 71 | ) : ( 72 | <> 73 |
You are not logged in.
74 | 75 | 76 | )} 77 | 78 | ) 79 | } 80 | 81 | const container = document.getElementById('root') 82 | const root = createRoot(container) 83 | 84 | root.render( 85 |
86 |
87 |

Demo using the 'react-oauth2-code-pkce' package

88 |

89 | Github:{' '} 90 | https://github.com/soofstad/react-oauth2-pkce 91 |

92 |

93 | NPM:{' '} 94 | 95 | https://www.npmjs.com/package/react-oauth2-code-pkce 96 | 97 |

98 |
99 | 100 | {/* @ts-ignore*/} 101 | 102 | 103 |
, 104 | document.getElementById('root') 105 | ) 106 | -------------------------------------------------------------------------------- /examples/microsoft-auth-provider/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | web: 5 | build: ./web-app 6 | restart: unless-stopped 7 | volumes: 8 | - ./web-app/src:/app/src 9 | ports: 10 | - "3000:3000" 11 | -------------------------------------------------------------------------------- /examples/microsoft-auth-provider/web-app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine as base 2 | WORKDIR /app 3 | ADD package.json ./ 4 | RUN yarn install 5 | ADD ./public ./public 6 | ADD ./src ./src 7 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /examples/microsoft-auth-provider/web-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ms-example-app", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "start": "ESLINT_NO_DEV_ERRORS='true' react-scripts start", 6 | "build": "react-scripts build" 7 | }, 8 | "dependencies": { 9 | "react": "^18.2.0", 10 | "react-dom": "^18.2.0", 11 | "react-oauth2-code-pkce": "1.17.1", 12 | "react-scripts": "^5.0.1" 13 | }, 14 | "devDependencies": { 15 | "@types/react": "^18.2.58", 16 | "typescript": "^5.3.3" 17 | }, 18 | "browserslist": { 19 | "production": [">0.2%", "not dead", "not op_mini all"], 20 | "development": [ 21 | "last 1 chrome version", 22 | "last 1 firefox version", 23 | "last 1 safari version" 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/microsoft-auth-provider/web-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /examples/microsoft-auth-provider/web-app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | // @ts-ignore 3 | import ReactDOM from 'react-dom' 4 | import { AuthContext, AuthProvider, type IAuthContext, type TAuthConfig } from 'react-oauth2-code-pkce' 5 | 6 | const authConfig: TAuthConfig = { 7 | clientId: '6559ce69-219d-4e82-b6ed-889a861c7c94', 8 | authorizationEndpoint: 9 | 'https://login.microsoftonline.com/d422398d-b6a5-454d-a202-7ed4c1bec457/oauth2/v2.0/authorize', 10 | tokenEndpoint: 'https://login.microsoftonline.com/d422398d-b6a5-454d-a202-7ed4c1bec457/oauth2/v2.0/token', 11 | redirectUri: 'http://localhost:3000/', 12 | onRefreshTokenExpire: (event) => 13 | window.confirm('Tokens have expired. Refresh page to continue using the site?') && event.logIn(), 14 | // Example to redirect back to original path after login has completed 15 | preLogin: () => localStorage.setItem('preLoginPath', window.location.pathname), 16 | postLogin: () => window.location.replace(localStorage.getItem('preLoginPath') || ''), 17 | decodeToken: true, 18 | scope: 'User.read', 19 | autoLogin: false, 20 | } 21 | 22 | function LoginInfo(): JSX.Element { 23 | const { tokenData, token, logOut, idToken, error, logIn }: IAuthContext = useContext(AuthContext) 24 | 25 | if (error) { 26 | return ( 27 | <> 28 |
An error occurred during authentication: {error}
29 | 32 | 33 | ) 34 | } 35 | 36 | return ( 37 | <> 38 | {token ? ( 39 |
50 |
61 |

Welcome, John Doe!

62 | 63 | 66 | 67 |

Use this token to authenticate yourself

68 |
 78 |               {token}
 79 |             
80 |
81 |
82 | ) : ( 83 |
94 |
105 |

Please login to continue

106 | 107 | 110 |
111 |
112 | )} 113 | 114 | ) 115 | } 116 | 117 | ReactDOM.render( 118 |
119 |
130 |

Demo using

131 |

react-oauth2-code-pkce

132 |
133 | 134 | {/* @ts-ignore*/} 135 | 136 | 137 |
, 138 | document.getElementById('root') 139 | ) 140 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | React App 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { '^.+\\.(ts|tsx)?$': 'ts-jest' }, 3 | testEnvironment: 'jsdom', 4 | testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 6 | setupFilesAfterEnv: ['./tests/jestSetup.js'], 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "package-development", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "start": "vite", 6 | "build": "vite build", 7 | "test": "jest --silent", 8 | "test:watch": "jest --silent --watch" 9 | }, 10 | "dependencies": { 11 | "react": "18.3.1", 12 | "react-dom": "18.3.1" 13 | }, 14 | "devDependencies": { 15 | "@testing-library/dom": "^10.4.0", 16 | "@testing-library/jest-dom": "^6.6.3", 17 | "@testing-library/react": "^16.0.1", 18 | "@testing-library/user-event": "^14.5.2", 19 | "@types/jest": "^29.5.14", 20 | "@types/react": "18.3.12", 21 | "@vitejs/plugin-react": "^4.3.3", 22 | "jest": "^29.7.0", 23 | "jest-environment-jsdom": "^29.7.0", 24 | "ts-jest": "^29.2.5", 25 | "typescript": "5.6.3", 26 | "vite": "^5.4.10" 27 | }, 28 | "browserslist": { 29 | "production": [">0.2%", "not dead", "not op_mini all"], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /publish.md: -------------------------------------------------------------------------------- 1 | # How to create a new release 2 | 3 | ```bash 4 | # Bump version in './src/package.json' 5 | git commit -m "bump version" 6 | git tag v?.?.? -m "A Message" 7 | git push --tags 8 | ``` 9 | -------------------------------------------------------------------------------- /src/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useEffect, useMemo, useRef, useState } from 'react' 2 | import useBrowserStorage from './Hooks' 3 | import { createInternalConfig } from './authConfig' 4 | import { fetchTokens, fetchWithRefreshToken, redirectToLogin, redirectToLogout, validateState } from './authentication' 5 | import { decodeAccessToken, decodeIdToken, decodeJWT } from './decodeJWT' 6 | import { FetchError } from './errors' 7 | import { FALLBACK_EXPIRE_TIME, epochAtSecondsFromNow, epochTimeIsPast, getRefreshExpiresIn } from './timeUtils' 8 | import type { 9 | IAuthContext, 10 | IAuthProvider, 11 | TInternalConfig, 12 | TLoginMethod, 13 | TPrimitiveRecord, 14 | TRefreshTokenExpiredEvent, 15 | TTokenData, 16 | TTokenResponse, 17 | } from './types' 18 | 19 | export const AuthContext = createContext({ 20 | token: '', 21 | login: () => null, 22 | logIn: () => null, 23 | logOut: () => null, 24 | error: null, 25 | loginInProgress: false, 26 | }) 27 | 28 | export const AuthProvider = ({ authConfig, children }: IAuthProvider) => { 29 | const config: TInternalConfig = useMemo(() => createInternalConfig(authConfig), [authConfig]) 30 | 31 | const [refreshToken, setRefreshToken] = useBrowserStorage( 32 | `${config.storageKeyPrefix}refreshToken`, 33 | undefined, 34 | config.storage 35 | ) 36 | const [refreshTokenExpire, setRefreshTokenExpire] = useBrowserStorage( 37 | `${config.storageKeyPrefix}refreshTokenExpire`, 38 | undefined, 39 | config.storage 40 | ) 41 | const [token, setToken] = useBrowserStorage(`${config.storageKeyPrefix}token`, '', config.storage) 42 | const [tokenExpire, setTokenExpire] = useBrowserStorage( 43 | `${config.storageKeyPrefix}tokenExpire`, 44 | epochAtSecondsFromNow(FALLBACK_EXPIRE_TIME), 45 | config.storage 46 | ) 47 | const [idToken, setIdToken] = useBrowserStorage( 48 | `${config.storageKeyPrefix}idToken`, 49 | undefined, 50 | config.storage 51 | ) 52 | const [loginInProgress, setLoginInProgress] = useBrowserStorage( 53 | `${config.storageKeyPrefix}loginInProgress`, 54 | false, 55 | config.storage 56 | ) 57 | const [refreshInProgress, setRefreshInProgress] = useBrowserStorage( 58 | `${config.storageKeyPrefix}refreshInProgress`, 59 | false, 60 | config.storage 61 | ) 62 | const [loginMethod, setLoginMethod] = useBrowserStorage( 63 | `${config.storageKeyPrefix}loginMethod`, 64 | 'redirect', 65 | config.storage 66 | ) 67 | const tokenData = useMemo(() => { 68 | if (config.decodeToken) return decodeAccessToken(token) 69 | }, [token]) 70 | const idTokenData = useMemo(() => decodeIdToken(idToken), [idToken]) 71 | const [error, setError] = useState(null) 72 | 73 | function clearStorage() { 74 | setRefreshToken(undefined) 75 | setToken('') 76 | setTokenExpire(epochAtSecondsFromNow(FALLBACK_EXPIRE_TIME)) 77 | setRefreshTokenExpire(undefined) 78 | setIdToken(undefined) 79 | setLoginInProgress(false) 80 | } 81 | 82 | function logOut(state?: string, logoutHint?: string, additionalParameters?: TPrimitiveRecord) { 83 | clearStorage() 84 | setError(null) 85 | if (config?.logoutEndpoint && token) 86 | redirectToLogout(config, token, refreshToken, idToken, state, logoutHint, additionalParameters) 87 | } 88 | 89 | function logIn(state?: string, additionalParameters?: TPrimitiveRecord, method: TLoginMethod = 'redirect') { 90 | clearStorage() 91 | setLoginInProgress(true) 92 | setLoginMethod(method) 93 | // TODO: Raise error on wrong state type in v2 94 | let typeSafePassedState = state 95 | if (state && typeof state !== 'string') { 96 | const jsonState = JSON.stringify(state) 97 | console.warn( 98 | `Passed login state must be of type 'string'. Received '${jsonState}'. Ignoring value. In a future version, an error will be thrown here.` 99 | ) 100 | typeSafePassedState = undefined 101 | } 102 | redirectToLogin(config, typeSafePassedState, additionalParameters, method).catch((error) => { 103 | console.error(error) 104 | setError(error.message) 105 | setLoginInProgress(false) 106 | }) 107 | } 108 | 109 | function handleTokenResponse(response: TTokenResponse) { 110 | setToken(response.access_token) 111 | if (response.id_token) { 112 | setIdToken(response.id_token) 113 | } 114 | let tokenExp = FALLBACK_EXPIRE_TIME 115 | // Decode IdToken, so we can use "exp" from that as fallback if expire not returned in the response 116 | try { 117 | if (response.id_token) { 118 | const decodedToken = decodeJWT(response.id_token) 119 | tokenExp = Math.round(Number(decodedToken.exp) - Date.now() / 1000) // number of seconds from now 120 | } 121 | } catch (e) { 122 | console.warn(`Failed to decode idToken: ${(e as Error).message}`) 123 | } 124 | const tokenExpiresIn = config.tokenExpiresIn ?? response.expires_in ?? tokenExp 125 | setTokenExpire(epochAtSecondsFromNow(tokenExpiresIn)) 126 | const refreshTokenExpiresIn = config.refreshTokenExpiresIn ?? getRefreshExpiresIn(tokenExpiresIn, response) 127 | if (response.refresh_token) { 128 | setRefreshToken(response.refresh_token) 129 | if (!refreshTokenExpire || config.refreshTokenExpiryStrategy !== 'absolute') { 130 | setRefreshTokenExpire(epochAtSecondsFromNow(refreshTokenExpiresIn)) 131 | } 132 | } 133 | } 134 | 135 | function handleExpiredRefreshToken(initial = false): void { 136 | if (config.autoLogin && initial) return logIn(undefined, undefined, config.loginMethod) 137 | 138 | // TODO: Breaking change - remove automatic login during ongoing session 139 | if (!config.onRefreshTokenExpire) return logIn(undefined, undefined, config.loginMethod) 140 | 141 | config.onRefreshTokenExpire({ 142 | login: logIn, 143 | logIn, 144 | } as TRefreshTokenExpiredEvent) 145 | } 146 | 147 | function refreshAccessToken(initial = false): void { 148 | if (!token) return 149 | // The token has not expired. Do nothing 150 | if (!epochTimeIsPast(tokenExpire)) return 151 | 152 | // Other instance (tab) is currently refreshing. This instance skip the refresh if not initial 153 | if (refreshInProgress && !initial) return 154 | 155 | // If no refreshToken, act as if the refreshToken expired (session expired) 156 | if (!refreshToken) return handleExpiredRefreshToken(initial) 157 | 158 | // The refreshToken has expired 159 | if (refreshTokenExpire && epochTimeIsPast(refreshTokenExpire)) return handleExpiredRefreshToken(initial) 160 | 161 | // The access_token has expired, and we have a non-expired refresh_token. Use it to refresh access_token. 162 | if (refreshToken) { 163 | setRefreshInProgress(true) 164 | fetchWithRefreshToken({ config, refreshToken }) 165 | .then((result: TTokenResponse) => handleTokenResponse(result)) 166 | .catch((error: unknown) => { 167 | if (error instanceof FetchError) { 168 | // If the fetch failed with status 400, assume expired refresh token 169 | if (error.status === 400) { 170 | handleExpiredRefreshToken(initial) 171 | return 172 | } 173 | // Unknown error. Set error, and log in if first page load 174 | console.error(error) 175 | setError(error.message) 176 | if (initial) logIn(undefined, undefined, config.loginMethod) 177 | } 178 | // Unknown error. Set error, and log in if first page load 179 | else if (error instanceof Error) { 180 | console.error(error) 181 | setError(error.message) 182 | if (initial) logIn(undefined, undefined, config.loginMethod) 183 | } 184 | }) 185 | .finally(() => { 186 | setRefreshInProgress(false) 187 | }) 188 | return 189 | } 190 | console.warn( 191 | 'Failed to refresh access_token. Most likely there is no refresh_token, or the authentication server did not reply with an explicit expire time, and the default expire times are longer than the actual tokens expire time' 192 | ) 193 | } 194 | 195 | // Register the 'check for soon expiring access token' interval (every ~10 seconds). 196 | useEffect(() => { 197 | // The randomStagger is used to avoid multiple tabs logging in at the exact same time. 198 | const randomStagger = 10000 * Math.random() 199 | const interval = setInterval(() => refreshAccessToken(), 5000 + randomStagger) 200 | return () => clearInterval(interval) 201 | }, [token, refreshToken, refreshTokenExpire, tokenExpire, refreshInProgress]) // Replace the interval with a new when values used inside refreshAccessToken changes 202 | 203 | // This ref is used to make sure the 'fetchTokens' call is only made once. 204 | // Multiple calls with the same code will, and should, return an error from the API 205 | // See: https://beta.reactjs.org/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development 206 | const didFetchTokens = useRef(false) 207 | 208 | // Runs once on page load 209 | useEffect(() => { 210 | // The client has been redirected back from the auth endpoint with an auth code 211 | if (loginInProgress) { 212 | const urlParams = new URLSearchParams(window.location.search) 213 | if (!urlParams.get('code')) { 214 | // This should not happen. There should be a 'code' parameter in the url by now... 215 | const error_description = 216 | urlParams.get('error_description') || 217 | 'Bad authorization state. Refreshing the page and log in again might solve the issue.' 218 | console.error( 219 | `${error_description}\nExpected to find a '?code=' parameter in the URL by now. Did the authentication get aborted or interrupted?` 220 | ) 221 | setError(error_description) 222 | clearStorage() 223 | return 224 | } 225 | // Make sure we only try to use the auth code once 226 | if (!didFetchTokens.current) { 227 | didFetchTokens.current = true 228 | try { 229 | validateState(urlParams, config.storage) 230 | } catch (e: unknown) { 231 | console.error(e) 232 | setError((e as Error).message) 233 | } 234 | // Request tokens from auth server with the auth code 235 | fetchTokens(config) 236 | .then((tokens: TTokenResponse) => { 237 | handleTokenResponse(tokens) 238 | // Call any postLogin function in authConfig 239 | if (config?.postLogin) config.postLogin() 240 | if (loginMethod === 'popup') window.close() 241 | }) 242 | .catch((error: Error) => { 243 | console.error(error) 244 | setError(error.message) 245 | }) 246 | .finally(() => { 247 | if (config.clearURL) { 248 | // Clear ugly url params 249 | window.history.replaceState(null, '', `${window.location.pathname}${window.location.hash}`) 250 | } 251 | setLoginInProgress(false) 252 | }) 253 | } 254 | return 255 | } 256 | 257 | // First page visit 258 | if (!token && config.autoLogin) return logIn(undefined, undefined, config.loginMethod) 259 | refreshAccessToken(true) // Check if token should be updated 260 | }, []) 261 | 262 | return ( 263 | 276 | {children} 277 | 278 | ) 279 | } 280 | -------------------------------------------------------------------------------- /src/Hooks.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | function useBrowserStorage(key: string, initialValue: T, type: 'session' | 'local'): [T, (v: T) => void] { 4 | const storage = type === 'session' ? sessionStorage : localStorage 5 | 6 | const [storedValue, setStoredValue] = useState(() => { 7 | const item = storage.getItem(key) 8 | try { 9 | return item ? JSON.parse(item) : initialValue 10 | } catch (error: unknown) { 11 | console.warn(`Failed to parse stored value for '${key}'.\nContinuing with default value.`) 12 | return initialValue 13 | } 14 | }) 15 | 16 | const setValue = (value: T | ((val: T) => T)): void => { 17 | if (value === undefined) { 18 | // Delete item if set to undefined. This avoids warning on loading invalid json 19 | setStoredValue(value) 20 | storage.removeItem(key) 21 | return 22 | } 23 | try { 24 | const valueToStore = value instanceof Function ? value(storedValue) : value 25 | setStoredValue(valueToStore) 26 | storage.setItem(key, JSON.stringify(valueToStore)) 27 | } catch (error) { 28 | console.error(`Failed to store value '${value}' for key '${key}'`) 29 | } 30 | } 31 | 32 | useEffect(() => { 33 | const storageEventHandler = (event: StorageEvent) => { 34 | if (event.storageArea === storage && event.key === key) { 35 | if (event.newValue === null) { 36 | setStoredValue(undefined as T) 37 | } else { 38 | try { 39 | setStoredValue(JSON.parse(event.newValue ?? '') as T) 40 | } catch (error: unknown) { 41 | console.warn(`Failed to handle storageEvent's newValue='${event.newValue}' for key '${key}'`) 42 | } 43 | } 44 | } 45 | } 46 | window.addEventListener('storage', storageEventHandler, false) 47 | return () => window.removeEventListener('storage', storageEventHandler, false) 48 | }) 49 | 50 | return [storedValue, setValue] 51 | } 52 | 53 | export default useBrowserStorage 54 | -------------------------------------------------------------------------------- /src/authConfig.ts: -------------------------------------------------------------------------------- 1 | import type { TAuthConfig, TInternalConfig } from './types' 2 | 3 | function stringIsUnset(value: string | null | undefined) { 4 | const unset = ['', undefined, null] 5 | return unset.includes(value) 6 | } 7 | 8 | export function createInternalConfig(passedConfig: TAuthConfig): TInternalConfig { 9 | // Set default values for internal config object 10 | const { 11 | autoLogin = true, 12 | clearURL = true, 13 | decodeToken = true, 14 | scope = undefined, 15 | preLogin = () => null, 16 | postLogin = () => null, 17 | loginMethod = 'redirect', 18 | onRefreshTokenExpire = undefined, 19 | storage = 'local', 20 | storageKeyPrefix = 'ROCP_', 21 | refreshWithScope = true, 22 | refreshTokenExpiryStrategy = 'renewable', 23 | tokenRequestCredentials = 'same-origin', 24 | }: TAuthConfig = passedConfig 25 | 26 | const config: TInternalConfig = { 27 | ...passedConfig, 28 | autoLogin: autoLogin, 29 | clearURL: clearURL, 30 | decodeToken: decodeToken, 31 | scope: scope, 32 | preLogin: preLogin, 33 | postLogin: postLogin, 34 | loginMethod: loginMethod, 35 | onRefreshTokenExpire: onRefreshTokenExpire, 36 | storage: storage, 37 | storageKeyPrefix: storageKeyPrefix, 38 | refreshWithScope: refreshWithScope, 39 | refreshTokenExpiryStrategy: refreshTokenExpiryStrategy, 40 | tokenRequestCredentials: tokenRequestCredentials, 41 | } 42 | validateConfig(config) 43 | return config 44 | } 45 | 46 | export function validateConfig(config: TInternalConfig) { 47 | if (stringIsUnset(config?.clientId)) 48 | throw Error("'clientId' must be set in the 'AuthConfig' object passed to 'react-oauth2-code-pkce' AuthProvider") 49 | if (stringIsUnset(config?.authorizationEndpoint)) 50 | throw Error( 51 | "'authorizationEndpoint' must be set in the 'AuthConfig' object passed to 'react-oauth2-code-pkce' AuthProvider" 52 | ) 53 | if (stringIsUnset(config?.tokenEndpoint)) 54 | throw Error( 55 | "'tokenEndpoint' must be set in the 'AuthConfig' object passed to 'react-oauth2-code-pkce' AuthProvider" 56 | ) 57 | if (stringIsUnset(config?.redirectUri)) 58 | throw Error("'redirectUri' must be set in the 'AuthConfig' object passed to 'react-oauth2-code-pkce' AuthProvider") 59 | if (!['session', 'local'].includes(config.storage)) throw Error("'storage' must be one of ('session', 'local')") 60 | if (config?.extraAuthParams) 61 | console.warn( 62 | "The 'extraAuthParams' configuration parameter will be deprecated. You should use " + 63 | "'extraTokenParameters' instead." 64 | ) 65 | if (config?.extraAuthParams && config?.extraTokenParameters) 66 | console.warn( 67 | "Using both 'extraAuthParams' and 'extraTokenParameters' is not recommended. " + 68 | "They do the same thing, and you should only use 'extraTokenParameters'" 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/authentication.ts: -------------------------------------------------------------------------------- 1 | import { postWithXForm } from './httpUtils' 2 | import { generateCodeChallenge, generateRandomString } from './pkceUtils' 3 | import { calculatePopupPosition } from './popupUtils' 4 | import type { 5 | TInternalConfig, 6 | TLoginMethod, 7 | TPrimitiveRecord, 8 | TTokenRequest, 9 | TTokenRequestForRefresh, 10 | TTokenRequestWithCodeAndVerifier, 11 | TTokenResponse, 12 | } from './types' 13 | 14 | const codeVerifierStorageKey = 'PKCE_code_verifier' 15 | const stateStorageKey = 'ROCP_auth_state' 16 | 17 | export async function redirectToLogin( 18 | config: TInternalConfig, 19 | customState?: string, 20 | additionalParameters?: TPrimitiveRecord, 21 | method: TLoginMethod = 'redirect' 22 | ): Promise { 23 | const storage = config.storage === 'session' ? sessionStorage : localStorage 24 | const navigationMethod = method === 'replace' ? 'replace' : 'assign' 25 | 26 | // Create and store a random string in storage, used as the 'code_verifier' 27 | const codeVerifier = generateRandomString(96) 28 | storage.setItem(codeVerifierStorageKey, codeVerifier) 29 | 30 | // Hash and Base64URL encode the code_verifier, used as the 'code_challenge' 31 | return generateCodeChallenge(codeVerifier).then((codeChallenge) => { 32 | // Set query parameters and redirect user to OAuth2 authentication endpoint 33 | const params = new URLSearchParams({ 34 | response_type: 'code', 35 | client_id: config.clientId, 36 | redirect_uri: config.redirectUri, 37 | code_challenge: codeChallenge, 38 | code_challenge_method: 'S256', 39 | ...config.extraAuthParameters, 40 | ...additionalParameters, 41 | }) 42 | 43 | if (config.scope !== undefined && !params.has('scope')) { 44 | params.append('scope', config.scope) 45 | } 46 | 47 | storage.removeItem(stateStorageKey) 48 | const state = customState ?? config.state 49 | if (state) { 50 | storage.setItem(stateStorageKey, state) 51 | params.append('state', state) 52 | } 53 | 54 | const loginUrl = `${config.authorizationEndpoint}?${params.toString()}` 55 | 56 | // Call any preLogin function in authConfig 57 | if (config?.preLogin) config.preLogin() 58 | 59 | if (method === 'popup') { 60 | const { width, height, left, top } = calculatePopupPosition(600, 600) 61 | const handle: null | WindowProxy = window.open( 62 | loginUrl, 63 | 'loginPopup', 64 | `width=${width},height=${height},top=${top},left=${left}` 65 | ) 66 | if (handle) return 67 | console.warn('Popup blocked. Redirecting to login page. Disable popup blocker to use popup login.') 68 | } 69 | window.location[navigationMethod](loginUrl) 70 | }) 71 | } 72 | 73 | // This is called a "type predicate". Which allow us to know which kind of response we got, in a type safe way. 74 | function isTokenResponse(body: unknown | TTokenResponse): body is TTokenResponse { 75 | return (body as TTokenResponse).access_token !== undefined 76 | } 77 | 78 | function postTokenRequest( 79 | tokenEndpoint: string, 80 | tokenRequest: TTokenRequest, 81 | credentials: RequestCredentials 82 | ): Promise { 83 | return postWithXForm({ url: tokenEndpoint, request: tokenRequest, credentials: credentials }).then((response) => { 84 | return response.json().then((body: TTokenResponse | unknown): TTokenResponse => { 85 | if (isTokenResponse(body)) { 86 | return body 87 | } 88 | throw Error(JSON.stringify(body)) 89 | }) 90 | }) 91 | } 92 | 93 | export const fetchTokens = (config: TInternalConfig): Promise => { 94 | const storage = config.storage === 'session' ? sessionStorage : localStorage 95 | /* 96 | The browser has been redirected from the authentication endpoint with 97 | a 'code' url parameter. 98 | This code will now be exchanged for Access- and Refresh Tokens. 99 | */ 100 | const urlParams = new URLSearchParams(window.location.search) 101 | const authCode = urlParams.get('code') 102 | const codeVerifier = storage.getItem(codeVerifierStorageKey) 103 | 104 | if (!authCode) { 105 | throw Error("Parameter 'code' not found in URL. \nHas authentication taken place?") 106 | } 107 | if (!codeVerifier) { 108 | throw Error("Can't get tokens without the CodeVerifier. \nHas authentication taken place?") 109 | } 110 | 111 | const tokenRequest: TTokenRequestWithCodeAndVerifier = { 112 | grant_type: 'authorization_code', 113 | code: authCode, 114 | client_id: config.clientId, 115 | redirect_uri: config.redirectUri, 116 | code_verifier: codeVerifier, 117 | ...config.extraTokenParameters, 118 | // TODO: Remove in 2.0 119 | ...config.extraAuthParams, 120 | } 121 | return postTokenRequest(config.tokenEndpoint, tokenRequest, config.tokenRequestCredentials) 122 | } 123 | 124 | export const fetchWithRefreshToken = (props: { 125 | config: TInternalConfig 126 | refreshToken: string 127 | }): Promise => { 128 | const { config, refreshToken } = props 129 | const refreshRequest: TTokenRequestForRefresh = { 130 | grant_type: 'refresh_token', 131 | refresh_token: refreshToken, 132 | client_id: config.clientId, 133 | redirect_uri: config.redirectUri, 134 | ...config.extraTokenParameters, 135 | } 136 | if (config.refreshWithScope) refreshRequest.scope = config.scope 137 | return postTokenRequest(config.tokenEndpoint, refreshRequest, config.tokenRequestCredentials) 138 | } 139 | 140 | export function redirectToLogout( 141 | config: TInternalConfig, 142 | token: string, 143 | refresh_token?: string, 144 | idToken?: string, 145 | state?: string, 146 | logoutHint?: string, 147 | additionalParameters?: TPrimitiveRecord 148 | ) { 149 | const params = new URLSearchParams({ 150 | token: refresh_token || token, 151 | token_type_hint: refresh_token ? 'refresh_token' : 'access_token', 152 | client_id: config.clientId, 153 | post_logout_redirect_uri: config.logoutRedirect ?? config.redirectUri, 154 | ui_locales: window.navigator.languages.join(' '), 155 | ...config.extraLogoutParameters, 156 | ...additionalParameters, 157 | }) 158 | if (idToken) params.append('id_token_hint', idToken) 159 | if (state) params.append('state', state) 160 | if (logoutHint) params.append('logout_hint', logoutHint) 161 | window.location.assign(`${config.logoutEndpoint}?${params.toString()}`) 162 | } 163 | 164 | export function validateState(urlParams: URLSearchParams, storageType: TInternalConfig['storage']) { 165 | const storage = storageType === 'session' ? sessionStorage : localStorage 166 | const receivedState = urlParams.get('state') 167 | const loadedState = storage.getItem(stateStorageKey) 168 | if (receivedState !== loadedState) { 169 | throw new Error( 170 | '"state" value received from authentication server does no match client request. Possible cross-site request forgery' 171 | ) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/decodeJWT.ts: -------------------------------------------------------------------------------- 1 | import type { TTokenData } from './types' 2 | 3 | /** 4 | * Decodes the base64 encoded JWT. Returns a TToken. 5 | */ 6 | export const decodeJWT = (token: string): TTokenData => { 7 | try { 8 | const base64Url = token.split('.')[1] 9 | const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') 10 | const jsonPayload = decodeURIComponent( 11 | atob(base64) 12 | .split('') 13 | .map((c) => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`) 14 | .join('') 15 | ) 16 | return JSON.parse(jsonPayload) 17 | } catch (e) { 18 | console.error(e) 19 | throw Error( 20 | 'Failed to decode the access token.\n\tIs it a proper JSON Web Token?\n\t' + 21 | "You can disable JWT decoding by setting the 'decodeToken' value to 'false' the configuration." 22 | ) 23 | } 24 | } 25 | 26 | export const decodeAccessToken = (token: string | null | undefined): TTokenData | undefined => { 27 | if (!token || !token.length) return undefined 28 | try { 29 | return decodeJWT(token) 30 | } catch (e) { 31 | console.warn(`Failed to decode access token: ${(e as Error).message}`) 32 | } 33 | } 34 | 35 | export const decodeIdToken = (idToken: string | null | undefined): TTokenData | undefined => { 36 | if (!idToken || !idToken.length) return undefined 37 | try { 38 | return decodeJWT(idToken) 39 | } catch (e) { 40 | console.warn(`Failed to decode idToken: ${(e as Error).message}`) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export class FetchError extends Error { 2 | status: number 3 | statusText: string 4 | 5 | constructor(status: number, statusText: string, message: string) { 6 | super(message) 7 | this.name = 'FetchError' 8 | this.status = status 9 | this.statusText = statusText 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/httpUtils.ts: -------------------------------------------------------------------------------- 1 | import { FetchError } from './errors' 2 | import type { TTokenRequest } from './types' 3 | 4 | function buildUrlEncodedRequest(request: TTokenRequest): string { 5 | let queryString = '' 6 | for (const [key, value] of Object.entries(request)) { 7 | queryString += `${queryString ? '&' : ''}${key}=${encodeURIComponent(value)}` 8 | } 9 | return queryString 10 | } 11 | 12 | interface PostWithXFormParams { 13 | url: string 14 | request: TTokenRequest 15 | credentials: RequestCredentials 16 | } 17 | 18 | export async function postWithXForm({ url, request, credentials }: PostWithXFormParams): Promise { 19 | return fetch(url, { 20 | method: 'POST', 21 | body: buildUrlEncodedRequest(request), 22 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 23 | credentials: credentials, 24 | }).then(async (response: Response) => { 25 | if (!response.ok) { 26 | const responseBody = await response.text() 27 | throw new FetchError(response.status, response.statusText, responseBody) 28 | } 29 | return response 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // ########################################## 4 | // NOTE: This file is not part of the package. 5 | // It's only function is to help development in testing and debugging. 6 | // If you want to run the project locally you will need to update the authConfig object with your own auth provider 7 | // ########################################## 8 | 9 | import React, { useContext } from 'react' 10 | import { createRoot } from 'react-dom/client' 11 | import { AuthContext, AuthProvider } from './AuthContext' 12 | 13 | // Get auth provider info from "https://keycloak.ofstad.xyz/realms/master/.well-known/openid-configuration" 14 | /** @type {import('./types').TAuthConfig} */ 15 | const authConfig = { 16 | clientId: 'account', 17 | authorizationEndpoint: 'https://keycloak.ofstad.xyz/realms/master/protocol/openid-connect/auth', 18 | tokenEndpoint: 'https://keycloak.ofstad.xyz/realms/master/protocol/openid-connect/token', 19 | logoutEndpoint: 'https://keycloak.ofstad.xyz/realms/master/protocol/openid-connect/logout', 20 | redirectUri: 'http://localhost:5173/', 21 | onRefreshTokenExpire: (event) => event.logIn('', {}, 'popup'), 22 | preLogin: () => console.log('Logging in...'), 23 | postLogin: () => console.log('Logged in!'), 24 | decodeToken: true, 25 | scope: 'profile openid', 26 | // state: 'testState', 27 | clearURL: true, 28 | autoLogin: false, 29 | storage: 'local', 30 | refreshWithScope: false, 31 | } 32 | 33 | function LoginInfo() { 34 | const { tokenData, token, idTokenData, logIn, logOut, error, loginInProgress, idToken } = useContext(AuthContext) 35 | 36 | if (loginInProgress) return null 37 | return ( 38 | <> 39 | {error &&
An error occurred during authentication: {error}
} 40 | <> 41 | 42 | 43 | 44 | 47 | 48 | {token ? ( 49 | <> 50 | 51 | 52 | Access token will expire at:{' '} 53 | {new Date(Number(localStorage.getItem('ROCP_tokenExpire')) * 1000).toLocaleTimeString()} 54 | 55 |
56 |
57 |

Access Token (JWT)

58 |
 68 |                 {token}
 69 |               
70 |
71 | {authConfig.decodeToken && ( 72 | <> 73 |
74 |

Login Information from Access Token

75 |
 85 |                     {JSON.stringify(tokenData, null, 2)}
 86 |                   
87 |
88 |
89 |

Login Information from ID Token

90 |
100 |                     {JSON.stringify(idTokenData, null, 2)}
101 |                   
102 |
103 | 104 | )} 105 |
106 | 107 | ) : ( 108 |
You are not logged in
109 | )} 110 | 111 | ) 112 | } 113 | 114 | const container = document.getElementById('root') 115 | if (!container) throw new Error('No container found') 116 | const root = createRoot(container) 117 | 118 | root.render( 119 | 120 |
121 |

Demo using the 'react-oauth2-code-pkce' package

122 |

123 | Github:{' '} 124 | https://github.com/soofstad/react-oauth2-pkce 125 |

126 |

127 | NPM:{' '} 128 | 129 | https://www.npmjs.com/package/react-oauth2-code-pkce 130 | 131 |

132 |
133 | 134 | 135 | 136 |
137 | ) 138 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { AuthProvider, AuthContext } from './AuthContext' 2 | export type { 3 | TAuthConfig, 4 | IAuthProvider, 5 | IAuthContext, 6 | TRefreshTokenExpiredEvent, 7 | } from './types' 8 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-oauth2-code-pkce", 3 | "version": "1.23.0", 4 | "description": "Provider agnostic react package for OAuth2 Authorization Code flow with PKCE", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "dependencies": {}, 8 | "devDependencies": { 9 | "@types/react": ">=16.8.0", 10 | "typescript": ">=4.4.3" 11 | }, 12 | "peerDependencies": { 13 | "react": ">=16.8.0" 14 | }, 15 | "scripts": { 16 | "test": "ts-jest", 17 | "start": "yarn run start" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/soofstad/react-oauth2-pkce.git" 22 | }, 23 | "keywords": [ 24 | "react", 25 | "oauth2", 26 | "pkce", 27 | "code", 28 | "flow", 29 | "azure", 30 | "github", 31 | "keycloak", 32 | "microsoft", 33 | "google", 34 | "fusionauth" 35 | ], 36 | "author": "Stig Oskar Ofstad", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/soofstad/react-oauth2-pkce/issues" 40 | }, 41 | "homepage": "https://github.com/soofstad/react-oauth2-pkce#readme", 42 | "files": ["dist/"] 43 | } 44 | -------------------------------------------------------------------------------- /src/pkceUtils.ts: -------------------------------------------------------------------------------- 1 | export function getRandomInteger(range: number): number { 2 | const max_range = 256 // Highest possible number in Uint8 3 | 4 | // Create byte array and fill with 1 random number 5 | const byteArray = new Uint8Array(1) 6 | window.crypto.getRandomValues(byteArray) // This is the new, and safer API than Math.Random() 7 | 8 | // If the generated number is out of range, try again 9 | if (byteArray[0] >= Math.floor(max_range / range) * range) return getRandomInteger(range) 10 | return byteArray[0] % range 11 | } 12 | 13 | export function generateRandomString(length: number): string { 14 | let text = '' 15 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 16 | for (let i = 0; i < length; i++) { 17 | text += possible.charAt(getRandomInteger(possible.length - 1)) 18 | } 19 | return text 20 | } 21 | /** 22 | * PKCE Code Challenge = base64url(hash(codeVerifier)) 23 | */ 24 | export async function generateCodeChallenge(codeVerifier: string): Promise { 25 | if (!window.crypto.subtle?.digest) { 26 | throw new Error( 27 | "The context/environment is not secure, and does not support the 'crypto.subtle' module. See: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle for details" 28 | ) 29 | } 30 | const encoder = new TextEncoder() 31 | const bytes: Uint8Array = encoder.encode(codeVerifier) // Encode the verifier to a byteArray 32 | const hash: ArrayBuffer = await window.crypto.subtle.digest('SHA-256', bytes) // sha256 hash it 33 | const hashString: string = String.fromCharCode(...new Uint8Array(hash)) 34 | const base64 = btoa(hashString) // Base64 encode the verifier hash 35 | return base64 // Base64Url encode the base64 encoded string, making it safe as a query param 36 | .replace(/=/g, '') 37 | .replace(/\+/g, '-') 38 | .replace(/\//g, '_') 39 | } 40 | -------------------------------------------------------------------------------- /src/popupUtils.ts: -------------------------------------------------------------------------------- 1 | import type { TPopupPosition } from './types' 2 | 3 | export function calculatePopupPosition(popupWidth = 600, popupHeight = 600): TPopupPosition { 4 | // Calculate the screen dimensions and position the popup at the center 5 | const screenLeft = window.screenLeft 6 | const screenTop = window.screenTop 7 | const screenWidth = window.innerWidth 8 | const screenHeight = window.innerHeight 9 | 10 | // Calculate the position to center the popup 11 | const defaultLeft = screenLeft + (screenWidth - popupWidth) / 2 12 | const defaultTop = screenTop + (screenHeight - popupHeight) / 2 13 | 14 | // Ensure the bottom-right corner does not go off the screen 15 | // Adjust the left and top positions if necessary 16 | const maxLeft = screenLeft + (screenWidth - popupWidth) 17 | const maxTop = screenTop + (screenHeight - popupHeight) 18 | 19 | return { 20 | width: Math.min(popupWidth, screenWidth), 21 | height: Math.min(popupHeight, screenHeight), 22 | left: Math.max(0, Math.min(defaultLeft, maxLeft)), 23 | top: Math.max(0, Math.min(defaultTop, maxTop)), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/timeUtils.ts: -------------------------------------------------------------------------------- 1 | import type { TTokenResponse } from './types' 2 | export const FALLBACK_EXPIRE_TIME = 600 // 10minutes 3 | 4 | // Returns epoch time (in seconds) for when the token will expire 5 | // 'secondsFromNow' should always be an integer, but some auth providers has decided that whole numbers should be strings... 6 | export const epochAtSecondsFromNow = (secondsFromNow: number | string) => 7 | Math.round(Date.now() / 1000 + Number(secondsFromNow)) 8 | 9 | /** 10 | * Check if the Access Token has expired. 11 | * Will return True if the token has expired, OR there is less than 30 seconds until it expires. 12 | */ 13 | export function epochTimeIsPast(timestamp: number): boolean { 14 | const now = Math.round(Date.now()) / 1000 15 | const nowWithBuffer = now + 30 16 | return nowWithBuffer >= timestamp 17 | } 18 | 19 | const refreshExpireKeys = [ 20 | 'refresh_expires_in', // KeyCloak 21 | 'refresh_token_expires_in', // Azure AD 22 | ] as const 23 | 24 | export function getRefreshExpiresIn(tokenExpiresIn: number, response: TTokenResponse): number { 25 | for (const key of refreshExpireKeys) { 26 | if (key in response) return response[key] as number 27 | } 28 | // If the response has a refresh_token, but no expire_time. Assume it's at least 10m longer than access_token's expire 29 | if (response.refresh_token) return tokenExpiresIn + FALLBACK_EXPIRE_TIME 30 | // The token response had no refresh_token. Set refresh_expire equals to access_token expire 31 | return tokenExpiresIn 32 | } 33 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "commonjs", 5 | "lib": ["ESNext", "DOM"], 6 | "allowJs": false, 7 | "jsx": "react", 8 | "declaration": true, 9 | "outDir": "dist", 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "skipLibCheck": true, 14 | "types": [] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | 3 | // Makes only the specified keys required in the provided type 4 | // Source: https://www.emmanuelgautier.com/blog/snippets/typescript-required-properties 5 | type WithRequired = T & { [P in K]-?: T[P] } 6 | 7 | interface TTokenRqBase { 8 | grant_type: string 9 | client_id: string 10 | redirect_uri: string 11 | } 12 | 13 | export interface TTokenRequestWithCodeAndVerifier extends TTokenRqBase { 14 | code: string 15 | code_verifier: string 16 | } 17 | 18 | export interface TTokenRequestForRefresh extends TTokenRqBase { 19 | scope?: string 20 | refresh_token: string 21 | } 22 | 23 | export type TTokenRequest = TTokenRequestWithCodeAndVerifier | TTokenRequestForRefresh 24 | 25 | export type TTokenData = { 26 | // biome-ignore lint: It really can be `any` (almost) 27 | [x: string]: any 28 | } 29 | 30 | export type TTokenResponse = { 31 | access_token: string 32 | scope: string 33 | token_type: string 34 | expires_in?: number 35 | refresh_token?: string 36 | refresh_token_expires_in?: number 37 | refresh_expires_in?: number 38 | id_token?: string 39 | } 40 | 41 | export type TLoginMethod = 'redirect' | 'replace' | 'popup' 42 | 43 | export type TPopupPosition = { 44 | left: number 45 | top: number 46 | width: number 47 | height: number 48 | } 49 | 50 | export interface IAuthProvider { 51 | authConfig: TAuthConfig 52 | children: ReactNode 53 | } 54 | 55 | type TLogInFunction = (state?: string, additionalParameters?: TPrimitiveRecord, method?: TLoginMethod) => void 56 | export interface IAuthContext { 57 | token: string 58 | logIn: TLogInFunction 59 | logOut: (state?: string, logoutHint?: string, additionalParameters?: TPrimitiveRecord) => void 60 | /** @deprecated Use `logIn` instead */ 61 | login: TLogInFunction 62 | error: string | null 63 | tokenData?: TTokenData 64 | idToken?: string 65 | idTokenData?: TTokenData 66 | loginInProgress: boolean 67 | } 68 | 69 | export type TPrimitiveRecord = { [key: string]: string | boolean | number } 70 | 71 | // Input from users of the package, some optional values 72 | export type TAuthConfig = { 73 | clientId: string 74 | authorizationEndpoint: string 75 | tokenEndpoint: string 76 | redirectUri: string 77 | scope?: string 78 | state?: string 79 | logoutEndpoint?: string 80 | logoutRedirect?: string 81 | preLogin?: () => void 82 | postLogin?: () => void 83 | loginMethod?: TLoginMethod 84 | onRefreshTokenExpire?: (event: TRefreshTokenExpiredEvent) => void 85 | decodeToken?: boolean 86 | autoLogin?: boolean 87 | clearURL?: boolean 88 | /** @deprecated Use `extraAuthParameters` instead. Will be removed in a future version. */ 89 | extraAuthParams?: TPrimitiveRecord 90 | extraAuthParameters?: TPrimitiveRecord 91 | extraTokenParameters?: TPrimitiveRecord 92 | extraLogoutParameters?: TPrimitiveRecord 93 | tokenExpiresIn?: number 94 | refreshTokenExpiresIn?: number 95 | refreshTokenExpiryStrategy?: 'renewable' | 'absolute' 96 | storage?: 'session' | 'local' 97 | storageKeyPrefix?: string 98 | refreshWithScope?: boolean 99 | tokenRequestCredentials?: RequestCredentials 100 | } 101 | 102 | export type TRefreshTokenExpiredEvent = { 103 | logIn: TLogInFunction 104 | /** @deprecated Use `logIn` instead. Will be removed in a future version. */ 105 | login: TLogInFunction 106 | } 107 | 108 | // The AuthProviders internal config type. All values will be set by user provided, or default values 109 | export type TInternalConfig = WithRequired< 110 | TAuthConfig, 111 | | 'loginMethod' 112 | | 'decodeToken' 113 | | 'autoLogin' 114 | | 'clearURL' 115 | | 'refreshTokenExpiryStrategy' 116 | | 'storage' 117 | | 'storageKeyPrefix' 118 | | 'refreshWithScope' 119 | | 'tokenRequestCredentials' 120 | > 121 | -------------------------------------------------------------------------------- /tests/auth-util.test.ts: -------------------------------------------------------------------------------- 1 | import { fetchWithRefreshToken } from '../src/authentication' 2 | import { decodeJWT } from '../src/decodeJWT' 3 | import { FetchError } from '../src/errors' 4 | import { epochAtSecondsFromNow, epochTimeIsPast } from '../src/timeUtils' 5 | import type { TInternalConfig } from '../src/types' 6 | 7 | const authConfig: TInternalConfig = { 8 | autoLogin: false, 9 | decodeToken: false, 10 | clientId: 'myClientID', 11 | authorizationEndpoint: 'myAuthEndpoint', 12 | tokenEndpoint: 'myTokenEndpoint', 13 | redirectUri: 'http://localhost:5173/', 14 | scope: 'someScope openid', 15 | clearURL: false, 16 | storage: 'local', 17 | refreshTokenExpiryStrategy: 'renewable', 18 | storageKeyPrefix: 'ROCP_', 19 | refreshWithScope: true, 20 | loginMethod: 'redirect', 21 | extraAuthParams: { 22 | prompt: true, 23 | client_id: 'anotherClientId', 24 | }, 25 | extraTokenParameters: { 26 | prompt: true, 27 | client_id: 'anotherClientId', 28 | testKey: 'test Value', 29 | }, 30 | tokenRequestCredentials: 'same-origin', 31 | } 32 | 33 | test('decode a JWT token', () => { 34 | const tokenData = decodeJWT( 35 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.Sfl' 36 | ) 37 | expect(tokenData?.name).toBe('John Doe') 38 | }) 39 | 40 | test('decode a non-JWT token', () => { 41 | console.error = jest.fn() 42 | expect(() => { 43 | decodeJWT('somethingStringWhateverThis is not a JWT') 44 | }).toThrow() 45 | }) 46 | 47 | test('check if expired token has expired', () => { 48 | const willExpireAt = epochAtSecondsFromNow(-5) // Expired 5 seconds ago 49 | const hasExpired = epochTimeIsPast(willExpireAt) 50 | expect(hasExpired).toBe(true) 51 | }) 52 | 53 | test('check if still valid token inside buffer has expired', () => { 54 | const willExpireAt = epochAtSecondsFromNow(5) // Will expire in 5 seconds 55 | const hasExpired = epochTimeIsPast(willExpireAt) 56 | expect(hasExpired).toBe(true) 57 | }) 58 | 59 | test('expire time as string gets correctly converted', () => { 60 | const expectedEpoch = Math.round(Date.now() / 1000 + 55555) 61 | const epochSumCalculated = epochAtSecondsFromNow('55555') 62 | expect(expectedEpoch).toBe(epochSumCalculated) 63 | }) 64 | 65 | test('expire time as int gets correctly converted', () => { 66 | const expectedEpoch = Math.round(Date.now() / 1000 + 55555) 67 | const epochSumCalculated = epochAtSecondsFromNow(55555) 68 | expect(expectedEpoch).toBe(epochSumCalculated) 69 | }) 70 | 71 | test('check if still valid token outside buffer has expired', () => { 72 | const willExpireAt = epochAtSecondsFromNow(301) // Will expire in 5min 73 | const hasExpired = epochTimeIsPast(willExpireAt) 74 | expect(hasExpired).toBe(false) 75 | }) 76 | 77 | test('failed refresh fetch raises FetchError', () => { 78 | // @ts-ignore 79 | global.fetch = jest.fn(() => 80 | Promise.resolve({ 81 | ok: false, 82 | status: 400, 83 | statusText: 'Bad request', 84 | text: async () => 'Failed to refresh token error body', 85 | }) 86 | ) 87 | fetchWithRefreshToken({ config: authConfig, refreshToken: '' }).catch((error: unknown) => { 88 | if (error instanceof FetchError) { 89 | expect(error.status).toBe(400) 90 | expect(error.message).toBe('Failed to refresh token error body') 91 | } else { 92 | throw new Error('This is the wrong error type') 93 | } 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /tests/get_token.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, waitFor } from '@testing-library/react' 2 | import React from 'react' 3 | import { AuthProvider } from '../src' 4 | import type { TTokenResponse } from '../src/types' 5 | import { AuthConsumer, authConfig } from './test-utils' 6 | 7 | // @ts-ignore 8 | global.fetch = jest.fn(() => 9 | Promise.resolve({ 10 | ok: true, 11 | json: () => 12 | Promise.resolve({ 13 | scope: 'value', 14 | refresh_token: '1234', 15 | token_type: 'dummy', 16 | access_token: 17 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.Sfl', 18 | }), 19 | }) 20 | ) 21 | 22 | describe('make token request', () => { 23 | beforeEach(() => { 24 | // Setting up a state similar to what it would be just after redirect back from auth provider 25 | localStorage.setItem('ROCP_loginInProgress', 'true') 26 | localStorage.setItem('PKCE_code_verifier', 'arandomstring') 27 | window.location.search = '?code=1234' 28 | }) 29 | 30 | test('with extra parameters', async () => { 31 | render( 32 | 33 | 34 | 35 | ) 36 | 37 | await waitFor(() => 38 | expect(fetch).toHaveBeenCalledWith('myTokenEndpoint', { 39 | body: 'grant_type=authorization_code&code=1234&client_id=anotherClientId&redirect_uri=http%3A%2F%2Flocalhost%2F&code_verifier=arandomstring&testTokenKey=tokenValue', 40 | headers: { 41 | 'Content-Type': 'application/x-www-form-urlencoded', 42 | }, 43 | method: 'POST', 44 | credentials: 'same-origin', 45 | }) 46 | ) 47 | }) 48 | 49 | test('with custom credentials', async () => { 50 | render( 51 | 52 | 53 | 54 | ) 55 | 56 | await waitFor(() => 57 | expect(fetch).toHaveBeenCalledWith('myTokenEndpoint', { 58 | body: 'grant_type=authorization_code&code=1234&client_id=anotherClientId&redirect_uri=http%3A%2F%2Flocalhost%2F&code_verifier=arandomstring&testTokenKey=tokenValue', 59 | headers: { 60 | 'Content-Type': 'application/x-www-form-urlencoded', 61 | }, 62 | method: 'POST', 63 | credentials: 'include', 64 | }) 65 | ) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /tests/jestSetup.js: -------------------------------------------------------------------------------- 1 | const { TextDecoder, TextEncoder } = require('node:util') 2 | const nodeCrypto = require('node:crypto') 3 | 4 | beforeEach(() => { 5 | localStorage.removeItem('ROCP_loginInProgress') 6 | localStorage.removeItem('ROCP_token') 7 | localStorage.removeItem('ROCP_refreshToken') 8 | localStorage.removeItem('PKCE_code_verifier') 9 | 10 | global.TextEncoder = TextEncoder 11 | global.TextDecoder = TextDecoder 12 | 13 | global.crypto.subtle = nodeCrypto.webcrypto.subtle 14 | 15 | // biome-ignore lint: set undefine does not work... 16 | delete window.location 17 | const location = new URL('https://www.example.com') 18 | location.assign = jest.fn() 19 | window.location = location 20 | window.open = jest.fn() 21 | }) 22 | -------------------------------------------------------------------------------- /tests/login.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import { render, screen, waitFor } from '@testing-library/react' 3 | import React from 'react' 4 | import { AuthProvider } from '../src' 5 | import { AuthConsumer, authConfig } from './test-utils' 6 | 7 | test('First page visit should redirect to auth provider for login', async () => { 8 | render( 9 | 10 | 11 | 12 | ) 13 | 14 | await waitFor(() => { 15 | expect(window.location.assign).toHaveBeenCalledWith( 16 | expect.stringMatching( 17 | /^myAuthEndpoint\?response_type=code&client_id=myClientID&redirect_uri=http%3A%2F%2Flocalhost%2F&code_challenge=.{43}&code_challenge_method=S256&scope=someScope\+openid&state=testState/gm 18 | ) 19 | ) 20 | }) 21 | }) 22 | 23 | test('First page visit should popup to auth provider for login', async () => { 24 | // set window size to 1200x800 to make test predictable in different environments 25 | global.innerWidth = 1200 26 | global.innerHeight = 800 27 | render( 28 | 29 | 30 | 31 | ) 32 | 33 | await waitFor(() => { 34 | expect(window.open).toHaveBeenCalledWith( 35 | expect.stringMatching( 36 | /^myAuthEndpoint\?response_type=code&client_id=myClientID&redirect_uri=http%3A%2F%2Flocalhost%2F&code_challenge=.{43}&code_challenge_method=S256&scope=someScope\+openid&state=testState/gm 37 | ), 38 | 'loginPopup', 39 | 'width=600,height=600,top=100,left=300' 40 | ) 41 | }) 42 | }) 43 | 44 | test('Attempting to log in with an unsecure context should raise error', async () => { 45 | // @ts-ignore 46 | window.crypto.subtle.digest = undefined 47 | render( 48 | 49 | 50 | 51 | ) 52 | 53 | const errorNode = await waitFor(() => screen.findByLabelText('error')) 54 | 55 | expect(errorNode).toHaveTextContent( 56 | "The context/environment is not secure, and does not support the 'crypto.subtle' module. See: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle for details" 57 | ) 58 | expect(screen.getByLabelText('loginInProgress')).toHaveTextContent('false') 59 | }) 60 | -------------------------------------------------------------------------------- /tests/logout.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import React from 'react' 4 | import { AuthProvider } from '../src' 5 | import { AuthConsumer, authConfig } from './test-utils' 6 | 7 | test('Full featured logout requests', async () => { 8 | localStorage.setItem('ROCP_loginInProgress', 'false') 9 | localStorage.setItem('ROCP_token', '"test-token-value"') 10 | localStorage.setItem('ROCP_refreshToken', '"test-refresh-value"') 11 | const user = userEvent.setup() 12 | 13 | render( 14 | 15 | 16 | 17 | ) 18 | 19 | await user.click(screen.getByText('Log out')) 20 | 21 | await waitFor(() => 22 | expect(window.location.assign).toHaveBeenCalledWith( 23 | 'myLogoutEndpoint?token=test-refresh-value&token_type_hint=refresh_token&client_id=myClientID&post_logout_redirect_uri=primary-logout-redirect&ui_locales=en-US+en&testLogoutKey=logoutValue&state=logoutState' 24 | ) 25 | ) 26 | expect(window.location.assign).toHaveBeenCalledTimes(1) 27 | }) 28 | 29 | test('No refresh token, no logoutRedirect, logout request', async () => { 30 | localStorage.setItem('ROCP_loginInProgress', 'false') 31 | localStorage.setItem('ROCP_token', '"test-token-value"') 32 | authConfig.logoutRedirect = undefined 33 | const user = userEvent.setup() 34 | 35 | render( 36 | 37 | 38 | 39 | ) 40 | 41 | await user.click(screen.getByText('Log out')) 42 | 43 | await waitFor(() => 44 | expect(window.location.assign).toHaveBeenCalledWith( 45 | 'myLogoutEndpoint?token=test-token-value&token_type_hint=access_token&client_id=myClientID&post_logout_redirect_uri=http%3A%2F%2Flocalhost%2F&ui_locales=en-US+en&testLogoutKey=logoutValue&state=logoutState' 46 | ) 47 | ) 48 | expect(window.location.assign).toHaveBeenCalledTimes(1) 49 | }) 50 | -------------------------------------------------------------------------------- /tests/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { AuthContext, type TAuthConfig } from '../src' 3 | 4 | export const authConfig: TAuthConfig = { 5 | autoLogin: true, 6 | clientId: 'myClientID', 7 | authorizationEndpoint: 'myAuthEndpoint', 8 | tokenEndpoint: 'myTokenEndpoint', 9 | logoutEndpoint: 'myLogoutEndpoint', 10 | redirectUri: 'http://localhost/', 11 | logoutRedirect: 'primary-logout-redirect', 12 | scope: 'someScope openid', 13 | decodeToken: false, 14 | state: 'testState', 15 | loginMethod: 'redirect', 16 | extraLogoutParameters: { 17 | testLogoutKey: 'logoutValue', 18 | }, 19 | extraAuthParams: { 20 | client_id: 'anotherClientId', 21 | }, 22 | extraTokenParameters: { 23 | testTokenKey: 'tokenValue', 24 | }, 25 | } 26 | 27 | export const AuthConsumer = () => { 28 | const { tokenData, logOut, loginInProgress, idToken, idTokenData, logIn, token, error } = useContext(AuthContext) 29 | return ( 30 | <> 31 |
{tokenData?.name}
32 | 35 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*"], 3 | "compilerOptions": { 4 | "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 6 | "lib": [ 7 | "es2016", 8 | "dom" 9 | ] /* Specify library files to be included in the compilation. */, 10 | "allowJs": false /* Allow javascript files to be compiled. */, 11 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 12 | "declaration": true /* Generates corresponding '.d.ts' file. */, 13 | "outDir": "dist" /* Redirect output structure to the directory. */, 14 | "strict": true /* Enable all strict type-checking options. */, 15 | "esModuleInterop": true, 16 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vite' 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | }) 7 | --------------------------------------------------------------------------------