├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md ├── stale.yml └── workflows │ └── build-and-test.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── package.json ├── src ├── __tests__ │ ├── url.spec.ts │ └── uuid.spec.ts ├── client.ts ├── index.ts ├── types.ts └── utils │ ├── deferred.ts │ ├── keycloak.ts │ ├── url.ts │ └── uuid.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | end_of_line = lf 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj -text 2 | # specific for windows script files 3 | *.bat text eol=crlf -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: reactkeycloak 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: [] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 8 | 9 | Fixes # (issue) 10 | 11 | ## Type of change 12 | 13 | 16 | 17 | - Bug fix (non-breaking change which fixes an issue) 18 | - New feature (non-breaking change which adds functionality) 19 | - Breaking change (fix or feature that would cause existing functionality to not work as expected) 20 | - This change requires a documentation update 21 | 22 | ## How Has This Been Tested? 23 | 24 | 29 | 30 | - [ ] Test A 31 | - [ ] Test B 32 | 33 | **Test Configuration**: 34 | 35 | - Browser: 36 | 37 | ## Checklist: 38 | 39 | - [ ] My code follows the style guidelines of this project 40 | - [ ] I have performed a self-review of my own code 41 | - [ ] I have commented my code, particularly in hard-to-understand areas 42 | - [ ] I have made corresponding changes to the documentation 43 | - [ ] My changes generate no new warnings 44 | - [ ] I have added tests that prove my fix is effective or that my feature works 45 | - [ ] New and existing unit tests pass locally with my changes 46 | - [ ] Any dependent changes have been merged and published in downstream modules 47 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 5 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CI: true 7 | 8 | jobs: 9 | lint: 10 | name: Lint 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Clone repository 15 | uses: actions/checkout@v2 16 | 17 | - run: node --version 18 | - run: yarn --version 19 | 20 | - name: Install dependencies 21 | run: yarn install --frozen-lockfile 22 | 23 | - name: Lint files 24 | run: yarn lint 25 | 26 | typescript: 27 | name: Typescript 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - name: Clone repository 32 | uses: actions/checkout@v2 33 | 34 | - run: node --version 35 | - run: yarn --version 36 | 37 | - name: Install dependencies 38 | run: yarn install --frozen-lockfile 39 | 40 | - name: Typecheck files 41 | run: yarn typescript 42 | 43 | unit-test: 44 | name: unit tests 45 | runs-on: ubuntu-latest 46 | 47 | steps: 48 | - name: Clone repository 49 | uses: actions/checkout@v2 50 | 51 | - run: node --version 52 | - run: yarn --version 53 | 54 | - name: Install dependencies 55 | run: yarn install --frozen-lockfile 56 | 57 | - name: Run unit tests 58 | run: yarn test --coverage 59 | 60 | build-package: 61 | name: Build 62 | runs-on: ubuntu-latest 63 | 64 | steps: 65 | - name: Clone repository 66 | uses: actions/checkout@v2 67 | 68 | - run: node --version 69 | - run: yarn --version 70 | 71 | - name: Install dependencies 72 | run: yarn install --frozen-lockfile 73 | 74 | - name: Build package 75 | run: yarn prepare 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # XDE 6 | .expo/ 7 | 8 | # VSCode 9 | .vscode/ 10 | jsconfig.json 11 | 12 | # Xcode 13 | # 14 | build/ 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | xcuserdata 24 | *.xccheckout 25 | *.moved-aside 26 | DerivedData 27 | *.hmap 28 | *.ipa 29 | *.xcuserstate 30 | project.xcworkspace 31 | 32 | # Android/IJ 33 | # 34 | .idea 35 | .gradle 36 | local.properties 37 | android.iml 38 | 39 | # Cocoapods 40 | # 41 | example/ios/Pods 42 | 43 | # node.js 44 | # 45 | node_modules/ 46 | npm-debug.log 47 | yarn-debug.log 48 | yarn-error.log 49 | 50 | # BUCK 51 | buck-out/ 52 | \.buckd/ 53 | android/app/libs 54 | android/keystores/debug.keystore 55 | 56 | # Expo 57 | .expo/* 58 | 59 | # generated by bob 60 | lib/ 61 | 62 | # coverage 63 | .coveralls.yml 64 | coverage/ 65 | 66 | *.tgz 67 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint && yarn typescript 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We want this community to be friendly and respectful to each other. Please follow it in all your interactions with the project. 4 | 5 | ## Development workflow 6 | 7 | To get started with the project, run `yarn install` in the root directory to install the required dependencies for each package: 8 | 9 | ```sh 10 | yarn install 11 | ``` 12 | 13 | Make sure your code passes TypeScript and ESLint. Run the following to verify: 14 | 15 | ```sh 16 | yarn typescript 17 | yarn lint 18 | ``` 19 | 20 | To fix formatting errors, run the following: 21 | 22 | ```sh 23 | yarn lint --fix 24 | ``` 25 | 26 | Remember to add tests for your change if possible. Run the unit tests by: 27 | 28 | ```sh 29 | yarn test 30 | ``` 31 | 32 | ### Commit message convention 33 | 34 | We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages: 35 | 36 | - `fix`: bug fixes, e.g. fix crash due to deprecated method. 37 | - `feat`: new features, e.g. add new method to the module. 38 | - `refactor`: code refactor, e.g. migrate from class components to hooks. 39 | - `docs`: changes into documentation, e.g. add usage example for the module.. 40 | - `test`: adding or updating tests, eg add integration tests using detox. 41 | - `chore`: tooling changes, e.g. change CI config. 42 | 43 | Our pre-commit hooks verify that your commit message matches this format when committing. 44 | 45 | ### Linting and tests 46 | 47 | [ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/) 48 | 49 | We use [TypeScript](https://www.typescriptlang.org/) for type checking, [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing. 50 | 51 | Our pre-commit hooks verify that the linter and tests pass when committing. 52 | 53 | ### Scripts 54 | 55 | The `package.json` file contains various scripts for common tasks: 56 | 57 | - `yarn typescript`: type-check files with TypeScript. 58 | - `yarn lint`: lint files with ESLint. 59 | - `yarn test`: run unit tests with Jest. 60 | 61 | ### Sending a pull request 62 | 63 | > **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). 64 | 65 | When you're sending a pull request: 66 | 67 | - Prefer small pull requests focused on one change. 68 | - Verify that linters and tests are passing. 69 | - Review the documentation to make sure it looks good. 70 | - Follow the pull request template when opening a pull request. 71 | - For pull requests that change the API or implementation, discuss with maintainers first by opening an issue. 72 | 73 | ## Code of Conduct 74 | 75 | ### Our Pledge 76 | 77 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 78 | 79 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 80 | 81 | ### Our Standards 82 | 83 | Examples of behavior that contributes to a positive environment for our community include: 84 | 85 | - Demonstrating empathy and kindness toward other people 86 | - Being respectful of differing opinions, viewpoints, and experiences 87 | - Giving and gracefully accepting constructive feedback 88 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 89 | - Focusing on what is best not just for us as individuals, but for the overall community 90 | 91 | Examples of unacceptable behavior include: 92 | 93 | - The use of sexualized language or imagery, and sexual attention or 94 | advances of any kind 95 | - Trolling, insulting or derogatory comments, and personal or political attacks 96 | - Public or private harassment 97 | - Publishing others' private information, such as a physical or email 98 | address, without their explicit permission 99 | - Other conduct which could reasonably be considered inappropriate in a 100 | professional setting 101 | 102 | ### Enforcement Responsibilities 103 | 104 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 105 | 106 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 107 | 108 | ### Scope 109 | 110 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 111 | 112 | ### Enforcement 113 | 114 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [GitHub](https://github.com/react-keycloak/keycloak-ts/issues). All complaints will be reviewed and investigated promptly and fairly. 115 | 116 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 117 | 118 | ### Enforcement Guidelines 119 | 120 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 121 | 122 | #### 1. Correction 123 | 124 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 125 | 126 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 127 | 128 | #### 2. Warning 129 | 130 | **Community Impact**: A violation through a single incident or series of actions. 131 | 132 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 133 | 134 | #### 3. Temporary Ban 135 | 136 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 137 | 138 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 139 | 140 | #### 4. Permanent Ban 141 | 142 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 143 | 144 | **Consequence**: A permanent ban from any sort of public interaction within the community. 145 | 146 | ### Attribution 147 | 148 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 149 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 150 | 151 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 152 | 153 | [homepage]: https://www.contributor-covenant.org 154 | 155 | For answers to common questions about this code of conduct, see the FAQ at 156 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 157 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present, Mattia Panzeri 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 | # KeycloakTS 2 | 3 | > Typescript porting of [Keycloak](https://www.keycloak.org/) javascript client 4 | 5 | ## :construction: WIP: Under development :construction: 6 | 7 | [![NPM (scoped)](https://img.shields.io/npm/v/@react-keycloak/keycloak-ts?label=npm%20%7C%20keycloak-ts)](https://www.npmjs.com/package/@react-keycloak/keycloak-ts) 8 | 9 | [![License](https://img.shields.io/github/license/react-keycloak/keycloak-ts.svg)](https://github.com/react-keycloak/keycloak-ts/blob/master/LICENSE) 10 | [![Github Issues](https://img.shields.io/github/issues/react-keycloak/keycloak-ts.svg)](https://github.com/react-keycloak/keycloak-ts/issues) 11 | 12 | [![Gitter](https://img.shields.io/gitter/room/react-keycloak/community)](https://gitter.im/react-keycloak/community) 13 | 14 | --- 15 | 16 | ## Table of Contents 17 | 18 | - [Install](#install) 19 | - [Getting Started](#getting-started) 20 | - [Create a custom KeycloakAdapter](#create-a-custom-keycloakadapter) 21 | - [Setup Keycloak instance](#setup-keycloak-instance) 22 | - [Contributing](#contributing) 23 | - [License](#license) 24 | 25 | --- 26 | 27 | ## Install 28 | 29 | ```sh 30 | yarn add @react-keycloak/keycloak-ts 31 | ``` 32 | 33 | or 34 | 35 | ```sh 36 | npm install @react-keycloak/keycloak-ts 37 | ``` 38 | 39 | ## Getting Started 40 | 41 | **KeycloakTS** provides a porting of the original [Keycloak javascript adapter](https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter) to allow better extensibility and usage on different platform by using custom adapters. 42 | 43 | ### Create a custom KeycloakAdapter 44 | 45 | Create a class which implements the `KeycloakAdapter` interface 46 | 47 | ```ts 48 | import type { KeycloakAdapter } from '@react-keycloak/keycloak-ts'; 49 | 50 | // Wrap everything inside ReactNativeKeycloakProvider 51 | class MyCustomAdapter implements KeycloakAdapter { 52 | ... 53 | }; 54 | 55 | export default MyCustomAdapter; 56 | ``` 57 | 58 | ### Setup Keycloak instance 59 | 60 | ```ts 61 | import { KeycloakClient } from '@react-keycloak/keycloak-ts'; 62 | 63 | import MyCustomAdapter from './adapter.ts'; 64 | 65 | // Setup Keycloak client as needed 66 | // Pass initialization options as required 67 | const keycloak = new KeycloakClient({ 68 | url: 'http://keycloak-server/auth', 69 | realm: 'kc-realm', 70 | clientId: 'web' 71 | }); 72 | 73 | // Call init passing a custom adapter 74 | 75 | keycloak.init({ 76 | adapter: MyCustomAdapter, 77 | }); 78 | 79 | export default keycloak; 80 | ``` 81 | 82 | ## Contributing 83 | 84 | See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow. 85 | 86 | ## License 87 | 88 | MIT 89 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // babel.config.js 2 | module.exports = { 3 | presets: [ 4 | [ 5 | '@babel/preset-env', 6 | { 7 | targets: { 8 | node: 'current', 9 | }, 10 | }, 11 | ], 12 | '@babel/preset-typescript', 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-keycloak/keycloak-ts", 3 | "version": "0.2.4", 4 | "license": "MIT", 5 | "description": "Keycloak typescript adapter", 6 | "repository": "https://github.com/react-keycloak/keycloak-ts", 7 | "author": "Mattia Panzeri (https://github.com/Panz3r)", 8 | "bugs": { 9 | "url": "https://github.com/react-keycloak/keycloak-ts/issues" 10 | }, 11 | "homepage": "https://github.com/react-keycloak/keycloak-ts#readme", 12 | "keywords": [ 13 | "react", 14 | "keycloak", 15 | "keycloak-js" 16 | ], 17 | "publishConfig": { 18 | "access": "public" 19 | }, 20 | "main": "lib/commonjs/index", 21 | "module": "lib/module/index", 22 | "types": "lib/typescript/index.d.ts", 23 | "files": [ 24 | "lib", 25 | "!**/__tests__", 26 | "!**/__fixtures__", 27 | "!**/__mocks__" 28 | ], 29 | "scripts": { 30 | "test": "jest", 31 | "typescript": "tsc --noEmit", 32 | "lint": "eslint \"**/*.{js,ts,tsx}\"", 33 | "prepare": "husky install && bob build", 34 | "prerelease": "bob build", 35 | "release": "release-it" 36 | }, 37 | "dependencies": { 38 | "base64-js": "^1.3.1", 39 | "js-sha256": "^0.9.0", 40 | "jwt-decode": "^3.1.2" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.15.0", 44 | "@babel/preset-env": "^7.15.0", 45 | "@babel/preset-typescript": "^7.15.0", 46 | "@commitlint/config-conventional": "^16.2.1", 47 | "@react-native-community/eslint-config": "^2.0.0", 48 | "@release-it/conventional-changelog": "^4.1.0", 49 | "@types/base64-js": "^1.3.0", 50 | "@types/jest": "^27.0.0", 51 | "babel-jest": "^27.5.1", 52 | "commitlint": "^16.2.1", 53 | "eslint": "7.32.0", 54 | "eslint-config-prettier": "^6.10.1", 55 | "eslint-plugin-prettier": "3.4.1", 56 | "husky": "^7.0.2", 57 | "jest": "^27.5.1", 58 | "prettier": "^2.0.5", 59 | "react-native-builder-bob": "^0.18.1", 60 | "release-it": "^14.6.2", 61 | "typescript": "^3.8.3" 62 | }, 63 | "jest": { 64 | "modulePathIgnorePatterns": [ 65 | "/example/node_modules", 66 | "/lib/" 67 | ] 68 | }, 69 | "commitlint": { 70 | "extends": [ 71 | "@commitlint/config-conventional" 72 | ] 73 | }, 74 | "release-it": { 75 | "git": { 76 | "commitMessage": "chore: release ${version}", 77 | "tagName": "v${version}" 78 | }, 79 | "npm": { 80 | "publish": true 81 | }, 82 | "github": { 83 | "release": true 84 | }, 85 | "plugins": { 86 | "@release-it/conventional-changelog": { 87 | "preset": "angular" 88 | } 89 | } 90 | }, 91 | "eslintConfig": { 92 | "extends": [ 93 | "@react-native-community", 94 | "prettier" 95 | ], 96 | "rules": { 97 | "prettier/prettier": [ 98 | "error", 99 | { 100 | "quoteProps": "consistent", 101 | "singleQuote": true, 102 | "tabWidth": 2, 103 | "trailingComma": "es5", 104 | "arrowParens": "avoid", 105 | "useTabs": false 106 | } 107 | ] 108 | } 109 | }, 110 | "eslintIgnore": [ 111 | "node_modules/", 112 | "lib/" 113 | ], 114 | "prettier": { 115 | "quoteProps": "consistent", 116 | "singleQuote": true, 117 | "tabWidth": 2, 118 | "trailingComma": "es5", 119 | "arrowParens": "avoid", 120 | "useTabs": false 121 | }, 122 | "react-native-builder-bob": { 123 | "source": "src", 124 | "output": "lib", 125 | "targets": [ 126 | "commonjs", 127 | "module", 128 | [ 129 | "typescript", 130 | { 131 | "project": "tsconfig.build.json" 132 | } 133 | ] 134 | ] 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/__tests__/url.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | extractQuerystringParameters, 3 | formatQuerystringParameters, 4 | } from '../utils/url'; 5 | 6 | describe('utils', () => { 7 | describe('url', () => { 8 | describe('extractQuerystringParameters', () => { 9 | it('should map a querystring - starting with "?"', () => { 10 | // Arrange 11 | const querystring = '?a=1&b=2'; 12 | 13 | // Act 14 | const params = extractQuerystringParameters(querystring); 15 | 16 | // Assert 17 | expect(params).toEqual({ 18 | a: '1', 19 | b: '2', 20 | }); 21 | }); 22 | 23 | it('should map a querystring - without "?"', () => { 24 | // Arrange 25 | const querystring = 'a=3&b=4'; 26 | 27 | // Act 28 | const params = extractQuerystringParameters(querystring); 29 | 30 | // Assert 31 | expect(params).toEqual({ 32 | a: '3', 33 | b: '4', 34 | }); 35 | }); 36 | 37 | it('should map a querystring - with special characters', () => { 38 | // Arrange 39 | const querystring = 40 | 'url=http%3A%2F%2Fa.com%2F%3Fc%3D7%26d%3D8%23%21%2Fasd&sample=hello+world&extra=%2B'; 41 | 42 | // Act 43 | const params = extractQuerystringParameters(querystring); 44 | 45 | // Assert 46 | expect(params).toEqual({ 47 | url: 'http://a.com/?c=7&d=8#!/asd', 48 | sample: 'hello world', 49 | extra: '+', 50 | }); 51 | }); 52 | }); 53 | 54 | describe('formatQuerystringParameters', () => { 55 | it('should format a Map into a Querystring - with a single parameter', () => { 56 | // Arrange 57 | const params = new Map(); 58 | params.set('url', 'http://sample.com/auth'); 59 | 60 | // Act 61 | const qs = formatQuerystringParameters(params); 62 | 63 | // Assert 64 | expect(qs).toBe('url=http%3A%2F%2Fsample.com%2Fauth'); 65 | }); 66 | 67 | it('should format a Map into a Querystring - with multiple parameters', () => { 68 | // Arrange 69 | const params = new Map(); 70 | params.set('url', 'http://sample.com/auth'); 71 | params.set('code', '123%456'); 72 | 73 | // Act 74 | const qs = formatQuerystringParameters(params); 75 | 76 | // Assert 77 | expect(qs).toBe('url=http%3A%2F%2Fsample.com%2Fauth&code=123%25456'); 78 | }); 79 | 80 | it('should format a Map into a Querystring - with a special character', () => { 81 | // Arrange 82 | const params = new Map(); 83 | params.set('url', 'http://sample.com/auth'); 84 | params.set('code', '123~456'); 85 | 86 | // Act 87 | const qs = formatQuerystringParameters(params); 88 | 89 | // Assert 90 | expect(qs).toBe('url=http%3A%2F%2Fsample.com%2Fauth&code=123%7E456'); 91 | }); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/__tests__/uuid.spec.ts: -------------------------------------------------------------------------------- 1 | import { generatePkceChallenge, generateCodeVerifier } from '../utils/uuid'; 2 | 3 | describe('utils', () => { 4 | describe('uuid', () => { 5 | describe('generatePkceChallenge', () => { 6 | it('should generate a pkce challenge', () => { 7 | // Arrange 8 | const codeVerifier = generateCodeVerifier(96); 9 | 10 | // Act 11 | const pkceChallenge = generatePkceChallenge('S256', codeVerifier); 12 | 13 | // Assert 14 | expect(pkceChallenge.length).toBeGreaterThan(0); 15 | }); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CallbackState, 3 | CallbackStorage, 4 | KeycloakAdapter, 5 | KeycloakConfig, 6 | KeycloakEndpoints, 7 | KeycloakError, 8 | KeycloakFlow, 9 | KeycloakInitOptions, 10 | KeycloakInstance, 11 | KeycloakLoginOptions, 12 | KeycloakLogoutOptions, 13 | KeycloakPkceMethod, 14 | KeycloakProfile, 15 | KeycloakRegisterOptions, 16 | KeycloakResourceAccess, 17 | KeycloakResponseMode, 18 | KeycloakResponseType, 19 | KeycloakRoles, 20 | KeycloakTokenParsed, 21 | OAuthResponse, 22 | } from './types'; 23 | 24 | import Deferred from './utils/deferred'; 25 | 26 | import { 27 | decodeToken, 28 | getRealmUrl, 29 | isKeycloakConfig, 30 | setupOidcEndoints, 31 | parseCallbackParams, 32 | } from './utils/keycloak'; 33 | 34 | import { formatQuerystringParameters } from './utils/url'; 35 | 36 | import { 37 | createUUID, 38 | generateCodeVerifier, 39 | generatePkceChallenge, 40 | } from './utils/uuid'; 41 | 42 | /** 43 | * A client for the Keycloak authentication server. 44 | * @see {@link https://keycloak.gitbooks.io/securing-client-applications-guide/content/topics/oidc/javascript-adapter.html|Keycloak JS adapter documentation} 45 | */ 46 | export class KeycloakClient implements KeycloakInstance { 47 | authenticated?: boolean; 48 | 49 | subject?: string; 50 | 51 | responseMode?: KeycloakResponseMode; 52 | 53 | responseType?: KeycloakResponseType; 54 | 55 | flow?: KeycloakFlow; 56 | 57 | realmAccess?: KeycloakRoles; 58 | 59 | resourceAccess?: KeycloakResourceAccess; 60 | 61 | token?: string; 62 | 63 | tokenParsed?: KeycloakTokenParsed; 64 | 65 | refreshToken?: string; 66 | 67 | refreshTokenParsed?: KeycloakTokenParsed; 68 | 69 | idToken?: string; 70 | 71 | idTokenParsed?: KeycloakTokenParsed; 72 | 73 | timeSkew?: number; 74 | 75 | loginRequired?: boolean; 76 | 77 | authServerUrl?: string; 78 | 79 | realm?: string; 80 | 81 | clientId?: string; 82 | 83 | redirectUri?: string; 84 | 85 | profile?: KeycloakProfile; 86 | 87 | userInfo?: unknown; // KeycloakUserInfo; 88 | 89 | enableLogging?: boolean; 90 | 91 | tokenTimeoutHandle?: NodeJS.Timeout | null; 92 | 93 | endpoints?: KeycloakEndpoints; 94 | 95 | clientConfig: KeycloakConfig; 96 | 97 | private adapter?: KeycloakAdapter; 98 | 99 | private callbackStorage?: CallbackStorage; 100 | 101 | private logInfo = this.createLogger(console.info); 102 | 103 | private logWarn = this.createLogger(console.warn); 104 | 105 | private refreshQueue: Array> = []; 106 | 107 | private useNonce?: boolean; 108 | 109 | private pkceMethod?: KeycloakPkceMethod; 110 | 111 | constructor(clientConfig: KeycloakConfig) { 112 | this.clientConfig = clientConfig; 113 | } 114 | 115 | /** 116 | * Called to initialize the adapter. 117 | * @param initOptions Initialization options. 118 | * @returns A promise to set functions to be invoked on success or error. 119 | */ 120 | public async init(initOptions: KeycloakInitOptions): Promise { 121 | this.authenticated = false; 122 | 123 | if (!initOptions.adapter) { 124 | throw new Error('Missing Keycloak adapter from initOptions'); 125 | } 126 | 127 | this.adapter = new initOptions.adapter( 128 | this, 129 | this.clientConfig, 130 | initOptions 131 | ); 132 | 133 | this.callbackStorage = this.adapter.createCallbackStorage(); 134 | 135 | if (initOptions) { 136 | if (typeof initOptions.useNonce !== 'undefined') { 137 | this.useNonce = initOptions.useNonce; 138 | } 139 | 140 | if (initOptions.onLoad === 'login-required') { 141 | this.loginRequired = true; 142 | } 143 | 144 | if (initOptions.responseMode) { 145 | if ( 146 | initOptions.responseMode === 'query' || 147 | initOptions.responseMode === 'fragment' 148 | ) { 149 | this.responseMode = initOptions.responseMode; 150 | } else { 151 | throw new Error('Invalid value for responseMode'); 152 | } 153 | } 154 | 155 | if (initOptions.flow) { 156 | switch (initOptions.flow) { 157 | case 'standard': 158 | this.responseType = 'code'; 159 | break; 160 | 161 | case 'implicit': 162 | this.responseType = 'id_token token'; 163 | break; 164 | 165 | case 'hybrid': 166 | this.responseType = 'code id_token token'; 167 | break; 168 | 169 | default: 170 | throw new Error('Invalid value for flow'); 171 | } 172 | 173 | this.flow = initOptions.flow; 174 | } 175 | 176 | if (initOptions.timeSkew != null) { 177 | this.timeSkew = initOptions.timeSkew; 178 | } 179 | 180 | if (initOptions.redirectUri) { 181 | this.redirectUri = initOptions.redirectUri; 182 | } 183 | 184 | if (initOptions.pkceMethod) { 185 | if (initOptions.pkceMethod !== 'S256') { 186 | throw new Error('Invalid value for pkceMethod'); 187 | } 188 | this.pkceMethod = initOptions.pkceMethod; 189 | } 190 | 191 | if (typeof initOptions.enableLogging === 'boolean') { 192 | this.enableLogging = initOptions.enableLogging; 193 | } else { 194 | this.enableLogging = false; 195 | } 196 | } 197 | 198 | if (!this.responseMode) { 199 | this.responseMode = 'fragment'; 200 | } 201 | 202 | if (!this.responseType) { 203 | this.responseType = 'code'; 204 | this.flow = 'standard'; 205 | } 206 | 207 | await this.loadConfig(this.clientConfig); 208 | 209 | // await check3pCookiesSupported(); // Not supported on RN 210 | 211 | await this.processInit(initOptions); 212 | 213 | // Notify onReady event handler if set 214 | this.onReady && this.onReady(this.authenticated); 215 | 216 | // Return authentication status 217 | return this.authenticated; 218 | } 219 | 220 | /** 221 | * Redirects to login form. 222 | * @param options Login options. 223 | */ 224 | public async login(options?: KeycloakLoginOptions): Promise { 225 | return this.adapter!.login(options); 226 | } 227 | 228 | /** 229 | * Redirects to logout. 230 | * @param options Logout options. 231 | */ 232 | public async logout(options?: KeycloakLogoutOptions): Promise { 233 | return this.adapter!.logout(options); 234 | } 235 | 236 | /** 237 | * Redirects to registration form. 238 | * @param options The options used for the registration. 239 | */ 240 | public async register(options?: KeycloakRegisterOptions): Promise { 241 | return this.adapter!.register(options); 242 | } 243 | 244 | /** 245 | * Redirects to the Account Management Console. 246 | */ 247 | public async accountManagement(): Promise { 248 | return this.adapter!.accountManagement(); 249 | } 250 | 251 | /** 252 | * Returns the URL to login form. 253 | * @param options Supports same options as Keycloak#login. 254 | */ 255 | public createLoginUrl(options?: KeycloakLoginOptions): string { 256 | const state = createUUID(); 257 | const nonce = createUUID(); 258 | 259 | const redirectUri = this.adapter!.redirectUri(options); 260 | const { 261 | scope: scopeOption, 262 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 263 | redirectUri: redirectUriOption, 264 | prompt, 265 | action, 266 | maxAge, 267 | loginHint, 268 | idpHint, 269 | locale, 270 | ...rest 271 | } = options ?? {}; 272 | 273 | let codeVerifier; 274 | let pkceChallenge; 275 | if (this.pkceMethod) { 276 | codeVerifier = generateCodeVerifier(96); 277 | pkceChallenge = generatePkceChallenge(this.pkceMethod, codeVerifier); 278 | } 279 | 280 | const callbackState: CallbackState = { 281 | state, 282 | nonce, 283 | pkceCodeVerifier: codeVerifier, 284 | prompt: options?.prompt ?? undefined, 285 | redirectUri, 286 | }; 287 | 288 | let scope; 289 | if (scopeOption) { 290 | if (scopeOption.indexOf('openid') !== -1) { 291 | scope = scopeOption; 292 | } else { 293 | scope = 'openid ' + scopeOption; 294 | } 295 | } else { 296 | scope = 'openid'; 297 | } 298 | 299 | const baseUrl = 300 | action === 'register' 301 | ? this.endpoints!.register() 302 | : this.endpoints!.authorize(); 303 | 304 | const params = new Map(); 305 | params.set('client_id', this.clientId!); 306 | params.set('redirect_uri', redirectUri); 307 | params.set('state', state); 308 | params.set('response_mode', this.responseMode!); 309 | params.set('response_type', this.responseType!); 310 | params.set('scope', scope); 311 | 312 | if (this.useNonce) { 313 | params.set('nonce', nonce); 314 | } 315 | 316 | if (prompt) { 317 | params.set('prompt', prompt); 318 | } 319 | 320 | if (maxAge) { 321 | params.set('max_age', `${maxAge}`); 322 | } 323 | 324 | if (loginHint) { 325 | params.set('login_hint', loginHint); 326 | } 327 | 328 | if (idpHint) { 329 | params.set('kc_idp_hint', idpHint); 330 | } 331 | 332 | if (action && action !== 'register') { 333 | params.set('kc_action', action); 334 | } 335 | 336 | if (locale) { 337 | params.set('ui_locales', locale); 338 | } 339 | 340 | if (this?.pkceMethod && !!pkceChallenge) { 341 | params.set('code_challenge', pkceChallenge); 342 | params.set('code_challenge_method', this.pkceMethod); 343 | } 344 | 345 | this.callbackStorage!.add(callbackState); 346 | 347 | Object.keys(rest).forEach(key => { 348 | params.set(key, `${rest[key]}`); 349 | }); 350 | 351 | return `${baseUrl}?${formatQuerystringParameters(params)}`; 352 | } 353 | 354 | /** 355 | * Returns the URL to logout the user. 356 | * @param options Logout options. 357 | */ 358 | public createLogoutUrl(options?: KeycloakLogoutOptions): string { 359 | const params = new Map(); 360 | params.set('redirect_uri', this.adapter!.redirectUri(options)); 361 | 362 | return `${this.endpoints!.logout()}?${formatQuerystringParameters(params)}`; 363 | } 364 | 365 | /** 366 | * Returns the URL to registration page. 367 | * @param options The options used for creating the registration URL. 368 | */ 369 | public createRegisterUrl(options: KeycloakRegisterOptions = {}): string { 370 | return this.createLoginUrl({ 371 | ...options, 372 | action: 'register', 373 | }); 374 | } 375 | 376 | /** 377 | * Returns the URL to the Account Management Console. 378 | */ 379 | public createAccountUrl(): string { 380 | const realm = getRealmUrl(this.realm!, this.authServerUrl); 381 | if (typeof realm === 'undefined') { 382 | throw new Error('Failed to create Account URL. realm is not defined.'); 383 | } 384 | 385 | const params = new Map(); 386 | params.set('referrer', this.clientId!); 387 | params.set('referrer_uri', this.adapter!.redirectUri()); 388 | 389 | return `${realm}/account?${formatQuerystringParameters(params)}`; 390 | } 391 | 392 | /** 393 | * Returns true if the token has less than `minValidity` seconds left before 394 | * it expires. 395 | * @param minValidity If not specified, `0` is used. 396 | */ 397 | public isTokenExpired(minValidity?: number): boolean { 398 | if (!this.tokenParsed || (!this.refreshToken && this.flow !== 'implicit')) { 399 | throw 'Not authenticated'; 400 | } 401 | 402 | if (this.timeSkew == null) { 403 | this.logInfo( 404 | '[KEYCLOAK] Unable to determine if token is expired as timeskew is not set' 405 | ); 406 | return true; 407 | } 408 | 409 | let expiresIn = 410 | (this.tokenParsed?.exp ?? 0) - 411 | Math.ceil(new Date().getTime() / 1000) + 412 | this.timeSkew; 413 | 414 | if (minValidity) { 415 | if (isNaN(minValidity)) { 416 | throw 'Invalid minValidity'; 417 | } 418 | 419 | expiresIn -= minValidity; 420 | } 421 | 422 | return expiresIn < 0; 423 | } 424 | 425 | private async runUpdateToken( 426 | minValidity: number, 427 | deffered: Deferred 428 | ) { 429 | let shouldRefreshToken: boolean = false; 430 | 431 | if (minValidity === -1) { 432 | shouldRefreshToken = true; 433 | this.logInfo('[KEYCLOAK] Refreshing token: forced refresh'); 434 | } else if (!this.tokenParsed || this.isTokenExpired(minValidity)) { 435 | shouldRefreshToken = true; 436 | this.logInfo('[KEYCLOAK] Refreshing token: token expired'); 437 | } 438 | 439 | if (!shouldRefreshToken) { 440 | deffered.resolve(false); 441 | } else { 442 | const tokenUrl = this.endpoints!.token(); 443 | 444 | const params = new Map(); 445 | params.set('client_id', this.clientId!); 446 | params.set('grant_type', 'refresh_token'); 447 | params.set('refresh_token', this.refreshToken!); 448 | 449 | this.refreshQueue.push(deffered); 450 | 451 | if (this.refreshQueue.length === 1) { 452 | let timeLocal = new Date().getTime(); 453 | 454 | try { 455 | const tokenResponse = await this.adapter!.refreshTokens( 456 | tokenUrl, 457 | formatQuerystringParameters(params) 458 | ); 459 | 460 | if (tokenResponse.error) { 461 | this.clearToken(); 462 | throw new Error(tokenResponse.error); 463 | } else { 464 | this.logInfo('[KEYCLOAK] Token refreshed'); 465 | 466 | timeLocal = (timeLocal + new Date().getTime()) / 2; 467 | 468 | this.setToken( 469 | tokenResponse.access_token, 470 | tokenResponse.refresh_token, 471 | tokenResponse.id_token, 472 | timeLocal 473 | ); 474 | 475 | // Notify onAuthRefreshSuccess event handler if set 476 | this.onAuthRefreshSuccess && this.onAuthRefreshSuccess(); 477 | 478 | for ( 479 | let p = this.refreshQueue.pop(); 480 | p != null; 481 | p = this.refreshQueue.pop() 482 | ) { 483 | p.resolve(true); 484 | } 485 | } 486 | } catch (err) { 487 | this.logWarn('[KEYCLOAK] Failed to refresh token'); 488 | 489 | // Notify onAuthRefreshError event handler if set 490 | this.onAuthRefreshError && this.onAuthRefreshError(); 491 | 492 | for ( 493 | let p = this.refreshQueue.pop(); 494 | p != null; 495 | p = this.refreshQueue.pop() 496 | ) { 497 | p.reject(true); 498 | } 499 | } 500 | } 501 | } 502 | } 503 | 504 | /** 505 | * If the token expires within `minValidity` seconds, the token is refreshed. 506 | * If the session status iframe is enabled, the session status is also 507 | * checked. 508 | * @returns A promise to set functions that can be invoked if the token is 509 | * still valid, or if the token is no longer valid. 510 | * @example 511 | * ```js 512 | * keycloak.updateToken(5).then(function(refreshed) { 513 | * if (refreshed) { 514 | * alert('Token was successfully refreshed'); 515 | * } else { 516 | * alert('Token is still valid'); 517 | * } 518 | * }).catch(function() { 519 | * alert('Failed to refresh the token, or the session has expired'); 520 | * }); 521 | */ 522 | public async updateToken(minValidity: number = 5): Promise { 523 | const deffered = new Deferred(); 524 | 525 | if (!this.refreshToken) { 526 | deffered.reject('missing refreshToken'); 527 | return deffered.getPromise(); 528 | } 529 | 530 | this.runUpdateToken(minValidity, deffered); 531 | 532 | return deffered.getPromise(); 533 | } 534 | 535 | /** 536 | * Clears authentication state, including tokens. This can be useful if 537 | * the application has detected the session was expired, for example if 538 | * updating token fails. Invoking this results in Keycloak#onAuthLogout 539 | * callback listener being invoked. 540 | */ 541 | public clearToken(): void { 542 | if (this.token) { 543 | this.setToken(null, null, null); 544 | 545 | // Notify onAuthLogout event handler if set 546 | this.onAuthLogout && this.onAuthLogout(); 547 | 548 | if (this.loginRequired) { 549 | this.login(); 550 | } 551 | } 552 | } 553 | 554 | /** 555 | * Returns true if the token has the given realm role. 556 | * @param role A realm role name. 557 | */ 558 | public hasRealmRole(role: string): boolean { 559 | return !!this.realmAccess && this.realmAccess.roles?.indexOf(role) >= 0; 560 | } 561 | 562 | /** 563 | * Returns true if the token has the given role for the resource. 564 | * @param role A role name. 565 | * @param resource If not specified, `clientId` is used. 566 | */ 567 | public hasResourceRole(role: string, resource?: string): boolean { 568 | if (!this.resourceAccess) { 569 | return false; 570 | } 571 | 572 | const access = this.resourceAccess[resource || this.clientId || '']; 573 | return !!access && access.roles.indexOf(role) >= 0; 574 | } 575 | 576 | /** 577 | * Loads the user's profile. 578 | * 579 | * @returns The current user KeycloakProfile. 580 | */ 581 | async loadUserProfile(): Promise { 582 | const profileUrl = 583 | getRealmUrl(this.realm!, this.authServerUrl) + '/account'; 584 | 585 | const userProfileRes = await this.adapter!.fetchUserProfile( 586 | profileUrl, 587 | this.token! 588 | ); 589 | 590 | this.profile = userProfileRes; 591 | return this.profile; 592 | } 593 | 594 | /** 595 | * @private Undocumented. 596 | */ 597 | async loadUserInfo(): Promise { 598 | const userInfoUrl = this.endpoints!.userinfo(); 599 | 600 | const userInfoRes = await this.adapter!.fetchUserInfo( 601 | userInfoUrl, 602 | this.token! 603 | ); 604 | 605 | this.userInfo = userInfoRes; 606 | return this.userInfo; 607 | } 608 | 609 | /** 610 | * Called when the adapter is initialized. 611 | */ 612 | onReady?(authenticated?: boolean): void; 613 | 614 | /** 615 | * Called when a user is successfully authenticated. 616 | */ 617 | onAuthSuccess?(): void; 618 | 619 | /** 620 | * Called if there was an error during authentication. 621 | */ 622 | onAuthError?(errorData: KeycloakError): void; 623 | 624 | /** 625 | * Called when the token is refreshed. 626 | */ 627 | onAuthRefreshSuccess?(): void; 628 | 629 | /** 630 | * Called if there was an error while trying to refresh the token. 631 | */ 632 | onAuthRefreshError?(): void; 633 | 634 | /** 635 | * Called if the user is logged out (will only be called if the session 636 | * status iframe is enabled, or in Cordova mode). 637 | */ 638 | onAuthLogout?(): void; 639 | 640 | /** 641 | * Called when the access token is expired. If a refresh token is available 642 | * the token can be refreshed with Keycloak#updateToken, or in cases where 643 | * it's not (ie. with implicit flow) you can redirect to login screen to 644 | * obtain a new access token. 645 | */ 646 | onTokenExpired?(): void; 647 | 648 | /** 649 | * Called when a AIA has been requested by the application. 650 | */ 651 | onActionUpdate?(status: 'success' | 'cancelled' | 'error'): void; 652 | 653 | /** 654 | * @private Undocumented. 655 | */ 656 | async processCallback(oauth: OAuthResponse) { 657 | const timeLocal = new Date().getTime(); 658 | 659 | if (oauth.kc_action_status) { 660 | this.onActionUpdate && this.onActionUpdate(oauth.kc_action_status); 661 | } 662 | 663 | const { code, error, prompt } = oauth; 664 | 665 | if (error) { 666 | if (prompt !== 'none') { 667 | this.onAuthError && 668 | this.onAuthError({ 669 | error, 670 | error_description: oauth.error_description ?? 'auth error', 671 | }); 672 | 673 | throw new Error(oauth.error_description); 674 | } 675 | 676 | return; 677 | } 678 | 679 | if (this.flow !== 'standard' && (oauth.access_token || oauth.id_token)) { 680 | return this.authSuccess(oauth, timeLocal, true); 681 | } 682 | 683 | if (this.flow !== 'implicit' && code) { 684 | const params = new Map(); 685 | params.set('code', code); 686 | params.set('grant_type', 'authorization_code'); 687 | params.set('client_id', this.clientId!); 688 | params.set('redirect_uri', oauth.redirectUri!); 689 | 690 | if (oauth.pkceCodeVerifier) { 691 | params.set('code_verifier', oauth.pkceCodeVerifier); 692 | } 693 | 694 | const tokenUrl = this.endpoints!.token(); 695 | try { 696 | const tokenResponse = await this.adapter!.fetchTokens( 697 | tokenUrl, 698 | formatQuerystringParameters(params) 699 | ); 700 | 701 | await this.authSuccess( 702 | { 703 | ...oauth, 704 | access_token: tokenResponse.access_token || undefined, 705 | refresh_token: tokenResponse.refresh_token || undefined, 706 | id_token: tokenResponse.id_token || undefined, 707 | }, 708 | timeLocal, 709 | this.flow === 'standard' 710 | ); 711 | } catch (err) { 712 | // Notify onAuthError event handler if set 713 | this.onAuthError && 714 | this.onAuthError({ 715 | error: err, 716 | error_description: 717 | 'Failed to refresh token during callback processing', 718 | }); 719 | 720 | throw new Error(err); 721 | } 722 | } 723 | } 724 | 725 | private async authSuccess( 726 | oauthObj: OAuthResponse, 727 | timeLocal: number, 728 | fulfillPromise: boolean 729 | ) { 730 | timeLocal = (timeLocal + new Date().getTime()) / 2; 731 | 732 | this.setToken( 733 | oauthObj.access_token ?? null, 734 | oauthObj.refresh_token ?? null, 735 | oauthObj.id_token ?? null, 736 | timeLocal 737 | ); 738 | 739 | if ( 740 | this.useNonce && 741 | ((this.tokenParsed && this.tokenParsed.nonce !== oauthObj.storedNonce) || 742 | (this.refreshTokenParsed && 743 | this.refreshTokenParsed.nonce !== oauthObj.storedNonce) || 744 | (this.idTokenParsed && 745 | this.idTokenParsed.nonce !== oauthObj.storedNonce)) 746 | ) { 747 | this.logInfo('[KEYCLOAK] Invalid nonce, clearing token'); 748 | this.clearToken(); 749 | 750 | throw new Error('invalid nonce, token cleared'); 751 | } 752 | 753 | if (fulfillPromise) { 754 | this.onAuthSuccess && this.onAuthSuccess(); 755 | } 756 | } 757 | 758 | /** 759 | * @private Undocumented. 760 | */ 761 | parseCallback(url: string): OAuthResponse { 762 | const oauthParsed = this.parseCallbackUrl(url); 763 | if (!oauthParsed) { 764 | throw new Error('Failed to parse redirect URL'); 765 | } 766 | 767 | const oauthState = this.callbackStorage!.get(oauthParsed.state as string); 768 | 769 | if (oauthState) { 770 | return { 771 | ...oauthParsed, 772 | valid: true, 773 | redirectUri: oauthState.redirectUri, 774 | storedNonce: oauthState.nonce, 775 | prompt: oauthState.prompt, 776 | pkceCodeVerifier: oauthState.pkceCodeVerifier, 777 | }; 778 | } 779 | 780 | return oauthParsed as OAuthResponse; 781 | } 782 | 783 | private async processInit(initOptions?: KeycloakInitOptions): Promise { 784 | if (initOptions) { 785 | if (initOptions.token && initOptions.refreshToken) { 786 | this.setToken( 787 | initOptions.token, 788 | initOptions.refreshToken, 789 | initOptions.idToken ?? null 790 | ); 791 | 792 | try { 793 | await this.updateToken(-1); 794 | 795 | // Notify onAuthSuccess event handler if set 796 | this.onAuthSuccess && this.onAuthSuccess(); 797 | } catch (error) { 798 | // Notify onAuthError event handler if set 799 | this.onAuthError && 800 | this.onAuthError({ 801 | error, 802 | error_description: 'Failed to refresh token during init', 803 | }); 804 | 805 | if (initOptions.onLoad) { 806 | this.onLoad(initOptions); 807 | } else { 808 | throw new Error('Failed to init'); 809 | } 810 | } 811 | // } 812 | } else if (initOptions.onLoad) { 813 | this.onLoad(initOptions); 814 | } 815 | } 816 | } 817 | 818 | private async onLoad(initOptions: KeycloakInitOptions): Promise { 819 | switch (initOptions.onLoad) { 820 | case 'login-required': 821 | this.doLogin(initOptions, true); 822 | break; 823 | 824 | case 'check-sso': 825 | break; 826 | 827 | default: 828 | throw new Error('Invalid value for onLoad'); 829 | } 830 | } 831 | 832 | private async doLogin( 833 | initOptions: KeycloakInitOptions, 834 | prompt?: boolean 835 | ): Promise { 836 | return this.login({ 837 | ...initOptions, 838 | prompt: !prompt ? 'none' : undefined, 839 | }); 840 | } 841 | 842 | private setToken( 843 | token: string | null, 844 | refreshToken: string | null, 845 | idToken: string | null, 846 | timeLocal?: number 847 | ) { 848 | if (this.tokenTimeoutHandle) { 849 | clearTimeout(this.tokenTimeoutHandle); 850 | this.tokenTimeoutHandle = null; 851 | } 852 | 853 | if (refreshToken) { 854 | this.refreshToken = refreshToken; 855 | this.refreshTokenParsed = decodeToken(refreshToken); 856 | } else { 857 | delete this.refreshToken; 858 | delete this.refreshTokenParsed; 859 | } 860 | 861 | if (idToken) { 862 | this.idToken = idToken; 863 | this.idTokenParsed = decodeToken(idToken); 864 | } else { 865 | delete this.idToken; 866 | delete this.idTokenParsed; 867 | } 868 | 869 | if (token) { 870 | this.token = token; 871 | this.tokenParsed = decodeToken(token); 872 | if (!this.tokenParsed) { 873 | throw new Error('Invalid tokenParsed'); 874 | } 875 | 876 | this.authenticated = true; 877 | this.subject = this.tokenParsed.sub; 878 | this.realmAccess = this.tokenParsed.realm_access; 879 | this.resourceAccess = this.tokenParsed.resource_access; 880 | 881 | if (timeLocal) { 882 | this.timeSkew = 883 | Math.floor(timeLocal / 1000) - (this.tokenParsed.iat ?? 0); 884 | } 885 | 886 | if (this.timeSkew != null) { 887 | this.logInfo( 888 | `[KEYCLOAK] Estimated time difference between browser and server is ${this.timeSkew} seconds` 889 | ); 890 | 891 | if (this.onTokenExpired) { 892 | const expiresIn = 893 | ((this.tokenParsed.exp ?? 0) - 894 | new Date().getTime() / 1000 + 895 | this.timeSkew) * 896 | 1000; 897 | 898 | this.logInfo( 899 | `[KEYCLOAK] Token expires in ${Math.round(expiresIn / 1000)} s` 900 | ); 901 | 902 | if (expiresIn <= 0) { 903 | this.onTokenExpired(); 904 | } else { 905 | this.tokenTimeoutHandle = setTimeout( 906 | this.onTokenExpired, 907 | expiresIn 908 | ); 909 | } 910 | } 911 | } 912 | } else { 913 | delete this.token; 914 | delete this.tokenParsed; 915 | delete this.subject; 916 | delete this.realmAccess; 917 | delete this.resourceAccess; 918 | 919 | this.authenticated = false; 920 | } 921 | } 922 | 923 | private createLogger(fn: (...optionalParams: unknown[]) => void): Function { 924 | return () => { 925 | if (this.enableLogging) { 926 | fn.apply(console, Array.prototype.slice.call(arguments)); 927 | } 928 | }; 929 | } 930 | 931 | private async loadConfig(config?: KeycloakConfig | string): Promise { 932 | let configUrl; 933 | if (!config) { 934 | configUrl = 'keycloak.json'; 935 | } else if (typeof config === 'string') { 936 | configUrl = config; 937 | } 938 | 939 | if (configUrl) { 940 | const configJSON = await this.adapter!.fetchKeycloakConfigJSON(configUrl); 941 | 942 | this.realm = configJSON.realm; 943 | this.clientId = configJSON.resource; 944 | 945 | this.endpoints = setupOidcEndoints({ 946 | realm: this.realm, 947 | authServerUrl: this.authServerUrl, 948 | }); 949 | 950 | return; 951 | } 952 | 953 | if (!isKeycloakConfig(config)) { 954 | throw new Error('invalid configuration format'); 955 | } 956 | 957 | if (!config.clientId) { 958 | throw new Error('clientId missing from configuration'); 959 | } 960 | 961 | this.clientId = config.clientId; 962 | 963 | const oidcProvider = config.oidcProvider; 964 | // When oidcProvider config is not supplied, use local configuration params 965 | if (!oidcProvider) { 966 | if (!config.realm) { 967 | throw new Error('realm missing from configuration'); 968 | } 969 | 970 | this.realm = config.realm; 971 | this.authServerUrl = config.url; 972 | 973 | this.endpoints = setupOidcEndoints({ 974 | realm: this.realm, 975 | authServerUrl: this.authServerUrl, 976 | }); 977 | 978 | return; 979 | } 980 | 981 | // When oidcProvider config is a string, load the config from the URL 982 | if (typeof oidcProvider === 'string') { 983 | let oidcProviderConfigUrl; 984 | if (oidcProvider.charAt(oidcProvider.length - 1) === '/') { 985 | oidcProviderConfigUrl = 986 | oidcProvider + '.well-known/openid-configuration'; 987 | } else { 988 | oidcProviderConfigUrl = 989 | oidcProvider + '/.well-known/openid-configuration'; 990 | } 991 | 992 | try { 993 | const oidcProviderConfig = 994 | await this.adapter!.fetchOIDCProviderConfigJSON( 995 | oidcProviderConfigUrl 996 | ); 997 | 998 | this.endpoints = setupOidcEndoints({ 999 | oidcConfiguration: oidcProviderConfig, 1000 | }); 1001 | 1002 | return; 1003 | } catch (err) { 1004 | throw err; 1005 | } 1006 | } 1007 | 1008 | // Otherwise oidcProvider is a config object and should be used 1009 | this.endpoints = setupOidcEndoints({ 1010 | oidcConfiguration: oidcProvider, 1011 | }); 1012 | } 1013 | 1014 | private parseCallbackUrl(url: string) { 1015 | let supportedParams: string[] = []; 1016 | switch (this.flow) { 1017 | case 'standard': 1018 | supportedParams = [ 1019 | 'code', 1020 | 'state', 1021 | 'session_state', 1022 | 'kc_action_status', 1023 | ]; 1024 | break; 1025 | 1026 | case 'implicit': 1027 | supportedParams = [ 1028 | 'access_token', 1029 | 'token_type', 1030 | 'id_token', 1031 | 'state', 1032 | 'session_state', 1033 | 'expires_in', 1034 | 'kc_action_status', 1035 | ]; 1036 | break; 1037 | 1038 | case 'hybrid': 1039 | supportedParams = [ 1040 | 'access_token', 1041 | 'id_token', 1042 | 'code', 1043 | 'state', 1044 | 'session_state', 1045 | 'kc_action_status', 1046 | ]; 1047 | break; 1048 | } 1049 | 1050 | supportedParams.push('error'); 1051 | supportedParams.push('error_description'); 1052 | supportedParams.push('error_uri'); 1053 | 1054 | const queryIndex = url.indexOf('?'); 1055 | const fragmentIndex = url.indexOf('#'); 1056 | 1057 | let newUrl: string; 1058 | let parsed; 1059 | 1060 | if (this.responseMode === 'query' && queryIndex !== -1) { 1061 | newUrl = url.substring(0, queryIndex); 1062 | parsed = parseCallbackParams( 1063 | url.substring( 1064 | queryIndex + 1, 1065 | fragmentIndex !== -1 ? fragmentIndex : url.length 1066 | ), 1067 | supportedParams 1068 | ); 1069 | if (parsed.paramsString !== '') { 1070 | newUrl += '?' + parsed.paramsString; 1071 | } 1072 | if (fragmentIndex !== -1) { 1073 | newUrl += url.substring(fragmentIndex); 1074 | } 1075 | } else if (this.responseMode === 'fragment' && fragmentIndex !== -1) { 1076 | newUrl = url.substring(0, fragmentIndex); 1077 | parsed = parseCallbackParams( 1078 | url.substring(fragmentIndex + 1), 1079 | supportedParams 1080 | ); 1081 | if (parsed.paramsString !== '') { 1082 | newUrl += '#' + parsed.paramsString; 1083 | } 1084 | } 1085 | 1086 | if (parsed && parsed.oauthParams) { 1087 | if (this.flow === 'standard' || this.flow === 'hybrid') { 1088 | if ( 1089 | (parsed.oauthParams.code || parsed.oauthParams.error) && 1090 | parsed.oauthParams.state 1091 | ) { 1092 | parsed.oauthParams.newUrl = newUrl!; 1093 | return parsed.oauthParams; 1094 | } 1095 | } else if (this.flow === 'implicit') { 1096 | if ( 1097 | (parsed.oauthParams.access_token || parsed.oauthParams.error) && 1098 | parsed.oauthParams.state 1099 | ) { 1100 | parsed.oauthParams.newUrl = newUrl!; 1101 | return parsed.oauthParams; 1102 | } 1103 | } 1104 | } 1105 | 1106 | return {}; 1107 | } 1108 | } 1109 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface OIDCProviderConfig { 2 | [key: string]: unknown; 3 | } 4 | 5 | export interface KeycloakConfig { 6 | /** 7 | * URL to the Keycloak server, for example: http://keycloak-server/auth 8 | */ 9 | url?: string; 10 | /** 11 | * Name of the realm, for example: 'myrealm' 12 | */ 13 | realm: string; 14 | /** 15 | * Client identifier, example: 'myapp' 16 | */ 17 | clientId: string; 18 | /** 19 | * OIDC-specific configuration parameters 20 | */ 21 | oidcProvider?: string | OIDCProviderConfig; 22 | } 23 | 24 | export type KeycloakOnLoad = 'login-required' | 'check-sso'; 25 | 26 | export type KeycloakResponseMode = 'query' | 'fragment'; 27 | 28 | export type KeycloakResponseType = 29 | | 'code' 30 | | 'id_token token' 31 | | 'code id_token token'; 32 | 33 | export type KeycloakFlow = 'standard' | 'implicit' | 'hybrid'; 34 | 35 | export type KeycloakPkceMethod = 'S256'; 36 | 37 | export type CallbackState = { 38 | state: string; 39 | 40 | nonce: string; 41 | 42 | pkceCodeVerifier?: string; 43 | 44 | prompt?: string; 45 | 46 | redirectUri?: string; 47 | 48 | /** 49 | * @private Internal use only 50 | */ 51 | expires?: number; 52 | }; 53 | 54 | export interface CallbackStorage { 55 | get: (state?: string) => CallbackState | undefined; 56 | 57 | add: (state: CallbackState) => void; 58 | } 59 | 60 | export interface KeycloakInitOptions { 61 | /** 62 | * Adds a [cryptographic nonce](https://en.wikipedia.org/wiki/Cryptographic_nonce) 63 | * to verify that the authentication response matches the request. 64 | * @default true 65 | */ 66 | useNonce?: boolean; 67 | 68 | /** 69 | * 70 | * Allow usage of a custom adapter to make Keycloak work in different environments. 71 | * 72 | * It's possible to pass in a custom adapter for the environment you are running Keycloak in. In order to do so extend the `KeycloakAdapter` interface and implement the methods that are defined there. 73 | * 74 | * For example: 75 | * 76 | * ```ts 77 | * import { KeycloakClient, KeycloakAdapter } from '@react-native/keycloak-ts'; 78 | * 79 | * // Implement the 'KeycloakAdapter' interface so that all required methods are guaranteed to be present. 80 | * const MyCustomAdapter: KeycloakAdapter = { 81 | * login(options) { 82 | * // Write your own implementation here. 83 | * } 84 | * 85 | * // The other methods go here... 86 | * }; 87 | * 88 | * const keycloak = new KeycloakClient(); 89 | * 90 | * keycloak.init({ 91 | * adapter: MyCustomAdapter, 92 | * }); 93 | * ``` 94 | */ 95 | adapter?: KeycloakAdapterConstructor; 96 | 97 | /** 98 | * Specifies an action to do on load. 99 | */ 100 | onLoad?: KeycloakOnLoad; 101 | 102 | /** 103 | * Set an initial value for the token. 104 | */ 105 | token?: string; 106 | 107 | /** 108 | * Set an initial value for the refresh token. 109 | */ 110 | refreshToken?: string; 111 | 112 | /** 113 | * Set an initial value for the id token (only together with `token` or 114 | * `refreshToken`). 115 | */ 116 | idToken?: string; 117 | 118 | /** 119 | * Set an initial value for skew between local time and Keycloak server in 120 | * seconds (only together with `token` or `refreshToken`). 121 | */ 122 | timeSkew?: number; 123 | 124 | /** 125 | * Set to enable/disable monitoring login state. 126 | * @default true 127 | */ 128 | checkLoginIframe?: boolean; 129 | 130 | /** 131 | * Set the interval to check login state (in seconds). 132 | * @default 5 133 | */ 134 | checkLoginIframeInterval?: number; 135 | 136 | /** 137 | * Set the OpenID Connect response mode to send to Keycloak upon login. 138 | * @default fragment After successful authentication Keycloak will redirect 139 | * to JavaScript application with OpenID Connect parameters 140 | * added in URL fragment. This is generally safer and 141 | * recommended over query. 142 | */ 143 | responseMode?: KeycloakResponseMode; 144 | 145 | /** 146 | * Specifies a default uri to redirect to after login or logout. 147 | * This is currently supported for adapter 'cordova-native' and 'default' 148 | */ 149 | redirectUri?: string; 150 | 151 | /** 152 | * Specifies an uri to redirect to after silent check-sso. 153 | * Silent check-sso will only happen, when this redirect uri is given and 154 | * the specified uri is available whithin the application. 155 | */ 156 | silentCheckSsoRedirectUri?: string; 157 | 158 | /** 159 | * Specifies whether the silent check-sso should fallback to "non-silent" 160 | * check-sso when 3rd party cookies are blocked by the browser. Defaults 161 | * to true. 162 | */ 163 | silentCheckSsoFallback?: boolean; 164 | 165 | /** 166 | * Set the OpenID Connect flow. 167 | * @default standard 168 | */ 169 | flow?: KeycloakFlow; 170 | 171 | /** 172 | * Configures the Proof Key for Code Exchange (PKCE) method to use. 173 | * The currently allowed method is 'S256'. 174 | * If not configured, PKCE will not be used. 175 | */ 176 | pkceMethod?: KeycloakPkceMethod; 177 | 178 | /** 179 | * Enables logging messages from Keycloak to the console. 180 | * @default false 181 | */ 182 | enableLogging?: boolean; 183 | } 184 | 185 | export interface KeycloakLoginOptions { 186 | /** 187 | * @private Undocumented. 188 | */ 189 | scope?: string; 190 | 191 | /** 192 | * Specifies the uri to redirect to after login. 193 | */ 194 | redirectUri?: string; 195 | 196 | /** 197 | * By default the login screen is displayed if the user is not logged into 198 | * Keycloak. To only authenticate to the application if the user is already 199 | * logged in and not display the login page if the user is not logged in, set 200 | * this option to `'none'`. To always require re-authentication and ignore 201 | * SSO, set this option to `'login'`. 202 | */ 203 | prompt?: 'none' | 'login'; 204 | 205 | /** 206 | * If value is `'register'` then user is redirected to registration page, 207 | * otherwise to login page. 208 | */ 209 | action?: string; 210 | 211 | /** 212 | * Used just if user is already authenticated. Specifies maximum time since 213 | * the authentication of user happened. If user is already authenticated for 214 | * longer time than `'maxAge'`, the SSO is ignored and he will need to 215 | * authenticate again. 216 | */ 217 | maxAge?: number; 218 | 219 | /** 220 | * Used to pre-fill the username/email field on the login form. 221 | */ 222 | loginHint?: string; 223 | 224 | /** 225 | * Used to tell Keycloak which IDP the user wants to authenticate with. 226 | */ 227 | idpHint?: string; 228 | 229 | /** 230 | * Sets the 'ui_locales' query param in compliance with section 3.1.2.1 231 | * of the OIDC 1.0 specification. 232 | */ 233 | locale?: string; 234 | 235 | /** 236 | * Extra params to pass on login 237 | */ 238 | [k: string]: unknown; 239 | } 240 | 241 | export interface KeycloakLogoutOptions { 242 | /** 243 | * Specifies the uri to redirect to after logout. 244 | */ 245 | redirectUri?: string; 246 | } 247 | 248 | export interface KeycloakRegisterOptions 249 | extends Omit {} 250 | 251 | export interface FetchTokenResponse { 252 | access_token: string | null; 253 | 254 | id_token: string | null; 255 | 256 | refresh_token: string | null; 257 | 258 | error: string | null; 259 | } 260 | 261 | export interface KeycloakAdapterConstructor { 262 | new ( 263 | client: Readonly, 264 | keycloakConfiguration: Readonly, 265 | initOptions: Readonly 266 | ): KeycloakAdapter; 267 | } 268 | 269 | export interface KeycloakAdapter { 270 | createCallbackStorage(): CallbackStorage; 271 | 272 | login(options?: KeycloakLoginOptions): Promise; 273 | 274 | logout(options?: KeycloakLogoutOptions): Promise; 275 | 276 | register(options?: KeycloakRegisterOptions): Promise; 277 | 278 | accountManagement(): Promise; 279 | 280 | fetchKeycloakConfigJSON(configUrl: string): Promise; 281 | 282 | fetchOIDCProviderConfigJSON(configUrl: string): Promise; 283 | 284 | fetchTokens(tokenUrl: string, params: string): Promise; 285 | 286 | refreshTokens(tokenUrl: string, params: string): Promise; 287 | 288 | fetchUserProfile(profileUrl: string, token: string): Promise; 289 | 290 | fetchUserInfo(userInfoUrl: string, token: string): Promise; 291 | 292 | redirectUri(options?: { redirectUri?: string }, encodeHash?: boolean): string; 293 | } 294 | 295 | export interface KeycloakEndpoints { 296 | [key: string]: () => string; 297 | } 298 | 299 | export interface KeycloakProfile { 300 | id?: string; 301 | 302 | username?: string; 303 | 304 | email?: string; 305 | 306 | firstName?: string; 307 | 308 | lastName?: string; 309 | 310 | enabled?: boolean; 311 | 312 | emailVerified?: boolean; 313 | 314 | totp?: boolean; 315 | 316 | createdTimestamp?: number; 317 | } 318 | 319 | export interface KeycloakTokenParsed { 320 | exp?: number; 321 | 322 | iat?: number; 323 | 324 | nonce?: string; 325 | 326 | sub?: string; 327 | 328 | session_state?: string; 329 | 330 | realm_access?: KeycloakRoles; 331 | 332 | resource_access?: KeycloakResourceAccess; 333 | } 334 | 335 | export interface KeycloakResourceAccess { 336 | [key: string]: KeycloakRoles; 337 | } 338 | 339 | export interface KeycloakRoles { 340 | roles: string[]; 341 | } 342 | 343 | export interface KeycloakError { 344 | error: string; 345 | 346 | error_description: string; 347 | } 348 | 349 | export interface KeycloakProfile { 350 | id?: string; 351 | 352 | username?: string; 353 | 354 | email?: string; 355 | 356 | firstName?: string; 357 | 358 | lastName?: string; 359 | 360 | enabled?: boolean; 361 | 362 | emailVerified?: boolean; 363 | 364 | totp?: boolean; 365 | 366 | createdTimestamp?: number; 367 | } 368 | 369 | export interface KeycloakJSON { 370 | realm?: string; 371 | 372 | resource?: string; 373 | } 374 | 375 | export interface OAuthResponse { 376 | valid?: boolean; 377 | 378 | code?: string; 379 | 380 | error?: string; 381 | 382 | prompt?: string; 383 | 384 | kc_action_status?: 'success' | 'cancelled' | 'error'; 385 | 386 | error_description?: string; 387 | 388 | access_token?: string; 389 | 390 | id_token?: string; 391 | 392 | pkceCodeVerifier?: string; 393 | 394 | redirectUri?: string; 395 | 396 | refresh_token?: string; 397 | 398 | storedNonce?: string; 399 | } 400 | 401 | /** 402 | * A client for the Keycloak authentication server. 403 | * @see {@link https://keycloak.gitbooks.io/securing-client-applications-guide/content/topics/oidc/javascript-adapter.html|Keycloak JS adapter documentation} 404 | */ 405 | export interface KeycloakInstance { 406 | /** 407 | * Is true if the user is authenticated, false otherwise. 408 | */ 409 | authenticated?: boolean; 410 | 411 | /** 412 | * The user id. 413 | */ 414 | subject?: string; 415 | 416 | /** 417 | * Response mode passed in init (default value is `'fragment'`). 418 | */ 419 | responseMode?: KeycloakResponseMode; 420 | 421 | /** 422 | * Response type sent to Keycloak with login requests. This is determined 423 | * based on the flow value used during initialization, but can be overridden 424 | * by setting this value. 425 | */ 426 | responseType?: KeycloakResponseType; 427 | 428 | /** 429 | * Flow passed in init. 430 | */ 431 | flow?: KeycloakFlow; 432 | 433 | /** 434 | * The realm roles associated with the token. 435 | */ 436 | realmAccess?: KeycloakRoles; 437 | 438 | /** 439 | * The resource roles associated with the token. 440 | */ 441 | resourceAccess?: KeycloakResourceAccess; 442 | 443 | /** 444 | * The base64 encoded token that can be sent in the Authorization header in 445 | * requests to services. 446 | */ 447 | token?: string; 448 | 449 | /** 450 | * The parsed token as a JavaScript object. 451 | */ 452 | tokenParsed?: KeycloakTokenParsed; 453 | 454 | /** 455 | * The base64 encoded refresh token that can be used to retrieve a new token. 456 | */ 457 | refreshToken?: string; 458 | 459 | /** 460 | * The parsed refresh token as a JavaScript object. 461 | */ 462 | refreshTokenParsed?: KeycloakTokenParsed; 463 | 464 | /** 465 | * The base64 encoded ID token. 466 | */ 467 | idToken?: string; 468 | 469 | /** 470 | * The parsed id token as a JavaScript object. 471 | */ 472 | idTokenParsed?: KeycloakTokenParsed; 473 | 474 | /** 475 | * The estimated time difference between the browser time and the Keycloak 476 | * server in seconds. This value is just an estimation, but is accurate 477 | * enough when determining if a token is expired or not. 478 | */ 479 | timeSkew?: number; 480 | 481 | /** 482 | * @private Undocumented. 483 | */ 484 | loginRequired?: boolean; 485 | 486 | /** 487 | * @private Undocumented. 488 | */ 489 | authServerUrl?: string; 490 | 491 | /** 492 | * @private Undocumented. 493 | */ 494 | realm?: string; 495 | 496 | /** 497 | * @private Undocumented. 498 | */ 499 | clientId?: string; 500 | 501 | /** 502 | * @private Undocumented. 503 | */ 504 | clientSecret?: string; 505 | 506 | /** 507 | * @private Undocumented. 508 | */ 509 | redirectUri?: string; 510 | 511 | /** 512 | * @private Undocumented. 513 | */ 514 | sessionId?: string; 515 | 516 | /** 517 | * @private Undocumented. 518 | */ 519 | profile?: KeycloakProfile; 520 | 521 | /** 522 | * @private Undocumented. 523 | */ 524 | userInfo?: unknown; // KeycloakUserInfo; 525 | 526 | /** 527 | * @private Undocumented. 528 | */ 529 | endpoints?: KeycloakEndpoints; 530 | 531 | /** 532 | * Called when the adapter is initialized. 533 | */ 534 | onReady?(authenticated?: boolean): void; 535 | 536 | /** 537 | * Called when a user is successfully authenticated. 538 | */ 539 | onAuthSuccess?(): void; 540 | 541 | /** 542 | * Called if there was an error during authentication. 543 | */ 544 | onAuthError?(errorData: KeycloakError): void; 545 | 546 | /** 547 | * Called when the token is refreshed. 548 | */ 549 | onAuthRefreshSuccess?(): void; 550 | 551 | /** 552 | * Called if there was an error while trying to refresh the token. 553 | */ 554 | onAuthRefreshError?(): void; 555 | 556 | /** 557 | * Called if the user is logged out (will only be called if the session 558 | * status iframe is enabled, or in Cordova mode). 559 | */ 560 | onAuthLogout?(): void; 561 | 562 | /** 563 | * Called when the access token is expired. If a refresh token is available 564 | * the token can be refreshed with Keycloak#updateToken, or in cases where 565 | * it's not (ie. with implicit flow) you can redirect to login screen to 566 | * obtain a new access token. 567 | */ 568 | onTokenExpired?(): void; 569 | 570 | /** 571 | * Called when a AIA has been requested by the application. 572 | */ 573 | onActionUpdate?(status: 'success' | 'cancelled' | 'error'): void; 574 | 575 | /** 576 | * Called to initialize the adapter. 577 | * @param initOptions Initialization options. 578 | * @returns A promise to set functions to be invoked on success or error. 579 | */ 580 | init(initOptions: KeycloakInitOptions): Promise; 581 | 582 | /** 583 | * Redirects to login form. 584 | * @param options Login options. 585 | */ 586 | login(options?: KeycloakLoginOptions): Promise; 587 | 588 | /** 589 | * Redirects to logout. 590 | * @param options Logout options. 591 | */ 592 | logout(options?: KeycloakLogoutOptions): Promise; 593 | 594 | /** 595 | * Redirects to registration form. 596 | * @param options The options used for the registration. 597 | */ 598 | register(options?: KeycloakRegisterOptions): Promise; 599 | 600 | /** 601 | * Redirects to the Account Management Console. 602 | */ 603 | accountManagement(): Promise; 604 | 605 | /** 606 | * Returns the URL to login form. 607 | * @param options Supports same options as Keycloak#login. 608 | */ 609 | createLoginUrl(options?: KeycloakLoginOptions): string; 610 | 611 | /** 612 | * Returns the URL to logout the user. 613 | * @param options Logout options. 614 | */ 615 | createLogoutUrl(options?: KeycloakLogoutOptions): string; 616 | 617 | /** 618 | * Returns the URL to registration page. 619 | * @param options The options used for creating the registration URL. 620 | */ 621 | createRegisterUrl(options?: KeycloakRegisterOptions): string; 622 | 623 | /** 624 | * Returns the URL to the Account Management Console. 625 | */ 626 | createAccountUrl(): string; 627 | 628 | /** 629 | * Returns true if the token has less than `minValidity` seconds left before 630 | * it expires. 631 | * @param minValidity If not specified, `0` is used. 632 | */ 633 | isTokenExpired(minValidity?: number): boolean; 634 | 635 | /** 636 | * If the token expires within `minValidity` seconds, the token is refreshed. 637 | * If the session status iframe is enabled, the session status is also 638 | * checked. 639 | * @returns A promise to set functions that can be invoked if the token is 640 | * still valid, or if the token is no longer valid. 641 | * @example 642 | * ```js 643 | * keycloak.updateToken(5).success(function(refreshed) { 644 | * if (refreshed) { 645 | * alert('Token was successfully refreshed'); 646 | * } else { 647 | * alert('Token is still valid'); 648 | * } 649 | * }).error(function() { 650 | * alert('Failed to refresh the token, or the session has expired'); 651 | * }); 652 | */ 653 | updateToken(minValidity: number): Promise; 654 | 655 | /** 656 | * Clears authentication state, including tokens. This can be useful if 657 | * the application has detected the session was expired, for example if 658 | * updating token fails. Invoking this results in Keycloak#onAuthLogout 659 | * callback listener being invoked. 660 | */ 661 | clearToken(): void; 662 | 663 | /** 664 | * Returns true if the token has the given realm role. 665 | * @param role A realm role name. 666 | */ 667 | hasRealmRole(role: string): boolean; 668 | 669 | /** 670 | * Returns true if the token has the given role for the resource. 671 | * @param role A role name. 672 | * @param resource If not specified, `clientId` is used. 673 | */ 674 | hasResourceRole(role: string, resource?: string): boolean; 675 | 676 | /** 677 | * Loads the user's profile. 678 | * 679 | * @returns The current user KeycloakProfile. 680 | */ 681 | loadUserProfile(): Promise; 682 | 683 | /** 684 | * @private Undocumented. 685 | */ 686 | loadUserInfo(): Promise; 687 | 688 | /** 689 | * @private Undocumented. 690 | */ 691 | processCallback(oauth: OAuthResponse): Promise; 692 | 693 | /** 694 | * @private Undocumented. 695 | */ 696 | parseCallback(url: string): OAuthResponse; 697 | } 698 | -------------------------------------------------------------------------------- /src/utils/deferred.ts: -------------------------------------------------------------------------------- 1 | export default class Deferred { 2 | private promise: Promise; 3 | public resolve!: (value: T | PromiseLike) => void; 4 | public reject!: (reason?: any) => void; 5 | 6 | constructor() { 7 | this.promise = new Promise((resolve, reject) => { 8 | this.reject = reject; 9 | this.resolve = resolve; 10 | }); 11 | } 12 | 13 | public getPromise(): Promise { 14 | return this.promise; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/keycloak.ts: -------------------------------------------------------------------------------- 1 | import jwtDecode from 'jwt-decode'; 2 | 3 | import type { 4 | CallbackStorage, 5 | KeycloakConfig, 6 | KeycloakInitOptions, 7 | OIDCProviderConfig, 8 | } from '../types'; 9 | 10 | import { 11 | extractQuerystringParameters, 12 | formatQuerystringParameters, 13 | } from './url'; 14 | 15 | function fromEntries(iterable: IterableIterator<[string, T]>): { 16 | [key: string]: T; 17 | } { 18 | return [...iterable].reduce<{ [key: string]: T }>( 19 | (obj, [key, val]) => ({ 20 | ...obj, 21 | [key]: val, 22 | }), 23 | {} 24 | ); 25 | } 26 | 27 | export function getRealmUrl(realm: string, authServerUrl?: string) { 28 | if (typeof authServerUrl === 'undefined') { 29 | return undefined; 30 | } 31 | 32 | if (authServerUrl.charAt(authServerUrl.length - 1) === '/') { 33 | return authServerUrl + 'realms/' + encodeURIComponent(realm); 34 | } else { 35 | return authServerUrl + '/realms/' + encodeURIComponent(realm); 36 | } 37 | } 38 | 39 | export function setupOidcEndoints({ 40 | oidcConfiguration, 41 | realm, 42 | authServerUrl, 43 | }: { 44 | realm?: string; 45 | authServerUrl?: string; 46 | oidcConfiguration?: OIDCProviderConfig; 47 | }) { 48 | if (!oidcConfiguration) { 49 | if (!realm) { 50 | throw new Error('Missing realm'); 51 | } 52 | 53 | return { 54 | authorize: function () { 55 | return ( 56 | getRealmUrl(realm, authServerUrl) + '/protocol/openid-connect/auth' 57 | ); 58 | }, 59 | token: function () { 60 | return ( 61 | getRealmUrl(realm, authServerUrl) + '/protocol/openid-connect/token' 62 | ); 63 | }, 64 | logout: function () { 65 | return ( 66 | getRealmUrl(realm, authServerUrl) + '/protocol/openid-connect/logout' 67 | ); 68 | }, 69 | register: function () { 70 | return ( 71 | getRealmUrl(realm, authServerUrl) + 72 | '/protocol/openid-connect/registrations' 73 | ); 74 | }, 75 | userinfo: function () { 76 | return ( 77 | getRealmUrl(realm, authServerUrl) + 78 | '/protocol/openid-connect/userinfo' 79 | ); 80 | }, 81 | }; 82 | } 83 | 84 | return { 85 | authorize: function () { 86 | return oidcConfiguration.authorization_endpoint as string; 87 | }, 88 | token: function () { 89 | return oidcConfiguration.token_endpoint as string; 90 | }, 91 | logout: function () { 92 | if (!oidcConfiguration.end_session_endpoint) { 93 | throw 'Not supported by the OIDC server'; 94 | } 95 | return oidcConfiguration.end_session_endpoint as string; 96 | }, 97 | register: function () { 98 | throw 'Redirection to "Register user" page not supported in standard OIDC mode'; 99 | }, 100 | userinfo: function () { 101 | if (!oidcConfiguration.userinfo_endpoint) { 102 | throw 'Not supported by the OIDC server'; 103 | } 104 | return oidcConfiguration.userinfo_endpoint as string; 105 | }, 106 | }; 107 | } 108 | 109 | export function decodeToken(str: string) { 110 | return jwtDecode(str); 111 | } 112 | 113 | export interface ParseCallbackParams { 114 | callbackStorage: CallbackStorage; 115 | 116 | clientOptions: KeycloakInitOptions; 117 | 118 | url: string; 119 | } 120 | 121 | export function parseCallbackParams( 122 | paramsString: string, 123 | supportedParams: string[] 124 | ) { 125 | const params = extractQuerystringParameters(paramsString); 126 | const [otherParams, oAuthParams] = Object.keys(params).reduce( 127 | ([oParams, oauthParams], key) => { 128 | if (supportedParams.includes(key)) { 129 | oauthParams.set(key, params[key]); 130 | } else { 131 | oParams.set(key, params[key]); 132 | } 133 | return [oParams, oauthParams]; 134 | }, 135 | [new Map(), new Map()] 136 | ); 137 | 138 | return { 139 | paramsString: formatQuerystringParameters(otherParams), 140 | oauthParams: fromEntries(oAuthParams.entries()), 141 | }; 142 | } 143 | 144 | export function isKeycloakConfig( 145 | config?: string | KeycloakConfig 146 | ): config is KeycloakConfig { 147 | return !!config && typeof config !== 'string'; 148 | } 149 | -------------------------------------------------------------------------------- /src/utils/url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Encode string in a format suitable for URL 3 | * 4 | * Ported from https://github.com/jerrybendy/url-search-params-polyfill 5 | * @see https://github.com/jerrybendy/url-search-params-polyfill/blob/fc69a9ed9b0425f93db2b842574044a615c86bc8/index.js#L240 6 | */ 7 | function encode(str: string) { 8 | const replace: Record = { 9 | '!': '%21', 10 | "'": '%27', 11 | '(': '%28', 12 | ')': '%29', 13 | '~': '%7E', 14 | '%20': '+', 15 | '%00': '\x00', 16 | }; 17 | 18 | return encodeURIComponent(str).replace(/[!'()~]|%20|%00/g, function (match) { 19 | return replace[match]; 20 | }); 21 | } 22 | 23 | /** 24 | * Decode an URL-suitable string into a string 25 | * 26 | * Ported from https://github.com/jerrybendy/url-search-params-polyfill 27 | * @see https://github.com/jerrybendy/url-search-params-polyfill/blob/fc69a9ed9b0425f93db2b842574044a615c86bc8/index.js#L255 28 | */ 29 | function decode(str: string) { 30 | return str 31 | .replace(/[ +]/g, '%20') 32 | .replace(/(%[a-f0-9]{2})+/gi, function (match) { 33 | return decodeURIComponent(match); 34 | }); 35 | } 36 | 37 | export function formatQuerystringParameters( 38 | parametersMap: Map 39 | ) { 40 | return Array.from(parametersMap.entries()) 41 | .map(([key, value]) => `${encode(key)}=${encode(`${value}`)}`) 42 | .join('&'); 43 | } 44 | 45 | export function extractQuerystringParameters(querystring: string) { 46 | return querystring 47 | .replace('?', '') 48 | .split('&') 49 | .map(segment => segment.split('=')) 50 | .reduce((obj, [key, value]) => { 51 | obj[decode(key)] = decode(value); 52 | return obj; 53 | }, {} as Record); 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-useless-escape */ 2 | import { fromByteArray } from 'base64-js'; 3 | import { sha256 } from 'js-sha256'; 4 | 5 | function generateRandomString(len: number, alphabet: string) { 6 | const randomData = generateRandomData(len); 7 | 8 | const chars = [...Array(len)].map((_, idx) => 9 | alphabet.charCodeAt(randomData[idx] % alphabet.length) 10 | ); 11 | 12 | return String.fromCharCode.apply(null, chars); 13 | } 14 | 15 | function generateRandomData(len: number): number[] { 16 | return [...Array(len)].map(() => Math.floor(256 * Math.random())); 17 | } 18 | 19 | export function generateCodeVerifier(len: number) { 20 | return generateRandomString( 21 | len, 22 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 23 | ); 24 | } 25 | 26 | const hexDigits = '0123456789abcdef'; 27 | 28 | export function createUUID(): string { 29 | const s = generateRandomString(36, hexDigits).split(''); 30 | 31 | s[14] = '4'; 32 | // eslint-disable-next-line no-bitwise 33 | s[19] = hexDigits.substr(((s[19] as any) & 0x3) | 0x8, 1); 34 | s[8] = s[13] = s[18] = s[23] = '-'; 35 | 36 | return s.join(''); 37 | } 38 | 39 | export function generatePkceChallenge( 40 | pkceMethod: string, 41 | codeVerifier: string 42 | ) { 43 | switch (pkceMethod) { 44 | // The use of the "plain" method is considered insecure and therefore not supported. 45 | case 'S256': 46 | // hash codeVerifier, then encode as url-safe base64 without padding 47 | const hashBytes = new Uint8Array(sha256.arrayBuffer(codeVerifier)); 48 | const encodedHash = fromByteArray(hashBytes) 49 | .replace(/\+/g, '-') 50 | .replace(/\//g, '_') 51 | .replace(/\=/g, ''); 52 | return encodedHash; 53 | 54 | default: 55 | throw 'Invalid value for pkceMethod'; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "exclude": [ 4 | "example", 5 | "src/__tests__" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "allowUnreachableCode": false, 5 | "allowUnusedLabels": false, 6 | "esModuleInterop": true, 7 | "importsNotUsedAsValues": "error", 8 | "forceConsistentCasingInFileNames": true, 9 | "lib": [ 10 | "esnext" 11 | ], 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | "noImplicitUseStrict": false, 17 | "noStrictGenericChecks": false, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "skipLibCheck": true, 22 | "strict": true, 23 | "target": "esnext" 24 | } 25 | } 26 | --------------------------------------------------------------------------------