├── .gitignore ├── .vscode ├── settings.json └── extensions.json ├── .prettierrc.json ├── vitest.config.ts ├── tsconfig.json ├── .editorconfig ├── .eslintrc.json ├── test ├── server.ts ├── error.test.ts └── server.test.ts ├── .github └── workflows │ ├── node.js.yml │ └── codeql-analysis.yml ├── LICENSE ├── package.json ├── README.md └── src └── keycloak.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["fastify", "keycloak"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "arrowParens": "always" 6 | } 7 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | hookTimeout: 300_000 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "editorconfig.editorconfig", 5 | "esbenp.prettier-vscode", 6 | "streetsidesoftware.code-spell-checker" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts"], 3 | "compilerOptions": { 4 | "target": "es6", 5 | "module": "commonjs", 6 | "declaration": true, 7 | "outDir": "dist", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "lib": ["es2017"] 11 | }, 12 | "exclude": ["dist", "node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # See editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | max_line_length = 120 14 | 15 | [*.md] 16 | max_line_length = off 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es2021": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 7 | "overrides": [ 8 | { 9 | "files": ["*.ts"], 10 | "rules": { 11 | "@typescript-eslint/no-empty-function": "off" 12 | } 13 | } 14 | ], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaVersion": "latest", 18 | "sourceType": "module" 19 | }, 20 | "plugins": ["@typescript-eslint", "simple-import-sort"], 21 | "rules": { "simple-import-sort/imports": "error", "simple-import-sort/exports": "error" } 22 | } 23 | -------------------------------------------------------------------------------- /test/server.ts: -------------------------------------------------------------------------------- 1 | import fastify, { FastifyInstance } from 'fastify' 2 | 3 | import keycloak, { KeycloakOptions } from '../src/keycloak' 4 | 5 | export const serverOf: () => FastifyInstance = () => fastify() 6 | 7 | export const serverStart: ( 8 | server: FastifyInstance, 9 | port: number, 10 | keycloakOptions: KeycloakOptions 11 | ) => Promise = async (server, port, keycloakOptions) => { 12 | server.get('/ping', async (_request, reply) => { 13 | return reply.status(200).send({ msg: 'pong' }) 14 | }) 15 | 16 | server.get('/me', async (request, reply) => { 17 | const user = request.session.user 18 | return reply.status(200).send({ user }) 19 | }) 20 | 21 | await server.register(keycloak, keycloakOptions) 22 | 23 | await server.listen({ 24 | port: port 25 | }) 26 | 27 | return server 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20.x, 22.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: npm ci 29 | - run: npm test 30 | - run: npm run build --if-present 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 YuBin, Hsu 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 | -------------------------------------------------------------------------------- /test/error.test.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | import { afterAll, beforeAll, describe, it } from 'vitest' 3 | 4 | import { KeycloakOptions } from '../src/keycloak' 5 | import { serverOf, serverStart } from './server' 6 | 7 | describe('Error behavior', () => { 8 | const server: FastifyInstance = serverOf() 9 | 10 | beforeAll(async () => {}) 11 | 12 | afterAll(async () => {}) 13 | 14 | it.fails('should error, when given an invalid appOrigin', async () => { 15 | const keycloakOptions: KeycloakOptions = { 16 | appOrigin: 'localhost:8888', 17 | keycloakSubdomain: `localhost:8080/auth/realms/demo`, 18 | clientId: 'client01', 19 | clientSecret: 'client01secret' 20 | } 21 | 22 | await serverStart(server, 8888, keycloakOptions) 23 | await server.ready() 24 | }) 25 | 26 | it.fails('should error, when given an invalid keycloakSubdomain', async () => { 27 | const keycloakOptions: KeycloakOptions = { 28 | appOrigin: 'http://localhost:8888', 29 | keycloakSubdomain: `localhost:8080/auth/realms/demo/`, 30 | clientId: 'client01', 31 | clientSecret: 'client01secret' 32 | } 33 | 34 | await serverStart(server, 8888, keycloakOptions) 35 | await server.ready() 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-keycloak-adapter", 3 | "author": "Yubin, Hsu", 4 | "version": "3.0.2", 5 | "main": "dist/keycloak", 6 | "keywords": [ 7 | "fastify", 8 | "keycloak", 9 | "plugin" 10 | ], 11 | "files": [ 12 | "dist/keycloak.js", 13 | "dist/keycloak.d.ts" 14 | ], 15 | "types": "dist/keycloak.d.ts", 16 | "description": "A fastify plugin for Keycloak", 17 | "license": "MIT", 18 | "homepage": "https://github.com/yubinTW/fastify-keycloak-adapter", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/yubinTW/fastify-keycloak-adapter" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/yubinTW/fastify-keycloak-adapter/issues" 25 | }, 26 | "scripts": { 27 | "build": "tsc", 28 | "test": "vitest run --coverage", 29 | "fix-prettier": "prettier --write \"{src,test}/**/*.{ts,json}\"", 30 | "check-lint": "eslint --color package.json \"{src,test}/**/*.ts\"", 31 | "fix-lint": "eslint --fix \"{src,test}/**/*.ts\"" 32 | }, 33 | "dependencies": { 34 | "@fastify/cookie": "^11.0.2", 35 | "@fastify/jwt": "^9.1.0", 36 | "@fastify/session": "^11.1.0", 37 | "axios": "^1.8.4", 38 | "axios-retry": "^4.5.0", 39 | "fastify-plugin": "^5.0.1", 40 | "fp-ts": "^2.16.10", 41 | "grant": "^5.4.24", 42 | "io-ts": "^2.2.22", 43 | "qs": "^6.14.0", 44 | "wildcard-match": "^5.1.4" 45 | }, 46 | "devDependencies": { 47 | "@types/node": "^20.17.30", 48 | "@types/qs": "^6.9.18", 49 | "@typescript-eslint/eslint-plugin": "^8.30.1", 50 | "@typescript-eslint/parser": "^8.30.1", 51 | "@vitest/coverage-v8": "^3.1.2", 52 | "eslint": "^8.57.1", 53 | "eslint-plugin-simple-import-sort": "^12.1.1", 54 | "fastify": "5.3.2", 55 | "prettier": "^3.5.3", 56 | "testcontainers-keycloak": "^0.4.1", 57 | "typescript": "^5.8.3", 58 | "vitest": "^3.1.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.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: '30 8 * * *' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript', 'typescript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 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 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fastify-Keycloak-Adapter 2 | 3 | [![Node.js CI](https://github.com/yubinTW/fastify-keycloak-adapter/actions/workflows/node.js.yml/badge.svg)](https://github.com/yubinTW/fastify-keycloak-adapter/actions/workflows/node.js.yml) 4 | [![NPM version](https://img.shields.io/npm/v/fastify-keycloak-adapter.svg?style=flat)](https://www.npmjs.com/package/fastify-keycloak-adapter) 5 | 6 | `fastify-keycloak-adapter` is a keycloak adapter for a Fastify app. 7 | 8 | ## Install 9 | 10 | 11 | 12 | ```bash 13 | npm i fastify-keycloak-adapter 14 | ``` 15 | 16 | ```bash 17 | yarn add fastify-keycloak-adapter 18 | ``` 19 | 20 | ## Fastify Version 21 | 22 | - Fastify 5 -> `npm i fastify-keycloak-adapter` 23 | - Fastify 4 -> `npm i fastify-keycloak-adapter@2.3.3` 24 | - Fastify 3 -> `npm i fastify-keycloak-adapter@0.6.3` (deprecated) 25 | 26 | ## Usage 27 | 28 | ```typescript 29 | import fastify from 'fastify' 30 | import keycloak, { KeycloakOptions } from 'fastify-keycloak-adapter' 31 | 32 | const server = fastify() 33 | 34 | const opts: KeycloakOptions = { 35 | appOrigin: 'http://localhost:8888', 36 | keycloakSubdomain: 'keycloak.yourcompany.com/auth/realms/realm01', 37 | clientId: 'client01', 38 | clientSecret: 'client01secret' 39 | } 40 | 41 | server.register(keycloak, opts) 42 | ``` 43 | 44 | ## Configuration 45 | 46 | - `appOrigin` app url, used for redirect to the app when user login successfully (required) 47 | 48 | - `keycloakSubdomain` keycloak subdomain, endpoint of a realm resource (required) 49 | 50 | - `useHttps` set true if keycloak server uses `https` (optional, defaults to `false`) 51 | 52 | - `clientId` client id (required) 53 | 54 | - `clientSecret` client secret (required) 55 | 56 | - `scope` client scope of keycloak (optional, string[], defaults to `['openid']`) 57 | 58 | - `callback` Relative or absolute URL to receive the response data (optional, defaults to `/`) 59 | 60 | - `retries` The number of times to retry before failing. (optional, number, defaults to 3) 61 | 62 | - `logoutEndpoint` route path of doing logout (optional, defaults to `/logout`) 63 | 64 | - `excludedPatterns` string array for non-authorized urls (optional, support `?`, `*` and `**` wildcards) 65 | 66 | - `autoRefreshToken` set true for refreshing token automatically when token has expired (optional, defaults to `false`) 67 | 68 | - `disableCookiePlugin` set true if your application register the [fastify-cookie](https://github.com/fastify/fastify-cookie) plugin itself. Otherwise **fastify-cookie** will be registered by this plugin, because it's mandatory. (optional, defaults to `false`) 69 | 70 | - `disableSessionPlugin` set true if your application register the [fastify-session](https://github.com/fastify/fastify-session) plugin itself. Otherwise **fastify-session** will be registered by this plugin, because it's mandatory. (optional, defaults to `false`) 71 | 72 | - `userPayloadMapper(userPayload)` defined the fields of `fastify.session.user` (optional) 73 | 74 | - `unauthorizedHandler(request, reply)` is a function to customize the handling (e.g. the response) of unauthorized requests (optional) 75 | 76 | - `bypassFn(request)` is a function that returns true if you want to stop the normal authentication workflow and allow the request. It will prevent `userPayloadMapper` from being called and `fastify.session.user` from being generated. 77 | 78 | - `usePostLogoutRedirect` set true to enable compatibility with Keycloak versions 18.0.0 and later, where `post_logout_redirect_uri` and `id_token_hint` are used instead of `redirect_uri` during logout. When set to false, the plugin will default to using the old `redirect_uri` for backward compatibility. (optional, defaults to `false`) 79 | 80 | ## Configuration example 81 | 82 | ```typescript 83 | import keycloak, { KeycloakOptions, UserInfo } from 'fastify-keycloak-adapter' 84 | import fastify, { FastifyInstance } from 'fastify' 85 | 86 | const server: FastifyInstance = fastify() 87 | 88 | const opts: KeycloakOptions = { 89 | appOrigin: 'http://localhost:8888', 90 | keycloakSubdomain: 'keycloak.mycompany.com/auth/realms/myrealm', 91 | useHttps: false, 92 | usePostLogoutRedirect: false, 93 | clientId: 'myclient01', 94 | clientSecret: 'myClientSecret', 95 | logoutEndpoint: '/logout', 96 | excludedPatterns: ['/metrics', '/manifest.json', '/api/todos/**'], 97 | callback: '/hello' 98 | } 99 | 100 | server.register(keycloak, opts) 101 | ``` 102 | 103 | ## Set userPayloadMapper 104 | 105 | defined the fields of `fastify.session.user`, use the payload from JWT token 106 | 107 | use `DefaultToken` in default case 108 | 109 | or you should define the type by yourself, in case the keycloak server has custom payload 110 | 111 | ```typescript 112 | import { KeycloakOptions, DefaultToken } from 'fastify-keycloak-adapter' 113 | 114 | const userPayloadMapper = (tokenPayload: unknown) => ({ 115 | account: (tokenPayload as DefaultToken).preferred_username, 116 | name: (tokenPayload as DefaultToken).name 117 | }) 118 | 119 | const opts: KeycloakOptions = { 120 | // ... 121 | userPayloadMapper: userPayloadMapper 122 | } 123 | ``` 124 | 125 | ## Set unauthorizedHandler 126 | 127 | Provides a custom handler for unauthorized requests. 128 | 129 | ```typescript 130 | import { FastifyReply, FastifyRequest } from 'fastify' 131 | import { KeycloakOptions } from 'fastify-keycloak-adapter' 132 | 133 | const unauthorizedHandler = (request: FastifyRequest, reply: FastifyReply) => { 134 | reply.status(401).send(`Invalid request`) 135 | } 136 | 137 | const opts: KeycloakOptions = { 138 | // ... 139 | unauthorizedHandler: unauthorizedHandler 140 | } 141 | ``` 142 | 143 | ## Set bypassFn 144 | 145 | Provides a function that returns true if you want to stop the normal authentication workflow and allow the request. 146 | 147 | ```typescript 148 | import { FastifyReply, FastifyRequest } from 'fastify' 149 | import { KeycloakOptions } from 'fastify-keycloak-adapter' 150 | 151 | const bypassFn = (request: FastifyRequest) => { 152 | return Math.random() * 6 < 1 // russian roulette of security DO NOT USE IT ! 153 | } 154 | 155 | const opts: KeycloakOptions = { 156 | // ... 157 | bypassFn: bypassFn 158 | } 159 | ``` 160 | 161 | ## Disable mandatory plugin registration 162 | 163 | Use the options to disable the cookie and session plugin registration, in case you want to initialize the plugins yourself, to provide your own set of configurations for these plugins. 164 | 165 | ```typescript 166 | import fastify from 'fastify' 167 | import fastifyCookie from '@fastify/cookie' 168 | import session from '@fastify/session' 169 | import keycloak, { KeycloakOptions } from 'fastify-keycloak-adapter' 170 | 171 | const server = fastify() 172 | 173 | server.register(fastifyCookie) 174 | server.register(session, { 175 | secret: '', 176 | cookie: { 177 | secure: false 178 | } 179 | }) 180 | 181 | const opts: KeycloakOptions = { 182 | // ... 183 | disableCookiePlugin: true, 184 | disableSessionPlugin: true 185 | } 186 | server.register(keycloak, opts) 187 | ``` 188 | 189 | ## Get login user 190 | 191 | use `request.session.user` 192 | 193 | ```typescript 194 | server.get('/users/me', async (request, reply) => { 195 | const user = request.session.user 196 | return reply.status(200).send({ user }) 197 | }) 198 | ``` 199 | 200 | ## Get OpenID Connect (OIDC) tokens 201 | 202 | in some case, you may want to handle the id_token (or access_token, refresh_token) by yourself 203 | 204 | use `request,session.grant` can get the [GrantResponse](https://github.com/simov/grant/blob/5f60a595161aa96cd110192bc810eb8fa478da21/grant.d.ts#L318) object 205 | 206 | ```typescript 207 | const id_token = request.session.grant.response?.id_token 208 | console.log('id_token', id_token) 209 | const access_token = request.session.grant.response?.access_token 210 | console.log('access_token', access_token) 211 | const refresh_token = request.session.grant.response?.refresh_token 212 | console.log('refresh_token', refresh_token) 213 | ``` 214 | 215 | ## License 216 | 217 | [MIT License](LICENSE) 218 | -------------------------------------------------------------------------------- /test/server.test.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance, FastifyRequest } from 'fastify' 2 | import { KeycloakContainer, StartedKeycloakContainer } from 'testcontainers-keycloak' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | 5 | import { KeycloakOptions } from '../src/keycloak' 6 | import { serverOf, serverStart } from './server' 7 | 8 | describe('server with keycloak testing', () => { 9 | const serverPort = 8888 10 | let keycloak: StartedKeycloakContainer 11 | let keycloakOptions: KeycloakOptions 12 | 13 | beforeAll(async () => { 14 | const keycloakPort = 8080 15 | 16 | keycloak = await new KeycloakContainer() 17 | .withStartupTimeout(600_000) 18 | .withAdminUsername('admin') 19 | .withAdminPassword('admin') 20 | .withExposedPorts(keycloakPort) 21 | .start() 22 | 23 | await keycloak.configCredentials('master', 'admin', 'admin') 24 | 25 | await keycloak.createRealm('demo') 26 | await keycloak.createUser('demo', 'user01', 'yubin', 'hsu', true) 27 | await keycloak.setUserPassword('demo', 'user01', 'user01password') 28 | await keycloak.createClient( 29 | 'demo', 30 | 'client01', 31 | 'client01secret', 32 | [`http://localhost:${serverPort}/*`], 33 | [`http://localhost:${serverPort}`] 34 | ) 35 | 36 | keycloakOptions = { 37 | appOrigin: `http://localhost:${serverPort}`, 38 | keycloakSubdomain: `${keycloak.getHost()}:${keycloak.getMappedPort(keycloakPort)}/auth/realms/demo`, 39 | clientId: 'client01', 40 | clientSecret: 'client01secret' 41 | } 42 | }) 43 | 44 | afterAll(async () => { 45 | await keycloak.stop() 46 | }) 47 | 48 | describe('minimal plugin configuration', () => { 49 | const server: FastifyInstance = serverOf() 50 | 51 | beforeAll(async () => { 52 | await serverStart(server, serverPort, keycloakOptions) 53 | await server.ready() 54 | }) 55 | 56 | afterAll(async () => { 57 | await server.close() 58 | }) 59 | 60 | it('should return 302, when send a request to an endpoint', async () => { 61 | const response = await server.inject({ 62 | method: 'GET', 63 | url: '/ping' 64 | }) 65 | expect(response.statusCode).toBe(302) 66 | }) 67 | 68 | it('should return 401 and "Unauthorized", when send a request to an endpoint without a valid token', async () => { 69 | const response = await server.inject({ 70 | method: 'GET', 71 | url: '/ping', 72 | headers: { authorization: `Bearer fakeToken` } 73 | }) 74 | expect(response.statusCode).toBe(401) 75 | expect(response.body).toBe('Unauthorized') 76 | }) 77 | 78 | it('should return 200, when send a request to an endpoint with a valid token', async () => { 79 | const token = await keycloak.getAccessToken('demo', 'user01', 'user01password', 'client01', 'client01secret') 80 | const response = await server.inject({ 81 | method: 'GET', 82 | url: '/ping', 83 | headers: { authorization: `Bearer ${token}` } 84 | }) 85 | expect(response.statusCode).toBe(200) 86 | }) 87 | 88 | it('should return default userPayload', async () => { 89 | const token = await keycloak.getAccessToken('demo', 'user01', 'user01password', 'client01', 'client01secret') 90 | const response = await server.inject({ 91 | method: 'GET', 92 | url: '/me', 93 | headers: { authorization: `Bearer ${token}` } 94 | }) 95 | expect(response.statusCode).toBe(200) 96 | const { 97 | user: { account } 98 | } = JSON.parse(response.body) 99 | expect(account).toBe('user01') 100 | }) 101 | }) 102 | 103 | describe('custom user payload mapping configuration', () => { 104 | const server: FastifyInstance = serverOf() 105 | 106 | type MyToken = { 107 | preferred_username: Readonly 108 | deptname: Readonly 109 | } 110 | 111 | const userPayload = (tokenPayload: unknown) => ({ 112 | username: (tokenPayload as MyToken).preferred_username, 113 | deptName: (tokenPayload as MyToken).deptname 114 | }) 115 | 116 | beforeAll(async () => { 117 | await serverStart(server, serverPort, { 118 | ...keycloakOptions, 119 | userPayloadMapper: userPayload 120 | }) 121 | await server.ready() 122 | }) 123 | 124 | afterAll(async () => { 125 | await server.close() 126 | }) 127 | 128 | it('should return custom userPayload', async () => { 129 | const token = await keycloak.getAccessToken('demo', 'user01', 'user01password', 'client01', 'client01secret') 130 | const response = await server.inject({ 131 | method: 'GET', 132 | url: '/me', 133 | headers: { authorization: `Bearer ${token}` } 134 | }) 135 | expect(response.statusCode).toBe(200) 136 | const { 137 | user: { username } 138 | } = JSON.parse(response.body) 139 | expect(username).toBe('user01') 140 | }) 141 | }) 142 | 143 | describe('custom unauthorized handler configuration', () => { 144 | const server: FastifyInstance = serverOf() 145 | 146 | beforeAll(async () => { 147 | await serverStart(server, serverPort, { 148 | ...keycloakOptions, 149 | unauthorizedHandler: (_request, reply) => { 150 | reply.status(403).send(`Forbidden`) 151 | } 152 | }) 153 | await server.ready() 154 | }) 155 | 156 | afterAll(async () => { 157 | await server.close() 158 | }) 159 | 160 | it('should return custom unauthorized reply, when send a request to an endpoint without a valid token', async () => { 161 | const response = await server.inject({ 162 | method: 'GET', 163 | url: '/ping', 164 | headers: { authorization: `Bearer fakeToken` } 165 | }) 166 | expect(response.statusCode).toBe(403) 167 | expect(response.body).toBe('Forbidden') 168 | }) 169 | }) 170 | 171 | describe('add bypassFn configuration', () => { 172 | const server: FastifyInstance = serverOf() 173 | 174 | const bypassFn = (request: FastifyRequest) => { 175 | return request.headers.password === 'sesame' 176 | } 177 | 178 | beforeAll(async () => { 179 | await serverStart(server, serverPort, { 180 | ...keycloakOptions, 181 | bypassFn 182 | }) 183 | await server.ready() 184 | }) 185 | 186 | afterAll(async () => { 187 | await server.close() 188 | }) 189 | 190 | it('should return 200, cause bypassFn returned true', async () => { 191 | const response = await server.inject({ 192 | method: 'GET', 193 | url: '/ping', 194 | headers: { 195 | authorization: `Bearer fakeToken`, 196 | password: 'sesame' 197 | } 198 | }) 199 | expect(response.statusCode).toBe(200) 200 | }) 201 | 202 | it('should return 401, cause bypassFn returned false', async () => { 203 | const response = await server.inject({ 204 | method: 'GET', 205 | url: '/ping', 206 | headers: { 207 | authorization: `Bearer fakeToken`, 208 | password: 'mellon' 209 | } 210 | }) 211 | expect(response.statusCode).toBe(401) 212 | expect(response.body).toBe('Unauthorized') 213 | }) 214 | }) 215 | 216 | describe('add async bypassFn configuration', () => { 217 | const server: FastifyInstance = serverOf() 218 | 219 | const bypassFn = (request: FastifyRequest) => 220 | new Promise((resolve) => { 221 | setTimeout(() => { 222 | resolve(request.headers.password === 'sesame') 223 | }, 100) 224 | }) 225 | 226 | beforeAll(async () => { 227 | await serverStart(server, serverPort, { 228 | ...keycloakOptions, 229 | bypassFn 230 | }) 231 | await server.ready() 232 | }) 233 | 234 | afterAll(async () => { 235 | await server.close() 236 | }) 237 | 238 | it('should return 200, cause bypassFn returned true', async () => { 239 | const response = await server.inject({ 240 | method: 'GET', 241 | url: '/ping', 242 | headers: { 243 | authorization: `Bearer fakeToken`, 244 | password: 'sesame' 245 | } 246 | }) 247 | expect(response.statusCode).toBe(200) 248 | }) 249 | 250 | it('should return 401, cause bypassFn returned false', async () => { 251 | const response = await server.inject({ 252 | method: 'GET', 253 | url: '/ping', 254 | headers: { 255 | authorization: `Bearer fakeToken`, 256 | password: 'mellon' 257 | } 258 | }) 259 | expect(response.statusCode).toBe(401) 260 | expect(response.body).toBe('Unauthorized') 261 | }) 262 | }) 263 | }) 264 | -------------------------------------------------------------------------------- /src/keycloak.ts: -------------------------------------------------------------------------------- 1 | import cookie from '@fastify/cookie' 2 | import jwt from '@fastify/jwt' 3 | import session from '@fastify/session' 4 | import axios, { AxiosError, AxiosResponse } from 'axios' 5 | import axiosRetry from 'axios-retry' 6 | import { FastifyInstance, FastifyReply, FastifyRequest, HookHandlerDoneFunction } from 'fastify' 7 | import fastifyPlugin from 'fastify-plugin' 8 | import * as B from 'fp-ts/boolean' 9 | import * as E from 'fp-ts/Either' 10 | import { pipe } from 'fp-ts/function' 11 | import * as O from 'fp-ts/Option' 12 | import * as TE from 'fp-ts/TaskEither' 13 | import * as TO from 'fp-ts/TaskOption' 14 | import grant, { GrantResponse, GrantSession } from 'grant' 15 | import * as t from 'io-ts' 16 | import qs from 'qs' 17 | import wcMatch from 'wildcard-match' 18 | 19 | declare module '@fastify/session' { 20 | interface FastifySessionObject { 21 | grant: GrantSession 22 | user: unknown 23 | } 24 | } 25 | 26 | let tokenEndpoint = '' 27 | 28 | type WellKnownConfiguration = { 29 | authorization_endpoint: string 30 | token_endpoint: string 31 | end_session_endpoint: string 32 | } 33 | 34 | type RealmResponse = { 35 | realm: string 36 | public_key: string 37 | } 38 | 39 | export type DefaultToken = { 40 | email_verified: Readonly 41 | name: Readonly 42 | preferred_username: Readonly 43 | given_name: Readonly 44 | family_name: Readonly 45 | } 46 | 47 | const AppOriginCodec = new t.Type( 48 | 'AppOrigin', 49 | (input: unknown): input is string => typeof input === 'string', 50 | (input, context) => 51 | typeof input === 'string' && 52 | (input.startsWith('http://') || input.startsWith('https://')) && 53 | input.endsWith('/') === false 54 | ? t.success(input) 55 | : t.failure(input, context), 56 | t.identity 57 | ) 58 | 59 | const KeycloakSubdomainCodec = new t.Type( 60 | 'KeycloakSubdomain', 61 | (input: unknown): input is string => typeof input === 'string', 62 | (input, context) => 63 | typeof input === 'string' && 64 | input.length > 0 && 65 | input.startsWith('http://') === false && 66 | input.startsWith('https://') === false && 67 | input.endsWith('/') === false 68 | ? t.success(input) 69 | : t.failure(input, context), 70 | t.identity 71 | ) 72 | 73 | const requiredOptions = t.type({ 74 | appOrigin: t.readonly(AppOriginCodec), 75 | keycloakSubdomain: t.readonly(KeycloakSubdomainCodec), 76 | clientId: t.readonly(t.string), 77 | clientSecret: t.readonly(t.string) 78 | }) 79 | 80 | const partialOptions = t.partial({ 81 | useHttps: t.readonly(t.boolean), 82 | logoutEndpoint: t.readonly(t.string), 83 | excludedPatterns: t.readonly(t.array(t.string)), 84 | scope: t.array(t.readonly(t.string)), 85 | callback: t.readonly(t.string), 86 | disableCookiePlugin: t.readonly(t.boolean), 87 | disableSessionPlugin: t.readonly(t.boolean), 88 | retries: t.readonly(t.number), 89 | autoRefreshToken: t.readonly(t.boolean), 90 | usePostLogoutRedirect: t.readonly(t.boolean) 91 | }) 92 | 93 | const KeycloakOptions = t.intersection([requiredOptions, partialOptions]) 94 | 95 | export type KeycloakOptions = t.TypeOf & { 96 | userPayloadMapper?: (tokenPayload: unknown) => object 97 | unauthorizedHandler?: (request: FastifyRequest, reply: FastifyReply) => void 98 | bypassFn?: (request: FastifyRequest) => boolean | Promise 99 | } 100 | 101 | const getWellKnownConfiguration: (url: string) => TE.TaskEither> = ( 102 | url: string 103 | ) => 104 | TE.tryCatch( 105 | () => axios.get(url), 106 | (e) => e as AxiosError 107 | ) 108 | 109 | const validAppOrigin: (opts: KeycloakOptions) => E.Either = (opts) => 110 | pipe( 111 | opts.appOrigin, 112 | AppOriginCodec.decode, 113 | E.match( 114 | () => E.left(new Error(`Invalid appOrigin: ${opts.appOrigin}`)), 115 | () => E.right(opts) 116 | ) 117 | ) 118 | 119 | const validKeycloakSubdomain: (opts: KeycloakOptions) => E.Either = (opts) => 120 | pipe( 121 | opts.keycloakSubdomain, 122 | KeycloakSubdomainCodec.decode, 123 | E.match( 124 | () => E.left(new Error(`Invalid keycloakSubdomain: ${opts.keycloakSubdomain}`)), 125 | () => E.right(opts) 126 | ) 127 | ) 128 | 129 | export default fastifyPlugin( 130 | async (fastify: FastifyInstance, opts: KeycloakOptions) => { 131 | axiosRetry(axios, { 132 | retries: opts.retries ? opts.retries : 3, 133 | retryDelay: axiosRetry.exponentialDelay, 134 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 135 | onRetry: (retryCount, error, _requestConfig) => { 136 | fastify.log.error(`Retry #${retryCount} ${error.message}`) 137 | } 138 | }) 139 | 140 | pipe( 141 | opts, 142 | validAppOrigin, 143 | E.chain(validKeycloakSubdomain), 144 | E.match( 145 | (e) => { 146 | fastify.log.error(`${e}`) 147 | throw new Error(e.message) 148 | }, 149 | () => { 150 | fastify.log.debug(`Keycloak Options valid successfully. Keycloak options: ${JSON.stringify(opts)}`) 151 | } 152 | ) 153 | ) 154 | 155 | const protocol = opts.useHttps ? 'https://' : 'http://' 156 | 157 | const keycloakConfiguration = await pipe( 158 | `${protocol}${opts.keycloakSubdomain}/.well-known/openid-configuration`, 159 | getWellKnownConfiguration, 160 | TE.map((response) => response.data) 161 | )() 162 | 163 | function registerDependentPlugin(config: WellKnownConfiguration) { 164 | if (!opts.disableCookiePlugin) { 165 | fastify.register(cookie) 166 | } 167 | 168 | if (!opts.disableSessionPlugin) { 169 | fastify.register(session, { 170 | secret: new Array(32).fill('a').join(''), 171 | cookie: { secure: false } 172 | }) 173 | } 174 | 175 | tokenEndpoint = config.token_endpoint 176 | 177 | fastify.register( 178 | grant.fastify()({ 179 | defaults: { 180 | origin: opts.appOrigin, 181 | transport: 'session' 182 | }, 183 | keycloak: { 184 | key: opts.clientId, 185 | secret: opts.clientSecret, 186 | oauth: 2, 187 | authorize_url: config.authorization_endpoint, 188 | access_url: config.token_endpoint, 189 | callback: opts.callback ?? '/', 190 | scope: opts.scope ?? ['openid'], 191 | nonce: true 192 | } 193 | }) 194 | ) 195 | } 196 | 197 | pipe( 198 | keycloakConfiguration, 199 | E.match( 200 | (error) => { 201 | throw new Error(`Failed to get openid-configuration: ${JSON.stringify(error.toJSON())}`) 202 | }, 203 | (config) => { 204 | registerDependentPlugin(config) 205 | } 206 | ) 207 | ) 208 | 209 | const getRealmResponse: (url: string) => TE.TaskEither> = (url) => 210 | TE.tryCatch( 211 | () => axios.get(url), 212 | (e) => new Error(`${e}`) 213 | ) 214 | 215 | const secretPublicKey = await pipe( 216 | `${protocol}${opts.keycloakSubdomain}`, 217 | getRealmResponse, 218 | TE.map((response) => response.data), 219 | TE.map((realmResponse) => realmResponse.public_key), 220 | TE.map((publicKey) => `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`) 221 | )() 222 | 223 | pipe( 224 | secretPublicKey, 225 | E.match( 226 | (e) => { 227 | fastify.log.fatal(`Failed to get public key: ${e}`) 228 | throw new Error(`Failed to get public key: ${e}`) 229 | }, 230 | (publicKey) => { 231 | fastify.register(jwt, { 232 | secret: { 233 | private: 'dummyPrivate', 234 | public: publicKey 235 | }, 236 | verify: { algorithms: ['RS256'] } 237 | }) 238 | } 239 | ) 240 | ) 241 | 242 | const getGrantFromSession: (request: FastifyRequest) => E.Either = (request) => 243 | pipe( 244 | request.session.grant, 245 | O.fromNullable, 246 | O.match( 247 | () => E.left(new Error(`grant not found in session`)), 248 | () => E.right(request.session.grant) 249 | ) 250 | ) 251 | 252 | const getResponseFromGrant: (grant: GrantSession) => E.Either = (grant) => 253 | pipe( 254 | grant.response, 255 | O.fromNullable, 256 | E.fromOption(() => new Error(`response not found in grant`)) 257 | ) 258 | 259 | const getIdTokenFromResponse: (response: GrantResponse) => E.Either = (response) => 260 | pipe( 261 | response.id_token, 262 | O.fromNullable, 263 | E.fromOption(() => new Error(`id_token not found in response with response: ${response}`)) 264 | ) 265 | 266 | const verifyIdToken: (idToken: string) => E.Either = (idToken) => 267 | E.tryCatch( 268 | () => fastify.jwt.verify(idToken), 269 | (e) => new Error(`Failed to verify id_token: ${(e as Error).message}`) 270 | ) 271 | 272 | const decodedTokenToJson: (decodedToken: string) => E.Either = (decodedToken) => 273 | E.tryCatch( 274 | () => JSON.parse(JSON.stringify(decodedToken)), 275 | (e) => new Error(`Failed to parsing json from decodedToken: ${e}`) 276 | ) 277 | 278 | const authentication: (request: FastifyRequest) => E.Either = (request) => 279 | pipe( 280 | getGrantFromSession(request), 281 | E.chain(getResponseFromGrant), 282 | E.chain(getIdTokenFromResponse), 283 | E.chain(verifyIdToken), 284 | E.chain(decodedTokenToJson) 285 | ) 286 | 287 | const getBearerTokenFromRequest: (request: FastifyRequest) => O.Option = (request) => 288 | pipe( 289 | request.headers.authorization, 290 | O.fromNullable, 291 | O.map((str) => str.substring(7)) 292 | ) 293 | 294 | type RefreshTokenResponse = { 295 | access_token: string 296 | expires_in: number 297 | refresh_expires_in: number 298 | refresh_token: string 299 | token_type: 'Bearer' 300 | session_state: string 301 | scope: string 302 | id_token: string 303 | } 304 | 305 | const getRefreshToken: (request: FastifyRequest) => Promise> = (request) => { 306 | const refresh_token = request.session.grant.response?.refresh_token 307 | const postData = qs.stringify({ 308 | client_id: opts.clientId, 309 | client_secret: opts.clientSecret, 310 | grant_type: 'refresh_token', 311 | refresh_token 312 | }) 313 | return axios.post(tokenEndpoint, postData) 314 | } 315 | 316 | const verifyJwtToken: (token: string) => E.Either = (token) => 317 | E.tryCatch( 318 | () => fastify.jwt.verify(token), 319 | (e) => new Error(`Failed to verify token: ${(e as Error).message}`) 320 | ) 321 | 322 | const grantRoutes = ['/connect/:provider', '/connect/:provider/:override'] 323 | 324 | const isGrantRoute: (request: FastifyRequest) => boolean = (request) => 325 | grantRoutes.includes(request.routeOptions.config.url) 326 | 327 | const userPayloadMapper = pipe( 328 | opts.userPayloadMapper, 329 | O.fromNullable, 330 | O.match( 331 | () => (tokenPayload: unknown) => ({ 332 | account: (tokenPayload as DefaultToken).preferred_username, 333 | name: (tokenPayload as DefaultToken).name 334 | }), 335 | (a) => a 336 | ) 337 | ) 338 | 339 | function updateToken(request: FastifyRequest, done: HookHandlerDoneFunction) { 340 | getRefreshToken(request) 341 | .then((response) => response.data) 342 | .then((response) => { 343 | request.session.grant.response!.refresh_token = response.refresh_token 344 | request.session.grant.response!.access_token = response.access_token 345 | request.session.grant.response!.id_token = response.id_token 346 | request.log.debug('Keycloak adapter: Refresh token done.') 347 | done() 348 | }) 349 | .catch((error) => { 350 | request.log.error(`Failed to refresh token: ${error}`) 351 | done() 352 | }) 353 | } 354 | 355 | function authenticationErrorHandler( 356 | e: Error, 357 | request: FastifyRequest, 358 | reply: FastifyReply, 359 | done: HookHandlerDoneFunction 360 | ) { 361 | request.log.debug(`Keycloak adapter: ${e.message}`) 362 | if (opts.autoRefreshToken && e.message.includes('The token has expired')) { 363 | request.log.debug('Keycloak adapter: The token has expired, refreshing token ...') 364 | updateToken(request, done) 365 | } else { 366 | if (request.method === 'GET' && !opts.unauthorizedHandler) { 367 | reply.redirect(`${opts.appOrigin}/connect/keycloak`) 368 | } else { 369 | unauthorizedHandler(request, reply) 370 | } 371 | } 372 | } 373 | 374 | function authenticationByGrant(request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction) { 375 | pipe( 376 | authentication(request), 377 | E.fold( 378 | (e) => { 379 | authenticationErrorHandler(e, request, reply, done) 380 | }, 381 | (decodedJson) => { 382 | request.session.user = userPayloadMapper(decodedJson as DefaultToken) 383 | request.log.debug(`${JSON.stringify(request.session.user)}`) 384 | done() 385 | } 386 | ) 387 | ) 388 | } 389 | 390 | const unauthorizedHandler = pipe( 391 | opts.unauthorizedHandler, 392 | O.fromNullable, 393 | O.match( 394 | () => (_request: FastifyRequest, reply: FastifyReply) => { 395 | reply.status(401).send(`Unauthorized`) 396 | }, 397 | (a) => a 398 | ) 399 | ) 400 | 401 | function authenticationByToken( 402 | request: FastifyRequest, 403 | reply: FastifyReply, 404 | bearerToken: string, 405 | done: HookHandlerDoneFunction 406 | ) { 407 | pipe( 408 | bearerToken, 409 | verifyJwtToken, 410 | E.chain(decodedTokenToJson), 411 | E.fold( 412 | (e) => { 413 | request.log.debug(`Keycloak adapter: ${e.message}`) 414 | unauthorizedHandler(request, reply) 415 | done() 416 | }, 417 | (decodedJson) => { 418 | request.session.user = userPayloadMapper(decodedJson as DefaultToken) 419 | request.log.debug(`${JSON.stringify(request.session.user)}`) 420 | done() 421 | } 422 | ) 423 | ) 424 | } 425 | 426 | const matchers = pipe( 427 | opts.excludedPatterns?.map((pattern) => wcMatch(pattern)), 428 | O.fromNullable 429 | ) 430 | 431 | const filterExcludedPattern: (request: FastifyRequest) => O.Option = (request) => 432 | pipe( 433 | matchers, 434 | O.map((matchers) => matchers.filter((matcher) => matcher(request.url))), 435 | O.map((matchers) => matchers.length > 0), 436 | O.match( 437 | () => O.of(request), 438 | (b) => 439 | pipe( 440 | b, 441 | B.match( 442 | () => O.of(request), 443 | () => O.none 444 | ) 445 | ) 446 | ) 447 | ) 448 | 449 | const bypassFn = (request: FastifyRequest): TO.TaskOption => 450 | pipe( 451 | TO.tryCatch(async () => await opts.bypassFn?.(request)), 452 | TO.chain((result) => pipe(O.fromNullable(result === true ? null : request), TO.fromOption)) 453 | ) 454 | 455 | const filterGrantRoute: (request: FastifyRequest) => O.Option = (request) => 456 | pipe( 457 | request, 458 | O.fromPredicate((request) => !isGrantRoute(request)) 459 | ) 460 | 461 | fastify.addHook('preValidation', (request: FastifyRequest, reply: FastifyReply, done) => { 462 | pipe( 463 | request, 464 | bypassFn, 465 | TO.match( 466 | () => { 467 | done() 468 | }, 469 | (request) => 470 | pipe( 471 | request, 472 | filterGrantRoute, 473 | O.chain(filterExcludedPattern), 474 | O.match( 475 | () => { 476 | done() 477 | }, 478 | (request) => 479 | pipe( 480 | request, 481 | getBearerTokenFromRequest, 482 | O.match( 483 | () => authenticationByGrant(request, reply, done), 484 | (bearerToken) => authenticationByToken(request, reply, bearerToken, done) 485 | ) 486 | ) 487 | ) 488 | ) 489 | ) 490 | )() 491 | }) 492 | 493 | const getLogoutUrl = (config: WellKnownConfiguration) => (idToken: string) => 494 | pipe( 495 | Boolean(opts.usePostLogoutRedirect), 496 | B.match( 497 | () => `${config.end_session_endpoint}?redirect_uri=${opts.appOrigin}`, 498 | () => `${config.end_session_endpoint}?id_token_hint=${idToken}&post_logout_redirect_uri=${opts.appOrigin}` 499 | ) 500 | ) 501 | 502 | function logout(request: FastifyRequest, reply: FastifyReply) { 503 | const idToken = request.session.grant.response?.id_token ?? '' 504 | 505 | request.session.destroy((error) => { 506 | pipe( 507 | error, 508 | O.fromNullable, 509 | O.match( 510 | () => { 511 | pipe( 512 | keycloakConfiguration, 513 | E.map((config) => { 514 | reply.redirect(getLogoutUrl(config)(idToken)) 515 | }) 516 | ) 517 | }, 518 | (e) => { 519 | request.log.error(`Failed to logout: ${e}`) 520 | reply.status(500).send({ msg: `Internal Server Error: ${e}` }) 521 | } 522 | ) 523 | ) 524 | }) 525 | } 526 | 527 | const logoutEndpoint = opts.logoutEndpoint ?? '/logout' 528 | 529 | fastify.get(logoutEndpoint, async (request, reply) => { 530 | pipe( 531 | request.session.user, 532 | O.fromNullable, 533 | O.match( 534 | () => { 535 | reply.redirect('/') 536 | }, 537 | () => { 538 | logout(request, reply) 539 | } 540 | ) 541 | ) 542 | }) 543 | 544 | fastify.log.info(`Keycloak registered successfully!`) 545 | }, 546 | { 547 | fastify: '5.x', 548 | name: 'fastify-keycloak-adapter' 549 | } 550 | ) 551 | --------------------------------------------------------------------------------