├── .gitattributes ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── eslint.config.js ├── examples ├── apple.js ├── discord.js ├── discovery.js ├── epic-games.js ├── facebook.js ├── github.js ├── google-with-pkce.js ├── google.js ├── linkedin.js ├── spotify.js ├── userinfo.js ├── vatsim-dev.js ├── vatsim.js ├── vkontakte.js └── yandex.js ├── index.js ├── package.json ├── test └── index.test.js └── types ├── index.d.ts └── index.test-d.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 15 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - "discussion" 8 | - "feature request" 9 | - "bug" 10 | - "help wanted" 11 | - "plugin suggestion" 12 | - "good first issue" 13 | # Label to use when marking an issue as stale 14 | staleLabel: stale 15 | # Comment to post when marking an issue as stale. Set to `false` to disable 16 | markComment: > 17 | This issue has been automatically marked as stale because it has not had 18 | recent activity. It will be closed if no further activity occurs. Thank you 19 | for your contributions. 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 26 | with: 27 | license-check: true 28 | lint: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ 153 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present The Fastify team 4 | 5 | The Fastify team members are listed at https://github.com/fastify/fastify#team. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fastify/oauth2 2 | 3 | [![CI](https://github.com/fastify/fastify-oauth2/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify-oauth2/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/@fastify/oauth2.svg?style=flat)](https://www.npmjs.com/package/@fastify/oauth2) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 6 | 7 | Wrapper around the [`simple-oauth2`](https://github.com/lelylan/simple-oauth2) library. 8 | 9 | v4.x of this module support Fastify v3.x 10 | [v3.x](https://github.com/fastify/fastify-oauth2/tree/3.x) of this module support Fastify v2.x 11 | 12 | ## Install 13 | 14 | ``` 15 | npm i @fastify/oauth2 16 | ``` 17 | 18 | ## Usage 19 | 20 | Two separate endpoints need to be created when using the fastify-oauth2 module, one for the callback from the OAuth2 service provider (such as Facebook or Discord) and another for initializing the OAuth2 login flow. 21 | 22 | ```js 23 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 24 | const oauthPlugin = require('@fastify/oauth2') 25 | 26 | fastify.register(oauthPlugin, { 27 | name: 'facebookOAuth2', 28 | credentials: { 29 | client: { 30 | id: '', 31 | secret: '' 32 | }, 33 | auth: oauthPlugin.FACEBOOK_CONFIGURATION 34 | }, 35 | // register a fastify url to start the redirect flow to the service provider's OAuth2 login 36 | startRedirectPath: '/login/facebook', 37 | // service provider redirects here after user login 38 | callbackUri: 'http://localhost:3000/login/facebook/callback' 39 | // You can also define callbackUri as a function that takes a FastifyRequest and returns a string 40 | // callbackUri: req => `${req.protocol}://${req.hostname}/login/facebook/callback`, 41 | }) 42 | 43 | // This is the new endpoint that initializes the OAuth2 login flow 44 | // This endpoint is only required if startRedirectPath has not been provided 45 | fastify.get('/login/facebook', {}, (req, reply) => { 46 | fastify.facebookOAuth2.generateAuthorizationUri( 47 | req, 48 | reply, 49 | (err, authorizationEndpoint) => { 50 | if (err) console.error(err) 51 | reply.redirect(authorizationEndpoint) 52 | } 53 | ); 54 | }); 55 | 56 | // The service provider redirect the user here after successful login 57 | fastify.get('/login/facebook/callback', async function (request, reply) { 58 | const { token } = await this.facebookOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 59 | 60 | console.log(token.access_token) 61 | 62 | // if later need to refresh the token this can be used 63 | // const { token: newToken } = await this.getNewAccessTokenUsingRefreshToken(token) 64 | 65 | reply.send({ access_token: token.access_token }) 66 | }) 67 | ``` 68 | 69 | In short, it is necessary to initially navigate to the `/login/facebook` endpoint manually in a web browser. This will redirect to the OAuth2 service provider's login screen. From there, the service provider will automatically redirect back to the `/login/facebook/callback` endpoint where the access token can be retrieved and used. The `CLIENT_ID` and `CLIENT_SECRET` need to be replaced with the ones provided by the service provider. 70 | 71 | A complete example is provided at [fastify-discord-oauth2-example](https://github.com/fastify/fastify-oauth2/blob/main/examples/discord.js) 72 | 73 | ### Usage with `@fastify/cookie` 74 | 75 | Since v7.2.0, `@fastify/oauth2` requires the use of cookies to securely implement the OAuth2 exchange. Therefore, if you need `@fastify/cookie` yourself, 76 | you will need to register it _before_ `@fastify/oauth2`. 77 | 78 | ```js 79 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 80 | const oauthPlugin = require('@fastify/oauth2') 81 | 82 | fastify.register(require('@fastify/cookie'), cookieOptions) 83 | fastify.register(oauthPlugin, oauthOptions) 84 | ``` 85 | 86 | Cookies are by default `httpOnly`, `sameSite: Lax`. If this does not suit your use case, it is possible to override the default cookie settings by providing options in the configuration object, for example 87 | 88 | ```js 89 | fastify.register(oauthPlugin, { 90 | ..., 91 | cookie: { 92 | secure: true, 93 | sameSite: 'none' 94 | } 95 | }) 96 | ``` 97 | 98 | Additionally, you can customize the names of the cookies by setting the `redirectStateCookieName` and `verifierCookieName` options. 99 | The default values for these cookies are `oauth2-code-verifier` for `verifierCookieName` and `oauth2-redirect-state` for `redirectStateCookieName`. 100 | 101 | ```js 102 | fastify.register(oauthPlugin, { 103 | ..., 104 | redirectStateCookieName: 'custom-redirect-state', 105 | verifierCookieName: 'custom-code-verifier' 106 | }) 107 | ``` 108 | 109 | ### Preset configurations 110 | 111 | You can choose some default setup to assign to `auth` option. 112 | 113 | - `APPLE_CONFIGURATION` 114 | - `FACEBOOK_CONFIGURATION` 115 | - `GITHUB_CONFIGURATION` 116 | - `GITLAB_CONFIGURATION` 117 | - `LINKEDIN_CONFIGURATION` 118 | - `GOOGLE_CONFIGURATION` 119 | - `MICROSOFT_CONFIGURATION` 120 | - `VKONTAKTE_CONFIGURATION` 121 | - `SPOTIFY_CONFIGURATION` 122 | - `DISCORD_CONFIGURATION` 123 | - `TWITCH_CONFIGURATION` 124 | - `VATSIM_CONFIGURATION` 125 | - `VATSIM_DEV_CONFIGURATION` 126 | - `EPIC_GAMES_CONFIGURATION` 127 | - `YANDEX_CONFIGURATION` 128 | 129 | ### Custom configuration 130 | 131 | Of course, you can set the OAUTH endpoints by yourself if a preset is not in our module: 132 | 133 | ```js 134 | fastify.register(oauthPlugin, { 135 | name: 'customOauth2', 136 | credentials: { 137 | client: { 138 | id: '', 139 | secret: '' 140 | }, 141 | auth: { 142 | authorizeHost: 'https://my-site.com', 143 | authorizePath: '/authorize', 144 | tokenHost: 'https://token.my-site.com', 145 | tokenPath: '/api/token' 146 | } 147 | }, 148 | startRedirectPath: '/login', 149 | callbackUri: 'http://localhost:3000/login/callback', 150 | callbackUriParams: { 151 | exampleParam: 'example param value' 152 | } 153 | }) 154 | ``` 155 | 156 | ## Use automated discovery endpoint 157 | 158 | When your provider supports OpenID connect discovery and you want to configure authorization, token and revocation endpoints automatically, 159 | then you can use discovery option. 160 | `discovery` is a simple object that requires `issuer` property. 161 | 162 | Issuer is expected to be string URL or metadata url. 163 | Variants with or without trailing slash are supported. 164 | 165 | You can see more in [example here](./examples/discovery.js). 166 | 167 | ```js 168 | fastify.register(oauthPlugin, { 169 | name: 'customOAuth2', 170 | scope: ['profile', 'email'], 171 | credentials: { 172 | client: { 173 | id: '', 174 | secret: '', 175 | }, 176 | // Note how "auth" is not needed anymore when discovery is used. 177 | }, 178 | startRedirectPath: '/login', 179 | callbackUri: 'http://localhost:3000/callback', 180 | discovery: { issuer: 'https://identity.mycustomdomain.com' } 181 | // pkce: 'S256', you can still do this explicitly, but since discovery is used, 182 | // it's BEST to let plugin do it itself 183 | // based on what Authorization Server Metadata response 184 | }); 185 | ``` 186 | 187 | Important notes for discovery: 188 | 189 | - You should not set up `credentials.auth` anymore when discovery mechanics is used. 190 | - When your provider supports it, plugin will also select appropriate PKCE method in authorization code grant 191 | - In case you still want to select method yourself, and know exactly what you are doing; you can still do it explicitly. 192 | 193 | ### Schema configuration 194 | 195 | You can specify your own schema for the `startRedirectPath` end-point. It allows you to create a well-documented document when using `@fastify/swagger` together. 196 | Note: `schema` option will override the `tags` option without merging them. 197 | 198 | ```js 199 | fastify.register(oauthPlugin, { 200 | name: 'facebookOAuth2', 201 | credentials: { 202 | client: { 203 | id: '', 204 | secret: '' 205 | }, 206 | auth: oauthPlugin.FACEBOOK_CONFIGURATION 207 | }, 208 | // register a fastify url to start the redirect flow 209 | startRedirectPath: '/login/facebook', 210 | // facebook redirect here after the user login 211 | callbackUri: 'http://localhost:3000/login/facebook/callback', 212 | // add tags for the schema 213 | tags: ['facebook', 'oauth2'], 214 | // add schema 215 | schema: { 216 | tags: ['facebook', 'oauth2'] // this will take the precedence 217 | } 218 | }) 219 | ``` 220 | 221 | ## Set custom state 222 | 223 | The `generateStateFunction` accepts a function to generate the `state` parameter for the OAUTH flow. This function receives the Fastify instance's `request` object as a parameter. 224 | The `state` parameter will be also set into a `httpOnly`, `sameSite: Lax` cookie. 225 | When you set it, it is required to provide the function `checkStateFunction` in order to validate the states generated. 226 | 227 | ```js 228 | fastify.register(oauthPlugin, { 229 | name: 'facebookOAuth2', 230 | credentials: { 231 | client: { 232 | id: '', 233 | secret: '' 234 | }, 235 | auth: oauthPlugin.FACEBOOK_CONFIGURATION 236 | }, 237 | // register a fastify url to start the redirect flow 238 | startRedirectPath: '/login/facebook', 239 | // facebook redirect here after the user login 240 | callbackUri: 'http://localhost:3000/login/facebook/callback', 241 | // custom function to generate the state 242 | generateStateFunction: (request) => { 243 | const state = request.query.customCode 244 | request.session.state = state 245 | return state 246 | }, 247 | // custom function to check the state is valid 248 | checkStateFunction: (request, callback) => { 249 | if (request.query.state === request.session.state) { 250 | callback() 251 | return 252 | } 253 | callback(new Error('Invalid state')) 254 | } 255 | }) 256 | ``` 257 | 258 | Async functions are supported here, and the fastify instance can be accessed via `this`. 259 | 260 | ```js 261 | fastify.register(oauthPlugin, { 262 | name: 'facebookOAuth2', 263 | credentials: { 264 | client: { 265 | id: '', 266 | secret: '' 267 | }, 268 | auth: oauthPlugin.FACEBOOK_CONFIGURATION 269 | }, 270 | // register a fastify url to start the redirect flow 271 | startRedirectPath: '/login/facebook', 272 | // facebook redirect here after the user login 273 | callbackUri: 'http://localhost:3000/login/facebook/callback', 274 | // custom function to generate the state and store it into the redis 275 | generateStateFunction: async function (request) { 276 | const state = request.query.customCode 277 | await this.redis.set(stateKey, state) 278 | return state 279 | }, 280 | // custom function to check the state is valid 281 | checkStateFunction: async function (request, callback) { 282 | if (request.query.state !== request.session.state) { 283 | throw new Error('Invalid state') 284 | } 285 | return true 286 | } 287 | }) 288 | ``` 289 | 290 | ## Set custom callbackUri Parameters 291 | 292 | The `callbackUriParams` accepts an object that will be translated to query parameters for the callback OAUTH flow. The default value is {}. 293 | 294 | ```js 295 | fastify.register(oauthPlugin, { 296 | name: 'googleOAuth2', 297 | scope: ['profile', 'email'], 298 | credentials: { 299 | client: { 300 | id: '', 301 | secret: '', 302 | }, 303 | auth: oauthPlugin.GOOGLE_CONFIGURATION, 304 | }, 305 | startRedirectPath: '/login/google', 306 | callbackUri: 'http://localhost:3000/login/google/callback', 307 | callbackUriParams: { 308 | // custom query param that will be passed to callbackUri 309 | access_type: 'offline', // will tell Google to send a refreshToken too 310 | }, 311 | pkce: 'S256' 312 | // check if your provider supports PKCE, 313 | // in case they do, 314 | // use of this parameter is highly encouraged 315 | // in order to prevent authorization code interception attacks 316 | }); 317 | ``` 318 | 319 | ## Set custom tokenRequest body Parameters 320 | 321 | The `tokenRequestParams` parameter accepts an object that will be translated to additional parameters in the POST body 322 | when requesting access tokens via the service’s token endpoint. 323 | 324 | ## Examples 325 | 326 | See the [`example/`](./examples/) folder for more examples. 327 | 328 | ## Reference 329 | 330 | This Fastify plugin decorates the fastify instance with the [`simple-oauth2`](https://github.com/lelylan/simple-oauth2) 331 | instance inside a **namespace** specified by the property `name` both with and without an `oauth2` prefix. 332 | 333 | E.g. For `name: 'customOauth2'`, the `simple-oauth2` instance will become accessible like this: 334 | 335 | `fastify.oauth2CustomOauth2.oauth2` and `fastify.customOauth2.oauth2` 336 | 337 | In this manner, we can register multiple OAuth providers and each OAuth providers `simple-oauth2` instance will live in its own **namespace**. 338 | 339 | E.g. 340 | 341 | - `fastify.oauth2Facebook.oauth2` 342 | - `fastify.oauth2Github.oauth2` 343 | - `fastify.oauth2Spotify.oauth2` 344 | - `fastify.oauth2Vkontakte.oauth2` 345 | 346 | Assuming we have registered multiple OAuth providers like this: 347 | 348 | - `fastify.register(oauthPlugin, { name: 'facebook', { ... } // facebooks credentials, startRedirectPath, callbackUri etc )` 349 | - `fastify.register(oauthPlugin, { name: 'github', { ... } // githubs credentials, startRedirectPath, callbackUri etc )` 350 | - `fastify.register(oauthPlugin, { name: 'spotify', { ... } // spotifys credentials, startRedirectPath, callbackUri etc )` 351 | - `fastify.register(oauthPlugin, { name: 'vkontakte', { ... } // vkontaktes credentials, startRedirectPath, callbackUri etc )` 352 | 353 | ## Utilities 354 | 355 | This fastify plugin adds 6 utility decorators to your fastify instance using the same **namespace**: 356 | 357 | - `getAccessTokenFromAuthorizationCodeFlow(request, callback)`: A function that uses the Authorization code flow to fetch an OAuth2 token using the data in the last request of the flow. If the callback is not passed it will return a promise. The callback call or promise resolution returns an [AccessToken](https://github.com/lelylan/simple-oauth2/blob/master/API.md#accesstoken) object, which has an `AccessToken.token` property with the following keys: 358 | - `access_token` 359 | - `refresh_token` (optional, only if the `offline scope` was originally requested, as seen in the callbackUriParams example) 360 | - `token_type` (generally `'Bearer'`) 361 | - `expires_in` (number of seconds for the token to expire, e.g. `240000`) 362 | 363 | - OR `getAccessTokenFromAuthorizationCodeFlow(request, reply, callback)` variant with 3 arguments, which should be used when PKCE extension is used. 364 | This allows fastify-oauth2 to delete PKCE code_verifier cookie so it doesn't stay in browser in case server has issue when fetching token. See [Google With PKCE example for more](./examples/google-with-pkce.js). 365 | 366 | *Important to note*: if your provider supports `S256` as code_challenge_method, always prefer that. 367 | Only use `plain` when your provider doesn't support `S256`. 368 | 369 | 370 | - `getNewAccessTokenUsingRefreshToken(Token, params, callback)`: A function that takes a `AccessToken`-Object as `Token` and retrieves a new `AccessToken`-Object. This is generally useful with background processing workers to re-issue a new AccessToken when the previous AccessToken has expired. The `params` argument is optional and it is an object that can be used to pass in additional parameters to the refresh request (e.g. a stricter set of scopes). If the callback is not passed this function will return a Promise. The object resulting from the callback call or the resolved Promise is a new `AccessToken` object (see above). Example of how you would use it for `name:googleOAuth2`: 371 | ```js 372 | fastify.googleOAuth2.getNewAccessTokenUsingRefreshToken(currentAccessToken, (err, newAccessToken) => { 373 | // Handle the new accessToken 374 | }); 375 | ``` 376 | 377 | - `generateAuthorizationUri(requestObject, replyObject, callback)`: A function that generates the authorization uri. If the callback is not passed this function will return a Promise. The string resulting from the callback call or the resolved Promise is the authorization uri. This is generally useful when you want to handle the redirect yourself in a specific route. The `requestObject` argument passes the request object to the `generateStateFunction`). You **do not** need to declare a `startRedirectPath` if you use this approach. Example of how you would use it: 378 | 379 | ```js 380 | fastify.get('/external', { /* Hooks can be used here */ }, (req, reply) => { 381 | fastify.oauth2CustomOAuth2.generateAuthorizationUri(req, reply, (err, authorizationEndpoint) => { 382 | reply.redirect(authorizationEndpoint) 383 | }); 384 | }); 385 | ``` 386 | 387 | - `revokeToken(Token, tokenType, params, callback)`: A function to revoke the current access_token or refresh_token on the authorization server. If the callback is not passed it will return a promise. The callback call or promise resolution returns `void` 388 | ```js 389 | fastify.googleOAuth2.revokeToken(currentAccessToken, 'access_token', undefined, (err) => { 390 | // Handle the reply here 391 | }); 392 | ``` 393 | - `revokeAllToken(Token, params, callback)`: A function to revoke the current access_token and refresh_token on the authorization server. If the callback is not passed it will return a promise. The callback call or promise resolution returns `void` 394 | ```js 395 | fastify.googleOAuth2.revokeAllToken(currentAccessToken, undefined, (err) => { 396 | // Handle the reply here 397 | }); 398 | ``` 399 | 400 | - `userinfo(tokenOrTokenSet)`: A function to retrieve userinfo data from Authorization Provider. Both token (as object) or `access_token` string value can be passed. 401 | 402 | Important note: 403 | Userinfo will only work when `discovery` option is used and such endpoint is advertised by identity provider. 404 | 405 | For a statically configured plugin, you need to make a HTTP call yourself. 406 | 407 | See more on OIDC standard definition for [Userinfo endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) 408 | 409 | See more on `userinfo_endpoint` property in [OIDC Discovery Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata) standard definition. 410 | 411 | ```js 412 | fastify.googleOAuth2.userinfo(currentAccessToken, (err, userinfo) => { 413 | // do something with userinfo 414 | }); 415 | // with custom params 416 | fastify.googleOAuth2.userinfo(currentAccessToken, { method: 'GET', params: { /* add your custom key value pairs here to be appended to request */ } }, (err, userinfo) => { 417 | // do something with userinfo 418 | }); 419 | 420 | // or promise version 421 | const userinfo = await fastify.googleOAuth2.userinfo(currentAccessToken); 422 | // use custom params 423 | const userinfo = await fastify.googleOAuth2.userinfo(currentAccessToken, { method: 'GET', params: { /* ... */ } }); 424 | ``` 425 | 426 | There are variants with callback and promises. 427 | Custom parameters can be passed as option. 428 | See [Types](./types/index.d.ts) and usage patterns [in examples](./examples/userinfo.js). 429 | 430 | Note: 431 | 432 | We support HTTP `GET` and `POST` requests to userinfo endpoint sending access token using `Bearer` schema in headers. 433 | You can do this by setting (`via: "header"` parameter), but it's not mandatory since it's a default value. 434 | 435 | We also support `POST` by sending `access_token` in a request body. You can do this by explicitly providing `via: "body"` parameter. 436 | 437 | E.g. For `name: 'customOauth2'`, the helpers `getAccessTokenFromAuthorizationCodeFlow` and `getNewAccessTokenUsingRefreshToken` will become accessible like this: 438 | 439 | - `fastify.oauth2CustomOauth2.getAccessTokenFromAuthorizationCodeFlow` 440 | - `fastify.oauth2CustomOauth2.getNewAccessTokenUsingRefreshToken` 441 | 442 | ## Usage with TypeScript 443 | 444 | Type definitions are provided with the package. Decorations are applied during runtime and are based on auth configuration name. One solution is to leverage TypeScript declaration merging to add type-safe namespace. Make sure you have `@types/node` installed for this to work correctly. 445 | 446 | In project declarations files .d.ts 447 | 448 | ```ts 449 | import { OAuth2Namespace } from '@fastify/oauth2'; 450 | 451 | declare module 'fastify' { 452 | interface FastifyInstance { 453 | facebookOAuth2: OAuth2Namespace; 454 | myCustomOAuth2: OAuth2Namespace; 455 | } 456 | } 457 | ``` 458 | 459 | All auth configurations are made available with an `oauth2` prefix that's typed to `OAuth2Namespace | undefined`, such as eg. `fastify.oauth2CustomOauth2` for `customOauth2`. 460 | 461 | ## Provider Quirks 462 | 463 | The following providers require additional work to be set up correctly. 464 | 465 | ### Twitch 466 | 467 | Twitch requires that the request for a token in the oauth2 flow contains the `client_id` and `client_secret` properties in `tokenRequestParams`: 468 | 469 | ```js 470 | fastify.register(oauthPlugin, { 471 | name: 'twitchOauth2', 472 | credentials: { 473 | client: { 474 | id: '', 475 | secret: '' 476 | }, 477 | auth: oauthPlugin.TWITCH_CONFIGURATION 478 | }, 479 | tokenRequestParams: { 480 | client_id: '', 481 | client_secret: '', 482 | }, 483 | // register a fastify url to start the redirect flow 484 | startRedirectPath: '/login/twitch', 485 | // twitch redirect here after the user login 486 | callbackUri: 'http://localhost:3000/login/twitch/callback' 487 | }) 488 | ``` 489 | 490 | ## License 491 | 492 | Licensed under [MIT](./LICENSE). 493 | 494 | *NB* See [`simple-oauth2`](https://github.com/lelylan/simple-oauth2) license too 495 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: require('neostandard').resolveIgnoresFromGitignore(), 5 | ts: true 6 | }) 7 | -------------------------------------------------------------------------------- /examples/apple.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * This example assumes the use of the npm package `apple-signin-auth` in your code. 5 | * This library is not included with fastify-oauth2. If you wish to implement 6 | * the verification part of Apple's Sign In REST API yourself, 7 | * look at {@link https://github.com/a-tokyo/apple-signin-auth} to see how they did 8 | * it, or look at {@link https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api} 9 | * for more details on how to do it from scratch. 10 | */ 11 | 12 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 13 | const appleSignin = require('apple-signin-auth') 14 | 15 | // const oauthPlugin = require('fastify-oauth2') 16 | const oauthPlugin = require('..') 17 | 18 | // All fields below must come from environment variables 19 | const [CLIENT_ID, TEAM_ID, PRIVATE_KEY, KEY_ID] = ['', '', '', ''] 20 | // In Apple OAuth2 the CLIENT_SECRET is not static and must be generated 21 | const CLIENT_SECRET = generateClientSecret() 22 | 23 | fastify.register(oauthPlugin, { 24 | name: 'appleOAuth2', 25 | credentials: { 26 | client: { 27 | id: CLIENT_ID, 28 | secret: CLIENT_SECRET 29 | }, 30 | auth: oauthPlugin.APPLE_CONFIGURATION, 31 | options: { 32 | /** 33 | * Based on offical Apple OAuth2 docs, an HTTP POST request is sent to the redirectURI for the `form_post` value. 34 | * And the result of the authorization is stored in the body as application/x-www-form-urlencoded content type. 35 | * See {@link https://developer.apple.com/documentation/sign_in_with_apple/request_an_authorization_to_the_sign_in_with_apple_server} 36 | */ 37 | authorizationMethod: 'body' 38 | } 39 | }, 40 | startRedirectPath: '/login/apple', 41 | callbackUri: 'http://localhost:3000/login/apple/callback' 42 | }) 43 | 44 | fastify.get('/login/apple/callback', function (request, reply) { 45 | /** 46 | * NOTE: Apple returns the "user" object only the 1st time the user authorizes the app. 47 | * For more information, visit https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple 48 | */ 49 | const { code, state, error, user } = request.body 50 | 51 | if (user) { 52 | // Make sure to validate and persist it. All subsequent authorization requests will not contain the user object 53 | } 54 | 55 | if (!state) { 56 | // If the endpoint was not redirected from social oauth flow 57 | throw new Error('Illegal invoking of endpoint.') 58 | } 59 | 60 | if (error === Error.CancelledAuth) { 61 | // If a user cancelled authorization process, redirect him back to the app 62 | const webClientUrl = '' 63 | reply.status(303).redirect(webClientUrl) 64 | } 65 | 66 | const authCodeFlow = { ...request, query: { code, state } } 67 | 68 | this.appleOAuth2 69 | .getAccessTokenFromAuthorizationCodeFlow(authCodeFlow, (err, result) => { 70 | if (err) { 71 | reply.send(err) 72 | return 73 | } 74 | 75 | decryptToken(result.id_token) 76 | .then(payload => { 77 | const userAppleId = payload.sub 78 | reply.send(userAppleId) 79 | }) 80 | .catch(err => { 81 | // Token is not verified 82 | reply.send(err) 83 | }) 84 | }) 85 | }) 86 | 87 | /** 88 | * Decrypts Token from Apple and returns decrypted user's info 89 | * 90 | * @param { string } token Info received from Apple's Authorization flow on Token request 91 | * @returns { object } Decrypted user's info 92 | */ 93 | function decryptToken (token) { 94 | /** 95 | * NOTE: Data format returned by Apple 96 | * 97 | * { 98 | * email: 'user_email@abc.com', 99 | * iss: 'https://appleid.apple.com' 100 | * sub: '10*****************27' // User ID, 101 | * email_verified: 'true', 102 | * is_private_email: 'false', 103 | * ... 104 | * } 105 | * 106 | * PS: All fields can be found here - {@link https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple} 107 | */ 108 | 109 | return appleSignin.verifyIdToken(token, CLIENT_ID) 110 | } 111 | 112 | /** 113 | * Generates Apple's OAuth2 secret key based on expiration date, Client ID, Team ID, Private key and Key ID. 114 | * See more {@link https://github.com/a-tokyo/apple-signin-auth} for implementation details. 115 | * 116 | * @returns { string } Apple Secret Key 117 | */ 118 | function generateClientSecret () { 119 | const expiresIn = 180 // in days (6 months) - custom time set based on requirements 120 | 121 | return appleSignin.getClientSecret({ 122 | clientID: CLIENT_ID, 123 | teamID: TEAM_ID, 124 | privateKey: PRIVATE_KEY, 125 | keyIdentifier: KEY_ID, 126 | expAfter: expiresIn * 24 * 3600 // in seconds 127 | }) 128 | } 129 | 130 | fastify.listen({ port: 3000 }) 131 | -------------------------------------------------------------------------------- /examples/discord.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 4 | const oauthPlugin = require('..') 5 | 6 | fastify.register(oauthPlugin, { 7 | name: 'discordOAuth2', 8 | credentials: { 9 | client: { 10 | id: '', 11 | secret: '' 12 | }, 13 | auth: oauthPlugin.DISCORD_CONFIGURATION 14 | }, 15 | startRedirectPath: '/login/facebook', 16 | callbackUri: 'http://localhost:3000/login/discord/callback' 17 | }) 18 | 19 | fastify.get('/login/discord/callback', async function (request, reply) { 20 | try { 21 | const token = 22 | await this.discordOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 23 | return reply.send(token) 24 | } catch (error) { 25 | return reply.send(error) 26 | } 27 | }) 28 | 29 | fastify.get('/login/discord', {}, (req, reply) => { 30 | fastify.discordOAuth2.generateAuthorizationUri( 31 | req, 32 | reply, 33 | (err, authorizationEndpoint) => { 34 | if (err) console.error(err) 35 | reply.redirect(authorizationEndpoint) 36 | } 37 | ) 38 | }) 39 | 40 | fastify.listen({ port: 3000 }) 41 | -------------------------------------------------------------------------------- /examples/discovery.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 4 | const sget = require('simple-get') 5 | 6 | const cookieOpts = { 7 | // domain: 'localhost', 8 | path: '/', 9 | secure: true, 10 | sameSite: 'lax', 11 | httpOnly: true 12 | } 13 | 14 | // const oauthPlugin = require('fastify-oauth2') 15 | fastify.register(require('@fastify/cookie'), { 16 | secret: ['my-secret'], 17 | parseOptions: cookieOpts 18 | }) 19 | 20 | const oauthPlugin = require('..') 21 | fastify.register(oauthPlugin, { 22 | name: 'googleOAuth2', 23 | // when provided, this userAgent will also be used at discovery endpoint 24 | // to fully omit for whatever reason, set it to false 25 | userAgent: 'my custom app (v1.0.0)', 26 | scope: ['openid', 'profile', 'email'], 27 | credentials: { 28 | client: { 29 | id: process.env.CLIENT_ID, 30 | secret: process.env.CLIENT_SECRET 31 | } 32 | }, 33 | startRedirectPath: '/login/google', 34 | callbackUri: 'http://localhost:3000/interaction/callback/google', 35 | cookie: cookieOpts, 36 | // pkce: 'S256' let discovery handle it itself 37 | discovery: { 38 | /* 39 | When OIDC provider is mounted at root: 40 | with trailing slash (99% of the cases) 41 | - 'https://accounts.google.com/' 42 | */ 43 | issuer: 'https://accounts.google.com' 44 | /* 45 | also these variants work: 46 | When OIDC provider is mounted at root: 47 | with trailing slash 48 | - 'https://accounts.google.com/' 49 | 50 | When given explicit metadata endpoint: 51 | - issuer: 'https://accounts.google.com/.well-known/openid-configuration' 52 | 53 | When OIDC provider is nested at some route: 54 | - with trailing slash 55 | 'https://id.mycustomdomain.com/nested/' 56 | - without trailing slash 57 | 'https://id.mycustomdomain.com/nested' 58 | */ 59 | } 60 | }) 61 | 62 | fastify.get('/interaction/callback/google', function (request, reply) { 63 | // Note that in this example a "reply" is also passed, it's so that code verifier cookie can be cleaned before 64 | // token is requested from token endpoint 65 | this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply, (err, result) => { 66 | if (err) { 67 | reply.send(err) 68 | return 69 | } 70 | 71 | sget.concat({ 72 | url: 'https://www.googleapis.com/oauth2/v2/userinfo', 73 | method: 'GET', 74 | headers: { 75 | Authorization: 'Bearer ' + result.token.access_token 76 | }, 77 | json: true 78 | }, function (err, _res, data) { 79 | if (err) { 80 | reply.send(err) 81 | return 82 | } 83 | reply.send(data) 84 | }) 85 | }) 86 | }) 87 | 88 | fastify.listen({ port: 3000 }) 89 | fastify.log.info('go to http://localhost:3000/login/google') 90 | -------------------------------------------------------------------------------- /examples/epic-games.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 4 | 5 | // const oauthPlugin = require('fastify-oauth2') 6 | const oauthPlugin = require('..') 7 | 8 | fastify.register(oauthPlugin, { 9 | name: 'egOAuth2', 10 | scope: ['basic_profile'], // 'basic_profile', 'friends_list', 'presence', 11 | credentials: { 12 | client: { 13 | id: process.env.CLIENT_ID, 14 | secret: process.env.CLIENT_SECRET 15 | }, 16 | auth: oauthPlugin.EPIC_GAMES_CONFIGURATION 17 | }, 18 | startRedirectPath: '/login/eg', 19 | callbackUri: `http://localhost:${process.env.PORT}/login/eg/callback` 20 | }) 21 | 22 | fastify.get('/login/eg/callback', async (req, reply) => { 23 | const token = await fastify.egOAuth2.getAccessTokenFromAuthorizationCodeFlow(req) 24 | 25 | req.log.info('The Epic Games token is %o', token) 26 | reply.send({ access_token: token.access_token }) 27 | }) 28 | 29 | fastify.listen(process.env.PORT, (err, address) => { 30 | if (err) { 31 | fastify.log.error(err) 32 | process.exit(1) 33 | } 34 | fastify.log.info(`server listening on ${address}`) 35 | }) 36 | -------------------------------------------------------------------------------- /examples/facebook.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 4 | const sget = require('simple-get') 5 | 6 | // const oauthPlugin = require('fastify-oauth2') 7 | const oauthPlugin = require('..') 8 | 9 | fastify.register(oauthPlugin, { 10 | name: 'facebookOAuth2', 11 | credentials: { 12 | client: { 13 | id: '', 14 | secret: '' 15 | }, 16 | auth: oauthPlugin.FACEBOOK_CONFIGURATION 17 | }, 18 | startRedirectPath: '/login/facebook', 19 | callbackUri: 'http://localhost:3000/login/facebook/callback' 20 | }) 21 | 22 | fastify.get('/login/facebook/callback', function (request, reply) { 23 | this.facebookOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, (err, result) => { 24 | if (err) { 25 | reply.send(err) 26 | return 27 | } 28 | 29 | sget.concat({ 30 | url: 'https://graph.facebook.com/v6.0/me', 31 | method: 'GET', 32 | headers: { 33 | Authorization: 'Bearer ' + result.access_token 34 | }, 35 | json: true 36 | }, function (err, _res, data) { 37 | if (err) { 38 | reply.send(err) 39 | return 40 | } 41 | reply.send(data) 42 | }) 43 | }) 44 | }) 45 | 46 | fastify.listen({ port: 3000 }) 47 | -------------------------------------------------------------------------------- /examples/github.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 4 | const sget = require('simple-get') 5 | 6 | // const oauthPlugin = require('fastify-oauth2') 7 | const oauthPlugin = require('..') 8 | 9 | fastify.register(oauthPlugin, { 10 | name: 'githubOAuth2', 11 | scope: [], 12 | credentials: { 13 | client: { 14 | id: '', 15 | secret: '' 16 | }, 17 | auth: oauthPlugin.GITHUB_CONFIGURATION 18 | }, 19 | startRedirectPath: '/login/github', 20 | callbackUri: 'http://localhost:3000/login/github/callback' 21 | }) 22 | 23 | const memStore = new Map() 24 | 25 | async function saveAccessToken (token) { 26 | memStore.set(token.refresh_token, token) 27 | } 28 | 29 | async function retrieveAccessToken (token) { 30 | // remove Bearer if needed 31 | if (token.startsWith('Bearer ')) { 32 | token = token.substring(6) 33 | } 34 | // any database or in-memory operation here 35 | // we use in-memory variable here 36 | if (memStore.has(token)) { 37 | memStore.get(token) 38 | } 39 | throw new Error('invalid refresh token') 40 | } 41 | 42 | fastify.get('/login/github/callback', async function (request, reply) { 43 | const token = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 44 | 45 | console.log(token.access_token) 46 | 47 | // you should store the `token` for further usage 48 | await saveAccessToken(token) 49 | 50 | reply.send({ access_token: token.access_token }) 51 | }) 52 | 53 | fastify.get('/login/github/refreshAccessToken', async function (request, reply) { 54 | // we assume the token is passed by authorization header 55 | const refreshToken = await retrieveAccessToken(request.headers.authorization) 56 | const newToken = await this.githubOAuth2.getAccessTokenFromRefreshToken(refreshToken, {}) 57 | 58 | // we save the token again 59 | await saveAccessToken(newToken) 60 | 61 | reply.send({ access_token: newToken.access_token }) 62 | }) 63 | 64 | // Check access token: https://docs.github.com/en/rest/apps/oauth-applications#check-a-token 65 | fastify.get('/login/github/verifyAccessToken', function (request, reply) { 66 | const { accessToken } = request.query 67 | 68 | sget.concat( 69 | { 70 | url: 'https://api.github.com/applications//token', 71 | method: 'POST', 72 | headers: { 73 | Authorization: 74 | 'Basic ' + 75 | Buffer.from('' + ':' + ' { 61 | if (err) { 62 | reply.send(err) 63 | return 64 | } 65 | 66 | sget.concat({ 67 | url: 'https://www.googleapis.com/oauth2/v2/userinfo', 68 | method: 'GET', 69 | headers: { 70 | Authorization: 'Bearer ' + result.token.access_token 71 | }, 72 | json: true 73 | }, function (err, _res, data) { 74 | if (err) { 75 | reply.send(err) 76 | return 77 | } 78 | reply.send(data) 79 | }) 80 | }) 81 | }) 82 | 83 | fastify.listen({ port: 3000 }) 84 | fastify.log.info('go to http://localhost:3000/login/google') 85 | -------------------------------------------------------------------------------- /examples/google.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 4 | const sget = require('simple-get') 5 | 6 | // const oauthPlugin = require('fastify-oauth2') 7 | const oauthPlugin = require('..') 8 | 9 | fastify.register(oauthPlugin, { 10 | name: 'googleOAuth2', 11 | scope: ['profile'], 12 | credentials: { 13 | client: { 14 | id: '', 15 | secret: '' 16 | }, 17 | auth: oauthPlugin.GOOGLE_CONFIGURATION 18 | }, 19 | startRedirectPath: '/login/google', 20 | callbackUri: 'http://localhost:3000/login/google/callback' 21 | }) 22 | 23 | fastify.get('/login/google/callback', function (request, reply) { 24 | this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, (err, result) => { 25 | if (err) { 26 | reply.send(err) 27 | return 28 | } 29 | 30 | sget.concat({ 31 | url: 'https://www.googleapis.com/oauth2/v2/userinfo', 32 | method: 'GET', 33 | headers: { 34 | Authorization: 'Bearer ' + result.token.access_token 35 | }, 36 | json: true 37 | }, function (err, _res, data) { 38 | if (err) { 39 | reply.send(err) 40 | return 41 | } 42 | reply.send(data) 43 | }) 44 | }) 45 | }) 46 | 47 | fastify.listen({ port: 3000 }) 48 | -------------------------------------------------------------------------------- /examples/linkedin.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 4 | const sget = require('simple-get') 5 | 6 | // const oauthPlugin = require('fastify-oauth2') 7 | const oauthPlugin = require('..') 8 | 9 | fastify.register(oauthPlugin, { 10 | name: 'linkedinOAuth2', 11 | scope: ['profile', 'email', 'openid'], 12 | credentials: { 13 | client: { 14 | id: '', 15 | secret: '' 16 | }, 17 | auth: oauthPlugin.LINKEDIN_CONFIGURATION 18 | }, 19 | tokenRequestParams: { 20 | client_id: '', 21 | client_secret: '' 22 | }, 23 | startRedirectPath: '/login/linkedin', 24 | callbackUri: 'http://localhost:3000/login/linkedin/callback' 25 | }) 26 | 27 | fastify.get('/login/linkedin/callback', function (request, reply) { 28 | this.linkedinOAuth2.getAccessTokenFromAuthorizationCodeFlow( 29 | request, 30 | (err, result) => { 31 | if (err) { 32 | reply.send(err) 33 | return 34 | } 35 | 36 | sget.concat( 37 | { 38 | url: 'https://api.linkedin.com/v2/userinfo', 39 | method: 'GET', 40 | headers: { 41 | Authorization: 'Bearer ' + result.token.access_token 42 | }, 43 | json: true 44 | }, 45 | function (err, _res, data) { 46 | if (err) { 47 | reply.send(err) 48 | return 49 | } 50 | reply.send(data) 51 | } 52 | ) 53 | } 54 | ) 55 | }) 56 | 57 | fastify.listen({ port: 3000 }) 58 | -------------------------------------------------------------------------------- /examples/spotify.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: true }) 4 | 5 | // const oauthPlugin = require('fastify-oauth2') 6 | const oauthPlugin = require('..') 7 | 8 | fastify.register(oauthPlugin, { 9 | name: 'Spotify', 10 | scope: ['user-read-currently-playing'], 11 | credentials: { 12 | client: { 13 | id: process.env.CLIENT_ID, 14 | secret: process.env.CLIENT_SECRET 15 | }, 16 | auth: oauthPlugin.SPOTIFY_CONFIGURATION 17 | }, 18 | startRedirectPath: '/login/spotify', 19 | callbackUri: `http://localhost:${process.env.PORT}/login/spotify/callback` 20 | }) 21 | 22 | fastify.get('/login/spotify/callback', async (req, reply) => { 23 | const result = await fastify.Spotify.getAccessTokenFromAuthorizationCodeFlow(req) 24 | 25 | req.log.info('The Spotify token is %o', result.token) 26 | reply.send({ access_token: result.token.access_token }) 27 | }) 28 | 29 | fastify.listen({ port: process.env.PORT }) 30 | -------------------------------------------------------------------------------- /examples/userinfo.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 4 | 5 | const cookieOpts = { 6 | path: '/', 7 | secure: true, 8 | sameSite: 'lax', 9 | httpOnly: true 10 | } 11 | 12 | // const oauthPlugin = require('fastify-oauth2') 13 | const oauthPlugin = require('..') 14 | 15 | fastify.register(require('@fastify/cookie'), { 16 | secret: ['my-secret'], 17 | parseOptions: cookieOpts 18 | }) 19 | 20 | fastify.register(oauthPlugin, { 21 | name: 'googleOAuth2', 22 | // when provided, this userAgent will also be used at discovery endpoint 23 | // to fully omit for whatever reason, set it to false 24 | userAgent: 'my custom app (v1.0.0)', 25 | scope: ['openid', 'profile', 'email'], 26 | credentials: { 27 | client: { 28 | id: process.env.CLIENT_ID, 29 | secret: process.env.CLIENT_SECRET 30 | } 31 | }, 32 | startRedirectPath: '/login/google', 33 | callbackUri: 'http://localhost:3000/interaction/callback/google', 34 | cookie: cookieOpts, 35 | discovery: { 36 | issuer: 'https://accounts.google.com' 37 | } 38 | }) 39 | 40 | // using async/await (promises API) -> 41 | // 1. simple one with async 42 | fastify.get('/interaction/callback/google', async function (request, reply) { 43 | const tokenResponse = await this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 44 | const userinfo = await this.googleOAuth2.userinfo(tokenResponse.token /* or tokenResponse.token.access_token */) 45 | return userinfo 46 | }) 47 | 48 | // 2. custom params one with async 49 | // fastify.get('/interaction/callback/google', { method: 'GET', params: { /* custom parameters to be added */ } }, async function (request, reply) { 50 | // const tokenResponse = await this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 51 | // const userinfo = await this.googleOAuth2.userinfo(tokenResponse.token /* or tokenResponse.token.access_token */) 52 | // return userinfo 53 | // }) 54 | 55 | // OR with a callback API 56 | 57 | // 3. simple one with callback 58 | // fastify.get('/interaction/callback/google', function (request, reply) { 59 | // const userInfoCallback = (err, userinfo) => { 60 | // if (err) { 61 | // reply.send(err) 62 | // return 63 | // } 64 | // reply.send(userinfo) 65 | // } 66 | 67 | // const accessTokenCallback = (err, result) => { 68 | // if (err) { 69 | // reply.send(err) 70 | // return 71 | // } 72 | // this.googleOAuth2.userinfo(result.token, userInfoCallback) 73 | // } 74 | 75 | // this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply, accessTokenCallback) 76 | // }) 77 | 78 | // 4. custom params one with with callback 79 | // fastify.get('/interaction/callback/google', { method: 'GET', params: { /** custom parameters to be added */ } }, function (request, reply) { 80 | // const userInfoCallback = (err, userinfo) => { 81 | // if (err) { 82 | // reply.send(err) 83 | // return 84 | // } 85 | // reply.send(userinfo) 86 | // } 87 | 88 | // const accessTokenCallback = (err, result) => { 89 | // if (err) { 90 | // reply.send(err) 91 | // return 92 | // } 93 | // this.googleOAuth2.userinfo(result.token, userInfoCallback) 94 | // } 95 | 96 | // this.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply, accessTokenCallback) 97 | // }) 98 | 99 | fastify.listen({ port: 3000 }) 100 | fastify.log.info('go to http://localhost:3000/login/google') 101 | -------------------------------------------------------------------------------- /examples/vatsim-dev.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 4 | const sget = require('simple-get') 5 | 6 | // const oauthPlugin = require('fastify-oauth2') 7 | const oauthPlugin = require('..') 8 | 9 | fastify.register(oauthPlugin, { 10 | name: 'vatsimoauthdev', 11 | scope: ['full_name', 'email', 'vatsim_details', 'country'], 12 | credentials: { 13 | client: { 14 | id: '', 15 | secret: '' 16 | }, 17 | auth: oauthPlugin.VATSIM_DEV_CONFIGURATION 18 | }, 19 | startRedirectPath: '/login/vatsim', 20 | callbackUri: 'http://localhost:3000/login/vatsim/callback' 21 | }) 22 | 23 | fastify.get('/login/vatsim/callback', function (request, reply) { 24 | this.vatsimoauthdev.getAccessTokenFromAuthorizationCodeFlow( 25 | request, 26 | (err, result) => { 27 | if (err) { 28 | reply.send(err) 29 | return 30 | } 31 | 32 | sget.concat( 33 | { 34 | url: 'https://auth-dev.vatsim.net/api/user', 35 | method: 'GET', 36 | headers: { 37 | Authorization: 'Bearer ' + result.access_token 38 | }, 39 | json: true 40 | }, 41 | function (err, _res, data) { 42 | if (err) { 43 | reply.send(err) 44 | return 45 | } 46 | reply.send(data) 47 | } 48 | ) 49 | } 50 | ) 51 | }) 52 | 53 | fastify.listen({ port: 3000 }) 54 | -------------------------------------------------------------------------------- /examples/vatsim.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 4 | const sget = require('fastify') 5 | 6 | // const oauthPlugin = require('fastify-oauth2') 7 | const oauthPlugin = require('..') 8 | 9 | fastify.register(oauthPlugin, { 10 | name: 'vatsimoauth', 11 | scope: ['full_name', 'email', 'vatsim_details', 'country'], 12 | credentials: { 13 | client: { 14 | id: '', 15 | secret: '' 16 | }, 17 | auth: oauthPlugin.VATSIM_CONFIGURATION 18 | }, 19 | startRedirectPath: '/login/vatsim', 20 | callbackUri: 'http://localhost:3000/login/vatsim/callback' 21 | }) 22 | 23 | fastify.get('/login/vatsim/callback', function (request, reply) { 24 | this.vatsimoauthdev.getAccessTokenFromAuthorizationCodeFlow( 25 | request, 26 | (err, result) => { 27 | if (err) { 28 | reply.send(err) 29 | return 30 | } 31 | 32 | sget.concat( 33 | { 34 | url: 'https://auth.vatsim.net/api/user', 35 | method: 'GET', 36 | headers: { 37 | Authorization: 'Bearer ' + result.access_token 38 | }, 39 | json: true 40 | }, 41 | function (err, _res, data) { 42 | if (err) { 43 | reply.send(err) 44 | return 45 | } 46 | reply.send(data) 47 | } 48 | ) 49 | } 50 | ) 51 | }) 52 | 53 | fastify.listen({ port: 3000 }) 54 | -------------------------------------------------------------------------------- /examples/vkontakte.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 4 | 5 | // const oauthPlugin = require('fastify-oauth2') 6 | const oauthPlugin = require('..') 7 | 8 | fastify.register(oauthPlugin, { 9 | name: 'vkOAuth2', 10 | scope: ['email'], 11 | credentials: { 12 | client: { 13 | id: process.env.CLIENT_ID, 14 | secret: process.env.CLIENT_SECRET 15 | }, 16 | auth: oauthPlugin.VKONTAKTE_CONFIGURATION 17 | }, 18 | startRedirectPath: '/login/vk', 19 | callbackUri: `http://localhost:${process.env.PORT}/login/vk/callback` 20 | }) 21 | 22 | fastify.get('/login/vk/callback', async (req, reply) => { 23 | const token = await fastify.vkOAuth2.getAccessTokenFromAuthorizationCodeFlow(req) 24 | 25 | console.log(token) 26 | reply.send({ access_token: token.access_token }) 27 | }) 28 | 29 | fastify.listen(process.env.PORT, (err, address) => { 30 | if (err) { 31 | fastify.log.error(err) 32 | process.exit(1) 33 | } 34 | fastify.log.info(`server listening on ${address}`) 35 | }) 36 | -------------------------------------------------------------------------------- /examples/yandex.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify')({ logger: { level: 'trace' } }) 4 | 5 | // const oauthPlugin = require('fastify-oauth2') 6 | const oauthPlugin = require('..') 7 | 8 | fastify.register(oauthPlugin, { 9 | name: 'yandexOAuth2', 10 | scope: ['login:email'], 11 | credentials: { 12 | client: { 13 | id: process.env.CLIENT_ID, 14 | secret: process.env.CLIENT_SECRET 15 | }, 16 | auth: oauthPlugin.YANDEX_CONFIGURATION 17 | }, 18 | startRedirectPath: '/login/yandex', 19 | callbackUri: `http://localhost:${process.env.PORT}/login/yandex/callback` 20 | }) 21 | 22 | fastify.get('/login/yandex/callback', async (req, reply) => { 23 | const token = await fastify.yandexOAuth2.getAccessTokenFromAuthorizationCodeFlow(req) 24 | 25 | console.log(token) 26 | reply.send({ access_token: token.access_token }) 27 | }) 28 | 29 | fastify.listen(process.env.PORT, (err, address) => { 30 | if (err) { 31 | fastify.log.error(err) 32 | process.exit(1) 33 | } 34 | fastify.log.info(`server listening on ${address}`) 35 | }) 36 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const url = require('node:url') 4 | const http = require('node:http') 5 | const https = require('node:https') 6 | 7 | const { randomBytes, createHash } = require('node:crypto') 8 | 9 | const fp = require('fastify-plugin') 10 | const { AuthorizationCode } = require('simple-oauth2') 11 | const kGenerateCallbackUriParams = Symbol.for('fastify-oauth2.generate-callback-uri-params') 12 | 13 | const { promisify, callbackify } = require('node:util') 14 | 15 | const DEFAULT_VERIFIER_COOKIE_NAME = 'oauth2-code-verifier' 16 | const DEFAULT_REDIRECT_STATE_COOKIE_NAME = 'oauth2-redirect-state' 17 | const USER_AGENT = 'fastify-oauth2' 18 | const PKCE_METHODS = ['S256', 'plain'] 19 | 20 | const random = (bytes = 32) => randomBytes(bytes).toString('base64url') 21 | const codeVerifier = random 22 | const codeChallenge = verifier => createHash('sha256').update(verifier).digest('base64url') 23 | 24 | function defaultGenerateStateFunction (_request, callback) { 25 | callback(null, random(16)) 26 | } 27 | 28 | function defaultCheckStateFunction (request, callback) { 29 | const state = request.query.state 30 | const stateCookie = 31 | request.cookies[ 32 | this.redirectStateCookieName 33 | ] 34 | if (stateCookie && state === stateCookie) { 35 | callback() 36 | return 37 | } 38 | callback(new Error('Invalid state')) 39 | } 40 | 41 | function defaultGenerateCallbackUriParams (callbackUriParams) { 42 | return callbackUriParams 43 | } 44 | 45 | /** 46 | * @param {FastifyInstance} fastify 47 | * @param {Partial} options 48 | * @param {Function} next 49 | * @return {*} 50 | */ 51 | function fastifyOauth2 (fastify, options, next) { 52 | if (typeof options.name !== 'string') { 53 | return next(new Error('options.name should be a string')) 54 | } 55 | if (typeof options.credentials !== 'object') { 56 | return next(new Error('options.credentials should be an object')) 57 | } 58 | if (typeof options.callbackUri !== 'string' && typeof options.callbackUri !== 'function') { 59 | return next(new Error('options.callbackUri should be a string or a function')) 60 | } 61 | if (options.callbackUriParams && typeof options.callbackUriParams !== 'object') { 62 | return next(new Error('options.callbackUriParams should be a object')) 63 | } 64 | if (options.tokenRequestParams && typeof options.tokenRequestParams !== 'object') { 65 | return next(new Error('options.tokenRequestParams should be a object')) 66 | } 67 | if (options.generateStateFunction && typeof options.generateStateFunction !== 'function') { 68 | return next(new Error('options.generateStateFunction should be a function')) 69 | } 70 | if (options.checkStateFunction && typeof options.checkStateFunction !== 'function') { 71 | return next(new Error('options.checkStateFunction should be a function')) 72 | } 73 | if (options.startRedirectPath && typeof options.startRedirectPath !== 'string') { 74 | return next(new Error('options.startRedirectPath should be a string')) 75 | } 76 | if (!options.generateStateFunction ^ !options.checkStateFunction) { 77 | return next(new Error('options.checkStateFunction and options.generateStateFunction have to be given')) 78 | } 79 | if (options.tags && !Array.isArray(options.tags)) { 80 | return next(new Error('options.tags should be a array')) 81 | } 82 | if (options.schema && typeof options.schema !== 'object') { 83 | return next(new Error('options.schema should be a object')) 84 | } 85 | if (options.cookie && typeof options.cookie !== 'object') { 86 | return next(new Error('options.cookie should be an object')) 87 | } 88 | if (options.userAgent && typeof options.userAgent !== 'string') { 89 | return next(new Error('options.userAgent should be a string')) 90 | } 91 | if (options.pkce && (typeof options.pkce !== 'string' || !PKCE_METHODS.includes(options.pkce))) { 92 | return next(new Error('options.pkce should be one of "S256" | "plain" when used')) 93 | } 94 | if (options.discovery && (typeof options.discovery !== 'object')) { 95 | return next(new Error('options.discovery should be an object')) 96 | } 97 | if (options.discovery && (typeof options.discovery.issuer !== 'string')) { 98 | return next(new Error('options.discovery.issuer should be a URL in string format')) 99 | } 100 | if (options.discovery && options.credentials.auth) { 101 | return next(new Error('when options.discovery.issuer is configured, credentials.auth should not be used')) 102 | } 103 | if (!options.discovery && !options.credentials.auth) { 104 | return next(new Error('options.discovery.issuer or credentials.auth have to be given')) 105 | } 106 | if ( 107 | options.verifierCookieName && 108 | typeof options.verifierCookieName !== 'string' 109 | ) { 110 | return next(new Error('options.verifierCookieName should be a string')) 111 | } 112 | if ( 113 | options.redirectStateCookieName && 114 | typeof options.redirectStateCookieName !== 'string' 115 | ) { 116 | return next( 117 | new Error('options.redirectStateCookieName should be a string') 118 | ) 119 | } 120 | if (!fastify.hasReplyDecorator('cookie')) { 121 | fastify.register(require('@fastify/cookie')) 122 | } 123 | const omitUserAgent = options.userAgent === false 124 | const discovery = options.discovery 125 | const userAgent = options.userAgent === false 126 | ? undefined 127 | : (options.userAgent || USER_AGENT) 128 | 129 | const configure = (configured, fetchedMetadata) => { 130 | const { 131 | name, 132 | callbackUri, 133 | callbackUriParams = {}, 134 | credentials, 135 | tokenRequestParams = {}, 136 | scope, 137 | generateStateFunction = defaultGenerateStateFunction, 138 | checkStateFunction = defaultCheckStateFunction.bind({ 139 | redirectStateCookieName: 140 | configured.redirectStateCookieName || 141 | DEFAULT_REDIRECT_STATE_COOKIE_NAME 142 | }), 143 | startRedirectPath, 144 | tags = [], 145 | schema = { tags }, 146 | redirectStateCookieName = DEFAULT_REDIRECT_STATE_COOKIE_NAME, 147 | verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME 148 | } = configured 149 | 150 | if (userAgent) { 151 | configured.credentials.http = { 152 | ...configured.credentials.http, 153 | headers: { 154 | 'User-Agent': userAgent, 155 | ...configured.credentials.http?.headers 156 | } 157 | } 158 | } 159 | const generateCallbackUriParams = credentials.auth?.[kGenerateCallbackUriParams] || defaultGenerateCallbackUriParams 160 | const cookieOpts = Object.assign({ httpOnly: true, sameSite: 'lax' }, options.cookie) 161 | 162 | const generateStateFunctionCallbacked = function (request, callback) { 163 | const boundGenerateStateFunction = generateStateFunction.bind(fastify) 164 | 165 | if (generateStateFunction.length <= 1) { 166 | callbackify(function (request) { 167 | return Promise.resolve(boundGenerateStateFunction(request)) 168 | })(request, callback) 169 | } else { 170 | boundGenerateStateFunction(request, callback) 171 | } 172 | } 173 | 174 | function generateAuthorizationUriCallbacked (request, reply, callback) { 175 | generateStateFunctionCallbacked(request, function (err, state) { 176 | if (err) { 177 | callback(err, null) 178 | return 179 | } 180 | 181 | reply.setCookie(redirectStateCookieName, state, cookieOpts) 182 | 183 | // when PKCE extension is used 184 | let pkceParams = {} 185 | if (configured.pkce) { 186 | const verifier = codeVerifier() 187 | const challenge = configured.pkce === 'S256' ? codeChallenge(verifier) : verifier 188 | pkceParams = { 189 | code_challenge: challenge, 190 | code_challenge_method: configured.pkce 191 | } 192 | reply.setCookie(verifierCookieName, verifier, cookieOpts) 193 | } 194 | 195 | const urlOptions = Object.assign({}, generateCallbackUriParams(callbackUriParams, request, scope, state), { 196 | redirect_uri: typeof callbackUri === 'function' ? callbackUri(request) : callbackUri, 197 | scope, 198 | state 199 | }, pkceParams) 200 | 201 | callback(null, oauth2.authorizeURL(urlOptions)) 202 | }) 203 | } 204 | 205 | const generateAuthorizationUriPromisified = promisify(generateAuthorizationUriCallbacked) 206 | 207 | function generateAuthorizationUri (request, reply, callback) { 208 | if (!callback) { 209 | return generateAuthorizationUriPromisified(request, reply) 210 | } 211 | 212 | generateAuthorizationUriCallbacked(request, reply, callback) 213 | } 214 | 215 | function startRedirectHandler (request, reply) { 216 | generateAuthorizationUriCallbacked(request, reply, function (err, authorizationUri) { 217 | if (err) { 218 | reply.code(500).send(err.message) 219 | return 220 | } 221 | 222 | reply.redirect(authorizationUri) 223 | }) 224 | } 225 | 226 | const cbk = function (o, request, code, pkceParams, callback) { 227 | const body = Object.assign({}, tokenRequestParams, { 228 | code, 229 | redirect_uri: typeof callbackUri === 'function' ? callbackUri(request) : callbackUri 230 | }, pkceParams) 231 | 232 | return callbackify(o.oauth2.getToken.bind(o.oauth2, body))(callback) 233 | } 234 | 235 | function checkStateFunctionCallbacked (request, callback) { 236 | const boundCheckStateFunction = checkStateFunction.bind(fastify) 237 | 238 | if (checkStateFunction.length <= 1) { 239 | Promise.resolve(boundCheckStateFunction(request)) 240 | .then(function (result) { 241 | if (result) { 242 | callback() 243 | } else { 244 | callback(new Error('Invalid state')) 245 | } 246 | }) 247 | .catch(function (err) { callback(err) }) 248 | } else { 249 | boundCheckStateFunction(request, callback) 250 | } 251 | } 252 | 253 | function getAccessTokenFromAuthorizationCodeFlowCallbacked (request, reply, callback) { 254 | const code = request.query.code 255 | const pkceParams = configured.pkce ? { code_verifier: request.cookies[verifierCookieName] } : {} 256 | 257 | const _callback = typeof reply === 'function' ? reply : callback 258 | 259 | if (reply && typeof reply !== 'function') { 260 | // cleanup a cookie if plugin user uses (req, res, cb) signature variant of getAccessToken fn 261 | clearCodeVerifierCookie(reply) 262 | } 263 | 264 | checkStateFunctionCallbacked(request, function (err) { 265 | if (err) { 266 | callback(err) 267 | return 268 | } 269 | cbk(fastify[name], request, code, pkceParams, _callback) 270 | }) 271 | } 272 | 273 | const getAccessTokenFromAuthorizationCodeFlowPromisified = promisify(getAccessTokenFromAuthorizationCodeFlowCallbacked) 274 | 275 | function getAccessTokenFromAuthorizationCodeFlow (request, reply, callback) { 276 | const _callback = typeof reply === 'function' ? reply : callback 277 | 278 | if (!_callback) { 279 | return getAccessTokenFromAuthorizationCodeFlowPromisified(request, reply) 280 | } 281 | getAccessTokenFromAuthorizationCodeFlowCallbacked(request, reply, _callback) 282 | } 283 | 284 | function getNewAccessTokenUsingRefreshTokenCallbacked (refreshToken, params, callback) { 285 | const accessToken = fastify[name].oauth2.createToken(refreshToken) 286 | callbackify(accessToken.refresh.bind(accessToken, params))(callback) 287 | } 288 | 289 | const getNewAccessTokenUsingRefreshTokenPromisified = promisify(getNewAccessTokenUsingRefreshTokenCallbacked) 290 | 291 | function getNewAccessTokenUsingRefreshToken (refreshToken, params, callback) { 292 | if (!callback) { 293 | return getNewAccessTokenUsingRefreshTokenPromisified(refreshToken, params) 294 | } 295 | getNewAccessTokenUsingRefreshTokenCallbacked(refreshToken, params, callback) 296 | } 297 | 298 | function revokeTokenCallbacked (token, tokenType, params, callback) { 299 | const accessToken = fastify[name].oauth2.createToken(token) 300 | callbackify(accessToken.revoke.bind(accessToken, tokenType, params))(callback) 301 | } 302 | 303 | const revokeTokenPromisified = promisify(revokeTokenCallbacked) 304 | 305 | function revokeToken (token, tokenType, params, callback) { 306 | if (!callback) { 307 | return revokeTokenPromisified(token, tokenType, params) 308 | } 309 | revokeTokenCallbacked(token, tokenType, params, callback) 310 | } 311 | 312 | function revokeAllTokenCallbacked (token, params, callback) { 313 | const accessToken = fastify[name].oauth2.createToken(token) 314 | callbackify(accessToken.revokeAll.bind(accessToken, token, params))(callback) 315 | } 316 | 317 | const revokeAllTokenPromisified = promisify(revokeAllTokenCallbacked) 318 | 319 | function revokeAllToken (token, params, callback) { 320 | if (!callback) { 321 | return revokeAllTokenPromisified(token, params) 322 | } 323 | revokeAllTokenCallbacked(token, params, callback) 324 | } 325 | 326 | function clearCodeVerifierCookie (reply) { 327 | reply.clearCookie(verifierCookieName, cookieOpts) 328 | } 329 | 330 | const pUserInfo = promisify(userInfoCallbacked) 331 | 332 | function userinfo (tokenSetOrToken, options, callback) { 333 | const _callback = typeof options === 'function' ? options : callback 334 | if (!_callback) { 335 | return pUserInfo(tokenSetOrToken, options) 336 | } 337 | return userInfoCallbacked(tokenSetOrToken, options, _callback) 338 | } 339 | 340 | function userInfoCallbacked (tokenSetOrToken, { method = 'GET', via = 'header', params = {} } = {}, callback) { 341 | if (!configured.discovery) { 342 | callback(new Error('userinfo can not be used without discovery')) 343 | return 344 | } 345 | const _method = method.toUpperCase() 346 | if (!['GET', 'POST'].includes(_method)) { 347 | callback(new Error('userinfo methods supported are only GET and POST')) 348 | return 349 | } 350 | 351 | if (method === 'GET' && via === 'body') { 352 | callback(new Error('body is supported only with POST')) 353 | return 354 | } 355 | 356 | let token 357 | if (typeof tokenSetOrToken !== 'object' && typeof tokenSetOrToken !== 'string') { 358 | callback(new Error('you should provide token object containing access_token or access_token as string directly')) 359 | return 360 | } 361 | 362 | if (typeof tokenSetOrToken === 'object') { 363 | if (typeof tokenSetOrToken.access_token !== 'string') { 364 | callback(new Error('access_token should be string')) 365 | return 366 | } 367 | token = tokenSetOrToken.access_token 368 | } else { 369 | token = tokenSetOrToken 370 | } 371 | 372 | fetchUserInfo(fetchedMetadata.userinfo_endpoint, token, { method: _method, params, via }, callback) 373 | } 374 | 375 | const oauth2 = new AuthorizationCode(configured.credentials) 376 | 377 | if (startRedirectPath) { 378 | fastify.get(startRedirectPath, { schema }, startRedirectHandler) 379 | } 380 | 381 | const decoration = { 382 | oauth2, 383 | getAccessTokenFromAuthorizationCodeFlow, 384 | getNewAccessTokenUsingRefreshToken, 385 | generateAuthorizationUri, 386 | revokeToken, 387 | revokeAllToken, 388 | userinfo 389 | } 390 | 391 | try { 392 | fastify.decorate(name, decoration) 393 | fastify.decorate(`oauth2${name.slice(0, 1).toUpperCase()}${name.slice(1)}`, decoration) 394 | } catch (e) { 395 | next(e) 396 | } 397 | } 398 | 399 | if (discovery) { 400 | discoverMetadata(discovery.issuer, (err, fetchedMetadata) => { 401 | if (err) { 402 | next(err) 403 | return 404 | } 405 | const authFromMetadata = getAuthFromMetadata(fetchedMetadata) 406 | 407 | const discoveredOptions = { 408 | ...options, 409 | credentials: { 410 | ...options.credentials, 411 | auth: authFromMetadata 412 | } 413 | } 414 | // respect users choice if they provided PKCE method explicitly 415 | // even with usage of discovery 416 | if (!options.pkce) { 417 | // otherwise select optimal pkce method for them, 418 | discoveredOptions.pkce = selectPkceFromMetadata(fetchedMetadata) 419 | } 420 | configure(discoveredOptions, fetchedMetadata) 421 | next() 422 | }) 423 | } else { 424 | configure(options) 425 | next() 426 | } 427 | function discoverMetadata (issuer, cb) { 428 | const discoveryUri = getDiscoveryUri(issuer) 429 | 430 | const httpOpts = { 431 | headers: { 432 | /* c8 ignore next */ 433 | ...options.credentials.http?.headers, 434 | 'User-Agent': userAgent 435 | } 436 | } 437 | if (omitUserAgent) { 438 | delete httpOpts.headers['User-Agent'] 439 | } 440 | 441 | const req = (discoveryUri.startsWith('https://') ? https : http).get(discoveryUri, httpOpts, onDiscoveryResponse) 442 | 443 | req.on('error', (e) => { 444 | const err = new Error('Problem calling discovery endpoint. See innerError for details.') 445 | err.innerError = e 446 | cb(err) 447 | }) 448 | 449 | function onDiscoveryResponse (res) { 450 | let rawData = '' 451 | res.on('data', (chunk) => { rawData += chunk }) 452 | res.on('end', () => { 453 | try { 454 | cb(null, JSON.parse(rawData)) 455 | } catch (err) { 456 | cb(err) 457 | } 458 | }) 459 | } 460 | } 461 | 462 | function fetchUserInfo (userinfoEndpoint, token, { method, via, params }, cb) { 463 | const httpOpts = { 464 | method, 465 | headers: { 466 | /* c8 ignore next */ 467 | ...options.credentials.http?.headers, 468 | 'User-Agent': userAgent, 469 | Authorization: `Bearer ${token}` 470 | } 471 | } 472 | 473 | if (omitUserAgent) { 474 | delete httpOpts.headers['User-Agent'] 475 | } 476 | 477 | const infoUrl = new URL(userinfoEndpoint) 478 | 479 | let body 480 | 481 | if (method === 'GET') { 482 | Object.entries(params).forEach(([k, v]) => { 483 | infoUrl.searchParams.append(k, v) 484 | }) 485 | } else { 486 | httpOpts.headers['Content-Type'] = 'application/x-www-form-urlencoded' 487 | body = new URLSearchParams() 488 | if (via === 'body') { 489 | delete httpOpts.headers.Authorization 490 | body.append('access_token', token) 491 | } 492 | Object.entries(params).forEach(([k, v]) => { 493 | body.append(k, v) 494 | }) 495 | } 496 | 497 | const aClient = (userinfoEndpoint.startsWith('https://') ? https : http) 498 | 499 | if (method === 'GET') { 500 | aClient.get(infoUrl, httpOpts, onUserinfoResponse) 501 | .on('error', errHandler) 502 | return 503 | } 504 | 505 | const req = aClient.request(infoUrl, httpOpts, onUserinfoResponse) 506 | .on('error', errHandler) 507 | 508 | req.write(body.toString()) 509 | req.end() 510 | 511 | function onUserinfoResponse (res) { 512 | let rawData = '' 513 | res.on('data', (chunk) => { rawData += chunk }) 514 | res.on('end', () => { 515 | try { 516 | cb(null, JSON.parse(rawData)) // should always be JSON since we don't do jwt auth response 517 | } catch (err) { 518 | cb(err) 519 | } 520 | }) 521 | } 522 | 523 | function errHandler (e) { 524 | const err = new Error('Problem calling userinfo endpoint. See innerError for details.') 525 | err.innerError = e 526 | cb(err) 527 | } 528 | } 529 | } 530 | 531 | function getDiscoveryUri (issuer) { 532 | // eslint-disable-next-line 533 | const parsed = url.parse(issuer) 534 | 535 | if (parsed.pathname.includes('/.well-known/')) { 536 | return issuer 537 | } else { 538 | let pathname 539 | if (parsed.pathname.endsWith('/')) { 540 | pathname = `${parsed.pathname}.well-known/openid-configuration` 541 | } else { 542 | pathname = `${parsed.pathname}/.well-known/openid-configuration` 543 | } 544 | return url.format({ ...parsed, pathname }) 545 | } 546 | } 547 | 548 | function selectPkceFromMetadata (metadata) { 549 | const methodsSupported = metadata.code_challenge_methods_supported 550 | if (methodsSupported && methodsSupported.length === 1 && methodsSupported.includes('plain')) { 551 | return 'plain' 552 | } 553 | return 'S256' 554 | } 555 | 556 | function getAuthFromMetadata (metadata) { 557 | /* bellow comments are from RFC 8414 (https://www.rfc-editor.org/rfc/rfc8414.html#section-2) documentation */ 558 | 559 | const processedResponse = {} 560 | /* 561 | authorization_endpoint 562 | URL of the authorization server's authorization endpoint 563 | [RFC6749]. This is REQUIRED unless no grant types are supported 564 | that use the authorization endpoint. 565 | */ 566 | if (metadata.authorization_endpoint) { 567 | const { path, host } = formatEndpoint(metadata.authorization_endpoint) 568 | processedResponse.authorizePath = path 569 | processedResponse.authorizeHost = host 570 | } 571 | /* 572 | token_endpoint 573 | URL of the authorization server's token endpoint [RFC6749]. This 574 | is REQUIRED unless only the implicit grant type is supported. 575 | */ 576 | if (metadata.token_endpoint) { 577 | const { path, host } = formatEndpoint(metadata.token_endpoint) 578 | processedResponse.tokenPath = path 579 | processedResponse.tokenHost = host 580 | } 581 | /* 582 | revocation_endpoint 583 | OPTIONAL. URL of the authorization server's OAuth 2.0 revocation 584 | endpoint [RFC7009]. 585 | */ 586 | if (metadata.revocation_endpoint) { 587 | const { path } = formatEndpoint(metadata.revocation_endpoint) 588 | processedResponse.revokePath = path 589 | } 590 | 591 | return processedResponse 592 | } 593 | 594 | function formatEndpoint (ep) { 595 | const { host, protocol, pathname } = new URL(ep) 596 | return { host: `${protocol}//${host}`, path: pathname } 597 | } 598 | 599 | fastifyOauth2.APPLE_CONFIGURATION = { 600 | authorizeHost: 'https://appleid.apple.com', 601 | authorizePath: '/auth/authorize', 602 | tokenHost: 'https://appleid.apple.com', 603 | tokenPath: '/auth/token', 604 | revokePath: '/auth/revoke', 605 | // kGenerateCallbackUriParams is used for dedicated behavior for each OAuth2.0 provider 606 | // It can update the callbackUriParams based on requestObject, scope and state 607 | // 608 | // Symbol used in here because we would not like the user to modify this behavior and 609 | // do not want to mess up with property name collision 610 | [kGenerateCallbackUriParams]: function (callbackUriParams, _requestObject, scope, _state) { 611 | const stringifyScope = Array.isArray(scope) ? scope.join(' ') : scope 612 | // This behavior is not documented on Apple Developer Docs, but it displays through runtime error. 613 | // Related Docs: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms 614 | // Related Issue: https://github.com/fastify/fastify-oauth2/issues/116 615 | // 616 | // `response_mode` must be `form_post` when scope include `email` or `name` 617 | if (stringifyScope.includes('email') || stringifyScope.includes('name')) { 618 | callbackUriParams.response_mode = 'form_post' 619 | } 620 | return callbackUriParams 621 | } 622 | } 623 | 624 | fastifyOauth2.FACEBOOK_CONFIGURATION = { 625 | authorizeHost: 'https://facebook.com', 626 | authorizePath: '/v6.0/dialog/oauth', 627 | tokenHost: 'https://graph.facebook.com', 628 | tokenPath: '/v6.0/oauth/access_token' 629 | } 630 | 631 | fastifyOauth2.GITHUB_CONFIGURATION = { 632 | tokenHost: 'https://github.com', 633 | tokenPath: '/login/oauth/access_token', 634 | authorizePath: '/login/oauth/authorize' 635 | } 636 | 637 | fastifyOauth2.GITLAB_CONFIGURATION = { 638 | authorizeHost: 'https://gitlab.com', 639 | authorizePath: '/oauth/authorize', 640 | tokenHost: 'https://gitlab.com', 641 | tokenPath: '/oauth/token', 642 | revokePath: '/oauth/revoke' 643 | } 644 | 645 | fastifyOauth2.LINKEDIN_CONFIGURATION = { 646 | authorizeHost: 'https://www.linkedin.com', 647 | authorizePath: '/oauth/v2/authorization', 648 | tokenHost: 'https://www.linkedin.com', 649 | tokenPath: '/oauth/v2/accessToken', 650 | revokePath: '/oauth/v2/revoke' 651 | } 652 | 653 | fastifyOauth2.GOOGLE_CONFIGURATION = { 654 | authorizeHost: 'https://accounts.google.com', 655 | authorizePath: '/o/oauth2/v2/auth', 656 | tokenHost: 'https://www.googleapis.com', 657 | tokenPath: '/oauth2/v4/token' 658 | } 659 | 660 | fastifyOauth2.MICROSOFT_CONFIGURATION = { 661 | authorizeHost: 'https://login.microsoftonline.com', 662 | authorizePath: '/common/oauth2/v2.0/authorize', 663 | tokenHost: 'https://login.microsoftonline.com', 664 | tokenPath: '/common/oauth2/v2.0/token' 665 | } 666 | 667 | fastifyOauth2.VKONTAKTE_CONFIGURATION = { 668 | authorizeHost: 'https://oauth.vk.com', 669 | authorizePath: '/authorize', 670 | tokenHost: 'https://oauth.vk.com', 671 | tokenPath: '/access_token' 672 | } 673 | 674 | fastifyOauth2.SPOTIFY_CONFIGURATION = { 675 | authorizeHost: 'https://accounts.spotify.com', 676 | authorizePath: '/authorize', 677 | tokenHost: 'https://accounts.spotify.com', 678 | tokenPath: '/api/token' 679 | } 680 | 681 | fastifyOauth2.DISCORD_CONFIGURATION = { 682 | authorizeHost: 'https://discord.com', 683 | authorizePath: '/api/oauth2/authorize', 684 | tokenHost: 'https://discord.com', 685 | tokenPath: '/api/oauth2/token', 686 | revokePath: '/api/oauth2/token/revoke' 687 | } 688 | 689 | fastifyOauth2.TWITCH_CONFIGURATION = { 690 | authorizeHost: 'https://id.twitch.tv', 691 | authorizePath: '/oauth2/authorize', 692 | tokenHost: 'https://id.twitch.tv', 693 | tokenPath: '/oauth2/token', 694 | revokePath: '/oauth2/revoke' 695 | } 696 | 697 | fastifyOauth2.VATSIM_CONFIGURATION = { 698 | authorizeHost: 'https://auth.vatsim.net', 699 | authorizePath: '/oauth/authorize', 700 | tokenHost: 'https://auth.vatsim.net', 701 | tokenPath: '/oauth/token' 702 | } 703 | 704 | fastifyOauth2.VATSIM_DEV_CONFIGURATION = { 705 | authorizeHost: 'https://auth-dev.vatsim.net', 706 | authorizePath: '/oauth/authorize', 707 | tokenHost: 'https://auth-dev.vatsim.net', 708 | tokenPath: '/oauth/token' 709 | } 710 | 711 | fastifyOauth2.EPIC_GAMES_CONFIGURATION = { 712 | authorizeHost: 'https://www.epicgames.com', 713 | authorizePath: '/id/authorize', 714 | tokenHost: 'https://api.epicgames.dev', 715 | tokenPath: '/epic/oauth/v1/token' 716 | } 717 | 718 | /** 719 | * Yandex ID docs https://yandex.ru/dev/id/doc/en/ 720 | */ 721 | fastifyOauth2.YANDEX_CONFIGURATION = { 722 | authorizeHost: 'https://oauth.yandex.com', 723 | authorizePath: '/authorize', 724 | tokenHost: 'https://oauth.yandex.com', 725 | tokenPath: '/token', 726 | revokePath: '/revoke_token' 727 | } 728 | 729 | module.exports = fp(fastifyOauth2, { 730 | fastify: '5.x', 731 | name: '@fastify/oauth2' 732 | }) 733 | module.exports.default = fastifyOauth2 734 | module.exports.fastifyOauth2 = fastifyOauth2 735 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/oauth2", 3 | "version": "8.1.2", 4 | "description": "Perform login using oauth2 protocol", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "types": "types/index.d.ts", 8 | "scripts": { 9 | "lint": "eslint", 10 | "lint:fix": "eslint --fix", 11 | "test": "npm run test:unit && npm run test:typescript", 12 | "test:coverage": "npm run test:unit -- --cov --coverage-report=html", 13 | "test:typescript": "tsd", 14 | "test:unit": "c8 --100 node --test" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/fastify/fastify-oauth2.git" 19 | }, 20 | "author": "Tommaso Allevi - @allevo", 21 | "contributors": [ 22 | { 23 | "name": "Matteo Collina", 24 | "email": "hello@matteocollina.com" 25 | }, 26 | { 27 | "name": "Manuel Spigolon", 28 | "email": "behemoth89@gmail.com" 29 | }, 30 | { 31 | "name": "KaKa Ng", 32 | "email": "kaka@kakang.dev", 33 | "url": "https://github.com/climba03003" 34 | }, 35 | { 36 | "name": "Frazer Smith", 37 | "email": "frazer.dev@icloud.com", 38 | "url": "https://github.com/fdawgs" 39 | } 40 | ], 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/fastify/fastify-oauth2/issues" 44 | }, 45 | "keywords": [ 46 | "fastify", 47 | "oauth2" 48 | ], 49 | "homepage": "https://github.com/fastify/fastify-oauth2#readme", 50 | "funding": [ 51 | { 52 | "type": "github", 53 | "url": "https://github.com/sponsors/fastify" 54 | }, 55 | { 56 | "type": "opencollective", 57 | "url": "https://opencollective.com/fastify" 58 | } 59 | ], 60 | "devDependencies": { 61 | "@fastify/pre-commit": "^2.1.0", 62 | "@types/node": "^24.0.10", 63 | "@types/simple-oauth2": "^5.0.7", 64 | "c8": "^10.1.3", 65 | "eslint": "^9.17.0", 66 | "fastify": "^5.0.0", 67 | "neostandard": "^0.12.0", 68 | "nock": "^13.5.4", 69 | "simple-get": "^4.0.1", 70 | "tsd": "^0.32.0" 71 | }, 72 | "dependencies": { 73 | "@fastify/cookie": "^11.0.1", 74 | "fastify-plugin": "^5.0.0", 75 | "simple-oauth2": "^5.0.0" 76 | }, 77 | "publishConfig": { 78 | "access": "public" 79 | }, 80 | "pre-commit": [ 81 | "lint", 82 | "test" 83 | ] 84 | } 85 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test, after } = require('node:test') 4 | const nock = require('nock') 5 | const createFastify = require('fastify') 6 | const crypto = require('node:crypto') 7 | const { Readable } = require('node:stream') 8 | const fastifyOauth2 = require('..') 9 | 10 | nock.disableNetConnect() 11 | 12 | function makeRequests (t, end, fastify, userAgentHeaderMatcher, pkce, discoveryHost, omitCodeChallenge, discoveryHostOptions = {}) { 13 | let discoveryScope 14 | if (discoveryHost) { 15 | const METADATA_BODY = { 16 | authorization_endpoint: 'https://github.com/login/oauth/access_token', 17 | token_endpoint: 'https://github.com/login/oauth/access_token', 18 | revocation_endpoint: 'https://github.com/login/oauth/access_token', 19 | userinfo_endpoint: discoveryHostOptions.userinfoEndpoint ? discoveryHostOptions.userinfoEndpoint : undefined, 20 | code_challenge_methods_supported: omitCodeChallenge ? null : pkce === 'S256' ? ['S256', 'plain'] : pkce === 'plain' ? ['plain'] : null 21 | } 22 | discoveryScope = nock(discoveryHost) 23 | .matchHeader('User-Agent', userAgentHeaderMatcher || 'fastify-oauth2') 24 | .get('/.well-known/openid-configuration') 25 | 26 | if (discoveryHostOptions.error) { 27 | discoveryScope = discoveryScope.replyWithError(discoveryHostOptions.error) 28 | } else if (discoveryHostOptions.noRevocation) { 29 | discoveryScope = discoveryScope.reply(200, { ...METADATA_BODY, revocation_endpoint: undefined }) 30 | } else if (discoveryHostOptions.noAuthorization) { 31 | discoveryScope = discoveryScope.reply(200, { ...METADATA_BODY, authorization_endpoint: undefined }) 32 | } else if (discoveryHostOptions.noToken) { 33 | discoveryScope = discoveryScope.reply(200, { ...METADATA_BODY, token_endpoint: undefined }) 34 | } else { 35 | discoveryScope = discoveryScope.reply(200, discoveryHostOptions.badJSON ? '####$$%' : METADATA_BODY) 36 | } 37 | } 38 | 39 | fastify.listen({ port: 0 }, function (err) { 40 | if (discoveryHostOptions.badJSON) { 41 | t.assert.ok(err.message.startsWith('Unexpected token')) 42 | discoveryScope?.done() 43 | end() 44 | return 45 | } 46 | 47 | if (discoveryHostOptions.error) { 48 | t.assert.strictEqual(err.message, 'Problem calling discovery endpoint. See innerError for details.') 49 | t.assert.strictEqual(err.innerError.code, 'ETIMEDOUT') 50 | discoveryScope?.done() 51 | end() 52 | return 53 | } 54 | 55 | if (discoveryHostOptions.noToken) { 56 | // Let simple-oauth2 configuration fail instead 57 | t.assert.strictEqual(err.message, 'Invalid options provided to simple-oauth2 "auth.tokenHost" is required') 58 | discoveryScope?.done() 59 | end() 60 | return 61 | } 62 | 63 | t.assert.ifError(err, 'not expecting error here!') 64 | 65 | fastify.inject({ 66 | method: 'GET', 67 | url: '/login/github' 68 | }, function (err, responseStart) { 69 | t.assert.ifError(err) 70 | 71 | t.assert.strictEqual(responseStart.statusCode, 302) 72 | 73 | const { searchParams } = new URL(responseStart.headers.location) 74 | const [state, codeChallengeMethod, codeChallenge] = ['state', 'code_challenge_method', 'code_challenge'].map(k => searchParams.get(k)) 75 | 76 | t.assert.ok(state) 77 | if (pkce) { 78 | t.assert.strictEqual(codeChallengeMethod, pkce, 'pkce method must match') 79 | t.assert.ok(codeChallenge, 'code challenge is present') 80 | } 81 | 82 | const RESPONSE_BODY = { 83 | access_token: 'my-access-token', 84 | refresh_token: 'my-refresh-token', 85 | token_type: 'Bearer', 86 | expires_in: '240000' 87 | } 88 | 89 | const RESPONSE_BODY_REFRESHED = { 90 | access_token: 'my-access-token-refreshed', 91 | refresh_token: 'my-refresh-token-refreshed', 92 | token_type: 'Bearer', 93 | expires_in: '240000' 94 | } 95 | 96 | const githubScope = nock('https://github.com') 97 | .matchHeader('Authorization', 'Basic bXktY2xpZW50LWlkOm15LXNlY3JldA==') 98 | .matchHeader('User-Agent', userAgentHeaderMatcher || 'fastify-oauth2') 99 | .post('/login/oauth/access_token', 'grant_type=authorization_code&code=my-code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback' + (pkce ? '&code_verifier=myverifier' : '')) 100 | .reply(200, RESPONSE_BODY) 101 | .post('/login/oauth/access_token', 'grant_type=refresh_token&refresh_token=my-refresh-token') 102 | .reply(200, RESPONSE_BODY_REFRESHED) 103 | let userinfoScope 104 | 105 | const gitHost = discoveryHostOptions.userinfoNonEncrypted ? 'http://github.com' : 'https://github.com' 106 | if (discoveryHostOptions.userinfoEndpoint && !discoveryHostOptions.userinfoBadArgs) { 107 | if (discoveryHostOptions.problematicUserinfo) { 108 | userinfoScope = nock(gitHost) 109 | .matchHeader('Authorization', 'Bearer my-access-token-refreshed') 110 | .matchHeader('User-Agent', userAgentHeaderMatcher || 'fastify-oauth2') 111 | .get('/me') 112 | .replyWithError({ code: 'ETIMEDOUT' }) 113 | } else { 114 | if (discoveryHostOptions.userinfoQuery) { 115 | if (discoveryHostOptions.userInfoMethod === 'POST') { 116 | if (discoveryHostOptions.userinfoVia === 'body') { 117 | userinfoScope = nock(gitHost) 118 | .matchHeader('User-Agent', userAgentHeaderMatcher || 'fastify-oauth2') 119 | .post('/me', 'access_token=my-access-token-refreshed&a=1') 120 | .reply(200, { sub: 'github.subjectid' }) 121 | } else { 122 | userinfoScope = nock(gitHost) 123 | .matchHeader('Authorization', 'Bearer my-access-token-refreshed') 124 | .matchHeader('User-Agent', userAgentHeaderMatcher || 'fastify-oauth2') 125 | .post('/me') 126 | .reply(200, { sub: 'github.subjectid' }) 127 | } 128 | } else { 129 | userinfoScope = nock(gitHost) 130 | .matchHeader('Authorization', 'Bearer my-access-token-refreshed') 131 | .matchHeader('User-Agent', userAgentHeaderMatcher || 'fastify-oauth2') 132 | .get('/me') 133 | .query({ a: 1 }) 134 | .reply(200, { sub: 'github.subjectid' }) 135 | } 136 | } else if (discoveryHostOptions.userinfoBadData) { 137 | userinfoScope = nock(gitHost) 138 | .matchHeader('Authorization', 'Bearer my-access-token-refreshed') 139 | .matchHeader('User-Agent', userAgentHeaderMatcher || 'fastify-oauth2') 140 | .get('/me') 141 | .reply(200, 'not a json') 142 | } else if (discoveryHostOptions.userinfoChunks) { 143 | function createStream () { 144 | const stream = new Readable() 145 | stream.push('{"sub":"gith') 146 | stream.push('ub.subjectid"}') 147 | stream.push(null) 148 | return stream 149 | } 150 | userinfoScope = nock(gitHost) 151 | .matchHeader('Authorization', 'Bearer my-access-token-refreshed') 152 | .matchHeader('User-Agent', userAgentHeaderMatcher || 'fastify-oauth2') 153 | .get('/me') 154 | .reply(200, createStream()) 155 | } else { 156 | userinfoScope = nock(gitHost) 157 | .matchHeader('Authorization', 'Bearer my-access-token-refreshed') 158 | .matchHeader('User-Agent', userAgentHeaderMatcher || 'fastify-oauth2') 159 | .get('/me') 160 | .reply(200, { sub: 'github.subjectid' }) 161 | } 162 | } 163 | } 164 | 165 | fastify.inject({ 166 | method: 'GET', 167 | url: '/?code=my-code&state=' + state, 168 | cookies: { 169 | 'oauth2-redirect-state': state, 170 | 'oauth2-code-verifier': pkce ? 'myverifier' : undefined 171 | } 172 | }, function (err, responseEnd) { 173 | t.assert.ifError(err) 174 | 175 | t.assert.strictEqual(responseEnd.statusCode, 200) 176 | t.assert.deepStrictEqual(JSON.parse(responseEnd.payload), RESPONSE_BODY_REFRESHED) 177 | 178 | githubScope.done() 179 | discoveryScope?.done() 180 | userinfoScope?.done() 181 | end() 182 | }) 183 | }) 184 | }) 185 | } 186 | 187 | test('fastify-oauth2', async t => { 188 | await t.test('callback', (t, end) => { 189 | const fastify = createFastify({ logger: { level: 'silent' } }) 190 | 191 | fastify.register(fastifyOauth2, { 192 | name: 'githubOAuth2', 193 | credentials: { 194 | client: { 195 | id: 'my-client-id', 196 | secret: 'my-secret' 197 | }, 198 | auth: fastifyOauth2.GITHUB_CONFIGURATION 199 | }, 200 | startRedirectPath: '/login/github', 201 | callbackUri: 'http://localhost:3000/callback', 202 | scope: ['notifications'] 203 | }) 204 | 205 | fastify.get('/', function (request, reply) { 206 | if (this.githubOAuth2 !== this.oauth2GithubOAuth2) { 207 | throw new Error('Expected oauth2GithubOAuth2 to match githubOAuth2') 208 | } 209 | this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, (err, result) => { 210 | if (err) throw err 211 | 212 | // attempts to refresh the token 213 | this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token, undefined, (err, result) => { 214 | if (err) throw err 215 | 216 | const newToken = result 217 | 218 | reply.send({ 219 | access_token: newToken.token.access_token, 220 | refresh_token: newToken.token.refresh_token, 221 | expires_in: newToken.token.expires_in, 222 | token_type: newToken.token.token_type 223 | }) 224 | }) 225 | }) 226 | }) 227 | 228 | after(() => fastify.close()) 229 | 230 | makeRequests(t, end, fastify) 231 | }) 232 | 233 | await t.test('callbackUri as function', (t, end) => { 234 | const fastify = createFastify({ logger: { level: 'silent' } }) 235 | 236 | fastify.register(fastifyOauth2, { 237 | name: 'githubOAuth2', 238 | credentials: { 239 | client: { 240 | id: 'my-client-id', 241 | secret: 'my-secret' 242 | }, 243 | auth: fastifyOauth2.GITHUB_CONFIGURATION 244 | }, 245 | startRedirectPath: '/login/github', 246 | callbackUri: req => `${req.protocol}://localhost:3000/callback`, 247 | scope: ['notifications'] 248 | }) 249 | 250 | fastify.get('/', function (request, reply) { 251 | if (this.githubOAuth2 !== this.oauth2GithubOAuth2) { 252 | throw new Error('Expected oauth2GithubOAuth2 to match githubOAuth2') 253 | } 254 | this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, (err, result) => { 255 | if (err) throw err 256 | 257 | // attempts to refresh the token 258 | this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token, undefined, (err, result) => { 259 | if (err) throw err 260 | 261 | const newToken = result 262 | 263 | reply.send({ 264 | access_token: newToken.token.access_token, 265 | refresh_token: newToken.token.refresh_token, 266 | expires_in: newToken.token.expires_in, 267 | token_type: newToken.token.token_type 268 | }) 269 | }) 270 | }) 271 | }) 272 | 273 | after(() => fastify.close()) 274 | 275 | makeRequests(t, end, fastify) 276 | }) 277 | 278 | await t.test('promise', (t, end) => { 279 | const fastify = createFastify({ logger: { level: 'silent' } }) 280 | 281 | fastify.register(fastifyOauth2, { 282 | name: 'githubOAuth2', 283 | credentials: { 284 | client: { 285 | id: 'my-client-id', 286 | secret: 'my-secret' 287 | }, 288 | auth: fastifyOauth2.GITHUB_CONFIGURATION 289 | }, 290 | startRedirectPath: '/login/github', 291 | callbackUri: 'http://localhost:3000/callback', 292 | scope: ['notifications'] 293 | }) 294 | 295 | fastify.get('/', function (request) { 296 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 297 | .then(result => { 298 | // attempts to refresh the token 299 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 300 | }) 301 | .then(token => { 302 | return { 303 | access_token: token.token.access_token, 304 | refresh_token: token.token.refresh_token, 305 | expires_in: token.token.expires_in, 306 | token_type: token.token.token_type 307 | } 308 | }) 309 | }) 310 | 311 | after(() => fastify.close()) 312 | 313 | makeRequests(t, end, fastify) 314 | }) 315 | 316 | await t.test('wrong state', (t, end) => { 317 | const fastify = createFastify({ logger: { level: 'silent' } }) 318 | 319 | fastify.register(fastifyOauth2, { 320 | name: 'githubOAuth2', 321 | credentials: { 322 | client: { 323 | id: 'my-client-id', 324 | secret: 'my-secret' 325 | }, 326 | auth: fastifyOauth2.GITHUB_CONFIGURATION 327 | }, 328 | startRedirectPath: '/login/github', 329 | callbackUri: '/callback' 330 | }) 331 | 332 | fastify.get('/', function (request, reply) { 333 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 334 | .catch(e => { 335 | reply.code(400) 336 | return e.message 337 | }) 338 | }) 339 | 340 | after(() => fastify.close()) 341 | 342 | fastify.inject({ 343 | method: 'GET', 344 | url: '/?code=my-code&state=wrong-state' 345 | }, function (err, responseEnd) { 346 | t.assert.ifError(err) 347 | 348 | t.assert.strictEqual(responseEnd.statusCode, 400) 349 | t.assert.strictEqual(responseEnd.payload, 'Invalid state') 350 | 351 | end() 352 | }) 353 | }) 354 | 355 | await t.test('custom user-agent', (t, end) => { 356 | const fastify = createFastify({ logger: { level: 'silent' } }) 357 | 358 | fastify.register(fastifyOauth2, { 359 | name: 'githubOAuth2', 360 | credentials: { 361 | client: { 362 | id: 'my-client-id', 363 | secret: 'my-secret' 364 | }, 365 | auth: fastifyOauth2.GITHUB_CONFIGURATION 366 | }, 367 | startRedirectPath: '/login/github', 368 | callbackUri: 'http://localhost:3000/callback', 369 | scope: ['notifications'], 370 | userAgent: 'test/1.2.3' 371 | }) 372 | 373 | fastify.get('/', function (request) { 374 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 375 | .then(result => { 376 | // attempts to refresh the token 377 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 378 | }) 379 | .then(token => { 380 | return { 381 | access_token: token.token.access_token, 382 | refresh_token: token.token.refresh_token, 383 | expires_in: token.token.expires_in, 384 | token_type: token.token.token_type 385 | } 386 | }) 387 | }) 388 | 389 | after(() => fastify.close()) 390 | 391 | makeRequests(t, end, fastify, 'test/1.2.3') 392 | }) 393 | 394 | await t.test('overridden user-agent', (t, end) => { 395 | const fastify = createFastify({ logger: { level: 'silent' } }) 396 | 397 | fastify.register(fastifyOauth2, { 398 | name: 'githubOAuth2', 399 | credentials: { 400 | client: { 401 | id: 'my-client-id', 402 | secret: 'my-secret' 403 | }, 404 | auth: fastifyOauth2.GITHUB_CONFIGURATION, 405 | http: { 406 | headers: { 407 | 'User-Agent': 'foo/4.5.6' 408 | } 409 | } 410 | }, 411 | startRedirectPath: '/login/github', 412 | callbackUri: 'http://localhost:3000/callback', 413 | scope: ['notifications'], 414 | userAgent: 'test/1.2.3' 415 | }) 416 | 417 | fastify.get('/', function (request) { 418 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 419 | .then(result => { 420 | // attempts to refresh the token 421 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 422 | }) 423 | .then(token => { 424 | return { 425 | access_token: token.token.access_token, 426 | refresh_token: token.token.refresh_token, 427 | expires_in: token.token.expires_in, 428 | token_type: token.token.token_type 429 | } 430 | }) 431 | }) 432 | 433 | after(() => fastify.close()) 434 | 435 | makeRequests(t, end, fastify, /^foo\/4\.5\.6$/) 436 | }) 437 | 438 | await t.test('disabled user-agent', (t, end) => { 439 | const fastify = createFastify({ logger: { level: 'silent' } }) 440 | 441 | fastify.register(fastifyOauth2, { 442 | name: 'githubOAuth2', 443 | credentials: { 444 | client: { 445 | id: 'my-client-id', 446 | secret: 'my-secret' 447 | }, 448 | auth: fastifyOauth2.GITHUB_CONFIGURATION 449 | }, 450 | startRedirectPath: '/login/github', 451 | callbackUri: 'http://localhost:3000/callback', 452 | scope: ['notifications'], 453 | userAgent: false 454 | }) 455 | 456 | fastify.get('/', function (request) { 457 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 458 | .then(result => { 459 | // attempts to refresh the token 460 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 461 | }) 462 | .then(token => { 463 | return { 464 | access_token: token.token.access_token, 465 | refresh_token: token.token.refresh_token, 466 | expires_in: token.token.expires_in, 467 | token_type: token.token.token_type 468 | } 469 | }) 470 | }) 471 | 472 | after(() => fastify.close()) 473 | 474 | makeRequests(t, end, fastify, userAgent => userAgent === undefined) 475 | }) 476 | 477 | await t.test('pkce.plain', (t, end) => { 478 | const fastify = createFastify({ logger: { level: 'silent' } }) 479 | 480 | fastify.register(fastifyOauth2, { 481 | name: 'githubOAuth2', 482 | credentials: { 483 | client: { 484 | id: 'my-client-id', 485 | secret: 'my-secret' 486 | }, 487 | auth: fastifyOauth2.GITHUB_CONFIGURATION 488 | }, 489 | startRedirectPath: '/login/github', 490 | callbackUri: 'http://localhost:3000/callback', 491 | scope: ['notifications'], 492 | pkce: 'plain' 493 | }) 494 | 495 | fastify.get('/', function (request, reply) { 496 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 497 | .then(result => { 498 | // attempts to refresh the token 499 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 500 | }) 501 | .then(token => { 502 | return { 503 | access_token: token.token.access_token, 504 | refresh_token: token.token.refresh_token, 505 | expires_in: token.token.expires_in, 506 | token_type: token.token.token_type 507 | } 508 | }) 509 | }) 510 | 511 | after(() => fastify.close()) 512 | 513 | makeRequests(t, end, fastify, undefined, 'plain') 514 | }) 515 | 516 | await t.test('pkce.S256', (t, end) => { 517 | const fastify = createFastify({ logger: { level: 'silent' } }) 518 | 519 | fastify.register(fastifyOauth2, { 520 | name: 'githubOAuth2', 521 | credentials: { 522 | client: { 523 | id: 'my-client-id', 524 | secret: 'my-secret' 525 | }, 526 | auth: fastifyOauth2.GITHUB_CONFIGURATION 527 | }, 528 | startRedirectPath: '/login/github', 529 | callbackUri: 'http://localhost:3000/callback', 530 | scope: ['notifications'], 531 | pkce: 'S256' 532 | }) 533 | 534 | fastify.get('/', async function (request, reply) { 535 | const result = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 536 | const token = await this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 537 | 538 | try { 539 | await this.githubOAuth2.userinfo('a try without a discovery option') 540 | } catch (error) { 541 | t.assert.strictEqual( 542 | error.message, 543 | 'userinfo can not be used without discovery', 544 | 'error signals to user that they should use discovery for this to work' 545 | ) 546 | } 547 | 548 | return { 549 | access_token: token.token.access_token, 550 | refresh_token: token.token.refresh_token, 551 | expires_in: token.token.expires_in, 552 | token_type: token.token.token_type 553 | } 554 | }) 555 | 556 | after(() => fastify.close()) 557 | 558 | makeRequests(t, end, fastify, undefined, 'S256') 559 | }) 560 | 561 | await t.test('discovery with S256 - automatic', (t, end) => { 562 | const fastify = createFastify({ logger: { level: 'silent' } }) 563 | 564 | fastify.register(fastifyOauth2, { 565 | name: 'githubOAuth2', 566 | credentials: { 567 | client: { 568 | id: 'my-client-id', 569 | secret: 'my-secret' 570 | } 571 | }, 572 | startRedirectPath: '/login/github', 573 | callbackUri: 'http://localhost:3000/callback', 574 | scope: ['notifications'], 575 | discovery: { 576 | issuer: 'https://github.com' 577 | } 578 | }) 579 | 580 | fastify.get('/', function (request, reply) { 581 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 582 | .then(result => { 583 | // attempts to refresh the token 584 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 585 | }) 586 | .then(token => { 587 | return { 588 | access_token: token.token.access_token, 589 | refresh_token: token.token.refresh_token, 590 | expires_in: token.token.expires_in, 591 | token_type: token.token.token_type 592 | } 593 | }) 594 | }) 595 | 596 | after(() => fastify.close()) 597 | 598 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com') 599 | }) 600 | 601 | await t.test('discovery with userinfo', (t, end) => { 602 | const fastify = createFastify({ logger: { level: 'silent' } }) 603 | 604 | fastify.register(fastifyOauth2, { 605 | name: 'githubOAuth2', 606 | credentials: { 607 | client: { 608 | id: 'my-client-id', 609 | secret: 'my-secret' 610 | } 611 | }, 612 | startRedirectPath: '/login/github', 613 | callbackUri: 'http://localhost:3000/callback', 614 | scope: ['notifications'], 615 | discovery: { 616 | issuer: 'https://github.com' 617 | } 618 | }) 619 | 620 | fastify.get('/', async function (request, reply) { 621 | const result = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 622 | const refreshResult = await this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 623 | 624 | try { 625 | await this.githubOAuth2.userinfo(refreshResult.token, { method: 'PUT' }) 626 | } catch (error) { 627 | t.assert.strictEqual(error.message, 'userinfo methods supported are only GET and POST', 'should not work for other methods') 628 | } 629 | 630 | try { 631 | await this.githubOAuth2.userinfo(refreshResult.token, { method: 'GET', via: 'body' }) 632 | } catch (error) { 633 | t.assert.strictEqual(error.message, 'body is supported only with POST', 'should report incompatible combo') 634 | } 635 | 636 | const userinfo = await this.githubOAuth2.userinfo(refreshResult.token, { params: { a: 1 } }) 637 | 638 | t.assert.strictEqual(userinfo.sub, 'github.subjectid', 'should match an id') 639 | 640 | return { ...refreshResult.token, expires_at: undefined } 641 | }) 642 | 643 | after(() => fastify.close()) 644 | 645 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', false, { userinfoEndpoint: 'https://github.com/me', userinfoQuery: '?a=1' }) 646 | }) 647 | 648 | await t.test('discovery with userinfo POST header', (t, end) => { 649 | const fastify = createFastify({ logger: { level: 'silent' } }) 650 | 651 | fastify.register(fastifyOauth2, { 652 | name: 'githubOAuth2', 653 | credentials: { 654 | client: { 655 | id: 'my-client-id', 656 | secret: 'my-secret' 657 | } 658 | }, 659 | startRedirectPath: '/login/github', 660 | callbackUri: 'http://localhost:3000/callback', 661 | scope: ['notifications'], 662 | discovery: { 663 | issuer: 'https://github.com' 664 | } 665 | }) 666 | 667 | fastify.get('/', async function (request, reply) { 668 | const result = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 669 | const refreshResult = await this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 670 | 671 | const userinfo = await this.githubOAuth2.userinfo(refreshResult.token, { method: 'POST', params: { a: 1 } }) 672 | t.assert.strictEqual(userinfo.sub, 'github.subjectid', 'should match an id') 673 | 674 | return { ...refreshResult.token, expires_at: undefined } 675 | }) 676 | 677 | after(() => fastify.close()) 678 | 679 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', false, 680 | { 681 | userinfoEndpoint: 'https://github.com/me', 682 | userInfoMethod: 'POST', 683 | userinfoQuery: '?a=1' 684 | }) 685 | }) 686 | 687 | await t.test('discovery with userinfo POST body', (t, end) => { 688 | const fastify = createFastify({ logger: { level: 'silent' } }) 689 | 690 | fastify.register(fastifyOauth2, { 691 | name: 'githubOAuth2', 692 | credentials: { 693 | client: { 694 | id: 'my-client-id', 695 | secret: 'my-secret' 696 | } 697 | }, 698 | startRedirectPath: '/login/github', 699 | callbackUri: 'http://localhost:3000/callback', 700 | scope: ['notifications'], 701 | discovery: { 702 | issuer: 'https://github.com' 703 | } 704 | }) 705 | 706 | fastify.get('/', async function (request, reply) { 707 | const result = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 708 | const refreshResult = await this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 709 | 710 | const userinfo = await this.githubOAuth2.userinfo(refreshResult.token, { method: 'POST', via: 'body', params: { a: 1 } }) 711 | t.assert.strictEqual(userinfo.sub, 'github.subjectid', 'should match an id') 712 | 713 | return { ...refreshResult.token, expires_at: undefined } 714 | }) 715 | 716 | after(() => fastify.close()) 717 | 718 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', false, 719 | { 720 | userinfoEndpoint: 'https://github.com/me', 721 | userInfoMethod: 'POST', 722 | userinfoQuery: '?a=1', 723 | userinfoVia: 'body' 724 | }) 725 | }) 726 | 727 | await t.test('discovery with userinfo -> callback API (full)', (t, end) => { 728 | const fastify = createFastify({ logger: { level: 'silent' } }) 729 | 730 | fastify.register(fastifyOauth2, { 731 | name: 'githubOAuth2', 732 | credentials: { 733 | client: { 734 | id: 'my-client-id', 735 | secret: 'my-secret' 736 | } 737 | }, 738 | startRedirectPath: '/login/github', 739 | callbackUri: 'http://localhost:3000/callback', 740 | scope: ['notifications'], 741 | discovery: { 742 | issuer: 'https://github.com' 743 | } 744 | }) 745 | 746 | fastify.get('/', async function (request, reply) { 747 | const result = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 748 | const refreshResult = await this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 749 | await new Promise((resolve) => { 750 | this.githubOAuth2.userinfo(refreshResult.token, {}, (err, userinfo) => { 751 | t.assert.ifError(err) 752 | t.assert.strictEqual(userinfo.sub, 'github.subjectid', 'should match an id') 753 | resolve() 754 | }) 755 | }) 756 | 757 | return { ...refreshResult.token, expires_at: undefined } 758 | }) 759 | 760 | after(() => fastify.close()) 761 | 762 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', false, { userinfoEndpoint: 'https://github.com/me' }) 763 | }) 764 | 765 | await t.test('discovery with userinfo -> callback API (userinfo request option ommited)', (t, end) => { 766 | const fastify = createFastify({ logger: { level: 'silent' } }) 767 | 768 | fastify.register(fastifyOauth2, { 769 | name: 'githubOAuth2', 770 | credentials: { 771 | client: { 772 | id: 'my-client-id', 773 | secret: 'my-secret' 774 | } 775 | }, 776 | startRedirectPath: '/login/github', 777 | callbackUri: 'http://localhost:3000/callback', 778 | scope: ['notifications'], 779 | discovery: { 780 | issuer: 'https://github.com' 781 | } 782 | }) 783 | 784 | fastify.get('/', async function (request, reply) { 785 | const result = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 786 | const refreshResult = await this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 787 | await new Promise((resolve) => { 788 | this.githubOAuth2.userinfo(refreshResult.token, (err, userinfo) => { 789 | t.assert.ifError(err) 790 | t.assert.strictEqual(userinfo.sub, 'github.subjectid', 'should match an id') 791 | resolve() 792 | }) 793 | }) 794 | 795 | return { ...refreshResult.token, expires_at: undefined } 796 | }) 797 | 798 | after(() => fastify.close()) 799 | 800 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', false, { userinfoEndpoint: 'https://github.com/me' }) 801 | }) 802 | 803 | await t.test('discovery with userinfo -> handles responses with multiple "data" events', (t, end) => { 804 | const fastify = createFastify({ logger: { level: 'silent' } }) 805 | 806 | fastify.register(fastifyOauth2, { 807 | name: 'githubOAuth2', 808 | credentials: { 809 | client: { 810 | id: 'my-client-id', 811 | secret: 'my-secret' 812 | } 813 | }, 814 | startRedirectPath: '/login/github', 815 | callbackUri: 'http://localhost:3000/callback', 816 | scope: ['notifications'], 817 | discovery: { 818 | issuer: 'https://github.com' 819 | } 820 | }) 821 | 822 | fastify.get('/', async function (request, reply) { 823 | const result = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 824 | const refreshResult = await this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 825 | await new Promise((resolve) => { 826 | this.githubOAuth2.userinfo(refreshResult.token, {}, (err, userinfo) => { 827 | t.assert.ifError(err) 828 | t.assert.strictEqual(userinfo.sub, 'github.subjectid', 'should match an id') 829 | resolve() 830 | }) 831 | }) 832 | 833 | return { ...refreshResult.token, expires_at: undefined } 834 | }) 835 | 836 | after(() => fastify.close()) 837 | 838 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', false, { userinfoEndpoint: 'https://github.com/me', userinfoChunks: true }) 839 | }) 840 | 841 | await t.test('discovery with userinfo -> fails gracefully when at format is bad', (t, end) => { 842 | const fastify = createFastify({ logger: { level: 'silent' } }) 843 | 844 | fastify.register(fastifyOauth2, { 845 | name: 'githubOAuth2', 846 | credentials: { 847 | client: { 848 | id: 'my-client-id', 849 | secret: 'my-secret' 850 | } 851 | }, 852 | startRedirectPath: '/login/github', 853 | callbackUri: 'http://localhost:3000/callback', 854 | scope: ['notifications'], 855 | discovery: { 856 | issuer: 'https://github.com' 857 | } 858 | }) 859 | 860 | fastify.get('/', async function (request, reply) { 861 | const result = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 862 | const refreshResult = await this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 863 | await new Promise((resolve) => { 864 | this.githubOAuth2.userinfo(123456789, (err) => { 865 | t.assert.strictEqual(err.message, 866 | 'you should provide token object containing access_token or access_token as string directly', 867 | 'should match error message') 868 | resolve() 869 | }) 870 | }) 871 | 872 | return { ...refreshResult.token, expires_at: undefined } 873 | }) 874 | 875 | after(() => fastify.close()) 876 | 877 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', false, { userinfoEndpoint: 'https://github.com/me', userinfoBadArgs: true }) 878 | }) 879 | 880 | await t.test('discovery with userinfo -> fails gracefully when nested at format is bad', (t, end) => { 881 | const fastify = createFastify({ logger: { level: 'silent' } }) 882 | 883 | fastify.register(fastifyOauth2, { 884 | name: 'githubOAuth2', 885 | credentials: { 886 | client: { 887 | id: 'my-client-id', 888 | secret: 'my-secret' 889 | } 890 | }, 891 | startRedirectPath: '/login/github', 892 | callbackUri: 'http://localhost:3000/callback', 893 | scope: ['notifications'], 894 | discovery: { 895 | issuer: 'https://github.com' 896 | } 897 | }) 898 | 899 | fastify.get('/', async function (request, reply) { 900 | const result = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 901 | const refreshResult = await this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 902 | await new Promise((resolve) => { 903 | this.githubOAuth2.userinfo({ access_token: 123456789 }, (err) => { 904 | t.assert.strictEqual(err.message, 'access_token should be string', 'message for nested access token format matched') 905 | resolve() 906 | }) 907 | }) 908 | 909 | return { ...refreshResult.token, expires_at: undefined } 910 | }) 911 | 912 | after(() => fastify.close()) 913 | 914 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', false, { userinfoEndpoint: 'https://github.com/me', userinfoBadArgs: true }) 915 | }) 916 | 917 | await t.test('discovery with userinfo -> fails gracefully with problematic /me endpoint', (t, end) => { 918 | const fastify = createFastify({ logger: { level: 'silent' } }) 919 | 920 | fastify.register(fastifyOauth2, { 921 | name: 'githubOAuth2', 922 | credentials: { 923 | client: { 924 | id: 'my-client-id', 925 | secret: 'my-secret' 926 | } 927 | }, 928 | startRedirectPath: '/login/github', 929 | callbackUri: 'http://localhost:3000/callback', 930 | scope: ['notifications'], 931 | discovery: { 932 | issuer: 'https://github.com' 933 | }, 934 | userAgent: false 935 | }) 936 | 937 | fastify.get('/', async function (request, reply) { 938 | const result = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 939 | const refreshResult = await this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 940 | await new Promise((resolve) => { 941 | this.githubOAuth2.userinfo(refreshResult.token.access_token, (err) => { 942 | t.assert.strictEqual(err.message, 943 | 'Problem calling userinfo endpoint. See innerError for details.', 944 | 'should match start of the error message' 945 | ) 946 | resolve() 947 | }) 948 | }) 949 | 950 | return { ...refreshResult.token, expires_at: undefined } 951 | }) 952 | 953 | after(() => fastify.close()) 954 | 955 | makeRequests(t, end, fastify, userAgent => userAgent === undefined, 'S256', 'https://github.com', false, { userinfoEndpoint: 'https://github.com/me', problematicUserinfo: true }) 956 | }) 957 | 958 | await t.test('discovery with userinfo -> works with http', (t, end) => { 959 | const fastify = createFastify({ logger: { level: 'silent' } }) 960 | 961 | fastify.register(fastifyOauth2, { 962 | name: 'githubOAuth2', 963 | credentials: { 964 | client: { 965 | id: 'my-client-id', 966 | secret: 'my-secret' 967 | } 968 | }, 969 | startRedirectPath: '/login/github', 970 | callbackUri: 'http://localhost:3000/callback', 971 | scope: ['notifications'], 972 | discovery: { 973 | issuer: 'https://github.com' 974 | }, 975 | userAgent: false 976 | }) 977 | 978 | fastify.get('/', async function (request, reply) { 979 | const result = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 980 | const refreshResult = await this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 981 | await new Promise((resolve) => { 982 | this.githubOAuth2.userinfo(refreshResult.token.access_token, (err) => { 983 | t.assert.ifError(err) 984 | resolve() 985 | }) 986 | }) 987 | 988 | return { ...refreshResult.token, expires_at: undefined } 989 | }) 990 | 991 | after(() => fastify.close()) 992 | 993 | makeRequests(t, end, fastify, userAgent => userAgent === undefined, 'S256', 'https://github.com', false, { userinfoEndpoint: 'http://github.com/me', userinfoNonEncrypted: true }) 994 | }) 995 | 996 | await t.test('discovery with userinfo -> fails gracefully with bad data', (t, end) => { 997 | const fastify = createFastify({ logger: { level: 'silent' } }) 998 | 999 | fastify.register(fastifyOauth2, { 1000 | name: 'githubOAuth2', 1001 | credentials: { 1002 | client: { 1003 | id: 'my-client-id', 1004 | secret: 'my-secret' 1005 | } 1006 | }, 1007 | startRedirectPath: '/login/github', 1008 | callbackUri: 'http://localhost:3000/callback', 1009 | scope: ['notifications'], 1010 | discovery: { 1011 | issuer: 'https://github.com' 1012 | } 1013 | }) 1014 | 1015 | fastify.get('/', async function (request, reply) { 1016 | const result = await this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 1017 | const refreshResult = await this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 1018 | await new Promise((resolve) => { 1019 | this.githubOAuth2.userinfo(refreshResult.token.access_token, (err) => { 1020 | t.assert.ok(err.message.startsWith('Unexpected token'), 'should match start of the error message') 1021 | resolve() 1022 | }) 1023 | }) 1024 | 1025 | return { ...refreshResult.token, expires_at: undefined } 1026 | }) 1027 | 1028 | after(() => fastify.close()) 1029 | 1030 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', false, { userinfoEndpoint: 'https://github.com/me', userinfoBadData: true }) 1031 | }) 1032 | 1033 | await t.test('discovery with plain - automatic', (t, end) => { 1034 | const fastify = createFastify({ logger: { level: 'silent' } }) 1035 | 1036 | fastify.register(fastifyOauth2, { 1037 | name: 'githubOAuth2', 1038 | credentials: { 1039 | client: { 1040 | id: 'my-client-id', 1041 | secret: 'my-secret' 1042 | } 1043 | }, 1044 | startRedirectPath: '/login/github', 1045 | callbackUri: 'http://localhost:3000/callback', 1046 | scope: ['notifications'], 1047 | discovery: { 1048 | issuer: 'https://github.com' 1049 | } 1050 | }) 1051 | 1052 | fastify.get('/', function (request, reply) { 1053 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 1054 | .then(result => { 1055 | // attempts to refresh the token 1056 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 1057 | }) 1058 | .then(token => { 1059 | return { 1060 | access_token: token.token.access_token, 1061 | refresh_token: token.token.refresh_token, 1062 | expires_in: token.token.expires_in, 1063 | token_type: token.token.token_type 1064 | } 1065 | }) 1066 | }) 1067 | 1068 | after(() => fastify.close()) 1069 | 1070 | makeRequests(t, end, fastify, undefined, 'plain', 'https://github.com') 1071 | }) 1072 | 1073 | await t.test('discovery with no code challenge method - explicitly set instead', (t, end) => { 1074 | const fastify = createFastify({ logger: { level: 'silent' } }) 1075 | 1076 | fastify.register(fastifyOauth2, { 1077 | name: 'githubOAuth2', 1078 | credentials: { 1079 | client: { 1080 | id: 'my-client-id', 1081 | secret: 'my-secret' 1082 | } 1083 | }, 1084 | startRedirectPath: '/login/github', 1085 | callbackUri: 'http://localhost:3000/callback', 1086 | scope: ['notifications'], 1087 | pkce: 'S256', 1088 | discovery: { 1089 | issuer: 'https://github.com' 1090 | } 1091 | }) 1092 | 1093 | fastify.get('/', function (request, reply) { 1094 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 1095 | .then(result => { 1096 | // attempts to refresh the token 1097 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 1098 | }) 1099 | .then(token => { 1100 | return { 1101 | access_token: token.token.access_token, 1102 | refresh_token: token.token.refresh_token, 1103 | expires_in: token.token.expires_in, 1104 | token_type: token.token.token_type 1105 | } 1106 | }) 1107 | }) 1108 | 1109 | after(() => fastify.close()) 1110 | 1111 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', true) 1112 | }) 1113 | 1114 | await t.test('discovery with S256 - automatic, supported full discovery URL', (t, end) => { 1115 | const fastify = createFastify({ logger: { level: 'silent' } }) 1116 | 1117 | fastify.register(fastifyOauth2, { 1118 | name: 'githubOAuth2', 1119 | credentials: { 1120 | client: { 1121 | id: 'my-client-id', 1122 | secret: 'my-secret' 1123 | } 1124 | }, 1125 | startRedirectPath: '/login/github', 1126 | callbackUri: 'http://localhost:3000/callback', 1127 | scope: ['notifications'], 1128 | discovery: { 1129 | issuer: 'https://github.com/.well-known/openid-configuration' 1130 | } 1131 | }) 1132 | 1133 | fastify.get('/', function (request, reply) { 1134 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 1135 | .then(result => { 1136 | // attempts to refresh the token 1137 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 1138 | }) 1139 | .then(token => { 1140 | return { 1141 | access_token: token.token.access_token, 1142 | refresh_token: token.token.refresh_token, 1143 | expires_in: token.token.expires_in, 1144 | token_type: token.token.token_type 1145 | } 1146 | }) 1147 | }) 1148 | 1149 | after(() => fastify.close()) 1150 | 1151 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com') 1152 | }) 1153 | 1154 | await t.test('discovery with S256 - automatic, supported deep mount without a trailing slash', (t, end) => { 1155 | const fastify = createFastify({ logger: { level: 'silent' } }) 1156 | 1157 | fastify.register(fastifyOauth2, { 1158 | name: 'githubOAuth2', 1159 | credentials: { 1160 | client: { 1161 | id: 'my-client-id', 1162 | secret: 'my-secret' 1163 | } 1164 | }, 1165 | startRedirectPath: '/login/github', 1166 | callbackUri: 'http://localhost:3000/callback', 1167 | scope: ['notifications'], 1168 | discovery: { 1169 | issuer: 'https://github.com/deepmount' // no trailin slash 1170 | } 1171 | }) 1172 | 1173 | fastify.get('/', function (request, reply) { 1174 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 1175 | .then(result => { 1176 | // attempts to refresh the token 1177 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 1178 | }) 1179 | .then(token => { 1180 | return { 1181 | access_token: token.token.access_token, 1182 | refresh_token: token.token.refresh_token, 1183 | expires_in: token.token.expires_in, 1184 | token_type: token.token.token_type 1185 | } 1186 | }) 1187 | }) 1188 | 1189 | after(() => fastify.close()) 1190 | 1191 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com/deepmount') // no trailin slash 1192 | }) 1193 | 1194 | await t.test('discovery - supports HTTP', (t, end) => { 1195 | const fastify = createFastify({ logger: { level: 'silent' } }) 1196 | 1197 | fastify.register(fastifyOauth2, { 1198 | name: 'githubOAuth2', 1199 | credentials: { 1200 | client: { 1201 | id: 'my-client-id', 1202 | secret: 'my-secret' 1203 | } 1204 | }, 1205 | startRedirectPath: '/login/github', 1206 | callbackUri: 'http://localhost:3000/callback', 1207 | scope: ['notifications'], 1208 | discovery: { 1209 | issuer: 'http://github.com' 1210 | } 1211 | }) 1212 | 1213 | fastify.get('/', function (request, reply) { 1214 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 1215 | .then(result => { 1216 | // attempts to refresh the token 1217 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 1218 | }) 1219 | .then(token => { 1220 | return { 1221 | access_token: token.token.access_token, 1222 | refresh_token: token.token.refresh_token, 1223 | expires_in: token.token.expires_in, 1224 | token_type: token.token.token_type 1225 | } 1226 | }) 1227 | }) 1228 | 1229 | after(() => fastify.close()) 1230 | 1231 | makeRequests(t, end, fastify, undefined, 'S256', 'http://github.com') 1232 | }) 1233 | 1234 | await t.test('discovery - supports omitting user agent', (t, end) => { 1235 | const fastify = createFastify({ logger: { level: 'silent' } }) 1236 | 1237 | fastify.register(fastifyOauth2, { 1238 | name: 'githubOAuth2', 1239 | credentials: { 1240 | client: { 1241 | id: 'my-client-id', 1242 | secret: 'my-secret' 1243 | } 1244 | }, 1245 | startRedirectPath: '/login/github', 1246 | callbackUri: 'http://localhost:3000/callback', 1247 | scope: ['notifications'], 1248 | discovery: { 1249 | issuer: 'http://github.com' 1250 | }, 1251 | userAgent: false 1252 | }) 1253 | 1254 | fastify.get('/', function (request, reply) { 1255 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 1256 | .then(result => { 1257 | // attempts to refresh the token 1258 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 1259 | }) 1260 | .then(token => { 1261 | return { 1262 | access_token: token.token.access_token, 1263 | refresh_token: token.token.refresh_token, 1264 | expires_in: token.token.expires_in, 1265 | token_type: token.token.token_type 1266 | } 1267 | }) 1268 | }) 1269 | 1270 | after(() => fastify.close()) 1271 | 1272 | makeRequests(t, end, fastify, userAgent => userAgent === undefined, 'S256', 'http://github.com') 1273 | }) 1274 | 1275 | await t.test('discovery - failed gracefully when discovery host gives bad data', (t, end) => { 1276 | const fastify = createFastify({ logger: { level: 'silent' } }) 1277 | 1278 | fastify.register(fastifyOauth2, { 1279 | name: 'githubOAuth2', 1280 | credentials: { 1281 | client: { 1282 | id: 'my-client-id', 1283 | secret: 'my-secret' 1284 | } 1285 | }, 1286 | startRedirectPath: '/login/github', 1287 | callbackUri: 'http://localhost:3000/callback', 1288 | scope: ['notifications'], 1289 | discovery: { 1290 | issuer: 'http://github.com' 1291 | } 1292 | }) 1293 | 1294 | fastify.get('/', function (request, reply) { 1295 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 1296 | .then(result => { 1297 | // attempts to refresh the token 1298 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 1299 | }) 1300 | .then(token => { 1301 | return { 1302 | access_token: token.token.access_token, 1303 | refresh_token: token.token.refresh_token, 1304 | expires_in: token.token.expires_in, 1305 | token_type: token.token.token_type 1306 | } 1307 | }) 1308 | }) 1309 | 1310 | after(() => fastify.close()) 1311 | 1312 | makeRequests(t, end, fastify, undefined, undefined, 'http://github.com', undefined, { badJSON: true }) 1313 | }) 1314 | 1315 | await t.test('discovery - failed gracefully when discovery host errs with ETIMEDOUT or similar', (t, end) => { 1316 | const fastify = createFastify({ logger: { level: 'silent' } }) 1317 | 1318 | fastify.register(fastifyOauth2, { 1319 | name: 'githubOAuth2', 1320 | credentials: { 1321 | client: { 1322 | id: 'my-client-id', 1323 | secret: 'my-secret' 1324 | } 1325 | }, 1326 | startRedirectPath: '/login/github', 1327 | callbackUri: 'http://localhost:3000/callback', 1328 | scope: ['notifications'], 1329 | discovery: { 1330 | issuer: 'http://github.com' 1331 | } 1332 | }) 1333 | 1334 | fastify.get('/', function (request, reply) { 1335 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 1336 | .then(result => { 1337 | // attempts to refresh the token 1338 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 1339 | }) 1340 | .then(token => { 1341 | return { 1342 | access_token: token.token.access_token, 1343 | refresh_token: token.token.refresh_token, 1344 | expires_in: token.token.expires_in, 1345 | token_type: token.token.token_type 1346 | } 1347 | }) 1348 | }) 1349 | 1350 | after(() => fastify.close()) 1351 | 1352 | makeRequests(t, end, fastify, undefined, undefined, 'http://github.com', undefined, { error: { code: 'ETIMEDOUT' } }) 1353 | }) 1354 | 1355 | await t.test('discovery - should work when OP doesn\'t announce revocation', (t, end) => { 1356 | // not that some Authorization servers might have revocation as optional, 1357 | // even token and authorization endpoints could be optional 1358 | // plugin should not break internally due to these responses 1359 | // however tokenHost is required by schema here 1360 | const fastify = createFastify({ logger: { level: 'silent' } }) 1361 | 1362 | fastify.register(fastifyOauth2, { 1363 | name: 'githubOAuth2', 1364 | credentials: { 1365 | client: { 1366 | id: 'my-client-id', 1367 | secret: 'my-secret' 1368 | } 1369 | }, 1370 | startRedirectPath: '/login/github', 1371 | callbackUri: 'http://localhost:3000/callback', 1372 | scope: ['notifications'], 1373 | discovery: { 1374 | issuer: 'https://github.com' 1375 | } 1376 | }) 1377 | 1378 | fastify.get('/', function (request, reply) { 1379 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 1380 | .then(result => { 1381 | // attempts to refresh the token 1382 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 1383 | }) 1384 | .then(token => { 1385 | return { 1386 | access_token: token.token.access_token, 1387 | refresh_token: token.token.refresh_token, 1388 | expires_in: token.token.expires_in, 1389 | token_type: token.token.token_type 1390 | } 1391 | }) 1392 | }) 1393 | 1394 | after(() => fastify.close()) 1395 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', undefined, { noRevocation: true }) 1396 | }) 1397 | 1398 | await t.test('discovery - should work when OP doesn\'t announce authorization endpoint', (t, end) => { 1399 | const fastify = createFastify({ logger: { level: 'silent' } }) 1400 | 1401 | fastify.register(fastifyOauth2, { 1402 | name: 'githubOAuth2', 1403 | credentials: { 1404 | client: { 1405 | id: 'my-client-id', 1406 | secret: 'my-secret' 1407 | } 1408 | }, 1409 | startRedirectPath: '/login/github', 1410 | callbackUri: 'http://localhost:3000/callback', 1411 | scope: ['notifications'], 1412 | discovery: { 1413 | issuer: 'https://github.com' 1414 | } 1415 | }) 1416 | 1417 | fastify.get('/', function (request, reply) { 1418 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 1419 | .then(result => { 1420 | // attempts to refresh the token 1421 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 1422 | }) 1423 | .then(token => { 1424 | return { 1425 | access_token: token.token.access_token, 1426 | refresh_token: token.token.refresh_token, 1427 | expires_in: token.token.expires_in, 1428 | token_type: token.token.token_type 1429 | } 1430 | }) 1431 | }) 1432 | 1433 | after(() => fastify.close()) 1434 | 1435 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', undefined, { noAuthorization: true }) 1436 | }) 1437 | 1438 | await t.test('discovery - should work when OP doesn\'t announce token endpoint', (t, end) => { 1439 | const fastify = createFastify({ logger: { level: 'silent' } }) 1440 | 1441 | fastify.register(fastifyOauth2, { 1442 | name: 'githubOAuth2', 1443 | credentials: { 1444 | client: { 1445 | id: 'my-client-id', 1446 | secret: 'my-secret' 1447 | } 1448 | }, 1449 | startRedirectPath: '/login/github', 1450 | callbackUri: 'http://localhost:3000/callback', 1451 | scope: ['notifications'], 1452 | discovery: { 1453 | issuer: 'https://github.com' 1454 | } 1455 | }) 1456 | 1457 | fastify.get('/', function (request, reply) { 1458 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply) 1459 | .then(result => { 1460 | // attempts to refresh the token 1461 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 1462 | }) 1463 | .then(token => { 1464 | return { 1465 | access_token: token.token.access_token, 1466 | refresh_token: token.token.refresh_token, 1467 | expires_in: token.token.expires_in, 1468 | token_type: token.token.token_type 1469 | } 1470 | }) 1471 | }) 1472 | 1473 | after(() => fastify.close()) 1474 | 1475 | makeRequests(t, end, fastify, undefined, 'S256', 'https://github.com', undefined, { noToken: true }) 1476 | }) 1477 | }) 1478 | 1479 | test('options.name should be a string', t => { 1480 | t.plan(1) 1481 | 1482 | const fastify = createFastify({ logger: { level: 'silent' } }) 1483 | 1484 | t.assert.rejects(fastify.register(fastifyOauth2).ready(), undefined, 'options.name should be a string') 1485 | }) 1486 | 1487 | test('options.credentials should be an object', t => { 1488 | t.plan(1) 1489 | 1490 | const fastify = createFastify({ logger: { level: 'silent' } }) 1491 | 1492 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1493 | name: 'the-name' 1494 | }) 1495 | .ready(), undefined, 'options.credentials should be an object') 1496 | }) 1497 | 1498 | test('options.callbackUri should be a string or a function', t => { 1499 | t.plan(1) 1500 | 1501 | const fastify = createFastify({ logger: { level: 'silent' } }) 1502 | 1503 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1504 | name: 'the-name', 1505 | credentials: { 1506 | client: { 1507 | id: 'my-client-id', 1508 | secret: 'my-secret' 1509 | }, 1510 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1511 | } 1512 | }) 1513 | .ready(), undefined, 'options.callbackUri should be a string or a function') 1514 | }) 1515 | 1516 | test('options.callbackUriParams should be an object', t => { 1517 | t.plan(1) 1518 | 1519 | const fastify = createFastify({ logger: { level: 'silent' } }) 1520 | 1521 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1522 | name: 'the-name', 1523 | credentials: { 1524 | client: { 1525 | id: 'my-client-id', 1526 | secret: 'my-secret' 1527 | }, 1528 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1529 | }, 1530 | callbackUri: '/callback', 1531 | callbackUriParams: 1 1532 | }) 1533 | .ready(), undefined, 'options.callbackUriParams should be a object') 1534 | }) 1535 | 1536 | test('options.callbackUriParams', (t, end) => { 1537 | const fastify = createFastify({ logger: { level: 'silent' } }) 1538 | 1539 | fastify.register(fastifyOauth2, { 1540 | name: 'the-name', 1541 | credentials: { 1542 | client: { 1543 | id: 'my-client-id', 1544 | secret: 'my-secret' 1545 | }, 1546 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1547 | }, 1548 | startRedirectPath: '/login/github', 1549 | callbackUri: '/callback', 1550 | callbackUriParams: { 1551 | access_type: 'offline' 1552 | }, 1553 | scope: ['notifications'] 1554 | }) 1555 | 1556 | after(() => fastify.close()) 1557 | 1558 | fastify.listen({ port: 0 }, function (err) { 1559 | t.assert.ifError(err) 1560 | 1561 | fastify.inject({ 1562 | method: 'GET', 1563 | url: '/login/github' 1564 | }, function (err, responseStart) { 1565 | t.assert.ifError(err) 1566 | 1567 | t.assert.strictEqual(responseStart.statusCode, 302) 1568 | const matched = responseStart.headers.location.match(/https:\/\/github\.com\/login\/oauth\/authorize\?response_type=code&client_id=my-client-id&access_type=offline&redirect_uri=%2Fcallback&scope=notifications&state=(.*)/) 1569 | t.assert.ok(matched) 1570 | end() 1571 | }) 1572 | }) 1573 | }) 1574 | 1575 | test('options.tokenRequestParams should be an object', t => { 1576 | t.plan(1) 1577 | 1578 | const fastify = createFastify({ logger: { level: 'silent' } }) 1579 | 1580 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1581 | name: 'the-name', 1582 | credentials: { 1583 | client: { 1584 | id: 'my-client-id', 1585 | secret: 'my-secret' 1586 | }, 1587 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1588 | }, 1589 | callbackUri: '/callback', 1590 | tokenRequestParams: 1 1591 | }) 1592 | .ready(), undefined, 'options.tokenRequestParams should be a object') 1593 | }) 1594 | 1595 | test('options.tokenRequestParams', async t => { 1596 | const fastify = createFastify({ logger: { level: 'silent' } }) 1597 | const oAuthCode = '123456789' 1598 | 1599 | fastify.register(fastifyOauth2, { 1600 | name: 'githubOAuth2', 1601 | credentials: { 1602 | client: { 1603 | id: 'my-client-id', 1604 | secret: 'my-secret' 1605 | }, 1606 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1607 | }, 1608 | startRedirectPath: '/login/github', 1609 | callbackUri: 'http://localhost:3000/callback', 1610 | generateStateFunction: function () { 1611 | return 'dummy' 1612 | }, 1613 | checkStateFunction: function (state, callback) { 1614 | callback() 1615 | }, 1616 | tokenRequestParams: { 1617 | param1: '123' 1618 | }, 1619 | scope: ['notifications'] 1620 | }) 1621 | 1622 | const githubScope = nock('https://github.com') 1623 | .post( 1624 | '/login/oauth/access_token', 1625 | 'grant_type=authorization_code¶m1=123&code=123456789&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback', 1626 | { 1627 | reqheaders: { 1628 | authorization: 'Basic bXktY2xpZW50LWlkOm15LXNlY3JldA==' 1629 | } 1630 | } 1631 | ) 1632 | .reply(200, {}) 1633 | 1634 | fastify.get('/callback', function (request, reply) { 1635 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 1636 | .catch(e => { 1637 | reply.code(400) 1638 | return e.message 1639 | }) 1640 | }) 1641 | 1642 | after(() => fastify.close()) 1643 | 1644 | await fastify.listen({ port: 0 }) 1645 | await fastify.inject({ 1646 | method: 'GET', 1647 | url: '/callback?code=' + oAuthCode 1648 | }) 1649 | 1650 | githubScope.done() 1651 | }) 1652 | 1653 | test('generateAuthorizationUri redirect with request object', (t, end) => { 1654 | const fastify = createFastify() 1655 | 1656 | fastify.register(fastifyOauth2, { 1657 | name: 'theName', 1658 | credentials: { 1659 | client: { 1660 | id: 'my-client-id', 1661 | secret: 'my-secret' 1662 | }, 1663 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1664 | }, 1665 | callbackUri: '/callback', 1666 | generateStateFunction: (request) => { 1667 | t.assert.ok(request, 'the request param has been set') 1668 | return request.query.code 1669 | }, 1670 | checkStateFunction: () => true, 1671 | scope: ['notifications'] 1672 | }) 1673 | 1674 | fastify.get('/gh', async function (request, reply) { 1675 | const redirectUrl = await this.theName.generateAuthorizationUri(request, reply) 1676 | return reply.redirect(redirectUrl) 1677 | }) 1678 | 1679 | after(() => fastify.close()) 1680 | 1681 | fastify.inject({ 1682 | method: 'GET', 1683 | url: '/gh', 1684 | query: { code: 'generated_code' } 1685 | }, function (err, responseStart) { 1686 | t.assert.ifError(err) 1687 | t.assert.strictEqual(responseStart.statusCode, 302) 1688 | const matched = responseStart.headers.location.match(/https:\/\/github\.com\/login\/oauth\/authorize\?response_type=code&client_id=my-client-id&redirect_uri=%2Fcallback&scope=notifications&state=generated_code/) 1689 | t.assert.ok(matched) 1690 | end() 1691 | }) 1692 | }) 1693 | 1694 | test('generateAuthorizationUri redirect with request object and callback', (t, end) => { 1695 | const fastify = createFastify() 1696 | 1697 | fastify.register(fastifyOauth2, { 1698 | name: 'theName', 1699 | credentials: { 1700 | client: { 1701 | id: 'my-client-id', 1702 | secret: 'my-secret' 1703 | }, 1704 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1705 | }, 1706 | callbackUri: '/callback', 1707 | generateStateFunction: (request) => { 1708 | t.assert.ok(request, 'the request param has been set') 1709 | return request.query.code 1710 | }, 1711 | checkStateFunction: () => true, 1712 | scope: ['notifications'] 1713 | }) 1714 | 1715 | fastify.get('/gh', function (request, reply) { 1716 | this.theName.generateAuthorizationUri(request, reply, (err, redirectUrl) => { 1717 | if (err) { 1718 | throw err 1719 | } 1720 | 1721 | reply.redirect(redirectUrl) 1722 | }) 1723 | }) 1724 | 1725 | after(() => fastify.close()) 1726 | 1727 | fastify.inject({ 1728 | method: 'GET', 1729 | url: '/gh', 1730 | query: { code: 'generated_code' } 1731 | }, function (err, responseStart) { 1732 | t.assert.ifError(err) 1733 | t.assert.strictEqual(responseStart.statusCode, 302) 1734 | const matched = responseStart.headers.location.match(/https:\/\/github\.com\/login\/oauth\/authorize\?response_type=code&client_id=my-client-id&redirect_uri=%2Fcallback&scope=notifications&state=generated_code/) 1735 | t.assert.ok(matched) 1736 | end() 1737 | }) 1738 | }) 1739 | 1740 | test('options.startRedirectPath should be a string', t => { 1741 | t.plan(1) 1742 | 1743 | const fastify = createFastify({ logger: { level: 'silent' } }) 1744 | 1745 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1746 | name: 'the-name', 1747 | credentials: { 1748 | client: { 1749 | id: 'my-client-id', 1750 | secret: 'my-secret' 1751 | }, 1752 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1753 | }, 1754 | callbackUri: '/callback', 1755 | startRedirectPath: 42 1756 | }) 1757 | .ready(), undefined, 'options.startRedirectPath should be a string') 1758 | }) 1759 | 1760 | test('options.generateStateFunction ^ options.checkStateFunction', t => { 1761 | t.plan(1) 1762 | 1763 | const fastify = createFastify({ logger: { level: 'silent' } }) 1764 | 1765 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1766 | name: 'the-name', 1767 | credentials: { 1768 | client: { 1769 | id: 'my-client-id', 1770 | secret: 'my-secret' 1771 | }, 1772 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1773 | }, 1774 | callbackUri: '/callback', 1775 | checkStateFunction: () => { } 1776 | }) 1777 | .ready(), undefined, 'options.checkStateFunction and options.generateStateFunction have to be given') 1778 | }) 1779 | 1780 | test('options.tags should be a array', t => { 1781 | t.plan(1) 1782 | 1783 | const fastify = createFastify({ logger: { level: 'silent' } }) 1784 | 1785 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1786 | name: 'the-name', 1787 | credentials: { 1788 | client: { 1789 | id: 'my-client-id', 1790 | secret: 'my-secret' 1791 | }, 1792 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1793 | }, 1794 | callbackUri: '/callback', 1795 | tags: 'invalid tags' 1796 | }) 1797 | .ready(), undefined, 'options.tags should be a array') 1798 | }) 1799 | 1800 | test('options.schema should be a object', t => { 1801 | t.plan(1) 1802 | 1803 | const fastify = createFastify({ logger: { level: 'silent' } }) 1804 | 1805 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1806 | name: 'the-name', 1807 | credentials: { 1808 | client: { 1809 | id: 'my-client-id', 1810 | secret: 'my-secret' 1811 | }, 1812 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1813 | }, 1814 | callbackUri: '/callback', 1815 | schema: 1 1816 | }) 1817 | .ready(), undefined, 'options.schema should be a object') 1818 | }) 1819 | 1820 | test('options.cookie should be an object', t => { 1821 | t.plan(1) 1822 | 1823 | const fastify = createFastify({ logger: { level: 'silent' } }) 1824 | 1825 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1826 | name: 'the-name', 1827 | credentials: { 1828 | client: { 1829 | id: 'my-client-id', 1830 | secret: 'my-secret' 1831 | }, 1832 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1833 | }, 1834 | callbackUri: '/callback', 1835 | cookie: 1 1836 | }) 1837 | .ready(), undefined, 'options.cookie should be an object') 1838 | }) 1839 | 1840 | test('options.userAgent should be a string', t => { 1841 | t.plan(1) 1842 | 1843 | const fastify = createFastify({ logger: { level: 'silent' } }) 1844 | 1845 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1846 | name: 'the-name', 1847 | credentials: { 1848 | client: { 1849 | id: 'my-client-id', 1850 | secret: 'my-secret' 1851 | }, 1852 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1853 | }, 1854 | callbackUri: '/callback', 1855 | userAgent: 1 1856 | }) 1857 | .ready(), undefined, 'options.userAgent should be a string') 1858 | }) 1859 | 1860 | test('options.pkce', t => { 1861 | t.plan(1) 1862 | 1863 | const fastify = createFastify({ logger: { level: 'silent' } }) 1864 | 1865 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1866 | name: 'the-name', 1867 | credentials: { 1868 | client: { 1869 | id: 'my-client-id', 1870 | secret: 'my-secret' 1871 | }, 1872 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1873 | }, 1874 | callbackUri: '/callback', 1875 | pkce: {} 1876 | }) 1877 | .ready(), undefined, 'options.pkce should be one of "S256" | "plain" when used') 1878 | }) 1879 | 1880 | test('options.discovery should be object', t => { 1881 | t.plan(1) 1882 | 1883 | const fastify = createFastify({ logger: { level: 'silent' } }) 1884 | 1885 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1886 | name: 'the-name', 1887 | credentials: { 1888 | client: { 1889 | id: 'my-client-id', 1890 | secret: 'my-secret' 1891 | }, 1892 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1893 | }, 1894 | callbackUri: '/callback', 1895 | discovery: 'string' 1896 | }) 1897 | .ready(), undefined, 'options.discovery should be an object') 1898 | }) 1899 | 1900 | test('options.discovery.issuer should be URL', t => { 1901 | t.plan(1) 1902 | 1903 | const fastify = createFastify({ logger: { level: 'silent' } }) 1904 | 1905 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1906 | name: 'the-name', 1907 | credentials: { 1908 | client: { 1909 | id: 'my-client-id', 1910 | secret: 'my-secret' 1911 | }, 1912 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1913 | }, 1914 | callbackUri: '/callback', 1915 | discovery: { 1916 | issuer: {} 1917 | } 1918 | }) 1919 | .ready(), undefined, 'options.discovery.issuer should be URL') 1920 | }) 1921 | 1922 | test('credentials.auth should not be provided when discovery is used', t => { 1923 | t.plan(1) 1924 | 1925 | const fastify = createFastify({ logger: { level: 'silent' } }) 1926 | 1927 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1928 | name: 'the-name', 1929 | credentials: { 1930 | client: { 1931 | id: 'my-client-id', 1932 | secret: 'my-secret' 1933 | }, 1934 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1935 | }, 1936 | callbackUri: '/callback', 1937 | discovery: { 1938 | issuer: 'https://valid.iss' 1939 | } 1940 | }) 1941 | .ready(), undefined, 'credentials.auth should not be provided when discovery is used') 1942 | }) 1943 | 1944 | test('not providing options.discovery.issuer and credentials.auth', t => { 1945 | t.plan(1) 1946 | 1947 | const fastify = createFastify({ logger: { level: 'silent' } }) 1948 | 1949 | return t.assert.rejects(fastify.register(fastifyOauth2, { 1950 | name: 'the-name', 1951 | credentials: { 1952 | client: { 1953 | id: 'my-client-id', 1954 | secret: 'my-secret' 1955 | } 1956 | }, 1957 | callbackUri: '/callback' 1958 | }) 1959 | .ready(), undefined, 'options.discovery.issuer or credentials.auth have to be given') 1960 | }) 1961 | 1962 | test('options.schema', (t, end) => { 1963 | const fastify = createFastify({ logger: { level: 'silent' }, exposeHeadRoutes: false }) 1964 | 1965 | fastify.addHook('onRoute', function (routeOptions) { 1966 | t.assert.deepStrictEqual(routeOptions.schema, { tags: ['oauth2', 'oauth'] }) 1967 | end() 1968 | }) 1969 | 1970 | fastify.register(fastifyOauth2, { 1971 | name: 'the-name', 1972 | credentials: { 1973 | client: { 1974 | id: 'my-client-id', 1975 | secret: 'my-secret' 1976 | }, 1977 | auth: fastifyOauth2.GITHUB_CONFIGURATION 1978 | }, 1979 | startRedirectPath: '/login/github', 1980 | callbackUri: '/callback', 1981 | callbackUriParams: { 1982 | access_type: 'offline' 1983 | }, 1984 | scope: ['notifications'], 1985 | schema: { 1986 | tags: ['oauth2', 'oauth'] 1987 | } 1988 | }) 1989 | 1990 | fastify.ready() 1991 | }) 1992 | 1993 | test('already decorated', t => { 1994 | t.plan(1) 1995 | 1996 | const fastify = createFastify({ logger: { level: 'silent' } }) 1997 | 1998 | return t.assert.rejects(fastify 1999 | .decorate('githubOAuth2', false) 2000 | .register(fastifyOauth2, { 2001 | name: 'githubOAuth2', 2002 | credentials: { 2003 | client: { 2004 | id: 'my-client-id', 2005 | secret: 'my-secret' 2006 | }, 2007 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2008 | }, 2009 | callbackUri: '/callback' 2010 | }) 2011 | .ready(), undefined, 'The decorator \'githubOAuth2\' has already been added!') 2012 | }) 2013 | 2014 | test('preset configuration generate-callback-uri-params', async t => { 2015 | t.plan(3) 2016 | 2017 | await t.test('array scope', (t, end) => { 2018 | const fastify = createFastify({ logger: { level: 'silent' } }) 2019 | 2020 | fastify.register(fastifyOauth2, { 2021 | name: 'the-name', 2022 | credentials: { 2023 | client: { 2024 | id: 'my-client-id', 2025 | secret: 'my-secret' 2026 | }, 2027 | auth: fastifyOauth2.APPLE_CONFIGURATION, 2028 | options: { 2029 | authorizationMethod: 'body' 2030 | } 2031 | }, 2032 | startRedirectPath: '/login/apple', 2033 | callbackUri: '/callback', 2034 | scope: ['email'] 2035 | }) 2036 | 2037 | after(() => fastify.close()) 2038 | 2039 | fastify.listen({ port: 0 }, function (err) { 2040 | t.assert.ifError(err) 2041 | 2042 | fastify.inject({ 2043 | method: 'GET', 2044 | url: '/login/apple' 2045 | }, function (err, responseStart) { 2046 | t.assert.ifError(err) 2047 | 2048 | t.assert.strictEqual(responseStart.statusCode, 302) 2049 | const matched = responseStart.headers.location.match(/https:\/\/appleid\.apple\.com\/auth\/authorize\?response_type=code&client_id=my-client-id&response_mode=form_post&redirect_uri=%2Fcallback&scope=email&state=(.*)/) 2050 | t.assert.ok(matched) 2051 | end() 2052 | }) 2053 | }) 2054 | }) 2055 | 2056 | await t.test('string scope', (t, end) => { 2057 | const fastify = createFastify({ logger: { level: 'silent' } }) 2058 | 2059 | fastify.register(fastifyOauth2, { 2060 | name: 'the-name', 2061 | credentials: { 2062 | client: { 2063 | id: 'my-client-id', 2064 | secret: 'my-secret' 2065 | }, 2066 | auth: fastifyOauth2.APPLE_CONFIGURATION, 2067 | options: { 2068 | authorizationMethod: 'body' 2069 | } 2070 | }, 2071 | startRedirectPath: '/login/apple', 2072 | callbackUri: '/callback', 2073 | scope: 'name' 2074 | }) 2075 | 2076 | after(() => fastify.close()) 2077 | 2078 | fastify.listen({ port: 0 }, function (err) { 2079 | t.assert.ifError(err) 2080 | 2081 | fastify.inject({ 2082 | method: 'GET', 2083 | url: '/login/apple' 2084 | }, function (err, responseStart) { 2085 | t.assert.ifError(err) 2086 | 2087 | t.assert.strictEqual(responseStart.statusCode, 302) 2088 | const matched = responseStart.headers.location.match(/https:\/\/appleid\.apple\.com\/auth\/authorize\?response_type=code&client_id=my-client-id&response_mode=form_post&redirect_uri=%2Fcallback&scope=name&state=(.*)/) 2089 | t.assert.ok(matched) 2090 | end() 2091 | }) 2092 | }) 2093 | }) 2094 | 2095 | await t.test('no scope', (t, end) => { 2096 | const fastify = createFastify({ logger: { level: 'silent' } }) 2097 | 2098 | fastify.register(fastifyOauth2, { 2099 | name: 'the-name', 2100 | credentials: { 2101 | client: { 2102 | id: 'my-client-id', 2103 | secret: 'my-secret' 2104 | }, 2105 | auth: fastifyOauth2.APPLE_CONFIGURATION, 2106 | options: { 2107 | authorizationMethod: 'body' 2108 | } 2109 | }, 2110 | startRedirectPath: '/login/apple', 2111 | callbackUri: '/callback', 2112 | scope: '' 2113 | }) 2114 | 2115 | after(() => fastify.close()) 2116 | 2117 | fastify.listen({ port: 0 }, function (err) { 2118 | t.assert.ifError(err) 2119 | 2120 | fastify.inject({ 2121 | method: 'GET', 2122 | url: '/login/apple' 2123 | }, function (err, responseStart) { 2124 | t.assert.ifError(err) 2125 | 2126 | t.assert.strictEqual(responseStart.statusCode, 302) 2127 | const matched = responseStart.headers.location.match(/https:\/\/appleid\.apple\.com\/auth\/authorize\?response_type=code&client_id=my-client-id&redirect_uri=%2Fcallback&scope=&state=(.*)/) 2128 | t.assert.ok(matched) 2129 | end() 2130 | }) 2131 | }) 2132 | }) 2133 | }) 2134 | 2135 | test('preset configuration generate-callback-uri-params', t => { 2136 | t.plan(56) 2137 | 2138 | const presetConfigs = [ 2139 | 'FACEBOOK_CONFIGURATION', 2140 | 'GITHUB_CONFIGURATION', 2141 | 'GITLAB_CONFIGURATION', 2142 | 'LINKEDIN_CONFIGURATION', 2143 | 'GOOGLE_CONFIGURATION', 2144 | 'MICROSOFT_CONFIGURATION', 2145 | 'VKONTAKTE_CONFIGURATION', 2146 | 'SPOTIFY_CONFIGURATION', 2147 | 'DISCORD_CONFIGURATION', 2148 | 'TWITCH_CONFIGURATION', 2149 | 'VATSIM_CONFIGURATION', 2150 | 'VATSIM_DEV_CONFIGURATION', 2151 | 'EPIC_GAMES_CONFIGURATION', 2152 | 'YANDEX_CONFIGURATION' 2153 | ] 2154 | 2155 | for (const configName of presetConfigs) { 2156 | t.assert.ok(fastifyOauth2[configName]) 2157 | t.assert.strictEqual(typeof fastifyOauth2[configName].tokenHost, 'string') 2158 | t.assert.strictEqual(typeof fastifyOauth2[configName].tokenPath, 'string') 2159 | t.assert.strictEqual(typeof fastifyOauth2[configName].authorizePath, 'string') 2160 | } 2161 | }) 2162 | 2163 | test('revoke token for gitlab with callback', (t, end) => { 2164 | t.plan(3) 2165 | const fastify = createFastify({ logger: { level: 'silent' } }) 2166 | 2167 | fastify.register(fastifyOauth2, { 2168 | name: 'gitlabOAuth2', 2169 | credentials: { 2170 | client: { 2171 | id: 'my-client-id', 2172 | secret: 'my-secret' 2173 | }, 2174 | auth: fastifyOauth2.GITLAB_CONFIGURATION 2175 | }, 2176 | startRedirectPath: '/login/gitlab', 2177 | callbackUri: 'http://localhost:3000/callback', 2178 | scope: ['user'] 2179 | }) 2180 | 2181 | fastify.get('/', function (request, reply) { 2182 | this.gitlabOAuth2.revokeToken({ 2183 | access_token: 'testToken', 2184 | token_type: 'access_token' 2185 | }, 'access_token', undefined, (err) => { 2186 | if (err) throw err 2187 | reply.send('ok') 2188 | }) 2189 | }) 2190 | 2191 | after(() => fastify.close()) 2192 | 2193 | fastify.listen({ port: 0 }, function (err) { 2194 | t.assert.ifError(err) 2195 | 2196 | const gitlabRevoke = nock('https://gitlab.com') 2197 | .post('/oauth/revoke', 'token=testToken&token_type_hint=access_token') 2198 | .reply(200, { status: 'ok' }) 2199 | 2200 | fastify.inject({ 2201 | method: 'GET', 2202 | url: '/' 2203 | }, function (err, responseStart) { 2204 | t.assert.ifError(err, 'No error should be thrown') 2205 | t.assert.strictEqual(responseStart.statusCode, 200) 2206 | gitlabRevoke.done() 2207 | 2208 | end() 2209 | }) 2210 | }) 2211 | }) 2212 | 2213 | test('revoke token for gitlab promisify', (t, end) => { 2214 | t.plan(3) 2215 | const fastify = createFastify({ logger: { level: 'silent' } }) 2216 | 2217 | fastify.register(fastifyOauth2, { 2218 | name: 'gitlabOAuth2', 2219 | credentials: { 2220 | client: { 2221 | id: 'my-client-id', 2222 | secret: 'my-secret' 2223 | }, 2224 | auth: fastifyOauth2.GITLAB_CONFIGURATION 2225 | }, 2226 | startRedirectPath: '/login/gitlab', 2227 | callbackUri: 'http://localhost:3000/callback', 2228 | scope: ['user'] 2229 | }) 2230 | 2231 | fastify.get('/', function (request, reply) { 2232 | return this.gitlabOAuth2.revokeToken({ 2233 | access_token: 'testToken', 2234 | token_type: 'access_token' 2235 | }, 'access_token', undefined).then(() => { 2236 | return reply.send('ok') 2237 | }).catch((e) => { 2238 | throw e 2239 | }) 2240 | }) 2241 | 2242 | after(() => fastify.close()) 2243 | 2244 | fastify.listen({ port: 0 }, function (err) { 2245 | t.assert.ifError(err) 2246 | 2247 | const gitlabRevoke = nock('https://gitlab.com') 2248 | .post('/oauth/revoke', 'token=testToken&token_type_hint=access_token') 2249 | .reply(200, { status: 'ok' }) 2250 | 2251 | fastify.inject({ 2252 | method: 'GET', 2253 | url: '/' 2254 | }, function (err, responseStart) { 2255 | t.assert.ifError(err, 'No error should be thrown') 2256 | t.assert.strictEqual(responseStart.statusCode, 200) 2257 | gitlabRevoke.done() 2258 | 2259 | end() 2260 | }) 2261 | }) 2262 | }) 2263 | 2264 | test('revoke all token for gitlab promisify', (t, end) => { 2265 | t.plan(3) 2266 | const fastify = createFastify({ logger: { level: 'silent' } }) 2267 | 2268 | fastify.register(fastifyOauth2, { 2269 | name: 'gitlabOAuth2', 2270 | credentials: { 2271 | client: { 2272 | id: 'my-client-id', 2273 | secret: 'my-secret' 2274 | }, 2275 | auth: fastifyOauth2.GITLAB_CONFIGURATION 2276 | }, 2277 | startRedirectPath: '/login/gitlab', 2278 | callbackUri: 'http://localhost:3000/callback', 2279 | scope: ['user'] 2280 | }) 2281 | 2282 | fastify.get('/', function (request, reply) { 2283 | return this.gitlabOAuth2.revokeAllToken({ 2284 | access_token: 'testToken', 2285 | token_type: 'access_token', 2286 | refresh_token: 'refreshToken' 2287 | }, undefined).then(() => { 2288 | return reply.send('ok') 2289 | }).catch((e) => { 2290 | throw e 2291 | }) 2292 | }) 2293 | 2294 | after(() => fastify.close()) 2295 | 2296 | fastify.listen({ port: 0 }, function (err) { 2297 | t.assert.ifError(err) 2298 | 2299 | const gitlabRevoke = nock('https://gitlab.com') 2300 | .post('/oauth/revoke', 'token=testToken&token_type_hint=access_token') 2301 | .reply(200, { status: 'ok' }) 2302 | .post('/oauth/revoke', 'token=refreshToken&token_type_hint=refresh_token') 2303 | .reply(200, { status: 'ok' }) 2304 | 2305 | fastify.inject({ 2306 | method: 'GET', 2307 | url: '/' 2308 | }, function (err, responseStart) { 2309 | t.assert.ifError(err, 'No error should be thrown') 2310 | t.assert.strictEqual(responseStart.statusCode, 200) 2311 | gitlabRevoke.done() 2312 | 2313 | end() 2314 | }) 2315 | }) 2316 | }) 2317 | 2318 | test('revoke all token for linkedin callback', (t, end) => { 2319 | t.plan(3) 2320 | const fastify = createFastify({ logger: { level: 'silent' } }) 2321 | 2322 | fastify.register(fastifyOauth2, { 2323 | name: 'linkedinOAuth2', 2324 | credentials: { 2325 | client: { 2326 | id: 'my-client-id', 2327 | secret: 'my-secret' 2328 | }, 2329 | auth: fastifyOauth2.LINKEDIN_CONFIGURATION 2330 | }, 2331 | startRedirectPath: '/login/gitlab', 2332 | callbackUri: 'http://localhost:3000/callback', 2333 | scope: ['user'] 2334 | }) 2335 | 2336 | fastify.get('/', function (request, reply) { 2337 | return this.linkedinOAuth2.revokeAllToken({ 2338 | access_token: 'testToken', 2339 | token_type: 'access_token', 2340 | refresh_token: 'refreshToken' 2341 | }, undefined, (err) => { 2342 | if (err) throw err 2343 | return reply.send('ok') 2344 | }) 2345 | }) 2346 | 2347 | after(() => fastify.close()) 2348 | 2349 | fastify.listen({ port: 0 }, function (err) { 2350 | t.assert.ifError(err) 2351 | 2352 | const gitlabRevoke = nock('https://www.linkedin.com') 2353 | .post('/oauth/v2/revoke', 'token=testToken&token_type_hint=access_token') 2354 | .reply(200, { status: 'ok' }) 2355 | .post('/oauth/v2/revoke', 'token=refreshToken&token_type_hint=refresh_token') 2356 | .reply(200, { status: 'ok' }) 2357 | 2358 | fastify.inject({ 2359 | method: 'GET', 2360 | url: '/' 2361 | }, function (err, responseStart) { 2362 | t.assert.ifError(err, 'No error should be thrown') 2363 | t.assert.strictEqual(responseStart.statusCode, 200) 2364 | gitlabRevoke.done() 2365 | 2366 | end() 2367 | }) 2368 | }) 2369 | }) 2370 | 2371 | test('options.generateStateFunction', async t => { 2372 | await t.test('with request', (t, end) => { 2373 | t.plan(5) 2374 | const fastify = createFastify() 2375 | 2376 | fastify.register(fastifyOauth2, { 2377 | name: 'the-name', 2378 | credentials: { 2379 | client: { 2380 | id: 'my-client-id', 2381 | secret: 'my-secret' 2382 | }, 2383 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2384 | }, 2385 | startRedirectPath: '/login/github', 2386 | callbackUri: '/callback', 2387 | generateStateFunction: (request) => { 2388 | t.assert.ok(request, 'the request param has been set') 2389 | return request.query.code 2390 | }, 2391 | checkStateFunction: () => true, 2392 | scope: ['notifications'] 2393 | }) 2394 | 2395 | after(() => fastify.close()) 2396 | 2397 | fastify.listen({ port: 0 }, function (err) { 2398 | t.assert.ifError(err) 2399 | 2400 | fastify.inject({ 2401 | method: 'GET', 2402 | url: '/login/github', 2403 | query: { code: 'generated_code' } 2404 | }, function (err, responseStart) { 2405 | t.assert.ifError(err) 2406 | t.assert.strictEqual(responseStart.statusCode, 302) 2407 | const matched = responseStart.headers.location.match(/https:\/\/github\.com\/login\/oauth\/authorize\?response_type=code&client_id=my-client-id&redirect_uri=%2Fcallback&scope=notifications&state=generated_code/) 2408 | t.assert.ok(matched) 2409 | end() 2410 | }) 2411 | }) 2412 | }) 2413 | 2414 | await t.test('should be an object', (t) => { 2415 | t.plan(1) 2416 | 2417 | const fastify = createFastify({ logger: { level: 'silent' } }) 2418 | 2419 | return t.assert.rejects(fastify.register(fastifyOauth2, { 2420 | name: 'the-name', 2421 | credentials: { 2422 | client: { 2423 | id: 'my-client-id', 2424 | secret: 'my-secret' 2425 | }, 2426 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2427 | }, 2428 | callbackUri: '/callback', 2429 | generateStateFunction: 42 2430 | }) 2431 | .ready(), undefined, 'options.generateStateFunction should be a function') 2432 | }) 2433 | 2434 | await t.test('with signing key', (t, end) => { 2435 | t.plan(5) 2436 | const fastify = createFastify() 2437 | 2438 | const hmacKey = 'hello' 2439 | const expectedState = crypto.createHmac('sha1', hmacKey).update('foo').digest('hex') 2440 | 2441 | fastify.register(require('@fastify/cookie')) 2442 | 2443 | fastify.register(fastifyOauth2, { 2444 | name: 'the-name', 2445 | credentials: { 2446 | client: { 2447 | id: 'my-client-id', 2448 | secret: 'my-secret' 2449 | }, 2450 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2451 | }, 2452 | startRedirectPath: '/login/github', 2453 | callbackUri: '/callback', 2454 | generateStateFunction: (request) => { 2455 | const state = crypto.createHmac('sha1', hmacKey).update(request.headers.foo).digest('hex') 2456 | t.assert.ok(request, 'the request param has been set') 2457 | return state 2458 | }, 2459 | checkStateFunction: (request) => { 2460 | const generatedState = crypto.createHmac('sha1', hmacKey).update(request.headers.foo).digest('hex') 2461 | return generatedState === request.query.state 2462 | }, 2463 | scope: ['notifications'] 2464 | }) 2465 | 2466 | after(() => fastify.close()) 2467 | 2468 | fastify.listen({ port: 0 }, function (err) { 2469 | t.assert.ifError(err) 2470 | fastify.inject({ 2471 | method: 'GET', 2472 | url: '/login/github', 2473 | query: { code: expectedState }, 2474 | headers: { foo: 'foo' } 2475 | }, function (err, responseStart) { 2476 | t.assert.ifError(err) 2477 | t.assert.strictEqual(responseStart.statusCode, 302) 2478 | const matched = responseStart.headers.location.match(/https:\/\/github\.com\/login\/oauth\/authorize\?response_type=code&client_id=my-client-id&redirect_uri=%2Fcallback&scope=notifications&state=1e864fbd840212c1ed9ce60175d373f3a48681b2/) 2479 | t.assert.ok(matched) 2480 | end() 2481 | }) 2482 | }) 2483 | }) 2484 | 2485 | await t.test('should accept fastify instance as this', (t, end) => { 2486 | const fastify = createFastify({ logger: { level: 'silent' } }) 2487 | 2488 | fastify.register(fastifyOauth2, { 2489 | name: 'theName', 2490 | credentials: { 2491 | client: { 2492 | id: 'my-client-id', 2493 | secret: 'my-secret' 2494 | }, 2495 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2496 | }, 2497 | callbackUri: '/callback', 2498 | generateStateFunction: function (request) { 2499 | t.assert.strictEqual(this, fastify) 2500 | return request.query.code 2501 | }, 2502 | checkStateFunction: () => true, 2503 | scope: ['notifications'] 2504 | }) 2505 | 2506 | fastify.get('/gh', async function (request, reply) { 2507 | const redirectUrl = await this.theName.generateAuthorizationUri(request, reply) 2508 | return reply.redirect(redirectUrl) 2509 | }) 2510 | 2511 | after(() => fastify.close()) 2512 | 2513 | fastify.inject({ 2514 | method: 'GET', 2515 | url: '/gh', 2516 | query: { code: 'generated_code' } 2517 | }, function (err, responseStart) { 2518 | t.assert.ifError(err) 2519 | t.assert.strictEqual(responseStart.statusCode, 302) 2520 | const matched = responseStart.headers.location.match(/https:\/\/github\.com\/login\/oauth\/authorize\?response_type=code&client_id=my-client-id&redirect_uri=%2Fcallback&scope=notifications&state=generated_code/) 2521 | t.assert.ok(matched) 2522 | end() 2523 | }) 2524 | }) 2525 | 2526 | await t.test('should accept async function', (t, end) => { 2527 | const fastify = createFastify({ logger: { level: 'silent' } }) 2528 | 2529 | fastify.register(fastifyOauth2, { 2530 | name: 'theName', 2531 | credentials: { 2532 | client: { 2533 | id: 'my-client-id', 2534 | secret: 'my-secret' 2535 | }, 2536 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2537 | }, 2538 | callbackUri: '/callback', 2539 | generateStateFunction: async function (request) { 2540 | return request.query.code 2541 | }, 2542 | checkStateFunction: () => true, 2543 | scope: ['notifications'] 2544 | }) 2545 | 2546 | fastify.get('/gh', async function (request, reply) { 2547 | const redirectUrl = await this.theName.generateAuthorizationUri(request, reply) 2548 | return reply.redirect(redirectUrl) 2549 | }) 2550 | 2551 | after(() => fastify.close()) 2552 | 2553 | fastify.inject({ 2554 | method: 'GET', 2555 | url: '/gh', 2556 | query: { code: 'generated_code' } 2557 | }, function (err, responseStart) { 2558 | t.assert.ifError(err) 2559 | t.assert.strictEqual(responseStart.statusCode, 302) 2560 | const matched = responseStart.headers.location.match(/https:\/\/github\.com\/login\/oauth\/authorize\?response_type=code&client_id=my-client-id&redirect_uri=%2Fcallback&scope=notifications&state=generated_code/) 2561 | t.assert.ok(matched) 2562 | end() 2563 | }) 2564 | }) 2565 | 2566 | await t.test('should accept callback function', (t, end) => { 2567 | const fastify = createFastify({ logger: { level: 'silent' } }) 2568 | 2569 | fastify.register(fastifyOauth2, { 2570 | name: 'theName', 2571 | credentials: { 2572 | client: { 2573 | id: 'my-client-id', 2574 | secret: 'my-secret' 2575 | }, 2576 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2577 | }, 2578 | callbackUri: '/callback', 2579 | generateStateFunction: function (request, cb) { 2580 | cb(null, request.query.code) 2581 | }, 2582 | checkStateFunction: () => true, 2583 | scope: ['notifications'] 2584 | }) 2585 | 2586 | fastify.get('/gh', async function (request, reply) { 2587 | const redirectUrl = await this.theName.generateAuthorizationUri(request, reply) 2588 | return reply.redirect(redirectUrl) 2589 | }) 2590 | 2591 | after(() => fastify.close()) 2592 | 2593 | fastify.inject({ 2594 | method: 'GET', 2595 | url: '/gh', 2596 | query: { code: 'generated_code' } 2597 | }, function (err, responseStart) { 2598 | t.assert.ifError(err) 2599 | t.assert.strictEqual(responseStart.statusCode, 302) 2600 | const matched = responseStart.headers.location.match(/https:\/\/github\.com\/login\/oauth\/authorize\?response_type=code&client_id=my-client-id&redirect_uri=%2Fcallback&scope=notifications&state=generated_code/) 2601 | t.assert.ok(matched) 2602 | end() 2603 | }) 2604 | }) 2605 | 2606 | await t.test('throws', (t, end) => { 2607 | const fastify = createFastify({ logger: { level: 'silent' } }) 2608 | 2609 | fastify.register(fastifyOauth2, { 2610 | name: 'theName', 2611 | credentials: { 2612 | client: { 2613 | id: 'my-client-id', 2614 | secret: 'my-secret' 2615 | }, 2616 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2617 | }, 2618 | callbackUri: '/callback', 2619 | generateStateFunction: function () { 2620 | return Promise.reject(new Error('generate state failed')) 2621 | }, 2622 | checkStateFunction: () => true, 2623 | scope: ['notifications'] 2624 | }) 2625 | 2626 | fastify.get('/gh', function (request, reply) { 2627 | this.theName.generateAuthorizationUri(request, reply) 2628 | .catch((err) => { 2629 | reply.code(500).send(err.message) 2630 | }) 2631 | }) 2632 | 2633 | after(() => fastify.close()) 2634 | 2635 | fastify.inject({ 2636 | method: 'GET', 2637 | url: '/gh', 2638 | query: { code: 'generated_code' } 2639 | }, function (err, responseStart) { 2640 | t.assert.ifError(err) 2641 | t.assert.strictEqual(responseStart.statusCode, 500) 2642 | t.assert.strictEqual(responseStart.body, 'generate state failed') 2643 | end() 2644 | }) 2645 | }) 2646 | 2647 | await t.test('throws with start redirect path', (t, end) => { 2648 | const fastify = createFastify({ logger: { level: 'silent' } }) 2649 | 2650 | fastify.register(fastifyOauth2, { 2651 | name: 'theName', 2652 | credentials: { 2653 | client: { 2654 | id: 'my-client-id', 2655 | secret: 'my-secret' 2656 | }, 2657 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2658 | }, 2659 | callbackUri: '/callback', 2660 | startRedirectPath: '/gh', 2661 | generateStateFunction: function () { 2662 | return Promise.reject(new Error('generate state failed')) 2663 | }, 2664 | checkStateFunction: () => true, 2665 | scope: ['notifications'] 2666 | }) 2667 | 2668 | after(() => fastify.close()) 2669 | 2670 | fastify.inject({ 2671 | method: 'GET', 2672 | url: '/gh', 2673 | query: { code: 'generated_code' } 2674 | }, function (err, responseStart) { 2675 | t.assert.ifError(err) 2676 | t.assert.strictEqual(responseStart.statusCode, 500) 2677 | t.assert.strictEqual(responseStart.body, 'generate state failed') 2678 | end() 2679 | }) 2680 | }) 2681 | }) 2682 | 2683 | test('options.checkStateFunction', async t => { 2684 | await t.test('should be an object', (t) => { 2685 | t.plan(1) 2686 | 2687 | const fastify = createFastify({ logger: { level: 'silent' } }) 2688 | 2689 | return t.assert.rejects(fastify.register(fastifyOauth2, { 2690 | name: 'the-name', 2691 | credentials: { 2692 | client: { 2693 | id: 'my-client-id', 2694 | secret: 'my-secret' 2695 | }, 2696 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2697 | }, 2698 | callbackUri: '/callback', 2699 | generateStateFunction: () => { }, 2700 | checkStateFunction: 42 2701 | }) 2702 | .ready(), undefined, 'options.checkStateFunction should be a function') 2703 | }) 2704 | 2705 | await t.test('should accept fastify instance as this', (t, end) => { 2706 | const fastify = createFastify({ logger: { level: 'silent' } }) 2707 | 2708 | fastify.register(fastifyOauth2, { 2709 | name: 'githubOAuth2', 2710 | credentials: { 2711 | client: { 2712 | id: 'my-client-id', 2713 | secret: 'my-secret' 2714 | }, 2715 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2716 | }, 2717 | callbackUri: 'http://localhost:3000/callback', 2718 | startRedirectPath: '/login/github', 2719 | generateStateFunction: function (request) { 2720 | return request.query.code 2721 | }, 2722 | checkStateFunction: function () { 2723 | t.assert.strictEqual(this, fastify) 2724 | return true 2725 | }, 2726 | scope: ['notifications'] 2727 | }) 2728 | 2729 | fastify.get('/', function (request) { 2730 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 2731 | .then(result => { 2732 | // attempts to refresh the token 2733 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 2734 | }) 2735 | .then(token => { 2736 | return { 2737 | access_token: token.token.access_token, 2738 | refresh_token: token.token.refresh_token, 2739 | expires_in: token.token.expires_in, 2740 | token_type: token.token.token_type 2741 | } 2742 | }) 2743 | }) 2744 | 2745 | after(() => fastify.close()) 2746 | 2747 | makeRequests(t, end, fastify) 2748 | }) 2749 | 2750 | await t.test('should accept async function', (t, end) => { 2751 | const fastify = createFastify({ logger: { level: 'silent' } }) 2752 | 2753 | fastify.register(fastifyOauth2, { 2754 | name: 'githubOAuth2', 2755 | credentials: { 2756 | client: { 2757 | id: 'my-client-id', 2758 | secret: 'my-secret' 2759 | }, 2760 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2761 | }, 2762 | callbackUri: 'http://localhost:3000/callback', 2763 | startRedirectPath: '/login/github', 2764 | generateStateFunction: async function (request) { 2765 | return request.query.code 2766 | }, 2767 | checkStateFunction: async function () { 2768 | return true 2769 | }, 2770 | scope: ['notifications'] 2771 | }) 2772 | 2773 | fastify.get('/', function (request) { 2774 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 2775 | .then(result => { 2776 | // attempts to refresh the token 2777 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 2778 | }) 2779 | .then(token => { 2780 | return { 2781 | access_token: token.token.access_token, 2782 | refresh_token: token.token.refresh_token, 2783 | expires_in: token.token.expires_in, 2784 | token_type: token.token.token_type 2785 | } 2786 | }) 2787 | }) 2788 | 2789 | after(() => fastify.close()) 2790 | 2791 | makeRequests(t, end, fastify) 2792 | }) 2793 | 2794 | await t.test('returns true', (t, end) => { 2795 | const fastify = createFastify({ logger: { level: 'silent' } }) 2796 | 2797 | fastify.register(fastifyOauth2, { 2798 | name: 'githubOAuth2', 2799 | credentials: { 2800 | client: { 2801 | id: 'my-client-id', 2802 | secret: 'my-secret' 2803 | }, 2804 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2805 | }, 2806 | callbackUri: 'http://localhost:3000/callback', 2807 | startRedirectPath: '/login/github', 2808 | generateStateFunction: function (request) { 2809 | return request.query.code 2810 | }, 2811 | checkStateFunction: async function () { 2812 | return true 2813 | }, 2814 | scope: ['notifications'] 2815 | }) 2816 | 2817 | fastify.get('/', function (request) { 2818 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 2819 | .then(result => { 2820 | // attempts to refresh the token 2821 | return this.githubOAuth2.getNewAccessTokenUsingRefreshToken(result.token) 2822 | }) 2823 | .then(token => { 2824 | return { 2825 | access_token: token.token.access_token, 2826 | refresh_token: token.token.refresh_token, 2827 | expires_in: token.token.expires_in, 2828 | token_type: token.token.token_type 2829 | } 2830 | }) 2831 | }) 2832 | 2833 | after(() => fastify.close()) 2834 | 2835 | makeRequests(t, end, fastify) 2836 | }) 2837 | 2838 | await t.test('returns false', (t, end) => { 2839 | const fastify = createFastify({ logger: { level: 'silent' } }) 2840 | 2841 | fastify.register(fastifyOauth2, { 2842 | name: 'githubOAuth2', 2843 | credentials: { 2844 | client: { 2845 | id: 'my-client-id', 2846 | secret: 'my-secret' 2847 | }, 2848 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2849 | }, 2850 | callbackUri: 'http://localhost:3000/callback', 2851 | startRedirectPath: '/login/github', 2852 | generateStateFunction: function (request) { 2853 | return request.query.code 2854 | }, 2855 | checkStateFunction: function () { 2856 | return false 2857 | }, 2858 | scope: ['notifications'] 2859 | }) 2860 | 2861 | fastify.get('/', function (request, reply) { 2862 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 2863 | .catch(e => { 2864 | reply.code(400) 2865 | return e.message 2866 | }) 2867 | }) 2868 | 2869 | after(() => fastify.close()) 2870 | 2871 | fastify.inject({ 2872 | method: 'GET', 2873 | url: '/?code=my-code&state=wrong-state' 2874 | }, function (err, responseEnd) { 2875 | t.assert.ifError(err) 2876 | 2877 | t.assert.strictEqual(responseEnd.statusCode, 400) 2878 | t.assert.strictEqual(responseEnd.payload, 'Invalid state') 2879 | 2880 | end() 2881 | }) 2882 | }) 2883 | 2884 | await t.test('throws', (t, end) => { 2885 | const fastify = createFastify({ logger: { level: 'silent' } }) 2886 | 2887 | const error = new Error('state is invalid') 2888 | 2889 | fastify.register(fastifyOauth2, { 2890 | name: 'githubOAuth2', 2891 | credentials: { 2892 | client: { 2893 | id: 'my-client-id', 2894 | secret: 'my-secret' 2895 | }, 2896 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2897 | }, 2898 | callbackUri: 'http://localhost:3000/callback', 2899 | startRedirectPath: '/login/github', 2900 | generateStateFunction: function (request) { 2901 | return request.query.code 2902 | }, 2903 | checkStateFunction: async function () { 2904 | return Promise.reject(error) 2905 | }, 2906 | scope: ['notifications'] 2907 | }) 2908 | 2909 | fastify.get('/', function (request, reply) { 2910 | return this.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) 2911 | .catch((err) => { 2912 | reply.code(400).send(err.message) 2913 | }) 2914 | }) 2915 | 2916 | after(() => fastify.close()) 2917 | 2918 | fastify.inject({ 2919 | method: 'GET', 2920 | url: '/?code=my-code&state=wrong-state' 2921 | }, function (err, responseEnd) { 2922 | t.assert.ifError(err) 2923 | 2924 | t.assert.strictEqual(responseEnd.statusCode, 400) 2925 | t.assert.strictEqual(responseEnd.payload, 'state is invalid') 2926 | 2927 | end() 2928 | }) 2929 | }) 2930 | }) 2931 | 2932 | test('options.redirectStateCookieName', async (t) => { 2933 | t.plan(2) 2934 | 2935 | await t.test('should be a string', (t) => { 2936 | t.plan(1) 2937 | 2938 | const fastify = createFastify({ logger: { level: 'silent' } }) 2939 | 2940 | return t.assert.rejects(fastify 2941 | .register( 2942 | fastifyOauth2, { 2943 | name: 'the-name', 2944 | credentials: { 2945 | client: { 2946 | id: 'my-client-id', 2947 | secret: 'my-secret' 2948 | }, 2949 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2950 | }, 2951 | callbackUri: '/callback', 2952 | redirectStateCookieName: 42 2953 | } 2954 | ) 2955 | .ready(), undefined, 'options.redirectStateCookieName should be a string') 2956 | }) 2957 | 2958 | await t.test('with custom cookie name', (t, end) => { 2959 | t.plan(4) 2960 | 2961 | const fastify = createFastify({ logger: { level: 'silent' } }) 2962 | 2963 | fastify.register(fastifyOauth2, { 2964 | name: 'the-name', 2965 | credentials: { 2966 | client: { 2967 | id: 'my-client-id', 2968 | secret: 'my-secret' 2969 | }, 2970 | auth: fastifyOauth2.GITHUB_CONFIGURATION 2971 | }, 2972 | callbackUri: '/callback', 2973 | startRedirectPath: '/login', 2974 | redirectStateCookieName: 'custom-redirect-state' 2975 | }) 2976 | 2977 | after(() => fastify.close()) 2978 | 2979 | fastify.inject( 2980 | { 2981 | method: 'GET', 2982 | url: '/login' 2983 | }, 2984 | function (err, responseEnd) { 2985 | t.assert.ifError(err) 2986 | 2987 | t.assert.strictEqual(responseEnd.statusCode, 302) 2988 | t.assert.strictEqual(responseEnd.cookies[0].name, 'custom-redirect-state') 2989 | t.assert.ok(typeof responseEnd.cookies[0].value === 'string') 2990 | 2991 | end() 2992 | } 2993 | ) 2994 | }) 2995 | }) 2996 | 2997 | test('options.verifierCookieName', async (t) => { 2998 | t.plan(2) 2999 | 3000 | await t.test('should be a string', (t) => { 3001 | t.plan(1) 3002 | 3003 | const fastify = createFastify({ logger: { level: 'silent' } }) 3004 | 3005 | return t.assert.rejects(fastify 3006 | .register(fastifyOauth2, { 3007 | name: 'the-name', 3008 | credentials: { 3009 | client: { 3010 | id: 'my-client-id', 3011 | secret: 'my-secret' 3012 | }, 3013 | auth: fastifyOauth2.GITHUB_CONFIGURATION 3014 | }, 3015 | callbackUri: '/callback', 3016 | verifierCookieName: 42 3017 | }) 3018 | .ready(), undefined, 'options.verifierCookieName should be a string') 3019 | }) 3020 | 3021 | await t.test('with custom cookie name', (t, end) => { 3022 | t.plan(4) 3023 | 3024 | const fastify = createFastify({ logger: { level: 'silent' } }) 3025 | 3026 | fastify.register(fastifyOauth2, { 3027 | name: 'the-name', 3028 | credentials: { 3029 | client: { 3030 | id: 'my-client-id', 3031 | secret: 'my-secret' 3032 | }, 3033 | auth: fastifyOauth2.GITHUB_CONFIGURATION 3034 | }, 3035 | callbackUri: '/callback', 3036 | startRedirectPath: '/login', 3037 | verifierCookieName: 'custom-verifier', 3038 | pkce: 'plain' 3039 | }) 3040 | 3041 | after(() => fastify.close()) 3042 | 3043 | fastify.inject( 3044 | { 3045 | method: 'GET', 3046 | url: '/login' 3047 | }, 3048 | function (err, responseEnd) { 3049 | t.assert.ifError(err) 3050 | 3051 | t.assert.strictEqual(responseEnd.statusCode, 302) 3052 | t.assert.strictEqual(responseEnd.cookies[1].name, 'custom-verifier') 3053 | t.assert.ok(typeof responseEnd.cookies[1].value === 'string') 3054 | 3055 | end() 3056 | } 3057 | ) 3058 | }) 3059 | }) 3060 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginCallback, FastifyReply, FastifyRequest, FastifyInstance } from 'fastify' 2 | import { CookieSerializeOptions } from '@fastify/cookie' 3 | 4 | interface FastifyOauth2 extends FastifyPluginCallback { 5 | APPLE_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 6 | DISCORD_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 7 | FACEBOOK_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 8 | GITHUB_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 9 | GITLAB_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 10 | LINKEDIN_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 11 | GOOGLE_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 12 | MICROSOFT_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 13 | SPOTIFY_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 14 | VKONTAKTE_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 15 | TWITCH_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 16 | VATSIM_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 17 | VATSIM_DEV_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 18 | EPIC_GAMES_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 19 | YANDEX_CONFIGURATION: fastifyOauth2.ProviderConfiguration; 20 | } 21 | 22 | declare namespace fastifyOauth2 { 23 | export interface FastifyGenerateStateFunction { 24 | (this: FastifyInstance, request: FastifyRequest): Promise | string 25 | (this: FastifyInstance, request: FastifyRequest, callback: (err: any, state: string) => void): void 26 | } 27 | 28 | export interface FastifyCheckStateFunction { 29 | (this: FastifyInstance, request: FastifyRequest): Promise | boolean 30 | (this: FastifyInstance, request: FastifyRequest, callback: (err?: any) => void): void 31 | } 32 | 33 | export interface FastifyOAuth2Options { 34 | name: string; 35 | scope?: string[]; 36 | credentials: Credentials; 37 | callbackUri: string | ((req: FastifyRequest) => string); 38 | callbackUriParams?: Object; 39 | tokenRequestParams?: Object; 40 | generateStateFunction?: FastifyGenerateStateFunction; 41 | checkStateFunction?: FastifyCheckStateFunction; 42 | startRedirectPath?: string; 43 | tags?: string[]; 44 | schema?: object; 45 | cookie?: CookieSerializeOptions; 46 | userAgent?: string | false; 47 | pkce?: 'S256' | 'plain'; 48 | discovery?: { issuer: string; } 49 | redirectStateCookieName?: string; 50 | verifierCookieName?: string; 51 | } 52 | 53 | export type TToken = 'access_token' | 'refresh_token' 54 | 55 | export interface Token { 56 | token_type: 'Bearer'; 57 | access_token: string; 58 | refresh_token?: string; 59 | id_token?: string; 60 | expires_in: number; 61 | expires_at: Date; 62 | } 63 | 64 | export interface OAuth2Token { 65 | /** 66 | * Immutable object containing the token object provided while constructing a new access token instance. 67 | * This property will usually have the schema as specified by RFC6750, 68 | * but the exact properties may vary between authorization servers. 69 | */ 70 | token: Token; 71 | 72 | /** 73 | * Determines if the current access token is definitely expired or not 74 | * @param expirationWindowSeconds Window of time before the actual expiration to refresh the token. Defaults to 0. 75 | */ 76 | expired(expirationWindowSeconds?: number): boolean; 77 | 78 | /** Refresh the access token */ 79 | refresh(params?: {}): Promise; 80 | 81 | /** Revoke access or refresh token */ 82 | revoke(tokenType: 'access_token' | 'refresh_token'): Promise; 83 | 84 | /** Revoke both the existing access and refresh tokens */ 85 | revokeAll(): Promise; 86 | } 87 | 88 | export interface ProviderConfiguration { 89 | /** String used to set the host to request the tokens to. Required. */ 90 | tokenHost: string; 91 | /** String path to request an access token. Default to /oauth/token. */ 92 | tokenPath?: string | undefined; 93 | /** String path to revoke an access token. Default to /oauth/revoke. */ 94 | revokePath?: string | undefined; 95 | /** String used to set the host to request an "authorization code". Default to the value set on auth.tokenHost. */ 96 | authorizeHost?: string | undefined; 97 | /** String path to request an authorization code. Default to /oauth/authorize. */ 98 | authorizePath?: string | undefined; 99 | } 100 | 101 | export interface Credentials { 102 | client: { 103 | /** Service registered client id. Required. */ 104 | id: string; 105 | /** Service registered client secret. Required. */ 106 | secret: string; 107 | /** Parameter name used to send the client secret. Default to client_secret. */ 108 | secretParamName?: string | undefined; 109 | /** Parameter name used to send the client id. Default to client_id. */ 110 | idParamName?: string | undefined; 111 | }; 112 | auth?: ProviderConfiguration; 113 | /** 114 | * Used to set global options to the internal http library (wreck). 115 | * All options except baseUrl are allowed 116 | * Defaults to header.Accept = "application/json" 117 | */ 118 | http?: {} | undefined; 119 | options?: { 120 | /** Format of data sent in the request body. Defaults to form. */ 121 | bodyFormat?: 'json' | 'form' | undefined; 122 | /** 123 | * Indicates the method used to send the client.id/client.secret authorization params at the token request. 124 | * If set to body, the bodyFormat option will be used to format the credentials. 125 | * Defaults to header 126 | */ 127 | authorizationMethod?: 'header' | 'body' | undefined; 128 | } | undefined; 129 | } 130 | 131 | export interface OAuth2Namespace { 132 | getAccessTokenFromAuthorizationCodeFlow( 133 | request: FastifyRequest, 134 | ): Promise; 135 | 136 | getAccessTokenFromAuthorizationCodeFlow( 137 | request: FastifyRequest, 138 | reply: FastifyReply, 139 | ): Promise; 140 | 141 | getAccessTokenFromAuthorizationCodeFlow( 142 | request: FastifyRequest, 143 | callback: (err: any, token: OAuth2Token) => void, 144 | ): void; 145 | 146 | getAccessTokenFromAuthorizationCodeFlow( 147 | request: FastifyRequest, 148 | reply: FastifyReply, 149 | callback: (err: any, token: OAuth2Token) => void, 150 | ): void; 151 | 152 | getNewAccessTokenUsingRefreshToken( 153 | refreshToken: Token, 154 | params: Object, 155 | callback: (err: any, token: OAuth2Token) => void, 156 | ): void; 157 | 158 | getNewAccessTokenUsingRefreshToken(refreshToken: Token, params: Object): Promise; 159 | 160 | generateAuthorizationUri( 161 | request: FastifyRequest, 162 | reply: FastifyReply, 163 | callback: (err: any, uri: string) => void 164 | ): void 165 | 166 | generateAuthorizationUri( 167 | request: FastifyRequest, 168 | reply: FastifyReply, 169 | ): Promise; 170 | 171 | revokeToken( 172 | revokeToken: Token, 173 | tokenType: TToken, 174 | httpOptions: Object | undefined, 175 | callback: (err: any) => void 176 | ): void; 177 | 178 | revokeToken(revokeToken: Token, tokenType: TToken, httpOptions: Object | undefined): Promise; 179 | 180 | revokeAllToken( 181 | revokeToken: Token, 182 | httpOptions: Object | undefined, 183 | callback: (err: any) => void 184 | ): void; 185 | 186 | revokeAllToken(revokeToken: Token, httpOptions: Object | undefined): Promise; 187 | 188 | userinfo(tokenSetOrToken: Token | string): Promise; 189 | 190 | userinfo(tokenSetOrToken: Token | string, userInfoExtraOptions: UserInfoExtraOptions | undefined): Promise; 191 | 192 | userinfo(tokenSetOrToken: Token | string, callback: (err: any, userinfo: Object) => void): void; 193 | 194 | userinfo(tokenSetOrToken: Token | string, userInfoExtraOptions: UserInfoExtraOptions | undefined, callback: (err: any, userinfo: Object) => void): void; 195 | } 196 | export type UserInfoExtraOptions = { method?: 'GET' | 'POST', via?: 'header' | 'body', params?: object } 197 | export const fastifyOauth2: FastifyOauth2 198 | export { fastifyOauth2 as default } 199 | } 200 | 201 | declare function fastifyOauth2 (...params: Parameters): ReturnType 202 | 203 | export = fastifyOauth2 204 | 205 | type UpperCaseCharacters = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z' 206 | 207 | declare module 'fastify' { 208 | interface FastifyInstance { 209 | // UpperCaseCharacters ensures that the name has at least one character in it + is a simple camel-case:ification 210 | [key: `oauth2${UpperCaseCharacters}${string}`]: fastifyOauth2.OAuth2Namespace | undefined; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import fastify, { FastifyInstance } from 'fastify' 2 | import { expectAssignable, expectError, expectNotAssignable, expectType } from 'tsd' 3 | import fastifyOauth2, { 4 | FastifyOAuth2Options, 5 | Credentials, 6 | OAuth2Namespace, 7 | OAuth2Token, 8 | ProviderConfiguration, 9 | UserInfoExtraOptions 10 | } from '..' 11 | import type { ModuleOptions } from 'simple-oauth2' 12 | 13 | /** 14 | * Preparing some data for testing. 15 | */ 16 | const auth = fastifyOauth2.GOOGLE_CONFIGURATION 17 | const scope = ['r_emailaddress', 'r_basicprofile'] 18 | const tags = ['oauth2', 'oauth'] 19 | const credentials: Credentials = { 20 | client: { 21 | id: 'test_id', 22 | secret: 'test_secret', 23 | }, 24 | auth, 25 | } 26 | 27 | const simpleOauth2Options: ModuleOptions = { 28 | client: { 29 | id: 'test_id', 30 | secret: 'test_secret', 31 | }, 32 | auth, 33 | } 34 | 35 | const OAuth2NoneOptional: FastifyOAuth2Options = { 36 | name: 'testOAuthName', 37 | credentials, 38 | callbackUri: 'http://localhost/testOauth/callback' 39 | } 40 | 41 | const OAuth2Options: FastifyOAuth2Options = { 42 | name: 'testOAuthName', 43 | scope, 44 | credentials, 45 | callbackUri: 'http://localhost/testOauth/callback', 46 | callbackUriParams: {}, 47 | generateStateFunction: function () { 48 | expectType(this) 49 | return 'test' 50 | }, 51 | checkStateFunction: function () { 52 | expectType(this) 53 | return true 54 | }, 55 | startRedirectPath: '/login/testOauth', 56 | cookie: { 57 | secure: true, 58 | sameSite: 'none' 59 | }, 60 | redirectStateCookieName: 'redirect-state-cookie', 61 | verifierCookieName: 'verifier-cookie', 62 | } 63 | 64 | expectAssignable({ 65 | name: 'testOAuthName', 66 | scope, 67 | credentials, 68 | callbackUri: 'http://localhost/testOauth/callback', 69 | callbackUriParams: {}, 70 | startRedirectPath: '/login/testOauth', 71 | pkce: 'S256' 72 | }) 73 | 74 | expectAssignable({ 75 | name: 'testOAuthName', 76 | scope, 77 | credentials, 78 | callbackUri: req => `${req.protocol}://${req.hostname}/callback`, 79 | callbackUriParams: {}, 80 | startRedirectPath: '/login/testOauth', 81 | pkce: 'S256' 82 | }) 83 | 84 | expectAssignable({ 85 | name: 'testOAuthName', 86 | scope, 87 | credentials, 88 | callbackUri: 'http://localhost/testOauth/callback', 89 | callbackUriParams: {}, 90 | startRedirectPath: '/login/testOauth', 91 | discovery: { issuer: 'https://idp.mycompany.com' } 92 | }) 93 | 94 | expectNotAssignable({ 95 | name: 'testOAuthName', 96 | scope, 97 | credentials, 98 | callbackUri: 'http://localhost/testOauth/callback', 99 | callbackUriParams: {}, 100 | startRedirectPath: '/login/testOauth', 101 | discovery: { issuer: 1 } 102 | }) 103 | 104 | expectAssignable({ 105 | name: 'testOAuthName', 106 | scope, 107 | credentials, 108 | callbackUri: 'http://localhost/testOauth/callback', 109 | callbackUriParams: {}, 110 | startRedirectPath: '/login/testOauth', 111 | pkce: 'plain' 112 | }) 113 | 114 | expectNotAssignable({ 115 | name: 'testOAuthName', 116 | scope, 117 | credentials, 118 | callbackUri: 'http://localhost/testOauth/callback', 119 | callbackUriParams: {}, 120 | generateStateFunction: () => { 121 | }, 122 | checkStateFunction: () => { 123 | }, 124 | startRedirectPath: '/login/testOauth', 125 | pkce: 'SOMETHING' 126 | }) 127 | 128 | const server = fastify() 129 | 130 | server.register(fastifyOauth2, OAuth2NoneOptional) 131 | server.register(fastifyOauth2, OAuth2Options) 132 | 133 | server.register(fastifyOauth2, { 134 | name: 'testOAuthName', 135 | scope, 136 | credentials, 137 | callbackUri: 'http://localhost/testOauth/callback', 138 | checkStateFunction: () => true, 139 | }) 140 | 141 | expectError(server.register(fastifyOauth2, { 142 | name: 'testOAuthName', 143 | scope, 144 | credentials, 145 | callbackUri: 'http://localhost/testOauth/callback', 146 | checkStateFunction: () => true, 147 | startRedirectPath: 2, 148 | })) 149 | 150 | declare module 'fastify' { 151 | // Developers need to define this in their code like they have to do with all decorators. 152 | interface FastifyInstance { 153 | testOAuthName: OAuth2Namespace; 154 | } 155 | } 156 | 157 | /** 158 | * Actual testing. 159 | */ 160 | expectType(auth) 161 | expectType(scope) 162 | expectType(tags) 163 | expectType(credentials) 164 | 165 | // Ensure duplicayed simple-oauth2 are compatible with simple-oauth2 166 | expectAssignable>({ auth: { tokenHost: '' }, ...credentials }) 167 | expectAssignable(auth) 168 | // Ensure published types of simple-oauth2 are accepted 169 | expectAssignable(simpleOauth2Options) 170 | expectAssignable(simpleOauth2Options.auth) 171 | 172 | expectError(fastifyOauth2()) // error because missing required arguments 173 | expectError(fastifyOauth2(server, {}, () => { 174 | })) // error because missing required options 175 | 176 | expectAssignable(fastifyOauth2.DISCORD_CONFIGURATION) 177 | expectAssignable(fastifyOauth2.FACEBOOK_CONFIGURATION) 178 | expectAssignable(fastifyOauth2.GITHUB_CONFIGURATION) 179 | expectAssignable(fastifyOauth2.GITLAB_CONFIGURATION) 180 | expectAssignable(fastifyOauth2.GOOGLE_CONFIGURATION) 181 | expectAssignable(fastifyOauth2.LINKEDIN_CONFIGURATION) 182 | expectAssignable(fastifyOauth2.MICROSOFT_CONFIGURATION) 183 | expectAssignable(fastifyOauth2.SPOTIFY_CONFIGURATION) 184 | expectAssignable(fastifyOauth2.VKONTAKTE_CONFIGURATION) 185 | expectAssignable(fastifyOauth2.TWITCH_CONFIGURATION) 186 | expectAssignable(fastifyOauth2.VATSIM_CONFIGURATION) 187 | expectAssignable(fastifyOauth2.VATSIM_DEV_CONFIGURATION) 188 | expectAssignable(fastifyOauth2.EPIC_GAMES_CONFIGURATION) 189 | expectAssignable(fastifyOauth2.YANDEX_CONFIGURATION) 190 | 191 | server.get('/testOauth/callback', async (request, reply) => { 192 | expectType(server.testOAuthName) 193 | expectType(server.oauth2TestOAuthName) 194 | 195 | expectType(await server.testOAuthName.getAccessTokenFromAuthorizationCodeFlow(request)) 196 | expectType>(server.testOAuthName.getAccessTokenFromAuthorizationCodeFlow(request)) 197 | 198 | expectType(await server.testOAuthName.getAccessTokenFromAuthorizationCodeFlow(request, reply)) 199 | expectType>(server.testOAuthName.getAccessTokenFromAuthorizationCodeFlow(request, reply)) 200 | expectType( 201 | server.testOAuthName.getAccessTokenFromAuthorizationCodeFlow(request, (_err: any, _t: OAuth2Token): void => { 202 | }) 203 | ) 204 | expectType( 205 | server.testOAuthName.getAccessTokenFromAuthorizationCodeFlow(request, reply, (_err: any, _t: OAuth2Token): void => { 206 | }) 207 | ) 208 | // error because Promise should not return void 209 | expectError(await server.testOAuthName.getAccessTokenFromAuthorizationCodeFlow(request)) 210 | // error because non-Promise function call should return void and have a callback argument 211 | expectError( 212 | server.testOAuthName.getAccessTokenFromAuthorizationCodeFlow(request, (_err: any, _t: OAuth2Token): void => { 213 | }) 214 | ) 215 | 216 | // error because function call does not pass a callback as second argument. 217 | expectError(server.testOAuthName.getAccessTokenFromAuthorizationCodeFlow(request)) 218 | 219 | const token = await server.testOAuthName.getAccessTokenFromAuthorizationCodeFlow(request) 220 | if (token.token.refresh_token) { 221 | expectType( 222 | await server.testOAuthName.getNewAccessTokenUsingRefreshToken(token.token, {}) 223 | ) 224 | expectType>( 225 | server.testOAuthName.getNewAccessTokenUsingRefreshToken(token.token, {}) 226 | ) 227 | expectType( 228 | server.testOAuthName.getNewAccessTokenUsingRefreshToken( 229 | token.token, 230 | {}, 231 | (_err: any, _t: OAuth2Token): void => { 232 | } 233 | ) 234 | ) 235 | // Expect error because Promise should not return void 236 | expectError(server.testOAuthName.revokeToken(token.token, 'access_token', undefined)) 237 | // Correct way 238 | expectType>(server.testOAuthName.revokeToken(token.token, 'access_token', undefined)) 239 | // Expect error because invalid Type test isn't an access_token or refresh_token 240 | expectError>(server.testOAuthName.revokeToken(token.token, 'test', undefined)) 241 | // Correct way 242 | expectType( 243 | server.testOAuthName.revokeToken(token.token, 'refresh_token', undefined, (_err: any): void => { 244 | }) 245 | ) 246 | // Expect error because invalid Type test isn't an access_token or refresh_token 247 | expectError( 248 | server.testOAuthName.revokeToken(token.token, 'test', undefined, (_err: any): void => { 249 | }) 250 | ) 251 | // Expect error because invalid Type test isn't an access_token or refresh_token 252 | expectError( 253 | server.testOAuthName.revokeToken(token.token, 'access_token', undefined, undefined) 254 | ) 255 | 256 | // Expect error because Promise should not return void 257 | expectError(server.testOAuthName.revokeAllToken(token.token, undefined)) 258 | // Correct way 259 | expectType>(server.testOAuthName.revokeAllToken(token.token, undefined)) 260 | // Correct way too 261 | expectType(server.testOAuthName.revokeAllToken(token.token, undefined, (_err: any): void => { 262 | })) 263 | // Invalid content 264 | expectError(server.testOAuthName.revokeAllToken(token.token, undefined, undefined)) 265 | // error because Promise should not return void 266 | expectError(await server.testOAuthName.getNewAccessTokenUsingRefreshToken(token.token, {})) 267 | // error because non-Promise function call should return void and have a callback argument 268 | expectError( 269 | server.testOAuthName.getNewAccessTokenUsingRefreshToken( 270 | token.token, 271 | {}, 272 | (_err: any, _t: OAuth2Token): void => { 273 | } 274 | ) 275 | ) 276 | // error because function call does not pass a callback as second argument. 277 | expectError(server.testOAuthName.getNewAccessTokenUsingRefreshToken(token.token, {})) 278 | } 279 | 280 | expectType>(server.testOAuthName.generateAuthorizationUri(request, reply)) 281 | expectType(server.testOAuthName.generateAuthorizationUri(request, reply, (_err) => {})) 282 | // BEGIN userinfo tests 283 | expectType>(server.testOAuthName.userinfo(token.token)) 284 | expectType>(server.testOAuthName.userinfo(token.token.access_token)) 285 | expectType(await server.testOAuthName.userinfo(token.token.access_token)) 286 | expectType(server.testOAuthName.userinfo(token.token.access_token, () => {})) 287 | expectType(server.testOAuthName.userinfo(token.token.access_token, undefined, () => {})) 288 | expectAssignable({ method: 'GET', params: {}, via: 'header' }) 289 | expectAssignable({ method: 'POST', params: { a: 1 }, via: 'header' }) 290 | expectAssignable({ via: 'body' }) 291 | expectNotAssignable({ via: 'donkey' }) 292 | expectNotAssignable({ something: 1 }) 293 | // END userinfo tests 294 | 295 | expectType(await server.testOAuthName.generateAuthorizationUri(request, reply)) 296 | // error because missing reply argument 297 | expectError(server.testOAuthName.generateAuthorizationUri(request)) 298 | 299 | return { 300 | access_token: token.token.access_token, 301 | } 302 | }) 303 | --------------------------------------------------------------------------------