├── .gitattributes ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── LICENSE ├── README.md ├── eslint.config.js ├── package-lock.json ├── package.json ├── src ├── AuthenticationRoute.ts ├── Authenticator.ts ├── CreateInitializePlugin.ts ├── decorators │ ├── index.ts │ ├── is-authenticated.ts │ ├── is-unauthenticated.ts │ ├── login.ts │ └── logout.ts ├── errors.ts ├── index.ts ├── session-managers │ └── SecureSessionManager.ts ├── strategies │ ├── SessionStrategy.ts │ ├── base.ts │ └── index.ts └── type-extensions.ts ├── test ├── authorize.test.ts ├── csrf-fixation.test.ts ├── decorators.test.ts ├── esm │ ├── default-esm-export.mjs │ ├── esm.test.ts │ └── named-esm-export.mjs ├── helpers.ts ├── independent-strategy-instances.test.ts ├── multi-instance.test.ts ├── passport.test.ts ├── secure-session-manager.test.ts ├── secure.key ├── session-isolation.test.ts ├── session-serialization.test.ts ├── session-strategy.test.ts ├── strategies-integration.test.ts └── strategy.test.ts ├── tsconfig.json └── tsconfig.test.json /.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 | versioning-strategy: increase-if-necessary 15 | -------------------------------------------------------------------------------- /.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 | pnpm-lock.yaml 144 | yarn.lock 145 | 146 | # editor files 147 | .vscode 148 | .idea 149 | 150 | #tap files 151 | .tap/ 152 | 153 | BUILD_SHA 154 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=true 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Fastify 4 | Copyright (c) 2011-2015 Jared Hanson 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fastify/passport 2 | 3 | [![CI](https://github.com/fastify/fastify-passport/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify-passport/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/@fastify/passport.svg?style=flat)](https://www.npmjs.com/package/@fastify/passport) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 6 | 7 | `@fastify/passport` is a port of [`passport`](http://www.passportjs.org/) for the Fastify ecosystem. It lets you use Passport strategies to authenticate requests and protect Fastify routes! 8 | 9 | ## Status 10 | 11 | Beta. `@fastify/passport` is still a relatively new project. There may be incompatibilities with express-based `passport` deployments, and bugs. Please report any issues so we can correct them! 12 | 13 | ## Installation 14 | 15 | ```shell 16 | npm i @fastify/passport 17 | ``` 18 | 19 | ## Google OAuth2 Video tutorial 20 | 21 | The community created this fast introduction to `@fastify/passport`: 22 | [![Google OAuth2 Tutorial Passport](https://img.youtube.com/vi/XRcQQWU0XOM/0.jpg)](https://youtu.be/XRcQQWU0XOM) 23 | 24 | 25 | ## Example 26 | 27 | ```js 28 | import fastifyPassport from '@fastify/passport' 29 | import fastifySecureSession from '@fastify/secure-session' 30 | 31 | const server = fastify() 32 | // set up secure sessions for @fastify/passport to store data in 33 | server.register(fastifySecureSession, { key: fs.readFileSync(path.join(__dirname, 'secret-key')) }) 34 | // initialize @fastify/passport and connect it to the secure-session storage. Note: both of these plugins are mandatory. 35 | server.register(fastifyPassport.initialize()) 36 | server.register(fastifyPassport.secureSession()) 37 | 38 | // register an example strategy for fastifyPassport to authenticate users using 39 | fastifyPassport.use('test', new SomePassportStrategy()) // you'd probably use some passport strategy from npm here 40 | 41 | // Add an authentication for a route that will use the strategy named "test" to protect the route 42 | server.get( 43 | '/', 44 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 45 | async () => 'hello world!' 46 | ) 47 | 48 | // Add an authentication for a route that will use the strategy named "test" to protect the route, and redirect on success to a particular other route. 49 | server.post( 50 | '/login', 51 | { preValidation: fastifyPassport.authenticate('test', { successRedirect: '/', authInfo: false }) }, 52 | () => {} 53 | ) 54 | 55 | server.listen() 56 | ``` 57 | 58 | Alternatively, [`@fastify/session`](https://github.com/fastify/session) is also supported and works out of the box for session storage. 59 | Here's an example: 60 | 61 | ```js 62 | import { Authenticator } from '@fastify/passport' 63 | import fastifyCookie from '@fastify/cookie' 64 | import fastifySession from '@fastify/session' 65 | 66 | const server = fastify() 67 | 68 | // setup an Authenticator instance which uses @fastify/session 69 | const fastifyPassport = new Authenticator() 70 | 71 | server.register(fastifyCookie) 72 | server.register(fastifySession, { secret: 'secret with minimum length of 32 characters' }) 73 | 74 | // initialize @fastify/passport and connect it to the secure-session storage. Note: both of these plugins are mandatory. 75 | server.register(fastifyPassport.initialize()) 76 | server.register(fastifyPassport.secureSession()) 77 | 78 | // register an example strategy for fastifyPassport to authenticate users using 79 | fastifyPassport.use('test', new SomePassportStrategy()) // you'd probably use some passport strategy from npm here 80 | ``` 81 | 82 | ## Session cleanup on logIn 83 | 84 | For security reasons the session is cleaned after login. You can manage this configuration at your own risk by: 85 | 1) Include `keepSessionInfo` true option when perform the passport `.authenticate` call; 86 | 2) Include `keepSessionInfo` true option when perform the request `.login` call; 87 | 3) Using `clearSessionOnLogin (default: true)` and `clearSessionIgnoreFields (default: ['passport', 'session'])`. 88 | 89 | ## Difference between `@fastify/secure-session` and `@fastify/session` 90 | `@fastify/secure-session` and `@fastify/session` are both session plugins for Fastify which are capable of encrypting/decrypting the session. The main difference is that `@fastify/secure-session` uses the stateless approach and stores the whole session in an encrypted cookie whereas `@fastify/session` uses the stateful approach for sessions and stores them in a session store. 91 | 92 | ## Session Serialization 93 | 94 | In a typical web application, the credentials used to authenticate a user will only be transmitted once when a user logs in, and after, they are considered logged in because of some data stored in their session. `@fastify/passport` implements this pattern by storing sessions using `@fastify/secure-session`, and serializing/deserializing user objects to and from the session referenced by the cookie. `@fastify/passport` cannot store rich object classes in the session, only JSON objects, so you must register a serializer/deserializer pair if you want to fetch a User object from your database, and store only a user ID in the session. 95 | 96 | ```js 97 | // register a serializer that stores the user object's id in the session ... 98 | fastifyPassport.registerUserSerializer(async (user, request) => user.id); 99 | 100 | // ... and then a deserializer that will fetch that user from the database when a request with an id in the session arrives 101 | fastifyPassport.registerUserDeserializer(async (id, request) => { 102 | return await User.findById(id); 103 | }); 104 | ``` 105 | 106 | ## API 107 | 108 | ### initialize() 109 | 110 | A hook that **must be added**. Sets up a `@fastify/passport` instance's hooks. 111 | 112 | ### secureSession() 113 | 114 | A hook that **must be added**. Sets up `@fastify/passport`'s connector with `@fastify/secure-session` to store authentication in the session. 115 | 116 | ### authenticate(strategy: string | Strategy | (string | Strategy)[], options: AuthenticateOptions, callback?: AuthenticateCallback) 117 | 118 | Returns a hook that authenticates requests, in other words, validates users and then signs them in. `authenticate` is intended for use as a `preValidation` hook on a particular route like `/login`. 119 | 120 | Applies the given strategy (or strategies) to the incoming request, in order to authenticate the request. Strategies are usually registered ahead of time using `.use`, and then passed to `.authenticate` by name. If authentication is successful, the user will be logged in and populated at `request.user` and a session will be established by default. If authentication fails, an unauthorized response will be sent. 121 | 122 | Strategies or arrays of strategies can also be passed as instances. This is useful when using a temporary strategy you only intend to use once for one user and don't want to register into the global list of available strategies. 123 | 124 | Options: 125 | 126 | - `session` Save login state in session, defaults to _true_ 127 | - `successRedirect` After successful login, redirect to given URL 128 | - `successMessage` True to store success message in 129 | req.session.messages, or a string to use as override 130 | message for success. 131 | - `successFlash` True to flash success messages or a string to use as a flash 132 | message for success (overrides any from the strategy itself). 133 | - `failureRedirect` After failed login, redirect to given URL 134 | - `failureMessage` True to store failure message in 135 | req.session.messages, or a string to use as override 136 | message for failure. 137 | - `failureFlash` True to flash failure messages or a string to use as a flash 138 | message for failures (overrides any from the strategy itself). 139 | - `assignProperty` Assign the object provided by the verify callback to given property 140 | - `state` Pass any provided state through to the strategy (e.g. for Google Oauth) 141 | - `keepSessionInfo` True to save existing session properties after authentication 142 | 143 | An optional `callback` can be supplied to allow the application to override the default manner in which authentication attempts are handled. The callback has the following signature: 144 | 145 | ```js 146 | (request, reply, err | null, user | false, info?, (status | statuses)?) => Promise 147 | ``` 148 | 149 | where `request` and `reply` will be set to the original `FastifyRequest` and `FastifyReply` objects, and `err` will be set to `null` in case of a success or an `Error` object in case of a failure. If `err` is not `null` then `user`, `info`, and `status` objects will be `undefined`. The `user` object will be set to the authenticated user on a successful authentication attempt, or `false` otherwise. 150 | 151 | An optional `info` argument will be passed, containing additional details provided by the strategy's verify callback - this could be information about a successful authentication or a challenge message for a failed authentication. 152 | 153 | An optional `status` or `statuses` argument will be passed when authentication fails - this could be a HTTP response code for a remote authentication failure or similar. 154 | 155 | ```js 156 | fastify.get( 157 | '/', 158 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 159 | async (request, reply, err, user, info, status) => { 160 | if (err !== null) { 161 | console.warn(err) 162 | } else if (user) { 163 | console.log(`Hello ${user.name}!`) 164 | } 165 | } 166 | ) 167 | ``` 168 | 169 | Examples: 170 | 171 | ```js 172 | // create a request handler that uses the Facebook strategy 173 | fastifyPassport.use(new FacebookStrategy('facebook', { 174 | // options for the Facebook strategy, see https://www.npmjs.com/package/passport-facebook 175 | }))) 176 | fastifyPassport.authenticate('facebook'); 177 | 178 | // create a request handler to test against the strategy named local, and automatically redirect when it succeeds or fails 179 | fastifyPassport.authenticate('local', { successRedirect: '/', failureRedirect: '/login' }); 180 | 181 | // create a request handler that won't use any user information stored in the secure session 182 | fastifyPassport.authenticate('basic', { session: false }); 183 | ``` 184 | 185 | Note that if a callback is supplied, it becomes the application's responsibility to log-in the user, establish a session, and otherwise perform the desired operations. 186 | 187 | #### Multiple Strategies 188 | 189 | `@fastify/passport` supports authenticating with a list of strategies, and will try each in order until one passes. Pass an array of strategy names to `authenticate` for this: 190 | 191 | ```js 192 | // somewhere before several strategies are registered 193 | fastifyPassport.use('bearer', new BearerTokenStrategy()) 194 | fastifyPassport.use('basic', new BasicAuthStrategy()) 195 | fastifyPassport.use('google', new FancyGoogleStrategy()) 196 | 197 | // and then an `authenticate` call can test incoming requests against multiple strategies 198 | fastify.get( 199 | '/', 200 | { preValidation: fastifyPassport.authenticate(['bearer', 'basic', 'google'], { authInfo: false }) }, 201 | async (request, reply, err, user, info, status) => { 202 | if (err !== null) { 203 | console.warn(err) 204 | } else if (user) { 205 | console.log(`Hello ${user.name}!`) 206 | } 207 | } 208 | ) 209 | ``` 210 | 211 | Note that multiple strategies that redirect to start an authentication flow, like OAuth2 strategies from major platforms, should not be used together in the same `authenticate` call. This is because `@fastify/passport` will run the strategies in order, and the first one that redirects will do so, preventing the user from ever using the other strategies. To set up multiple OAuth2 strategies, add several routes that each use a different strategy in their own `authenticate` call, and then direct users to the right route for the strategy they pick. 212 | 213 | Multiple strategies can also be passed as instances if you only intend to use them for that route handler or for that request. 214 | 215 | ```js 216 | // use an `authenticate` call can test incoming requests against multiple strategies without registering them for use elsewhere 217 | fastify.get( 218 | '/', 219 | { 220 | preValidation: fastifyPassport.authenticate([new BearerTokenStrategy(), new BasicAuthStrategy()], { 221 | authInfo: false, 222 | }), 223 | }, 224 | async (request, reply, err, user, info, status) => { 225 | if (err !== null) { 226 | console.warn(err) 227 | } else if (user) { 228 | console.log(`Hello ${user.name}!`) 229 | } 230 | } 231 | ) 232 | ``` 233 | 234 | ### authorize(strategy: string | Strategy | (string | Strategy)[], options: AuthenticateOptions = {}, callback?: AuthenticateCallback) 235 | 236 | Returns a hook that will authorize a third-party account using the given `strategy`, with optional `options`. Intended for use as a `preValidation` hook on any route. `.authorize` has the same API as `.authenticate`, but has one key difference: it doesn't modify the logged in user's details. Instead, if authorization is successful, the result provided by the strategy's verify callback will be assigned to `request.account`. The existing login session and `request.user` will be unaffected. 237 | 238 | This function is particularly useful when connecting third-party accounts to the local account of a user that is currently authenticated. 239 | 240 | Examples: 241 | 242 | ```js 243 | fastifyPassport.authorize('twitter-authz', { failureRedirect: '/account' }) 244 | ``` 245 | 246 | `.authorize` allows the use of multiple strategies by passing an array of strategy names and allows the use of already instantiated Strategy instances by passing the instance as the strategy, or an array of instances. 247 | 248 | ### use(name?: string, strategy: Strategy) 249 | 250 | Utilize the given `strategy` with optional `name`, overridding the strategy's default name. 251 | 252 | Examples: 253 | 254 | ```js 255 | fastifyPassport.use(new TwitterStrategy(...)); 256 | 257 | fastifyPassport.use('api', new http.Strategy(...)); 258 | ``` 259 | 260 | ### unuse(name: string) 261 | 262 | Un-utilize the `strategy` with given `name`. 263 | 264 | In typical applications, the necessary authentication strategies are static, configured once and always available. As such, there is often no need to invoke this function. 265 | 266 | However, in certain situations, applications may need dynamically configure and de-configure authentication strategies. The `use()`/`unuse()` combination satisfies these scenarios. 267 | 268 | Example: 269 | 270 | ```js 271 | fastifyPassport.unuse('legacy-api') 272 | ``` 273 | 274 | ### registerUserSerializer(serializer: (user, request) => Promise) 275 | 276 | Registers an async user serializer function for taking a high-level User object from your application and serializing it for storage into the session. `@fastify/passport` cannot store rich object classes in the session, only JSON objects, so you must register a serializer/deserializer pair if you want to fetch a User object from your database, and store only a user ID in the session. 277 | 278 | ```js 279 | // register a serializer that stores the user object's id in the session ... 280 | fastifyPassport.registerUserSerializer(async (user, request) => user.id) 281 | ``` 282 | 283 | ### registerUserDeserializer(deserializer: (serializedUser, request) => Promise) 284 | 285 | Registers an async user deserializer function for taking a low-level serialized user object (often just a user ID) from a session, and deserializing it from storage into the request context. `@fastify/passport` cannot store rich object classes in the session, only JSON objects, so you must register a serializer/deserializer pair if you want to fetch a User object from your database, and store only a user ID in the session. 286 | 287 | ```js 288 | fastifyPassport.registerUserDeserializer(async (id, request) => { 289 | return await User.findById(id); 290 | }); 291 | ``` 292 | 293 | Deserializers can throw the string `"pass"` if they do not apply to the current session and the next deserializer should be tried. This is useful if you are using `@fastify/passport` to store two different kinds of user objects. An example: 294 | 295 | ```js 296 | // register a deserializer for database users 297 | fastifyPassport.registerUserDeserializer(async (id, request) => { 298 | if (id.startsWith("db-")) { 299 | return await User.findById(id); 300 | } else { 301 | throw "pass" 302 | } 303 | }); 304 | 305 | // register a deserializer for redis users 306 | fastifyPassport.registerUserDeserializer(async (id, request) => { 307 | if (id.startsWith("redis-")) { 308 | return await redis.get(id); 309 | } else { 310 | throw "pass" 311 | } 312 | }); 313 | ``` 314 | 315 | Sessions may specify serialized users that have since been deleted from the datastore storing them for the application. In that case, deserialization often fails because the user row cannot be found for a given id. Depending on the application, this can either be an error condition, or expected if users are deleted from the database while logged in. `@fastify/passport`'s behavior in this case is configurable. Errors are thrown if a deserializer returns undefined, and the session is logged out if a deserializer returns `null` or `false.` This matches the behavior of the original `passport` module. 316 | 317 | Therefore, a deserializer can return several things: 318 | 319 | - if a deserializer returns an object, that object is assumed to be a successfully deserialized user 320 | - if a deserializer returns `undefined`, `@fastify/passport` interprets that as an erroneously missing user, and throws an error because the user could not be deserialized. 321 | - if a deserializer returns `null` or `false`, `@fastify/passport` interprets that as a missing but expected user, and resets the session to log the user out 322 | - if a deserializer throws the string `"pass"`, `@fastify/passport` will try the next deserializer if it exists, or throw an error because the user could not be deserialized. 323 | 324 | ### Request#isUnauthenticated() 325 | 326 | Test if request is unauthenticated. 327 | 328 | ## Using with TypeScript 329 | 330 | `@fastify/passport` is written in TypeScript, so it includes type definitions for all of its API. You can also strongly type the `FastifyRequest.user` property using TypeScript declaration merging. You must re-declare the `PassportUser` interface in the `fastify` module within your own code to add the properties you expect to be assigned by the strategy when authenticating: 331 | 332 | ```typescript 333 | declare module 'fastify' { 334 | interface PassportUser { 335 | id: string 336 | } 337 | } 338 | ``` 339 | 340 | or, if you already have a type for the objects returned from all of the strategies, you can make `PassportUser` extend it: 341 | 342 | ```typescript 343 | import { User } from './my/types' 344 | 345 | declare module 'fastify' { 346 | interface PassportUser extends User {} 347 | } 348 | ``` 349 | 350 | ## Using multiple instances 351 | 352 | `@fastify/passport` supports being registered multiple times in different plugin encapsulation contexts. This is useful to implement two separate authentication stacks. For example, you might have a set of strategies that authenticate users of your application and a whole other set of strategies for authenticating staff members of your application that access an administration area. Users might be stored at `request.user`, and administrators at `request.admin`, and logging in as one should have no bearing on the other. It is important to register each instance of `@fastify/passport` in a different Fastify plugin context so that the decorators `@fastify/passport` like `request.logIn` and `request.logOut` do not collide. 353 | 354 | To register @fastify/passport more than once, you must instantiate more copies with different `keys` and `userProperty`s so they do not collide when decorating your fastify instance or storing things in the session. 355 | 356 | ```typescript 357 | import { Authenticator } from '@fastify/passport' 358 | 359 | const server = fastify() 360 | 361 | // setup an Authenticator instance for users that stores the login result at `request.user` 362 | const userPassport = new Authenticator({ key: 'users', userProperty: 'user' }) 363 | userPassport.use('some-strategy', new CoolOAuthStrategy('some-strategy')) 364 | server.register(userPassport.initialize()) 365 | server.register(userPassport.secureSession()) 366 | 367 | // setup an Authenticator instance for users that stores the login result at `request.admin` 368 | const adminPassport = new Authenticator({ key: 'admin', userProperty: 'admin' }) 369 | adminPassport.use('admin-google', new GoogleOAuth2Strategy('admin-google')) 370 | server.register(adminPassport.initialize()) 371 | server.register(adminPassport.secureSession()) 372 | 373 | // protect some routes with the userPassport 374 | server.get( 375 | `/`, 376 | { preValidation: userPassport.authenticate('some-strategy') }, 377 | async () => `hello ${JSON.serialize(request.user)}!` 378 | ) 379 | 380 | // and protect others with the adminPassport 381 | server.get( 382 | `/admin`, 383 | { preValidation: adminPassport.authenticate('admin-google') }, 384 | async () => `hello administrator ${JSON.serialize(request.admin)}!` 385 | ) 386 | ``` 387 | 388 | **Note**: Each `Authenticator` instance's initialize plugin and session plugin must be registered separately. 389 | 390 | It is important to note that using multiple `@fastify/passport` instances is not necessary if you want to use multiple strategies to login the same type of user. `@fastify/passport` supports multiple strategies by passing an array to any `.authenticate` call. 391 | 392 | # Differences from Passport.js 393 | 394 | `@fastify/passport` is an adapted version of Passport that tries to be as compatible as possible but is an adapted version that has some incompatibilities. Passport strategies that adhere to the passport strategy API should work fine, but there are some differences in other APIs made to integrate better with Fastify and to stick with Fastify's theme of performance. 395 | 396 | Differences: 397 | 398 | - `serializeUser` renamed to `registerUserSerializer` and always takes an async function with the signature `(user: User, request: FastifyRequest) => Promise` 399 | - `deserializeUser` renamed to `registerUserDeserializer` and always takes an async function with the signature `(serialized: SerializedUser, request: FastifyRequest) => Promise` 400 | - `transformAuthInfo` renamed to `registerAuthInfoTransformer` and always takes an async function with the signature `(info: any, request: FastifyRequest) => Promise` 401 | - `.authenticate` and `.authorize` accept strategy instances in addition to strategy names. This allows for using one-time strategy instances (say for testing given user credentials) without adding them to the global list of registered strategies. 402 | 403 | ## License 404 | 405 | [MIT](./LICENSE) 406 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | semi: false, 5 | ignores: ['dist/**/*', 'node_modules/**/*', 'coverage/**/*'], 6 | ts: true, 7 | }) 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/passport", 3 | "version": "3.0.2", 4 | "description": "Simple, unobtrusive authentication for Fastify.", 5 | "main": "dist/index.js", 6 | "type": "commonjs", 7 | "types": "dist/index.d.ts", 8 | "scripts": { 9 | "build": "rimraf ./dist && tsc && git rev-parse HEAD > BUILD_SHA", 10 | "build:test": "tsc --project tsconfig.test.json", 11 | "lint": "eslint", 12 | "lint:fix": "eslint --fix", 13 | "prepublishOnly": "npm run build", 14 | "test": "npm run build:test && npm run test:unit", 15 | "test:unit": "borp --coverage" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/fastify/fastify-passport.git" 20 | }, 21 | "keywords": [ 22 | "fastify", 23 | "auth", 24 | "authentication" 25 | ], 26 | "contributors": [ 27 | "Maksim Sinik ", 28 | "Harry Brundage " 29 | ], 30 | "license": "MIT", 31 | "licenses": [ 32 | { 33 | "type": "MIT", 34 | "url": "http://opensource.org/licenses/MIT" 35 | } 36 | ], 37 | "bugs": { 38 | "url": "https://github.com/fastify/fastify-passport/issues" 39 | }, 40 | "homepage": "http://passportjs.org/", 41 | "dependencies": { 42 | "@fastify/flash": "^6.0.0", 43 | "fastify-plugin": "^5.0.0" 44 | }, 45 | "devDependencies": { 46 | "@fastify/cookie": "^11.0.1", 47 | "@fastify/csrf-protection": "^7.0.0", 48 | "@fastify/secure-session": "^8.0.0", 49 | "@fastify/session": "^11.0.0", 50 | "@types/node": "^22.1.0", 51 | "@types/passport": "^1.0.5", 52 | "@types/set-cookie-parser": "^2.4.0", 53 | "borp": "^0.20.0", 54 | "eslint": "^9.17.0", 55 | "fastify": "^5.0.0", 56 | "got": "^11.8.1", 57 | "neostandard": "^0.12.0", 58 | "openid-client": "^5.6.1", 59 | "passport-facebook": "^3.0.0", 60 | "passport-github2": "^0.1.12", 61 | "passport-google-oauth": "^2.0.0", 62 | "rimraf": "^6.0.1", 63 | "set-cookie-parser": "^2.4.6", 64 | "tsd": "^0.32.0", 65 | "typescript": "~5.8.2" 66 | }, 67 | "files": [ 68 | "dist" 69 | ], 70 | "publishConfig": { 71 | "access": "public" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/AuthenticationRoute.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'node:http' 2 | import AuthenticationError from './errors' 3 | import Authenticator from './Authenticator' 4 | import { AnyStrategy, Strategy } from './strategies' 5 | import { FastifyReply, FastifyRequest } from 'fastify' 6 | import { types } from 'node:util' 7 | 8 | type FlashObject = { type?: string; message?: string } 9 | type FailureObject = { 10 | challenge?: string | FlashObject 11 | status?: number 12 | type?: string 13 | } 14 | 15 | declare module '@fastify/secure-session' { 16 | interface SessionData { 17 | messages: string[] 18 | returnTo: string | undefined 19 | } 20 | } 21 | 22 | const addMessage = (request: FastifyRequest, message: string) => { 23 | const existing = request.session.get('messages') 24 | const messages = existing ? [...existing, message] : [message] 25 | request.session.set('messages', messages) 26 | } 27 | 28 | export interface AuthenticateOptions { 29 | scope?: string | string[] 30 | failureFlash?: boolean | string | FlashObject 31 | failureMessage?: boolean | string 32 | successRedirect?: string 33 | failureRedirect?: string 34 | failWithError?: boolean 35 | successFlash?: boolean | string | FlashObject 36 | successMessage?: boolean | string 37 | assignProperty?: string 38 | successReturnToOrRedirect?: string 39 | state?: string 40 | authInfo?: boolean 41 | session?: boolean 42 | pauseStream?: boolean 43 | keepSessionInfo?: boolean 44 | } 45 | 46 | export type SingleStrategyCallback = ( 47 | request: FastifyRequest, 48 | reply: FastifyReply, 49 | err: null | Error, 50 | user?: unknown, 51 | info?: unknown, 52 | status?: number 53 | ) => Promise 54 | export type MultiStrategyCallback = ( 55 | request: FastifyRequest, 56 | reply: FastifyReply, 57 | err: null | Error, 58 | user?: unknown, 59 | info?: unknown, 60 | statuses?: (number | undefined)[] 61 | ) => Promise 62 | 63 | export type AuthenticateCallback = 64 | StrategyOrStrategies extends any[] ? MultiStrategyCallback : SingleStrategyCallback 65 | 66 | const Unhandled = Symbol.for('passport-unhandled') 67 | 68 | export class AuthenticationRoute { 69 | readonly options: AuthenticateOptions 70 | readonly strategies: (string | Strategy)[] 71 | readonly isMultiStrategy: boolean 72 | 73 | /** 74 | * Create a new route handler that runs authentication strategies. 75 | * 76 | * @param authenticator aggregator instance that owns the chain of strategies 77 | * @param strategyOrStrategies list of strategies this handler tries as string names of registered strategies or strategy instances 78 | * @param options options governing behaviour of strategies 79 | * @param callback optional custom callback to process the result of the strategy invocations 80 | */ 81 | constructor ( 82 | readonly authenticator: Authenticator, 83 | strategyOrStrategies: StrategyOrStrategies, 84 | options?: AuthenticateOptions, 85 | readonly callback?: AuthenticateCallback 86 | ) { 87 | this.options = options || {} 88 | 89 | // Cast `name` to an array, allowing authentication to pass through a chain of strategies. The first strategy to succeed, redirect, or error will halt the chain. Authentication failures will proceed through each strategy in series, ultimately failing if all strategies fail. 90 | // This is typically used on API endpoints to allow clients to authenticate using their preferred choice of Basic, Digest, token-based schemes, etc. It is not feasible to construct a chain of multiple strategies that involve redirection (for example both Facebook and Twitter), since the first one to redirect will halt the chain. 91 | if (Array.isArray(strategyOrStrategies)) { 92 | this.strategies = strategyOrStrategies 93 | this.isMultiStrategy = false 94 | } else { 95 | this.strategies = [strategyOrStrategies] 96 | this.isMultiStrategy = false 97 | } 98 | } 99 | 100 | handler = async (request: FastifyRequest, reply: FastifyReply) => { 101 | if (!request.passport) { 102 | throw new Error('passport.initialize() plugin not in use') 103 | } 104 | // accumulator for failures from each strategy in the chain 105 | const failures: FailureObject[] = [] 106 | 107 | for (const nameOrInstance of this.strategies) { 108 | try { 109 | return await this.attemptStrategy( 110 | failures, 111 | this.getStrategyName(nameOrInstance), 112 | this.getStrategy(nameOrInstance), 113 | request, 114 | reply 115 | ) 116 | } catch (e) { 117 | if (e === Unhandled) { 118 | continue 119 | } else { 120 | throw e 121 | } 122 | } 123 | } 124 | 125 | return this.onAllFailed(failures, request, reply) 126 | } 127 | 128 | attemptStrategy ( 129 | failures: FailureObject[], 130 | name: string, 131 | prototype: AnyStrategy, 132 | request: FastifyRequest, 133 | reply: FastifyReply 134 | ) { 135 | const strategy = Object.create(prototype) as Strategy 136 | 137 | // This is a messed up way of adapting passport's API to fastify's async world. We create a promise that the strategy's per-call functions close over and resolve/reject with the result of the strategy. This augmentation business is a key part of how Passport strategies expect to work. 138 | return new Promise((resolve, reject) => { 139 | /** 140 | * Authenticate `user`, with optional `info`. 141 | * 142 | * Strategies should call this function to successfully authenticate a user. `user` should be an object supplied by the application after it has been given an opportunity to verify credentials. `info` is an optional argument containing additional user information. This is useful for third-party authentication strategies to pass profile details. 143 | */ 144 | strategy.success = (user: any, info: { type?: string; message?: string }) => { 145 | request.log.debug({ strategy: name }, 'passport strategy success') 146 | if (this.callback) { 147 | return resolve(this.callback(request, reply, null, user, info)) 148 | } 149 | 150 | info = info || {} 151 | this.applyFlashOrMessage('success', request, info) 152 | 153 | if (this.options.assignProperty) { 154 | request[this.options.assignProperty] = user 155 | return resolve() 156 | } 157 | 158 | request 159 | .logIn(user, this.options) 160 | .catch(reject) 161 | .then(() => { 162 | const complete = () => { 163 | if (this.options.successReturnToOrRedirect) { 164 | let url = this.options.successReturnToOrRedirect 165 | const returnTo = request.session?.get('returnTo') 166 | if (typeof returnTo === 'string') { 167 | url = returnTo 168 | request.session.set('returnTo', undefined) 169 | } 170 | 171 | reply.redirect(url) 172 | } else if (this.options.successRedirect) { 173 | reply.redirect(this.options.successRedirect) 174 | } 175 | return resolve() 176 | } 177 | 178 | if (this.options.authInfo !== false) { 179 | this.authenticator 180 | .transformAuthInfo(info, request) 181 | .catch(reject) 182 | .then((transformedInfo) => { 183 | request.authInfo = transformedInfo 184 | complete() 185 | }) 186 | } else { 187 | complete() 188 | } 189 | }) 190 | } 191 | 192 | /** 193 | * Fail authentication, with optional `challenge` and `status`, defaulting to 401. 194 | * 195 | * Strategies should call this function to fail an authentication attempt. 196 | */ 197 | strategy.fail = function (challengeOrStatus?: string | number | undefined, status?: number) { 198 | request.log.trace({ strategy: name }, 'passport strategy failed') 199 | 200 | let challenge 201 | if (typeof challengeOrStatus === 'number') { 202 | status = challengeOrStatus 203 | challenge = undefined 204 | } else { 205 | challenge = challengeOrStatus 206 | } 207 | 208 | // push this failure into the accumulator and attempt authentication using the next strategy 209 | failures.push({ challenge, status: status! }) 210 | reject(Unhandled) 211 | } 212 | 213 | /** 214 | * Redirect to `url` with optional `status`, defaulting to 302. 215 | * 216 | * Strategies should call this function to redirect the user (via their user agent) to a third-party website for authentication. 217 | */ 218 | strategy.redirect = (url: string, status?: number) => { 219 | request.log.trace({ strategy: name, url }, 'passport strategy redirecting') 220 | 221 | reply.status(status || 302) 222 | reply.redirect(url) 223 | resolve() 224 | } 225 | 226 | /** 227 | * Pass without making a success or fail decision. 228 | * 229 | * Under most circumstances, Strategies should not need to call this function. It exists primarily to allow previous authentication state to be restored, for example from an HTTP session. 230 | */ 231 | strategy.pass = () => { 232 | request.log.trace({ strategy: name }, 'passport strategy passed') 233 | 234 | resolve() 235 | } 236 | 237 | const error = (err: Error) => { 238 | request.log.trace({ strategy: name, err }, 'passport strategy errored') 239 | 240 | if (this.callback) { 241 | return resolve(this.callback(request, reply, err)) 242 | } 243 | 244 | reject(err) 245 | } 246 | 247 | /** 248 | * Internal error while performing authentication. 249 | * 250 | * Strategies should call this function when an internal error occurs during the process of performing authentication; for example, if the user directory is not available. 251 | */ 252 | strategy.error = error 253 | 254 | request.log.trace({ strategy: name }, 'attempting passport strategy authentication') 255 | 256 | try { 257 | const result = strategy.authenticate(request, this.options) 258 | if (types.isPromise(result)) { 259 | result.catch(error) 260 | } 261 | } catch (err) { 262 | error(err as Error) 263 | } 264 | }) 265 | } 266 | 267 | async onAllFailed (failures: FailureObject[], request: FastifyRequest, reply: FastifyReply) { 268 | request.log.trace('all passport strategies failed') 269 | 270 | if (this.callback) { 271 | if (this.isMultiStrategy) { 272 | const challenges = failures.map((f) => f.challenge) 273 | const statuses = failures.map((f) => f.status) 274 | return await (this.callback as MultiStrategyCallback)(request, reply, null, false, challenges, statuses) 275 | } else { 276 | return await (this.callback as SingleStrategyCallback)( 277 | request, 278 | reply, 279 | null, 280 | false, 281 | failures[0].challenge, 282 | failures[0].status 283 | ) 284 | } 285 | } 286 | 287 | // Strategies are ordered by priority. For the purpose of flashing a message, the first failure will be displayed. 288 | this.applyFlashOrMessage('failure', request, this.toFlashObject(failures[0]?.challenge, 'error')) 289 | if (this.options.failureRedirect) { 290 | return reply.redirect(this.options.failureRedirect) 291 | } 292 | 293 | // When failure handling is not delegated to the application, the default is to respond with 401 Unauthorized. Note that the WWW-Authenticate header will be set according to the strategies in use (see actions#fail). If multiple strategies failed, each of their challenges will be included in the response. 294 | const rchallenge: string[] = [] 295 | let rstatus: number | undefined 296 | 297 | for (const failure of failures) { 298 | rstatus = rstatus || failure.status 299 | if (typeof failure.challenge === 'string') { 300 | rchallenge.push(failure.challenge) 301 | } 302 | } 303 | 304 | rstatus = rstatus || 401 305 | reply.code(rstatus) 306 | 307 | if (reply.statusCode === 401 && rchallenge.length) { 308 | reply.header('WWW-Authenticate', rchallenge) 309 | } 310 | 311 | if (this.options.failWithError) { 312 | throw new AuthenticationError(http.STATUS_CODES[reply.statusCode]!, rstatus) 313 | } 314 | 315 | reply.send(http.STATUS_CODES[reply.statusCode]) 316 | } 317 | 318 | applyFlashOrMessage (event: 'success' | 'failure', request: FastifyRequest, result?: FlashObject) { 319 | const flashOption = this.options[`${event}Flash`] 320 | const level = event === 'success' ? 'success' : 'error' 321 | 322 | if (flashOption) { 323 | let flash: FlashObject | undefined 324 | if (typeof flashOption === 'boolean') { 325 | flash = this.toFlashObject(result, level) 326 | } else { 327 | flash = this.toFlashObject(flashOption, level) 328 | } 329 | 330 | if (flash && flash.type && flash.message) { 331 | request.flash(flash.type, flash.message) 332 | } 333 | } 334 | 335 | const messageOption = this.options[`${event}Message`] 336 | if (messageOption) { 337 | const message = typeof messageOption === 'boolean' ? this.toFlashObject(result, level)?.message : messageOption 338 | if (message) { 339 | addMessage(request, message) 340 | } 341 | } 342 | } 343 | 344 | toFlashObject (input: string | FlashObject | undefined, type: string) { 345 | if (input === undefined) { 346 | // fall-through 347 | } else if (typeof input === 'string') { 348 | return { type, message: input } 349 | } else { 350 | return input 351 | } 352 | } 353 | 354 | private getStrategyName (nameOrInstance: string | Strategy): string { 355 | if (typeof nameOrInstance === 'string') { 356 | return nameOrInstance 357 | } else if (nameOrInstance.name) { 358 | return nameOrInstance.name 359 | } else { 360 | return nameOrInstance.constructor.name 361 | } 362 | } 363 | 364 | private getStrategy (nameOrInstance: string | Strategy): AnyStrategy { 365 | if (typeof nameOrInstance === 'string') { 366 | const prototype = this.authenticator.strategy(nameOrInstance) 367 | if (!prototype) { 368 | throw new Error( 369 | `Unknown authentication strategy ${nameOrInstance}, no strategy with this name has been registered.` 370 | ) 371 | } 372 | return prototype 373 | } else { 374 | return nameOrInstance 375 | } 376 | } 377 | } 378 | -------------------------------------------------------------------------------- /src/Authenticator.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync, FastifyRequest, RouteHandlerMethod } from 'fastify' 2 | import fastifyPlugin from 'fastify-plugin' 3 | import { AuthenticateCallback, AuthenticateOptions, AuthenticationRoute } from './AuthenticationRoute' 4 | import { CreateInitializePlugin } from './CreateInitializePlugin' 5 | import { SecureSessionManager } from './session-managers/SecureSessionManager' 6 | import { AnyStrategy, SessionStrategy, Strategy } from './strategies' 7 | 8 | export type SerializeFunction = ( 9 | user: User, 10 | req: FastifyRequest 11 | ) => Promise 12 | 13 | export type DeserializeFunction = ( 14 | serialized: SerializedUser, 15 | req: FastifyRequest 16 | ) => Promise 17 | 18 | export type InfoTransformerFunction = (info: any) => Promise 19 | 20 | export interface AuthenticatorOptions { 21 | key?: string 22 | userProperty?: string 23 | clearSessionOnLogin?: boolean 24 | clearSessionIgnoreFields?: string[] 25 | } 26 | 27 | export class Authenticator { 28 | // a Fastify-instance wide unique string identifying this instance of fastify-passport (default: "passport") 29 | public key: string 30 | // the key on the request at which to store the deserialized user value (default: "user") 31 | public userProperty: string 32 | public sessionManager: SecureSessionManager 33 | 34 | private strategies: { [k: string]: AnyStrategy } = {} 35 | private serializers: SerializeFunction[] = [] 36 | private deserializers: DeserializeFunction[] = [] 37 | private infoTransformers: InfoTransformerFunction[] = [] 38 | private clearSessionOnLogin: boolean 39 | private clearSessionIgnoreFields: string[] 40 | 41 | constructor (options: AuthenticatorOptions = {}) { 42 | this.key = options.key || 'passport' 43 | this.userProperty = options.userProperty || 'user' 44 | this.use(new SessionStrategy(this.deserializeUser.bind(this))) 45 | this.clearSessionOnLogin = options.clearSessionOnLogin ?? true 46 | this.clearSessionIgnoreFields = ['passport', 'session', ...(options.clearSessionIgnoreFields || [])] 47 | this.sessionManager = new SecureSessionManager( 48 | { 49 | key: this.key, 50 | clearSessionOnLogin: this.clearSessionOnLogin, 51 | clearSessionIgnoreFields: this.clearSessionIgnoreFields 52 | }, 53 | this.serializeUser.bind(this) 54 | ) 55 | } 56 | 57 | use (strategy: AnyStrategy): this 58 | use (name: string, strategy: AnyStrategy): this 59 | use (name: AnyStrategy | string, strategy?: AnyStrategy): this { 60 | if (!strategy) { 61 | strategy = name as AnyStrategy 62 | name = strategy.name as string 63 | } 64 | if (!name) { 65 | throw new Error('Authentication strategies must have a name') 66 | } 67 | 68 | this.strategies[name as string] = strategy 69 | return this 70 | } 71 | 72 | public unuse (name: string): this { 73 | delete this.strategies[name] 74 | return this 75 | } 76 | 77 | public initialize (): FastifyPluginAsync { 78 | return CreateInitializePlugin(this) 79 | } 80 | 81 | /** 82 | * Authenticates requests. 83 | * 84 | * Applies the `name`ed strategy (or strategies) to the incoming request, in order to authenticate the request. If authentication is successful, the user will be logged in and populated at `req.user` and a session will be established by default. If authentication fails, an unauthorized response will be sent. 85 | * 86 | * Options: 87 | * - `session` Save login state in session, defaults to _true_ 88 | * - `successRedirect` After successful login, redirect to given URL 89 | * - `successMessage` True to store success message in 90 | * req.session.messages, or a string to use as override 91 | * message for success. 92 | * - `successFlash` True to flash success messages or a string to use as a flash 93 | * message for success (overrides any from the strategy itself). 94 | * - `failureRedirect` After failed login, redirect to given URL 95 | * - `failureMessage` True to store failure message in 96 | * req.session.messages, or a string to use as override 97 | * message for failure. 98 | * - `failureFlash` True to flash failure messages or a string to use as a flash 99 | * message for failures (overrides any from the strategy itself). 100 | * - `assignProperty` Assign the object provided by the verify callback to given property 101 | * 102 | * An optional `callback` can be supplied to allow the application to override the default manner in which authentication attempts are handled. The callback has the following signature, where `user` will be set to the authenticated user on a successful authentication attempt, or `false` otherwise. An optional `info` argument will be passed, containing additional details provided by the strategy's verify callback - this could be information about a successful authentication or a challenge message for a failed authentication. An optional `status` argument will be passed when authentication fails - this could be a HTTP response code for a remote authentication failure or similar. 103 | * 104 | * fastify.get('/protected', function(req, res, next) { 105 | * passport.authenticate('local', function(err, user, info, status) { 106 | * if (err) { return next(err) } 107 | * if (!user) { return res.redirect('/signin') } 108 | * res.redirect('/account'); 109 | * })(req, res, next); 110 | * }); 111 | * 112 | * Note that if a callback is supplied, it becomes the application's responsibility to log-in the user, establish a session, and otherwise perform the desired operations. 113 | * 114 | * Examples: 115 | * 116 | * // protect a route with a validation handler 117 | * fastify.get( 118 | * '/protected', 119 | * { preValidation: fastifyPassport.authenticate('local', {failureRedirect: '/login}) }, 120 | * async (request, reply) => { 121 | * reply.send("Hello " + request.user.name); 122 | * } 123 | * ) 124 | * 125 | * // handle a route with a custom callback that uses request/reply to handle the request depending on the authentication result 126 | * fastify.get('/checkLogin', fastifyPassport.authenticate('local', async (request, reply, err, user) => { 127 | * if (user) { 128 | * return reply.redirect(request.session.get('returnTo')); 129 | * } else { 130 | * return reply.redirect('/login'); 131 | * } 132 | }) 133 | * 134 | * fastifyPassport.authenticate('basic', { session: false })(req, res); 135 | * 136 | * fastify.get('/auth/twitter', fastifyPassport.authenticate('twitter')); 137 | * fastify.get('/auth/twitter/callback', fastifyPassport.authenticate('twitter', { successRedirect: '/', failureRedirect: '/login' })) 138 | * 139 | * @param {|String|Array} name 140 | * @param {Object} options 141 | * @param {Function} callback 142 | * @return {Function} 143 | * @api public 144 | */ 145 | 146 | public authenticate( 147 | strategy: StrategyOrStrategies, 148 | callback?: AuthenticateCallback 149 | ): RouteHandlerMethod 150 | public authenticate( 151 | strategy: StrategyOrStrategies, 152 | options?: AuthenticateOptions 153 | ): RouteHandlerMethod 154 | public authenticate( 155 | strategy: StrategyOrStrategies, 156 | options?: AuthenticateOptions, 157 | callback?: AuthenticateCallback 158 | ): RouteHandlerMethod 159 | public authenticate( 160 | strategyOrStrategies: StrategyOrStrategies, 161 | optionsOrCallback?: AuthenticateOptions | AuthenticateCallback, 162 | callback?: AuthenticateCallback 163 | ): RouteHandlerMethod { 164 | let options: AuthenticateOptions | undefined 165 | if (typeof optionsOrCallback === 'function') { 166 | options = {} 167 | callback = optionsOrCallback 168 | } else { 169 | options = optionsOrCallback 170 | } 171 | 172 | return new AuthenticationRoute(this, strategyOrStrategies, options, callback).handler 173 | } 174 | 175 | /** 176 | * Hook or handler that will authorize a third-party account using the given `strategy` name, with optional `options`. 177 | * 178 | * If authorization is successful, the result provided by the strategy's verify callback will be assigned to `request.account`. The existing login session and `request.user` will be unaffected. 179 | * 180 | * This function is particularly useful when connecting third-party accounts to the local account of a user that is currently authenticated. 181 | * 182 | * Examples: 183 | * 184 | * passport.authorize('twitter-authz', { failureRedirect: '/account' }); 185 | * 186 | * @param {String} strategy 187 | * @param {Object} options 188 | * @return {Function} middleware 189 | * @api public 190 | */ 191 | 192 | public authorize( 193 | strategy: StrategyOrStrategies, 194 | callback?: AuthenticateCallback 195 | ): RouteHandlerMethod 196 | public authorize( 197 | strategy: StrategyOrStrategies, 198 | options?: AuthenticateOptions 199 | ): RouteHandlerMethod 200 | public authorize( 201 | strategy: StrategyOrStrategies, 202 | options?: AuthenticateOptions, 203 | callback?: AuthenticateCallback 204 | ): RouteHandlerMethod 205 | public authorize( 206 | strategyOrStrategies: StrategyOrStrategies, 207 | optionsOrCallback?: AuthenticateOptions | AuthenticateCallback, 208 | callback?: AuthenticateCallback 209 | ): RouteHandlerMethod { 210 | let options: AuthenticateOptions | undefined 211 | if (typeof optionsOrCallback === 'function') { 212 | options = {} 213 | callback = optionsOrCallback 214 | } else { 215 | options = optionsOrCallback 216 | } 217 | options || (options = {}) 218 | options.assignProperty = 'account' 219 | 220 | return new AuthenticationRoute(this, strategyOrStrategies, options, callback).handler 221 | } 222 | 223 | /** 224 | * Hook or handler that will restore login state from a session managed by @fastify/secure-session. 225 | * 226 | * Web applications typically use sessions to maintain login state between requests. For example, a user will authenticate by entering credentials into a form which is submitted to the server. If the credentials are valid, a login session is established by setting a cookie containing a session identifier in the user's web browser. The web browser will send this cookie in subsequent requests to the server, allowing a session to be maintained. 227 | * 228 | * If sessions are being utilized, and a login session has been established, this middleware will populate `request.user` with the current user. 229 | * 230 | * Note that sessions are not strictly required for Passport to operate. However, as a general rule, most web applications will make use of sessions. An exception to this rule would be an API server, which expects each HTTP request to provide credentials in an Authorization header. 231 | * 232 | * Examples: 233 | * 234 | * server.register(FastifySecureSession); 235 | * server.register(FastifyPassport.initialize()); 236 | * server.register(FastifyPassport.secureSession()); 237 | * 238 | * Options: 239 | * - `pauseStream` Pause the request stream before deserializing the user 240 | * object from the session. Defaults to _false_. Should 241 | * be set to true in cases where middleware consuming the 242 | * request body is configured after passport and the 243 | * deserializeUser method is asynchronous. 244 | * 245 | * @return {Function} middleware 246 | */ 247 | public secureSession (options?: AuthenticateOptions): FastifyPluginAsync { 248 | return fastifyPlugin(async (fastify) => { 249 | fastify.addHook('preValidation', new AuthenticationRoute(this, 'session', options).handler) 250 | }) 251 | } 252 | 253 | /** 254 | * Registers a function used to serialize user objects into the session. 255 | * 256 | * Examples: 257 | * 258 | * passport.registerUserSerializer(async (user) => user.id); 259 | * 260 | * @api public 261 | */ 262 | registerUserSerializer(fn: SerializeFunction) { 263 | this.serializers.push(fn) 264 | } 265 | 266 | /** Runs the chain of serializers to find the first one that serializes a user, and returns it. */ 267 | async serializeUser(user: User, request: FastifyRequest): Promise { 268 | const result = await this.runStack(this.serializers, user, request) 269 | 270 | if (result) { 271 | return result 272 | } else { 273 | throw new Error(`Failed to serialize user into session. Tried ${this.serializers.length} serializers.`) 274 | } 275 | } 276 | 277 | /** 278 | * Registers a function used to deserialize user objects out of the session. 279 | * 280 | * Examples: 281 | * 282 | * fastifyPassport.registerUserDeserializer(async (id) => { 283 | * return await User.findById(id); 284 | * }); 285 | * 286 | * @api public 287 | */ 288 | registerUserDeserializer(fn: DeserializeFunction) { 289 | this.deserializers.push(fn) 290 | } 291 | 292 | async deserializeUser(stored: StoredUser, request: FastifyRequest): Promise { 293 | const result = await this.runStack(this.deserializers, stored, request) 294 | 295 | if (result) { 296 | return result 297 | } else if (result === null || result === false) { 298 | return false 299 | } else { 300 | throw new Error(`Failed to deserialize user out of session. Tried ${this.deserializers.length} serializers.`) 301 | } 302 | } 303 | 304 | /** 305 | * Registers a function used to transform auth info. 306 | * 307 | * In some circumstances authorization details are contained in authentication credentials or loaded as part of verification. 308 | * 309 | * For example, when using bearer tokens for API authentication, the tokens may encode (either directly or indirectly in a database), details such as scope of access or the client to which the token was issued. 310 | * 311 | * Such authorization details should be enforced separately from authentication. Because Passport deals only with the latter, this is the responsibility of middleware or routes further along the chain. However, it is not optimal to decode the same data or execute the same database query later. To avoid this, Passport accepts optional `info` along with the authenticated `user` in a strategy's `success()` action. This info is set at `request.authInfo`, where said later middlware or routes can access it. 312 | * 313 | * Optionally, applications can register transforms to process this info, which take effect prior to `request.authInfo` being set. This is useful, forexample, when the info contains a client ID. The transform can load the client from the database and include the instance in the transformed info, allowing the full set of client properties to be convieniently accessed. 314 | * 315 | * If no transforms are registered, `info` supplied by the strategy will be left unmodified. 316 | * 317 | * Examples: 318 | * 319 | * fastifyPassport.registerAuthInfoTransformer(async (info) => { 320 | * info.client = await Client.findById(info.clientID); 321 | * return info; 322 | * }); 323 | * 324 | * @api public 325 | */ 326 | registerAuthInfoTransformer (fn: InfoTransformerFunction) { 327 | this.infoTransformers.push(fn) 328 | } 329 | 330 | async transformAuthInfo (info: any, request: FastifyRequest) { 331 | const result = await this.runStack(this.infoTransformers, info, request) 332 | // if no transformers are registered (or they all pass), the default behavior is to use the un-transformed info as-is 333 | return result || info 334 | } 335 | 336 | /** 337 | * Return strategy with given `name`. 338 | * 339 | * @param {String} name 340 | * @return {AnyStrategy} 341 | * @api private 342 | */ 343 | strategy (name: string): AnyStrategy | undefined { 344 | return this.strategies[name] 345 | } 346 | 347 | private async runStack(stack: ((...args: [A, B]) => Promise)[], ...args: [A, B]) { 348 | for (const attempt of stack) { 349 | try { 350 | return await attempt(...args) 351 | } catch (e) { 352 | if (e === 'pass') { 353 | continue 354 | } else { 355 | throw e 356 | } 357 | } 358 | } 359 | } 360 | } 361 | 362 | export default Authenticator 363 | -------------------------------------------------------------------------------- /src/CreateInitializePlugin.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import { logIn, logOut, isAuthenticated, isUnauthenticated } from './decorators' 3 | import Authenticator from './Authenticator' 4 | import flash = require('@fastify/flash') 5 | 6 | export function CreateInitializePlugin (passport: Authenticator) { 7 | return fp(async (fastify) => { 8 | fastify.register(flash) 9 | fastify.decorateRequest('passport', { 10 | getter () { 11 | return passport 12 | } 13 | }) 14 | fastify.decorateRequest('logIn', logIn) 15 | fastify.decorateRequest('login', logIn) 16 | fastify.decorateRequest('logOut', logOut) 17 | fastify.decorateRequest('logout', logOut) 18 | fastify.decorateRequest('isAuthenticated', isAuthenticated) 19 | fastify.decorateRequest('isUnauthenticated', isUnauthenticated) 20 | fastify.decorateRequest(passport.userProperty, null) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | import { logIn } from './login' 2 | import { logOut } from './logout' 3 | import { isAuthenticated } from './is-authenticated' 4 | import { isUnauthenticated } from './is-unauthenticated' 5 | 6 | export { logIn, logOut, isAuthenticated, isUnauthenticated } 7 | -------------------------------------------------------------------------------- /src/decorators/is-authenticated.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from 'fastify' 2 | 3 | export function isAuthenticated (this: FastifyRequest): boolean { 4 | const property = this.passport.userProperty 5 | return !!this[property] 6 | } 7 | -------------------------------------------------------------------------------- /src/decorators/is-unauthenticated.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from 'fastify' 2 | 3 | export function isUnauthenticated (this: FastifyRequest): boolean { 4 | return !this.isAuthenticated() 5 | } 6 | -------------------------------------------------------------------------------- /src/decorators/login.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from 'fastify' 2 | 3 | export type DoneCallback = (err?: Error) => void 4 | /** 5 | * Initiate a login session for `user`. 6 | * 7 | * Options: 8 | * - `session` Save login state in session, defaults to _true_ 9 | * 10 | * Examples: 11 | * 12 | * req.logIn(user, { session: false }); 13 | * 14 | * req.logIn(user, function(err) { 15 | * if (err) { throw err; } 16 | * // session saved 17 | * }); 18 | * 19 | * @param {User} user 20 | * @param {Object} options 21 | * @param {Function} done 22 | * @api public 23 | */ 24 | export async function logIn (this: FastifyRequest, user: T): Promise 25 | export async function logIn ( 26 | this: FastifyRequest, 27 | user: T, 28 | options: { session?: boolean; keepSessionInfo?: boolean } 29 | ): Promise 30 | export async function logIn ( 31 | this: FastifyRequest, 32 | user: T, 33 | options: { session?: boolean; keepSessionInfo?: boolean } = {} 34 | ) { 35 | if (!this.passport) { 36 | throw new Error('passport.initialize() plugin not in use') 37 | } 38 | 39 | const property = this.passport.userProperty 40 | const session = options.session === undefined ? true : options.session 41 | 42 | this[property] = user 43 | if (session) { 44 | try { 45 | await this.passport.sessionManager.logIn(this, user, options) 46 | } catch (e) { 47 | this[property] = null 48 | throw e 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/decorators/logout.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from 'fastify' 2 | 3 | /** 4 | * Terminate an existing login session. 5 | * 6 | * @api public 7 | */ 8 | export async function logOut (this: FastifyRequest): Promise { 9 | const property = this.passport.userProperty 10 | this[property] = null 11 | await this.passport.sessionManager.logOut(this) 12 | } 13 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | class AuthenticationError extends Error { 2 | status: number 3 | 4 | constructor (message: string, status: number) { 5 | super() 6 | 7 | Error.captureStackTrace(this, this.constructor) 8 | this.name = 'AuthenticationError' 9 | this.message = message 10 | this.status = status || 401 11 | } 12 | } 13 | 14 | export default AuthenticationError 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Authenticator } from './Authenticator' 2 | import './type-extensions' // necessary to make sure that the fastify types are augmented 3 | const passport = new Authenticator() 4 | 5 | // Workaround for importing fastify-passport in native ESM context 6 | module.exports = exports = passport 7 | export default passport 8 | export { Strategy } from './strategies' 9 | export { Authenticator } from './Authenticator' 10 | -------------------------------------------------------------------------------- /src/session-managers/SecureSessionManager.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from 'fastify' 2 | import { AuthenticateOptions } from '../AuthenticationRoute' 3 | import { SerializeFunction } from '../Authenticator' 4 | import { FastifySessionObject } from '@fastify/session' 5 | import { Session, SessionData } from '@fastify/secure-session' 6 | 7 | type Request = FastifyRequest & { session: FastifySessionObject | Session } 8 | 9 | /** Class for storing passport data in the session using `@fastify/secure-session` or `@fastify/session` */ 10 | export class SecureSessionManager { 11 | key: string 12 | clearSessionOnLogin: boolean 13 | clearSessionIgnoreFields: string[] = ['session'] 14 | serializeUser: SerializeFunction 15 | 16 | constructor (serializeUser: SerializeFunction) 17 | constructor ( 18 | options: { key?: string; clearSessionOnLogin?: boolean; clearSessionIgnoreFields?: string[] }, 19 | serializeUser: SerializeFunction 20 | ) 21 | constructor ( 22 | options: SerializeFunction | { key?: string; clearSessionOnLogin?: boolean; clearSessionIgnoreFields?: string[] }, 23 | serializeUser?: SerializeFunction 24 | ) { 25 | if (typeof options === 'function') { 26 | this.serializeUser = options 27 | this.key = 'passport' 28 | this.clearSessionOnLogin = true 29 | } else if (typeof serializeUser === 'function') { 30 | this.serializeUser = serializeUser 31 | this.key = 32 | (options && typeof options === 'object' && typeof options.key === 'string' && options.key) || 'passport' 33 | this.clearSessionOnLogin = options.clearSessionOnLogin ?? true 34 | this.clearSessionIgnoreFields = [...this.clearSessionIgnoreFields, ...(options.clearSessionIgnoreFields || [])] 35 | } else { 36 | throw new Error('SecureSessionManager#constructor must have a valid serializeUser-function passed as a parameter') 37 | } 38 | } 39 | 40 | async logIn (request: Request, user: any, options?: AuthenticateOptions) { 41 | const object = await this.serializeUser(user, request) 42 | 43 | // Handle @fastify/session to prevent token/CSRF fixation 44 | if (request.session.regenerate) { 45 | if (this.clearSessionOnLogin && object) { 46 | const keepSessionInfoKeys: string[] = [...this.clearSessionIgnoreFields] 47 | if (options?.keepSessionInfo) { 48 | keepSessionInfoKeys.push(...Object.keys(request.session)) 49 | } 50 | await request.session.regenerate(keepSessionInfoKeys) 51 | } else { 52 | await request.session.regenerate() 53 | } 54 | 55 | // Handle @fastify/secure-session against CSRF fixation 56 | // TODO: This is quite hacky. The best option would be having a regenerate method 57 | // on secure-session as well 58 | } else if (this.clearSessionOnLogin && object) { 59 | const currentData: SessionData = request.session?.data() ?? {} 60 | for (const field of Object.keys(currentData)) { 61 | if (options?.keepSessionInfo || this.clearSessionIgnoreFields.includes(field)) { 62 | continue 63 | } 64 | request.session.set(field, undefined) 65 | } 66 | } 67 | request.session.set(this.key, object) 68 | } 69 | 70 | async logOut (request: Request) { 71 | request.session.set(this.key, undefined) 72 | if (request.session.regenerate) { 73 | await request.session.regenerate() 74 | } 75 | } 76 | 77 | getUserFromSession (request: Request) { 78 | return request.session.get(this.key) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/strategies/SessionStrategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from './base' 2 | import { DeserializeFunction } from '../Authenticator' 3 | import type { FastifyRequest } from 'fastify' 4 | 5 | /** 6 | * Default strategy that authenticates already-authenticated requests by retrieving their auth information from the Fastify session. 7 | * */ 8 | export class SessionStrategy extends Strategy { 9 | private deserializeUser: DeserializeFunction 10 | 11 | constructor (deserializeUser: DeserializeFunction) 12 | constructor (options: any, deserializeUser: DeserializeFunction) 13 | constructor (options: any, deserializeUser?: DeserializeFunction) { 14 | super('session') 15 | if (typeof options === 'function') { 16 | this.deserializeUser = options 17 | } else if (typeof deserializeUser === 'function') { 18 | this.deserializeUser = deserializeUser 19 | } else { 20 | throw new Error('SessionStrategy#constructor must have a valid deserializeUser-function passed as a parameter') 21 | } 22 | } 23 | 24 | /** 25 | * Authenticate request based on the current session state. 26 | * 27 | * The session authentication strategy uses the session to restore any login state across requests. If a login session has been established, `request.user` will be populated with the current user. 28 | * 29 | * This strategy is registered automatically by fastify-passport. 30 | * 31 | * @param {Object} request 32 | * @param {Object} options 33 | * @api protected 34 | */ 35 | authenticate (request: FastifyRequest, options?: { pauseStream?: boolean }) { 36 | if (!request.passport) { 37 | return this.error(new Error('passport.initialize() plugin not in use')) 38 | } 39 | options = options || {} 40 | // we need this to prevent basic passport's strategies to use unsupported feature. 41 | if (options.pauseStream) { 42 | return this.error(new Error("fastify-passport doesn't support pauseStream option.")) 43 | } 44 | 45 | const sessionUser = request.passport.sessionManager.getUserFromSession(request) 46 | 47 | if (sessionUser || sessionUser === 0) { 48 | this.deserializeUser(sessionUser, request) 49 | .catch((err: Error) => this.error(err)) 50 | .then(async (user?: any) => { 51 | if (!user) { 52 | await request.passport.sessionManager.logOut(request) 53 | } else { 54 | request[request.passport.userProperty] = user 55 | } 56 | this.pass() 57 | }) 58 | } else { 59 | this.pass() 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/strategies/base.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from 'fastify' 2 | 3 | export class Strategy { 4 | name: string 5 | 6 | constructor (name: string) { 7 | this.name = name 8 | } 9 | 10 | /** 11 | * Authenticate request. 12 | * 13 | * This function must be overridden by subclasses. In abstract form, it always 14 | * throws an exception. 15 | * 16 | * @param {Object} req The request to authenticate. 17 | * @param {Object} [options] Strategy-specific options. 18 | * @api public 19 | */ 20 | authenticate (request: FastifyRequest, options?: any): void | Promise 21 | authenticate () { 22 | throw new Error('Strategy#authenticate must be overridden by subclass') 23 | } 24 | 25 | // 26 | // Augmented strategy functions. 27 | // These are available only from the 'authenticate' function. 28 | // They are added manually by the passport framework. 29 | // 30 | /** 31 | * Authenticate `user`, with optional `info`. 32 | * 33 | * Strategies should call this function to successfully authenticate a 34 | * user. `user` should be an object supplied by the application after it 35 | * has been given an opportunity to verify credentials. `info` is an 36 | * optional argument containing additional user information. This is 37 | * useful for third-party authentication strategies to pass profile 38 | * details. 39 | * 40 | * @param {Object} user 41 | * @param {Object} info 42 | * @api public 43 | */ 44 | success!: (user: any, info?: any) => void 45 | 46 | /** 47 | * Fail authentication, with optional `challenge` and `status`, defaulting 48 | * to 401. 49 | * 50 | * Strategies should call this function to fail an authentication attempt. 51 | * 52 | * @param {String} challenge (Can also be an object with 'message' and 'type' fields). 53 | * @param {Number} status 54 | * @api public 55 | */ 56 | fail!: ((challenge?: any, status?: number) => void) & ((status?: number) => void) 57 | 58 | /** 59 | * Redirect to `url` with optional `status`, defaulting to 302. 60 | * 61 | * Strategies should call this function to redirect the user (via their 62 | * user agent) to a third-party website for authentication. 63 | * 64 | * @param {String} url 65 | * @param {Number} status 66 | * @api public 67 | */ 68 | redirect!: (url: string, status?: number) => void 69 | 70 | /** 71 | * Pass without making a success or fail decision. 72 | * 73 | * Under most circumstances, Strategies should not need to call this 74 | * function. It exists primarily to allow previous authentication state 75 | * to be restored, for example from an HTTP session. 76 | * 77 | * @api public 78 | */ 79 | pass!: () => void 80 | 81 | /** 82 | * Internal error while performing authentication. 83 | * 84 | * Strategies should call this function when an internal error occurs 85 | * during the process of performing authentication; for example, if the 86 | * user directory is not available. 87 | * 88 | * @param {Error} err 89 | * @api public 90 | */ 91 | error!: (err: Error) => void 92 | } 93 | -------------------------------------------------------------------------------- /src/strategies/index.ts: -------------------------------------------------------------------------------- 1 | import type { Strategy as ExpressStrategy } from 'passport' 2 | import type { Strategy } from './base' 3 | export * from './base' 4 | export * from './SessionStrategy' 5 | 6 | export type AnyStrategy = Strategy | ExpressStrategy 7 | -------------------------------------------------------------------------------- /src/type-extensions.ts: -------------------------------------------------------------------------------- 1 | import { flashFactory } from '@fastify/flash/lib/flash' 2 | import { logIn, logOut, isAuthenticated, isUnauthenticated } from './decorators' 3 | import Authenticator from './Authenticator' 4 | 5 | declare module 'fastify' { 6 | /** 7 | * An empty interface representing the type of users that applications using `fastify-passport` might assign to the request 8 | * Suitable for TypeScript users of the library to declaration merge with, like so: 9 | * ``` 10 | * import { User } from "./my/types"; 11 | * 12 | * declare module 'fastify' { 13 | * interface PassportUser { 14 | * [Key in keyof User]: User[Key] 15 | * } 16 | * } 17 | * ``` 18 | */ 19 | interface PassportUser {} 20 | 21 | interface ExpressSessionData { 22 | [key: string]: any 23 | } 24 | 25 | interface FastifyRequest { 26 | flash: ReturnType['request'] 27 | 28 | login: typeof logIn 29 | logIn: typeof logIn 30 | logout: typeof logOut 31 | logOut: typeof logOut 32 | isAuthenticated: typeof isAuthenticated 33 | isUnauthenticated: typeof isUnauthenticated 34 | passport: Authenticator 35 | user?: PassportUser 36 | authInfo?: Record 37 | account?: PassportUser 38 | } 39 | 40 | interface FastifyReply { 41 | flash: ReturnType['reply'] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/authorize.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe } from 'node:test' 2 | import assert from 'node:assert' 3 | import { RouteHandlerMethod } from 'fastify' 4 | import { expectType } from 'tsd' 5 | import { Strategy } from '../src/strategies' 6 | import { generateTestUser, getConfiguredTestServer } from './helpers' 7 | 8 | export class TestThirdPartyStrategy extends Strategy { 9 | authenticate (_request: any, _options?: { pauseStream?: boolean }) { 10 | return this.success(generateTestUser()) 11 | } 12 | } 13 | 14 | const testSuite = (sessionPluginName: string) => { 15 | describe(`${sessionPluginName} tests`, () => { 16 | describe('.authorize', () => { 17 | test('should return 401 Unauthorized if not logged in', async () => { 18 | const { server, fastifyPassport } = getConfiguredTestServer() 19 | fastifyPassport.use(new TestThirdPartyStrategy('third-party')) 20 | expectType(fastifyPassport.authorize('third-party')) 21 | server.get('/', { preValidation: fastifyPassport.authorize('third-party') }, async (request) => { 22 | const user = request.user as any 23 | assert.ifError(user) 24 | const account = request.account as any 25 | assert.ok(account.id) 26 | assert.strictEqual(account.name, 'test') 27 | 28 | return 'it worked' 29 | }) 30 | 31 | const response = await server.inject({ method: 'GET', url: '/' }) 32 | assert.strictEqual(response.statusCode, 200) 33 | }) 34 | }) 35 | }) 36 | } 37 | 38 | testSuite('@fastify/session') 39 | testSuite('@fastify/secure-session') 40 | -------------------------------------------------------------------------------- /test/csrf-fixation.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe, beforeEach } from 'node:test' 2 | import assert from 'node:assert' 3 | import { getConfiguredTestServer, TestBrowserSession } from './helpers' 4 | import fastifyCsrfProtection from '@fastify/csrf-protection' 5 | 6 | function createServer (sessionPluginName: '@fastify/session' | '@fastify/secure-session') { 7 | const { server, fastifyPassport } = getConfiguredTestServer() 8 | 9 | server.register(fastifyCsrfProtection, { sessionPlugin: sessionPluginName }) 10 | 11 | server.post( 12 | '/login', 13 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 14 | async () => 'success' 15 | ) 16 | 17 | server.get('/csrf', async (_req, reply) => { 18 | return reply.generateCsrf() 19 | }) 20 | server.get('/session', async (req) => { 21 | return req.session.get('_csrf') 22 | }) 23 | return server 24 | } 25 | 26 | const testSuite = (sessionPluginName: '@fastify/session' | '@fastify/secure-session') => { 27 | process.env.SESSION_PLUGIN = sessionPluginName 28 | const server = createServer(sessionPluginName) 29 | describe(`${sessionPluginName} tests`, () => { 30 | describe('guard against fixation', () => { 31 | let user: TestBrowserSession 32 | 33 | beforeEach(() => { 34 | user = new TestBrowserSession(server) 35 | }) 36 | 37 | test('should renegerate csrf token on login', async () => { 38 | { 39 | const sess = await user.inject({ method: 'GET', url: '/session' }) 40 | assert.equal(sess.body, '') 41 | } 42 | await user.inject({ method: 'GET', url: '/csrf' }) 43 | { 44 | const sess = await user.inject({ method: 'GET', url: '/session' }) 45 | assert.notEqual(sess.body, '') 46 | } 47 | await user.inject({ 48 | method: 'POST', 49 | url: '/login', 50 | payload: { login: 'test', password: 'test' } 51 | }) 52 | { 53 | const sess = await user.inject({ method: 'GET', url: '/session' }) 54 | assert.equal(sess.body, '') 55 | } 56 | }) 57 | }) 58 | }) 59 | delete process.env.SESSION_PLUGIN 60 | } 61 | 62 | testSuite('@fastify/session') 63 | testSuite('@fastify/secure-session') 64 | -------------------------------------------------------------------------------- /test/decorators.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe } from 'node:test' 2 | import assert from 'node:assert' 3 | import { getConfiguredTestServer, TestStrategy } from './helpers' 4 | 5 | const testSuite = (sessionPluginName: string) => { 6 | describe(`${sessionPluginName} tests`, () => { 7 | const sessionOnlyTest = sessionPluginName === '@fastify/session' ? test : test.skip 8 | const secureSessionOnlyTest = sessionPluginName === '@fastify/secure-session' ? test : test.skip 9 | 10 | describe('Request decorators', () => { 11 | test('logIn allows logging in an arbitrary user', async () => { 12 | const { server, fastifyPassport } = getConfiguredTestServer() 13 | server.get( 14 | '/', 15 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 16 | async (request) => (request.user as any).name 17 | ) 18 | server.post('/force-login', async (request, reply) => { 19 | await request.logIn({ name: 'force logged in user' }) 20 | reply.send('logged in') 21 | }) 22 | 23 | const login = await server.inject({ 24 | method: 'POST', 25 | url: '/force-login' 26 | }) 27 | 28 | assert.strictEqual(login.statusCode, 200) 29 | 30 | const response = await server.inject({ 31 | url: '/', 32 | headers: { 33 | cookie: login.headers['set-cookie'] 34 | }, 35 | method: 'GET' 36 | }) 37 | 38 | assert.strictEqual(login.statusCode, 200) 39 | assert.strictEqual(response.body, 'force logged in user') 40 | }) 41 | 42 | secureSessionOnlyTest( 43 | 'logIn allows logging in an arbitrary user for the duration of the request if session=false', 44 | async () => { 45 | const { server } = getConfiguredTestServer() 46 | server.post('/force-login', async (request, reply) => { 47 | await request.logIn({ name: 'force logged in user' }, { session: false }) 48 | reply.send((request.user as any).name) 49 | }) 50 | 51 | const login = await server.inject({ 52 | method: 'POST', 53 | url: '/force-login' 54 | }) 55 | 56 | assert.strictEqual(login.statusCode, 200) 57 | assert.strictEqual(login.body, 'force logged in user') 58 | assert.strictEqual(login.headers['set-cookie'], undefined) // no user added to session 59 | } 60 | ) 61 | 62 | sessionOnlyTest( 63 | 'logIn allows logging in an arbitrary user for the duration of the request if session=false', 64 | async () => { 65 | const sessionOptions = { 66 | secret: 'a secret with minimum length of 32 characters', 67 | cookie: { secure: false }, 68 | saveUninitialized: false 69 | } 70 | const { server } = getConfiguredTestServer('test', new TestStrategy('test'), sessionOptions) 71 | server.post('/force-login', async (request, reply) => { 72 | await request.logIn({ name: 'force logged in user' }, { session: false }) 73 | reply.send((request.user as any).name) 74 | }) 75 | 76 | const login = await server.inject({ 77 | method: 'POST', 78 | url: '/force-login' 79 | }) 80 | 81 | assert.strictEqual(login.statusCode, 200) 82 | assert.strictEqual(login.body, 'force logged in user') 83 | assert.strictEqual(login.headers['set-cookie'], undefined) // no user added to session 84 | } 85 | ) 86 | 87 | test('should logout', async () => { 88 | const { server, fastifyPassport } = getConfiguredTestServer() 89 | server.get( 90 | '/', 91 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 92 | async () => 'the root!' 93 | ) 94 | server.get( 95 | '/logout', 96 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 97 | async (request, reply) => { 98 | request.logout() 99 | reply.send('logged out') 100 | } 101 | ) 102 | server.post( 103 | '/login', 104 | { preValidation: fastifyPassport.authenticate('test', { successRedirect: '/', authInfo: false }) }, 105 | async () => '' 106 | ) 107 | 108 | const login = await server.inject({ 109 | method: 'POST', 110 | payload: { login: 'test', password: 'test' }, 111 | url: '/login' 112 | }) 113 | assert.strictEqual(login.statusCode, 302) 114 | assert.strictEqual(login.headers.location, '/') 115 | 116 | const logout = await server.inject({ 117 | url: '/logout', 118 | headers: { 119 | cookie: login.headers['set-cookie'] 120 | }, 121 | method: 'GET' 122 | }) 123 | 124 | assert.strictEqual(logout.statusCode, 200) 125 | assert.ok(logout.headers['set-cookie']) 126 | 127 | const retry = await server.inject({ 128 | url: '/', 129 | headers: { 130 | cookie: logout.headers['set-cookie'] 131 | }, 132 | method: 'GET' 133 | }) 134 | 135 | assert.strictEqual(retry.statusCode, 401) 136 | }) 137 | }) 138 | }) 139 | } 140 | 141 | testSuite('@fastify/session') 142 | testSuite('@fastify/secure-session') 143 | -------------------------------------------------------------------------------- /test/esm/default-esm-export.mjs: -------------------------------------------------------------------------------- 1 | import passport from '../../dist/src/index.js' 2 | 3 | passport.initialize() 4 | -------------------------------------------------------------------------------- /test/esm/esm.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe } from 'node:test' 2 | import assert from 'node:assert' 3 | import { spawnSync } from 'node:child_process' 4 | import { join } from 'node:path' 5 | 6 | describe('Native ESM import', () => { 7 | test('should be able to use default export', () => { 8 | const { status } = spawnSync('node', [join(__dirname, '../../../test/esm', 'default-esm-export.mjs')]) 9 | assert.strictEqual(status, 0) 10 | }) 11 | 12 | test('should be able to use named export', () => { 13 | const { status } = spawnSync('node', [join(__dirname, '../../../test/esm', 'named-esm-export.mjs')]) 14 | 15 | assert.strictEqual(status, 0) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/esm/named-esm-export.mjs: -------------------------------------------------------------------------------- 1 | import { Strategy } from '../../dist/src/index.js' 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | class MS extends Strategy {} 5 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import { join } from 'node:path' 3 | import fastify, { FastifyInstance } from 'fastify' 4 | import fastifySecureSession, { SecureSessionPluginOptions } from '@fastify/secure-session' 5 | import fastifyCookie from '@fastify/cookie' 6 | import Authenticator, { AuthenticatorOptions } from '../src/Authenticator' 7 | import { Strategy } from '../src/strategies' 8 | import { InjectOptions, Response as LightMyRequestResponse } from 'light-my-request' 9 | import parseCookies from 'set-cookie-parser' 10 | import { IncomingMessage } from 'node:http' 11 | import { FastifyRegisterOptions } from 'fastify/types/register' 12 | import { fastifySession, FastifySessionOptions } from '@fastify/session' 13 | 14 | const SecretKey = fs.readFileSync(join(__dirname, '../../test', 'secure.key')) 15 | 16 | let counter = 0 17 | export const generateTestUser = () => ({ name: 'test', id: String(counter++) }) 18 | 19 | export class TestStrategy extends Strategy { 20 | authenticate (request: any, _options?: { pauseStream?: boolean }) { 21 | if (request.isAuthenticated()) { 22 | return this.pass() 23 | } 24 | if (request.body && request.body.login === 'test' && request.body.password === 'test') { 25 | return this.success(generateTestUser()) 26 | } 27 | 28 | this.fail() 29 | } 30 | } 31 | 32 | export class TestDatabaseStrategy extends Strategy { 33 | constructor ( 34 | name: string, 35 | readonly database: Record = {} 36 | ) { 37 | super(name) 38 | } 39 | 40 | authenticate (request: any, _options?: { pauseStream?: boolean }) { 41 | if (request.isAuthenticated()) { 42 | return this.pass() 43 | } 44 | if (request.body) { 45 | const user = Object.values(this.database).find( 46 | (user) => user.login === request.body.login && user.password === request.body.password 47 | ) 48 | if (user) { 49 | return this.success(user) 50 | } 51 | } 52 | 53 | this.fail() 54 | } 55 | } 56 | 57 | /** Class representing a browser in tests */ 58 | export class TestBrowserSession { 59 | cookies: Record 60 | 61 | constructor (readonly server: FastifyInstance) { 62 | this.cookies = {} 63 | } 64 | 65 | async inject (opts: InjectOptions): Promise { 66 | opts.headers || (opts.headers = {}) 67 | opts.headers.cookie = Object.entries(this.cookies) 68 | .map(([key, value]) => `${key}=${value}`) 69 | .join('; ') 70 | 71 | const result = await this.server.inject(opts) 72 | if (result.statusCode < 500) { 73 | for (const { name, value } of parseCookies(result as unknown as IncomingMessage, { decodeValues: false })) { 74 | this.cookies[name] = value 75 | } 76 | } 77 | return result 78 | } 79 | } 80 | 81 | type SessionOptions = FastifyRegisterOptions | null 82 | 83 | const loadSessionPlugins = (server: FastifyInstance, sessionOptions: SessionOptions = null) => { 84 | if (process.env.SESSION_PLUGIN === '@fastify/session') { 85 | server.register(fastifyCookie) 86 | const options = >(sessionOptions || { 87 | secret: 'a secret with minimum length of 32 characters', 88 | cookie: { secure: false } 89 | }) 90 | server.register(fastifySession, options) 91 | } else { 92 | server.register( 93 | fastifySecureSession, 94 | | undefined>(sessionOptions || { key: SecretKey }) 95 | ) 96 | } 97 | } 98 | 99 | /** Create a fastify instance with a few simple setup bits added, but without fastify-passport registered or any strategies set up. */ 100 | export const getTestServer = (sessionOptions: SessionOptions = null) => { 101 | const server = fastify() 102 | loadSessionPlugins(server, sessionOptions) 103 | 104 | server.setErrorHandler((error, _request, reply) => { 105 | reply.status(500) 106 | reply.send(error) 107 | }) 108 | return server 109 | } 110 | 111 | /** Create a fastify instance with fastify-passport plugin registered but with no strategies registered yet. */ 112 | export const getRegisteredTestServer = ( 113 | sessionOptions: SessionOptions = null, 114 | passportOptions: AuthenticatorOptions = {} 115 | ) => { 116 | const fastifyPassport = new Authenticator(passportOptions) 117 | fastifyPassport.registerUserSerializer(async (user) => JSON.stringify(user)) 118 | fastifyPassport.registerUserDeserializer(async (serialized: string) => JSON.parse(serialized)) 119 | 120 | const server = getTestServer(sessionOptions) 121 | server.register(fastifyPassport.initialize()) 122 | server.register(fastifyPassport.secureSession()) 123 | 124 | return { fastifyPassport, server } 125 | } 126 | 127 | /** Create a fastify instance with fastify-passport plugin registered and the given strategy registered with it. */ 128 | export const getConfiguredTestServer = ( 129 | name = 'test', 130 | strategy = new TestStrategy('test'), 131 | sessionOptions: SessionOptions = null, 132 | passportOptions: AuthenticatorOptions = {} 133 | ) => { 134 | const { fastifyPassport, server } = getRegisteredTestServer(sessionOptions, passportOptions) 135 | fastifyPassport.use(name, strategy) 136 | return { fastifyPassport, server } 137 | } 138 | -------------------------------------------------------------------------------- /test/independent-strategy-instances.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe } from 'node:test' 2 | import assert from 'node:assert' 3 | import { Strategy } from '../src/strategies' 4 | import { TestThirdPartyStrategy } from './authorize.test' 5 | import { getConfiguredTestServer, getRegisteredTestServer, TestStrategy } from './helpers' 6 | 7 | class WelcomeStrategy extends Strategy { 8 | authenticate (request: any, _options?: { pauseStream?: boolean }) { 9 | if (request.isAuthenticated()) { 10 | return this.pass() 11 | } 12 | if (request.body && request.body.login === 'welcomeuser' && request.body.password === 'test') { 13 | return this.success({ name: 'test' }, { message: 'welcome from strategy' }) 14 | } 15 | this.fail() 16 | } 17 | } 18 | 19 | const testSuite = (sessionPluginName: string) => { 20 | describe(`${sessionPluginName} tests`, () => { 21 | test('should allow passing a specific Strategy instance to an authenticate call', async () => { 22 | const { server, fastifyPassport } = getRegisteredTestServer(null, { clearSessionIgnoreFields: ['messages'] }) 23 | server.get( 24 | '/', 25 | { 26 | preValidation: fastifyPassport.authenticate(new WelcomeStrategy('welcome'), { authInfo: false }) 27 | }, 28 | async (request) => request.session.get('messages') 29 | ) 30 | server.post( 31 | '/login', 32 | { 33 | preValidation: fastifyPassport.authenticate(new WelcomeStrategy('welcome'), { 34 | successRedirect: '/', 35 | successMessage: true, 36 | authInfo: false 37 | }) 38 | }, 39 | () => {} 40 | ) 41 | 42 | const login = await server.inject({ 43 | method: 'POST', 44 | payload: { login: 'welcomeuser', password: 'test' }, 45 | url: '/login' 46 | }) 47 | assert.strictEqual(login.statusCode, 302) 48 | assert.strictEqual(login.headers.location, '/') 49 | 50 | const response = await server.inject({ 51 | url: '/', 52 | headers: { 53 | cookie: login.headers['set-cookie'] 54 | }, 55 | method: 'GET' 56 | }) 57 | 58 | assert.strictEqual(response.body, '["welcome from strategy"]') 59 | assert.strictEqual(response.statusCode, 200) 60 | }) 61 | 62 | test('should allow passing a multiple specific Strategy instances to an authenticate call', async () => { 63 | const { server, fastifyPassport } = getRegisteredTestServer() 64 | server.get( 65 | '/', 66 | { 67 | preValidation: fastifyPassport.authenticate([new WelcomeStrategy('welcome'), new TestStrategy('test')], { 68 | authInfo: false 69 | }) 70 | }, 71 | async (request) => `messages: ${request.session.get('messages')}` 72 | ) 73 | server.post( 74 | '/login', 75 | { 76 | preValidation: fastifyPassport.authenticate([new WelcomeStrategy('welcome'), new TestStrategy('test')], { 77 | successRedirect: '/', 78 | successMessage: true, 79 | authInfo: false 80 | }) 81 | }, 82 | () => {} 83 | ) 84 | 85 | const login = await server.inject({ 86 | method: 'POST', 87 | payload: { login: 'test', password: 'test' }, 88 | url: '/login' 89 | }) 90 | assert.strictEqual(login.statusCode, 302) 91 | assert.strictEqual(login.headers.location, '/') 92 | 93 | const response = await server.inject({ 94 | url: '/', 95 | headers: { 96 | cookie: login.headers['set-cookie'] 97 | }, 98 | method: 'GET' 99 | }) 100 | 101 | assert.strictEqual(response.body, 'messages: undefined') 102 | assert.strictEqual(response.statusCode, 200) 103 | }) 104 | 105 | test('should allow passing a mix of Strategy instances and strategy names', async () => { 106 | const { server, fastifyPassport } = getConfiguredTestServer() 107 | server.get( 108 | '/', 109 | { 110 | preValidation: fastifyPassport.authenticate([new WelcomeStrategy('welcome'), 'test'], { 111 | authInfo: false 112 | }) 113 | }, 114 | async (request) => `messages: ${request.session.get('messages')}` 115 | ) 116 | server.post( 117 | '/login', 118 | { 119 | preValidation: fastifyPassport.authenticate([new WelcomeStrategy('welcome'), 'test'], { 120 | successRedirect: '/', 121 | successMessage: true, 122 | authInfo: false 123 | }) 124 | }, 125 | () => {} 126 | ) 127 | 128 | const login = await server.inject({ 129 | method: 'POST', 130 | payload: { login: 'test', password: 'test' }, 131 | url: '/login' 132 | }) 133 | assert.strictEqual(login.statusCode, 302) 134 | assert.strictEqual(login.headers.location, '/') 135 | 136 | const response = await server.inject({ 137 | url: '/', 138 | headers: { 139 | cookie: login.headers['set-cookie'] 140 | }, 141 | method: 'GET' 142 | }) 143 | 144 | assert.strictEqual(response.body, 'messages: undefined') 145 | assert.strictEqual(response.statusCode, 200) 146 | }) 147 | 148 | test('should allow passing specific instances to an authorize call', async () => { 149 | const { server, fastifyPassport } = getConfiguredTestServer() 150 | 151 | server.get( 152 | '/', 153 | { preValidation: fastifyPassport.authorize(new TestThirdPartyStrategy('third-party')) }, 154 | async (request) => { 155 | const user = request.user as any 156 | assert.ifError(user) 157 | const account = request.account as any 158 | assert.ok(account.id) 159 | assert.strictEqual(account.name, 'test') 160 | 161 | return 'it worked' 162 | } 163 | ) 164 | 165 | const response = await server.inject({ method: 'GET', url: '/' }) 166 | assert.strictEqual(response.statusCode, 200) 167 | }) 168 | 169 | test('Strategy instances used during one authentication shouldn\'t be registered', async () => { 170 | const { fastifyPassport } = getRegisteredTestServer() 171 | // build a handler with the welcome strategy 172 | fastifyPassport.authenticate(new WelcomeStrategy('welcome'), { authInfo: false }) 173 | assert.strictEqual(fastifyPassport.strategy('welcome'), undefined) 174 | }) 175 | }) 176 | } 177 | 178 | testSuite('@fastify/session') 179 | testSuite('@fastify/secure-session') 180 | -------------------------------------------------------------------------------- /test/multi-instance.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe, beforeEach } from 'node:test' 2 | import assert from 'node:assert' 3 | import { FastifyInstance } from 'fastify' 4 | import { Authenticator } from '../src/Authenticator' 5 | import { Strategy } from '../src/strategies' 6 | import { getTestServer, TestBrowserSession } from './helpers' 7 | 8 | let counter: number 9 | let authenticators: Record 10 | 11 | async function TestStrategyModule (instance: FastifyInstance, { namespace, clearSessionOnLogin }) { 12 | class TestStrategy extends Strategy { 13 | authenticate (request: any, _options?: { pauseStream?: boolean }) { 14 | if (request.isAuthenticated()) { 15 | return this.pass() 16 | } 17 | if (request.body && request.body.login === 'test' && request.body.password === 'test') { 18 | return this.success({ namespace, id: String(counter++) }) 19 | } 20 | 21 | this.fail() 22 | } 23 | } 24 | 25 | const strategyName = `test-${namespace}` 26 | const authenticator = new Authenticator({ 27 | key: `passport${namespace}`, 28 | userProperty: `user${namespace}`, 29 | clearSessionOnLogin 30 | }) 31 | authenticator.use(strategyName, new TestStrategy(strategyName)) 32 | authenticator.registerUserSerializer(async (user) => { 33 | if (user.namespace === namespace) { 34 | return namespace + '-' + JSON.stringify(user) 35 | } 36 | throw 'pass' // eslint-disable-line no-throw-literal 37 | }) 38 | authenticator.registerUserDeserializer(async (serialized: string) => { 39 | if (serialized.startsWith(`${namespace}-`)) { 40 | return JSON.parse(serialized.slice(`${namespace}-`.length)) 41 | } 42 | throw 'pass' // eslint-disable-line no-throw-literal 43 | }) 44 | 45 | await instance.register(authenticator.initialize()) 46 | await instance.register(authenticator.secureSession()) 47 | authenticators[namespace] = authenticator 48 | 49 | instance.get( 50 | `/${namespace}`, 51 | { preValidation: authenticator.authenticate(strategyName, { authInfo: false }) }, 52 | async () => `hello ${namespace}!` 53 | ) 54 | 55 | instance.get( 56 | `/user/${namespace}`, 57 | { preValidation: authenticator.authenticate(strategyName, { authInfo: false }) }, 58 | async (request) => JSON.stringify(request[`user${namespace}`]) 59 | ) 60 | 61 | instance.post( 62 | `/login-${namespace}`, 63 | { 64 | preValidation: authenticator.authenticate(strategyName, { 65 | successRedirect: `/${namespace}`, 66 | authInfo: false 67 | }) 68 | }, 69 | () => { 70 | 71 | } 72 | ) 73 | 74 | instance.post( 75 | `/logout-${namespace}`, 76 | { preValidation: authenticator.authenticate(strategyName, { authInfo: false }) }, 77 | async (request, reply) => { 78 | await request.logout() 79 | reply.send('logged out') 80 | } 81 | ) 82 | } 83 | 84 | const testSuite = (sessionPluginName: string) => { 85 | describe(`${sessionPluginName} tests`, () => { 86 | describe('multiple registered instances (clearSessionOnLogin: false)', () => { 87 | let server: FastifyInstance 88 | let session: TestBrowserSession 89 | 90 | beforeEach(async () => { 91 | counter = 0 92 | authenticators = {} 93 | server = getTestServer() 94 | session = new TestBrowserSession(server) 95 | 96 | for (const namespace of ['a', 'b']) { 97 | await server.register(TestStrategyModule, { namespace, clearSessionOnLogin: false }) 98 | } 99 | }) 100 | 101 | test('logging in with one instance should not log in the other instance', async () => { 102 | let response = await session.inject({ method: 'GET', url: '/a' }) 103 | assert.strictEqual(response.body, 'Unauthorized') 104 | assert.strictEqual(response.statusCode, 401) 105 | 106 | response = await session.inject({ method: 'GET', url: '/b' }) 107 | assert.strictEqual(response.body, 'Unauthorized') 108 | assert.strictEqual(response.statusCode, 401) 109 | 110 | // login a 111 | const loginResponse = await session.inject({ 112 | method: 'POST', 113 | url: '/login-a', 114 | payload: { login: 'test', password: 'test' } 115 | }) 116 | 117 | assert.strictEqual(loginResponse.statusCode, 302) 118 | assert.strictEqual(loginResponse.headers.location, '/a') 119 | 120 | // access protected route 121 | response = await session.inject({ 122 | method: 'GET', 123 | url: '/a' 124 | }) 125 | assert.strictEqual(response.statusCode, 200) 126 | assert.strictEqual(response.body, 'hello a!') 127 | 128 | // access user data 129 | response = await session.inject({ 130 | method: 'GET', 131 | url: '/user/a' 132 | }) 133 | assert.strictEqual(response.statusCode, 200) 134 | 135 | // try to access route protected by other instance 136 | response = await session.inject({ 137 | method: 'GET', 138 | url: '/b' 139 | }) 140 | assert.strictEqual(response.statusCode, 401) 141 | }) 142 | 143 | test('simultaneous login should be possible', async () => { 144 | // login a 145 | let response = await session.inject({ 146 | method: 'POST', 147 | url: '/login-a', 148 | payload: { login: 'test', password: 'test' } 149 | }) 150 | 151 | assert.strictEqual(response.statusCode, 302) 152 | assert.strictEqual(response.headers.location, '/a') 153 | 154 | // login b 155 | response = await session.inject({ 156 | method: 'POST', 157 | url: '/login-b', 158 | payload: { login: 'test', password: 'test' } 159 | }) 160 | 161 | assert.strictEqual(response.statusCode, 302) 162 | assert.strictEqual(response.headers.location, '/b') 163 | 164 | // access a protected route 165 | response = await session.inject({ 166 | method: 'GET', 167 | url: '/a' 168 | }) 169 | assert.strictEqual(response.statusCode, 200) 170 | assert.strictEqual(response.body, 'hello a!') 171 | 172 | // access b protected route 173 | response = await session.inject({ 174 | method: 'GET', 175 | url: '/b' 176 | }) 177 | assert.strictEqual(response.statusCode, 200) 178 | assert.strictEqual(response.body, 'hello b!') 179 | }) 180 | 181 | test('logging out with one instance should not log out the other instance', async () => { 182 | // login a 183 | let response = await session.inject({ 184 | method: 'POST', 185 | url: '/login-a', 186 | payload: { login: 'test', password: 'test' } 187 | }) 188 | 189 | assert.strictEqual(response.statusCode, 302) 190 | assert.strictEqual(response.headers.location, '/a') 191 | 192 | // login b 193 | response = await session.inject({ 194 | method: 'POST', 195 | url: '/login-b', 196 | payload: { login: 'test', password: 'test' } 197 | }) 198 | 199 | assert.strictEqual(response.statusCode, 302) 200 | assert.strictEqual(response.headers.location, '/b') 201 | 202 | // logout a 203 | response = await session.inject({ 204 | method: 'POST', 205 | url: '/logout-a' 206 | }) 207 | assert.strictEqual(response.statusCode, 200) 208 | 209 | // try to access route protected by now logged out instance 210 | response = await session.inject({ 211 | method: 'GET', 212 | url: '/a' 213 | }) 214 | assert.strictEqual(response.statusCode, 401) 215 | 216 | // access b protected route which should still be logged in 217 | response = await session.inject({ 218 | method: 'GET', 219 | url: '/b' 220 | }) 221 | assert.strictEqual(response.statusCode, 200) 222 | assert.strictEqual(response.body, 'hello b!') 223 | }) 224 | 225 | test('user objects from different instances should be different', async () => { 226 | // login a 227 | let response = await session.inject({ 228 | method: 'POST', 229 | url: '/login-a', 230 | payload: { login: 'test', password: 'test' } 231 | }) 232 | 233 | assert.strictEqual(response.statusCode, 302) 234 | assert.strictEqual(response.headers.location, '/a') 235 | 236 | // login b 237 | response = await session.inject({ 238 | method: 'POST', 239 | url: '/login-b', 240 | payload: { login: 'test', password: 'test' } 241 | }) 242 | 243 | assert.strictEqual(response.statusCode, 302) 244 | assert.strictEqual(response.headers.location, '/b') 245 | 246 | response = await session.inject({ 247 | method: 'GET', 248 | url: '/user/a' 249 | }) 250 | assert.strictEqual(response.statusCode, 200) 251 | const userA = JSON.parse(response.body) 252 | 253 | response = await session.inject({ 254 | method: 'GET', 255 | url: '/user/b' 256 | }) 257 | assert.strictEqual(response.statusCode, 200) 258 | const userB = JSON.parse(response.body) 259 | 260 | assert.notStrictEqual(userA.id, userB.id) 261 | }) 262 | }) 263 | 264 | describe('multiple registered instances (clearSessionOnLogin: true)', () => { 265 | let server: FastifyInstance 266 | let session: TestBrowserSession 267 | 268 | beforeEach(async () => { 269 | server = getTestServer() 270 | session = new TestBrowserSession(server) 271 | authenticators = {} 272 | counter = 0 273 | 274 | for (const namespace of ['a', 'b']) { 275 | await server.register(TestStrategyModule, { namespace, clearSessionOnLogin: true }) 276 | } 277 | }) 278 | 279 | test('logging in with one instance should not log in the other instance', async () => { 280 | let response = await session.inject({ method: 'GET', url: '/a' }) 281 | assert.strictEqual(response.body, 'Unauthorized') 282 | assert.strictEqual(response.statusCode, 401) 283 | 284 | response = await session.inject({ method: 'GET', url: '/b' }) 285 | assert.strictEqual(response.body, 'Unauthorized') 286 | assert.strictEqual(response.statusCode, 401) 287 | 288 | // login a 289 | const loginResponse = await session.inject({ 290 | method: 'POST', 291 | url: '/login-a', 292 | payload: { login: 'test', password: 'test' } 293 | }) 294 | 295 | assert.strictEqual(loginResponse.statusCode, 302) 296 | assert.strictEqual(loginResponse.headers.location, '/a') 297 | 298 | // access protected route 299 | response = await session.inject({ 300 | method: 'GET', 301 | url: '/a' 302 | }) 303 | assert.strictEqual(response.statusCode, 200) 304 | assert.strictEqual(response.body, 'hello a!') 305 | 306 | // access user data 307 | response = await session.inject({ 308 | method: 'GET', 309 | url: '/user/a' 310 | }) 311 | assert.strictEqual(response.statusCode, 200) 312 | 313 | // try to access route protected by other instance 314 | response = await session.inject({ 315 | method: 'GET', 316 | url: '/b' 317 | }) 318 | assert.strictEqual(response.statusCode, 401) 319 | }) 320 | 321 | test('simultaneous login should NOT be possible', async () => { 322 | // login a 323 | let response = await session.inject({ 324 | method: 'POST', 325 | url: '/login-a', 326 | payload: { login: 'test', password: 'test' } 327 | }) 328 | 329 | assert.strictEqual(response.statusCode, 302) 330 | assert.strictEqual(response.headers.location, '/a') 331 | 332 | // login b 333 | response = await session.inject({ 334 | method: 'POST', 335 | url: '/login-b', 336 | payload: { login: 'test', password: 'test' } 337 | }) 338 | 339 | assert.strictEqual(response.statusCode, 302) 340 | assert.strictEqual(response.headers.location, '/b') 341 | 342 | // access a protected route (/a) was invalidated after login /b 343 | response = await session.inject({ 344 | method: 'GET', 345 | url: '/a' 346 | }) 347 | assert.strictEqual(response.statusCode, 401) 348 | assert.strictEqual(response.body, 'Unauthorized') 349 | 350 | // access b protected route 351 | response = await session.inject({ 352 | method: 'GET', 353 | url: '/b' 354 | }) 355 | assert.strictEqual(response.statusCode, 200) 356 | assert.strictEqual(response.body, 'hello b!') 357 | }) 358 | 359 | test('logging out with one instance should log out the other instance', async () => { 360 | // login a 361 | let response = await session.inject({ 362 | method: 'POST', 363 | url: '/login-a', 364 | payload: { login: 'test', password: 'test' } 365 | }) 366 | 367 | assert.strictEqual(response.statusCode, 302) 368 | assert.strictEqual(response.headers.location, '/a') 369 | 370 | // login b 371 | response = await session.inject({ 372 | method: 'POST', 373 | url: '/login-b', 374 | payload: { login: 'test', password: 'test' } 375 | }) 376 | 377 | assert.strictEqual(response.statusCode, 302) 378 | assert.strictEqual(response.headers.location, '/b') 379 | 380 | // logout a 381 | response = await session.inject({ 382 | method: 'POST', 383 | url: '/logout-a' 384 | }) 385 | assert.strictEqual(response.statusCode, 401) 386 | 387 | // try to access route protected by now logged out instance 388 | response = await session.inject({ 389 | method: 'GET', 390 | url: '/a' 391 | }) 392 | assert.strictEqual(response.statusCode, 401) 393 | 394 | // access b protected route which should still be logged in 395 | response = await session.inject({ 396 | method: 'GET', 397 | url: '/b' 398 | }) 399 | assert.strictEqual(response.statusCode, 200) 400 | assert.strictEqual(response.body, 'hello b!') 401 | }) 402 | 403 | test('user objects from different instances should be different', async () => { 404 | // login a 405 | let response = await session.inject({ 406 | method: 'POST', 407 | url: '/login-a', 408 | payload: { login: 'test', password: 'test' } 409 | }) 410 | assert.strictEqual(response.statusCode, 302) 411 | assert.strictEqual(response.headers.location, '/a') 412 | 413 | response = await session.inject({ 414 | method: 'GET', 415 | url: '/user/a' 416 | }) 417 | assert.strictEqual(response.statusCode, 200) 418 | const userA = JSON.parse(response.body) 419 | 420 | // login b 421 | response = await session.inject({ 422 | method: 'POST', 423 | url: '/login-b', 424 | payload: { login: 'test', password: 'test' } 425 | }) 426 | 427 | assert.strictEqual(response.statusCode, 302) 428 | assert.strictEqual(response.headers.location, '/b') 429 | 430 | response = await session.inject({ 431 | method: 'GET', 432 | url: '/user/b' 433 | }) 434 | assert.strictEqual(response.statusCode, 200) 435 | const userB = JSON.parse(response.body) 436 | 437 | assert.notStrictEqual(userA.id, userB.id) 438 | }) 439 | }) 440 | }) 441 | } 442 | 443 | testSuite('@fastify/session') 444 | testSuite('@fastify/secure-session') 445 | -------------------------------------------------------------------------------- /test/passport.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe } from 'node:test' 2 | import assert from 'node:assert' 3 | import got from 'got' 4 | import { AddressInfo } from 'node:net' 5 | import { AuthenticateOptions } from '../src/AuthenticationRoute' 6 | import Authenticator from '../src/Authenticator' 7 | import { Strategy } from '../src/strategies' 8 | import { getConfiguredTestServer, getRegisteredTestServer, getTestServer, TestStrategy } from './helpers' 9 | 10 | const testSuite = (sessionPluginName: string) => { 11 | describe(`${sessionPluginName} tests`, () => { 12 | test('should return 401 Unauthorized if not logged in', async () => { 13 | const { server, fastifyPassport } = getConfiguredTestServer() 14 | 15 | server.get( 16 | '/', 17 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 18 | async () => 'hello world!' 19 | ) 20 | server.post('/login', { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, () => {}) 21 | 22 | const response = await server.inject({ method: 'GET', url: '/' }) 23 | assert.strictEqual(response.body, 'Unauthorized') 24 | assert.strictEqual(response.statusCode, 401) 25 | }) 26 | 27 | test('should allow login, and add successMessage to session upon logged in', async () => { 28 | const { server, fastifyPassport } = getConfiguredTestServer('test', new TestStrategy('test'), null, { 29 | clearSessionIgnoreFields: ['messages'] 30 | }) 31 | 32 | server.get( 33 | '/', 34 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 35 | async (request, reply) => { 36 | reply.send(request.session.get('messages')) 37 | } 38 | ) 39 | server.post( 40 | '/login', 41 | { 42 | preValidation: fastifyPassport.authenticate('test', { 43 | successRedirect: '/', 44 | successMessage: 'welcome', 45 | authInfo: false 46 | }) 47 | }, 48 | () => {} 49 | ) 50 | 51 | const loginResponse = await server.inject({ 52 | method: 'POST', 53 | url: '/login', 54 | payload: { login: 'test', password: 'test' } 55 | }) 56 | 57 | assert.strictEqual(loginResponse.statusCode, 302) 58 | assert.strictEqual(loginResponse.headers.location, '/') 59 | 60 | const homeResponse = await server.inject({ 61 | url: '/', 62 | headers: { 63 | cookie: loginResponse.headers['set-cookie'] 64 | }, 65 | method: 'GET' 66 | }) 67 | 68 | assert.strictEqual(homeResponse.body, '["welcome"]') 69 | assert.strictEqual(homeResponse.statusCode, 200) 70 | }) 71 | 72 | test('should allow login, and add successMessage to the session from a strategy that sets it', async () => { 73 | class WelcomeStrategy extends Strategy { 74 | authenticate (request: any, _options?: { pauseStream?: boolean }) { 75 | if (request.isAuthenticated()) { 76 | return this.pass() 77 | } 78 | if (request.body && request.body.login === 'welcomeuser' && request.body.password === 'test') { 79 | return this.success({ name: 'test' }, { message: 'welcome from strategy' }) 80 | } 81 | this.fail() 82 | } 83 | } 84 | 85 | const { server, fastifyPassport } = getConfiguredTestServer('test', new WelcomeStrategy('test'), null, { 86 | clearSessionIgnoreFields: ['messages'] 87 | }) 88 | server.get( 89 | '/', 90 | { 91 | preValidation: fastifyPassport.authenticate('test', { authInfo: false }) 92 | }, 93 | async (request) => request.session.get('messages') 94 | ) 95 | server.post( 96 | '/login', 97 | { 98 | preValidation: fastifyPassport.authenticate('test', { 99 | successRedirect: '/', 100 | successMessage: true, 101 | authInfo: false 102 | }) 103 | }, 104 | () => {} 105 | ) 106 | 107 | const login = await server.inject({ 108 | method: 'POST', 109 | payload: { login: 'welcomeuser', password: 'test' }, 110 | url: '/login' 111 | }) 112 | assert.strictEqual(login.statusCode, 302) 113 | assert.strictEqual(login.headers.location, '/') 114 | 115 | const response = await server.inject({ 116 | url: '/', 117 | headers: { 118 | cookie: login.headers['set-cookie'] 119 | }, 120 | method: 'GET' 121 | }) 122 | 123 | assert.strictEqual(response.body, '["welcome from strategy"]') 124 | assert.strictEqual(response.statusCode, 200) 125 | }) 126 | 127 | test('should throw error if pauseStream is being used', async () => { 128 | const fastifyPassport = new Authenticator({ clearSessionIgnoreFields: ['messages'] }) 129 | fastifyPassport.use('test', new TestStrategy('test')) 130 | fastifyPassport.registerUserSerializer(async (user) => JSON.stringify(user)) 131 | fastifyPassport.registerUserDeserializer(async (serialized: string) => JSON.parse(serialized)) 132 | 133 | const server = getTestServer() 134 | server.register(fastifyPassport.initialize()) 135 | server.register( 136 | fastifyPassport.secureSession({ 137 | pauseStream: true 138 | } as AuthenticateOptions) 139 | ) 140 | server.get('/', { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, async (request) => 141 | request.session.get('messages') 142 | ) 143 | server.post( 144 | '/login', 145 | { 146 | preValidation: fastifyPassport.authenticate('test', { 147 | successRedirect: '/', 148 | successMessage: 'welcome', 149 | authInfo: false 150 | }) 151 | }, 152 | () => {} 153 | ) 154 | 155 | let response = await server.inject({ 156 | method: 'POST', 157 | payload: { login: 'test', password: 'test' }, 158 | url: '/login' 159 | }) 160 | assert.strictEqual(response.statusCode, 500) 161 | 162 | response = await server.inject({ 163 | url: '/', 164 | method: 'GET' 165 | }) 166 | 167 | assert.strictEqual(response.statusCode, 500) 168 | }) 169 | 170 | test('should execute successFlash if logged in', async () => { 171 | const { server, fastifyPassport } = getConfiguredTestServer('test', new TestStrategy('test'), null, { 172 | clearSessionIgnoreFields: ['flash'] 173 | }) 174 | server.get( 175 | '/', 176 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 177 | async (_request, reply) => reply.flash('success') 178 | ) 179 | server.post( 180 | '/login', 181 | { 182 | preValidation: fastifyPassport.authenticate('test', { 183 | successRedirect: '/', 184 | successFlash: 'welcome', 185 | authInfo: false 186 | }) 187 | }, 188 | () => {} 189 | ) 190 | 191 | const login = await server.inject({ 192 | method: 'POST', 193 | payload: { login: 'test', password: 'test' }, 194 | url: '/login' 195 | }) 196 | assert.strictEqual(login.statusCode, 302) 197 | assert.strictEqual(login.headers.location, '/') 198 | 199 | const response = await server.inject({ 200 | url: '/', 201 | headers: { 202 | cookie: login.headers['set-cookie'] 203 | }, 204 | method: 'GET' 205 | }) 206 | 207 | assert.strictEqual(response.body, '["welcome"]') 208 | assert.strictEqual(response.statusCode, 200) 209 | }) 210 | 211 | test('should execute successFlash=true if logged in', async () => { 212 | const { server, fastifyPassport } = getConfiguredTestServer() 213 | server.get( 214 | '/', 215 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 216 | async (_request, reply) => reply.flash('success') 217 | ) 218 | server.post( 219 | '/login', 220 | { 221 | preValidation: fastifyPassport.authenticate('test', { 222 | successRedirect: '/', 223 | successFlash: true, 224 | authInfo: false 225 | }) 226 | }, 227 | () => {} 228 | ) 229 | 230 | const login = await server.inject({ 231 | method: 'POST', 232 | payload: { login: 'test', password: 'test' }, 233 | url: '/login' 234 | }) 235 | assert.strictEqual(login.statusCode, 302) 236 | assert.strictEqual(login.headers.location, '/') 237 | 238 | const response = await server.inject({ 239 | url: '/', 240 | headers: { 241 | cookie: login.headers['set-cookie'] 242 | }, 243 | method: 'GET' 244 | }) 245 | 246 | assert.strictEqual(response.body, '[]') 247 | assert.strictEqual(response.statusCode, 200) 248 | }) 249 | 250 | test('should return 200 if logged in and redirect to the successRedirect from options', async () => { 251 | const { server, fastifyPassport } = getConfiguredTestServer() 252 | server.get( 253 | '/', 254 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 255 | async () => 'hello world!' 256 | ) 257 | server.post( 258 | '/login', 259 | { preValidation: fastifyPassport.authenticate('test', { successRedirect: '/', authInfo: false }) }, 260 | () => {} 261 | ) 262 | 263 | const login = await server.inject({ 264 | method: 'POST', 265 | payload: { login: 'test', password: 'test' }, 266 | url: '/login' 267 | }) 268 | assert.strictEqual(login.statusCode, 302) 269 | assert.strictEqual(login.headers.location, '/') 270 | 271 | const response = await server.inject({ 272 | url: String(login.headers.location), 273 | headers: { 274 | cookie: login.headers['set-cookie'] 275 | }, 276 | method: 'GET' 277 | }) 278 | 279 | assert.strictEqual(response.body, 'hello world!') 280 | assert.strictEqual(response.statusCode, 200) 281 | }) 282 | 283 | test('should return use assignProperty option', async () => { 284 | const { server, fastifyPassport } = getConfiguredTestServer() 285 | server.post( 286 | '/login', 287 | { 288 | preValidation: fastifyPassport.authenticate('test', { 289 | successRedirect: '/', 290 | assignProperty: 'user', 291 | authInfo: false 292 | }) 293 | }, 294 | (request: any, reply: any) => { 295 | reply.send(request.user) 296 | } 297 | ) 298 | 299 | const login = await server.inject({ 300 | method: 'POST', 301 | payload: { login: 'test', password: 'test' }, 302 | url: '/login' 303 | }) 304 | assert.strictEqual(JSON.parse(login.body).name, 'test') 305 | }) 306 | 307 | test('should redirect to the returnTo set in the session upon login', async () => { 308 | const { server, fastifyPassport } = getConfiguredTestServer('test', new TestStrategy('test'), null, { 309 | clearSessionIgnoreFields: ['returnTo'] 310 | }) 311 | server.addHook('preValidation', async (request, _reply) => { 312 | request.session.set('returnTo', '/success') 313 | }) 314 | server.get( 315 | '/success', 316 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 317 | async () => 'hello world!' 318 | ) 319 | server.post( 320 | '/login', 321 | { preValidation: fastifyPassport.authenticate('test', { successReturnToOrRedirect: '/', authInfo: false }) }, 322 | () => {} 323 | ) 324 | 325 | const login = await server.inject({ 326 | method: 'POST', 327 | payload: { login: 'test', password: 'test' }, 328 | url: '/login' 329 | }) 330 | assert.strictEqual(login.statusCode, 302) 331 | assert.strictEqual(login.headers.location, '/success') 332 | 333 | const response = await server.inject({ 334 | url: String(login.headers.location), 335 | headers: { 336 | cookie: login.headers['set-cookie'] 337 | }, 338 | method: 'GET' 339 | }) 340 | 341 | assert.strictEqual(response.statusCode, 200) 342 | assert.strictEqual(response.body, 'hello world!') 343 | }) 344 | 345 | test('should return 200 if logged in and authInfo is true', async () => { 346 | const { server, fastifyPassport } = getConfiguredTestServer() 347 | server.get( 348 | '/', 349 | { preValidation: fastifyPassport.authenticate('test', { authInfo: true }) }, 350 | async () => 'hello world!' 351 | ) 352 | server.post( 353 | '/login', 354 | { preValidation: fastifyPassport.authenticate('test', { successRedirect: '/', authInfo: true }) }, 355 | () => {} 356 | ) 357 | 358 | const login = await server.inject({ 359 | method: 'POST', 360 | payload: { login: 'test', password: 'test' }, 361 | url: '/login' 362 | }) 363 | assert.strictEqual(login.statusCode, 302) 364 | assert.strictEqual(login.headers.location, '/') 365 | 366 | const response = await server.inject({ 367 | url: '/', 368 | headers: { 369 | cookie: login.headers['set-cookie'] 370 | }, 371 | method: 'GET' 372 | }) 373 | 374 | assert.strictEqual(response.body, 'hello world!') 375 | assert.strictEqual(response.statusCode, 200) 376 | }) 377 | 378 | test('should return 200 if logged in against a running server', async () => { 379 | const { server, fastifyPassport } = getConfiguredTestServer() 380 | server.get( 381 | '/', 382 | { preValidation: fastifyPassport.authenticate('test', { authInfo: true }) }, 383 | async () => 'hello world!' 384 | ) 385 | server.post( 386 | '/login', 387 | { preValidation: fastifyPassport.authenticate('test', { successRedirect: '/', authInfo: true }) }, 388 | () => {} 389 | ) 390 | 391 | await server.listen() 392 | server.server.unref() 393 | 394 | const port = (server.server.address() as AddressInfo).port 395 | const login = await got('http://localhost:' + port + '/login', { 396 | method: 'POST', 397 | json: { login: 'test', password: 'test' }, 398 | followRedirect: false 399 | }) 400 | assert.strictEqual(login.statusCode, 302) 401 | assert.strictEqual(login.headers.location, '/') 402 | const cookies = login.headers['set-cookie']! 403 | assert.strictEqual(cookies.length, 1) 404 | 405 | const home = await got({ 406 | url: 'http://localhost:' + port, 407 | headers: { 408 | cookie: cookies[0] 409 | }, 410 | method: 'GET' 411 | }) 412 | 413 | assert.strictEqual(home.statusCode, 200) 414 | }) 415 | 416 | test('should execute failureRedirect if failed to log in', async () => { 417 | const { server, fastifyPassport } = getConfiguredTestServer() 418 | server.post( 419 | '/login', 420 | { preValidation: fastifyPassport.authenticate('test', { failureRedirect: '/failure', authInfo: false }) }, 421 | () => {} 422 | ) 423 | 424 | const login = await server.inject({ 425 | method: 'POST', 426 | payload: { login: 'test1', password: 'test' }, 427 | url: '/login' 428 | }) 429 | assert.strictEqual(login.statusCode, 302) 430 | assert.strictEqual(login.headers.location, '/failure') 431 | }) 432 | 433 | test('should add failureMessage to session if failed to log in', async () => { 434 | const { server, fastifyPassport } = getConfiguredTestServer() 435 | server.get('/', async (request, reply) => reply.send(request.session.get('messages'))) 436 | server.post( 437 | '/login', 438 | { 439 | preValidation: fastifyPassport.authenticate('test', { 440 | failureMessage: 'try again', 441 | authInfo: false 442 | }) 443 | }, 444 | async () => 'login page' 445 | ) 446 | 447 | const login = await server.inject({ 448 | method: 'POST', 449 | payload: { login: 'not-correct', password: 'test' }, 450 | url: '/login' 451 | }) 452 | assert.strictEqual(login.statusCode, 401) 453 | 454 | const headers = {} 455 | if (login.headers['set-cookie']) { 456 | headers['cookie'] = login.headers['set-cookie'] 457 | } 458 | const home = await server.inject({ 459 | url: '/', 460 | headers, 461 | method: 'GET' 462 | }) 463 | 464 | assert.strictEqual(home.body, '["try again"]') 465 | assert.strictEqual(home.statusCode, 200) 466 | }) 467 | 468 | test('should add failureFlash to session if failed to log in', async () => { 469 | const { server, fastifyPassport } = getConfiguredTestServer() 470 | 471 | server.get('/', async (_request, reply) => reply.flash('error')) 472 | server.post( 473 | '/login', 474 | { 475 | preValidation: fastifyPassport.authenticate('test', { 476 | failureFlash: 'try again', 477 | authInfo: false 478 | }) 479 | }, 480 | () => {} 481 | ) 482 | 483 | const login = await server.inject({ 484 | method: 'POST', 485 | payload: { login: 'not-correct', password: 'test' }, 486 | url: '/login' 487 | }) 488 | assert.strictEqual(login.statusCode, 401) 489 | 490 | const response = await server.inject({ 491 | url: '/', 492 | headers: { 493 | cookie: login.headers['set-cookie'] 494 | }, 495 | method: 'GET' 496 | }) 497 | 498 | assert.strictEqual(response.body, '["try again"]') 499 | assert.strictEqual(response.statusCode, 200) 500 | }) 501 | 502 | test('should add failureFlash=true to session if failed to log in', async () => { 503 | const { server, fastifyPassport } = getConfiguredTestServer() 504 | server.get('/', async (_request, reply) => reply.flash('error')) 505 | server.post( 506 | '/login', 507 | { 508 | preValidation: fastifyPassport.authenticate('test', { 509 | failureFlash: true, 510 | authInfo: false 511 | }) 512 | }, 513 | () => {} 514 | ) 515 | 516 | const login = await server.inject({ 517 | method: 'POST', 518 | payload: { login: 'not-correct', password: 'test' }, 519 | url: '/login' 520 | }) 521 | assert.strictEqual(login.statusCode, 401) 522 | 523 | const response = await server.inject({ 524 | url: '/', 525 | method: 'GET' 526 | }) 527 | 528 | assert.strictEqual(response.statusCode, 200) 529 | assert.strictEqual(response.body, '[]') 530 | }) 531 | 532 | test('should return 401 Unauthorized if not logged in when used as a handler', async () => { 533 | const { server, fastifyPassport } = getConfiguredTestServer() 534 | 535 | server.get( 536 | '/', 537 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 538 | async () => 'hello world!' 539 | ) 540 | server.post('/login', fastifyPassport.authenticate('test', { authInfo: false, successRedirect: '/' })) 541 | 542 | const response = await server.inject({ method: 'GET', url: '/' }) 543 | assert.strictEqual(response.body, 'Unauthorized') 544 | assert.strictEqual(response.statusCode, 401) 545 | }) 546 | 547 | test('should redirect when used as a handler', async () => { 548 | const { server, fastifyPassport } = getConfiguredTestServer() 549 | server.get( 550 | '/', 551 | { preValidation: fastifyPassport.authenticate('test', { authInfo: true }) }, 552 | async () => 'hello world!' 553 | ) 554 | server.post('/login', fastifyPassport.authenticate('test', { successRedirect: '/', authInfo: true })) 555 | 556 | const login = await server.inject({ 557 | method: 'POST', 558 | payload: { login: 'test', password: 'test' }, 559 | url: '/login' 560 | }) 561 | assert.strictEqual(login.statusCode, 302) 562 | assert.strictEqual(login.headers.location, '/') 563 | 564 | const response = await server.inject({ 565 | url: '/', 566 | headers: { 567 | cookie: login.headers['set-cookie'] 568 | }, 569 | method: 'GET' 570 | }) 571 | 572 | assert.strictEqual(response.body, 'hello world!') 573 | assert.strictEqual(response.statusCode, 200) 574 | }) 575 | 576 | test('should not log the user in when passed a callback', async () => { 577 | const { server, fastifyPassport } = getConfiguredTestServer() 578 | server.get( 579 | '/', 580 | { preValidation: fastifyPassport.authenticate('test', { authInfo: true }) }, 581 | async () => 'hello world!' 582 | ) 583 | server.post( 584 | '/login', 585 | fastifyPassport.authenticate('test', async (_request, _reply, _err, user) => { 586 | return (user as any).name 587 | }) 588 | ) 589 | 590 | const login = await server.inject({ 591 | method: 'POST', 592 | payload: { login: 'test', password: 'test' }, 593 | url: '/login' 594 | }) 595 | assert.strictEqual(login.statusCode, 200) 596 | assert.strictEqual(login.body, 'test') 597 | 598 | const headers: Record = {} 599 | if (login.headers['set-cookie']) { 600 | headers['cookie'] = login.headers['set-cookie'] 601 | } 602 | 603 | const response = await server.inject({ 604 | url: '/', 605 | headers, 606 | method: 'GET' 607 | }) 608 | 609 | assert.strictEqual(response.statusCode, 401) 610 | }) 611 | 612 | test('should allow registering strategies after creating routes referring to those strategies by name', async () => { 613 | const { server, fastifyPassport } = getRegisteredTestServer(null, { clearSessionIgnoreFields: ['messages'] }) 614 | 615 | server.get( 616 | '/', 617 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 618 | async (request, reply) => { 619 | reply.send(request.session.get('messages')) 620 | } 621 | ) 622 | 623 | server.post( 624 | '/login', 625 | { 626 | preValidation: fastifyPassport.authenticate('test', { 627 | successRedirect: '/', 628 | successMessage: 'welcome', 629 | authInfo: false 630 | }) 631 | }, 632 | () => {} 633 | ) 634 | 635 | // register the test strategy late (after the above .authenticate calls) 636 | fastifyPassport.use(new TestStrategy('test')) 637 | 638 | const loginResponse = await server.inject({ 639 | method: 'POST', 640 | url: '/login', 641 | payload: { login: 'test', password: 'test' } 642 | }) 643 | 644 | assert.strictEqual(loginResponse.statusCode, 302) 645 | assert.strictEqual(loginResponse.headers.location, '/') 646 | 647 | const homeResponse = await server.inject({ 648 | url: '/', 649 | headers: { 650 | cookie: loginResponse.headers['set-cookie'] 651 | }, 652 | method: 'GET' 653 | }) 654 | 655 | assert.strictEqual(homeResponse.body, '["welcome"]') 656 | assert.strictEqual(homeResponse.statusCode, 200) 657 | }) 658 | }) 659 | } 660 | 661 | testSuite('@fastify/session') 662 | testSuite('@fastify/secure-session') 663 | -------------------------------------------------------------------------------- /test/secure-session-manager.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe, mock } from 'node:test' 2 | import assert from 'node:assert' 3 | import { FastifyRequest } from 'fastify' 4 | import { SerializeFunction } from '../src/Authenticator' 5 | import { SecureSessionManager } from '../src/session-managers/SecureSessionManager' 6 | 7 | describe('SecureSessionManager', () => { 8 | test('should throw an Error if no parameter was passed', () => { 9 | assert.throws( 10 | // @ts-expect-error - strictEqual-error expecting atleast a parameter 11 | () => new SecureSessionManager(), 12 | (err) => { 13 | assert(err instanceof Error) 14 | assert.strictEqual( 15 | err.message, 16 | 'SecureSessionManager#constructor must have a valid serializeUser-function passed as a parameter' 17 | ) 18 | return true 19 | } 20 | ) 21 | }) 22 | 23 | test('should throw an Error if no serializeUser-function was passed as second parameter', () => { 24 | assert.throws( 25 | // @ts-expect-error - strictEqual-error expecting a function as second parameter 26 | () => new SecureSessionManager({}), 27 | (err) => { 28 | assert(err instanceof Error) 29 | assert.strictEqual( 30 | err.message, 31 | 'SecureSessionManager#constructor must have a valid serializeUser-function passed as a parameter' 32 | ) 33 | return true 34 | } 35 | ) 36 | }) 37 | 38 | test('should throw an Error if no serializeUser-function was passed as second parameter', () => { 39 | assert.throws( 40 | // @ts-expect-error - strictEqual-error expecting a function as second parameter 41 | () => new SecureSessionManager({}), 42 | (err) => { 43 | assert(err instanceof Error) 44 | assert.strictEqual( 45 | err.message, 46 | 47 | 'SecureSessionManager#constructor must have a valid serializeUser-function passed as a parameter' 48 | ) 49 | return true 50 | } 51 | ) 52 | }) 53 | 54 | test('should not throw an Error if no serializeUser-function was passed as first parameter', () => { 55 | const sessionManager = new SecureSessionManager(((id) => id) as unknown as SerializeFunction) 56 | assert.strictEqual(sessionManager.key, 'passport') 57 | }) 58 | 59 | test('should not throw an Error if no serializeUser-function was passed as second parameter', () => { 60 | const sessionManager = new SecureSessionManager({}, ((id) => id) as unknown as SerializeFunction) 61 | assert.strictEqual(sessionManager.key, 'passport') 62 | }) 63 | 64 | test('should set the key accordingly', () => { 65 | const sessionManager = new SecureSessionManager({ key: 'test' }, ((id) => id) as unknown as SerializeFunction) 66 | assert.strictEqual(sessionManager.key, 'test') 67 | }) 68 | 69 | test('should ignore non-string keys', () => { 70 | // @ts-expect-error - strictEqual-error key has to be of type string 71 | const sessionManager = new SecureSessionManager({ key: 1 }, ((id) => id) as unknown as SerializeFunction) 72 | assert.strictEqual(sessionManager.key, 'passport') 73 | }) 74 | 75 | test('should only call request.session.regenerate once if a function', async () => { 76 | const sessionManger = new SecureSessionManager({}, ((id) => id) as unknown as SerializeFunction) 77 | const user = { id: 'test' } 78 | const request = { 79 | session: { regenerate: mock.fn(() => {}), set: () => {}, data: () => {} } 80 | } as unknown as FastifyRequest 81 | await sessionManger.logIn(request, user) 82 | // @ts-expect-error - regenerate is a mock function 83 | assert.strictEqual(request.session.regenerate.mock.callCount(), 1) 84 | }) 85 | 86 | test('should call request.session.regenerate function if clearSessionOnLogin is false', async () => { 87 | const sessionManger = new SecureSessionManager( 88 | { clearSessionOnLogin: false }, 89 | ((id) => id) as unknown as SerializeFunction 90 | ) 91 | const user = { id: 'test' } 92 | const request = { 93 | session: { regenerate: mock.fn(() => {}), set: () => {}, data: () => {} } 94 | } as unknown as FastifyRequest 95 | await sessionManger.logIn(request, user) 96 | // @ts-expect-error - regenerate is a mock function 97 | assert.strictEqual(request.session.regenerate.mock.callCount(), 1) 98 | mock.reset() 99 | }) 100 | 101 | test('should call request.session.regenerate function with all properties from session if keepSessionInfo is true', async () => { 102 | const sessionManger = new SecureSessionManager( 103 | { clearSessionOnLogin: true }, 104 | ((id) => id) as unknown as SerializeFunction 105 | ) 106 | const user = { id: 'test' } 107 | const request = { 108 | session: { regenerate: mock.fn(() => {}), set: () => {}, data: () => {}, sessionValue: 'exist' } 109 | } as unknown as FastifyRequest 110 | await sessionManger.logIn(request, user, { keepSessionInfo: true }) 111 | // @ts-expect-error - regenerate is a mock function 112 | assert.strictEqual(request.session.regenerate.mock.callCount(), 1) 113 | // @ts-expect-error - regenerate is a mock function 114 | assert.deepStrictEqual(request.session.regenerate.mock.calls[0].arguments, [ 115 | ['session', 'regenerate', 'set', 'data', 'sessionValue'] 116 | ]) 117 | mock.reset() 118 | }) 119 | 120 | test('should call request.session.regenerate function with default properties from session if keepSessionInfo is false', async () => { 121 | const sessionManger = new SecureSessionManager( 122 | { clearSessionOnLogin: true }, 123 | ((id) => id) as unknown as SerializeFunction 124 | ) 125 | const user = { id: 'test' } 126 | const request = { 127 | session: { regenerate: mock.fn(() => {}), set: () => {}, data: () => {}, sessionValue: 'exist' } 128 | } as unknown as FastifyRequest 129 | await sessionManger.logIn(request, user, { keepSessionInfo: false }) 130 | // @ts-expect-error - regenerate is a mock function 131 | assert.strictEqual(request.session.regenerate.mock.callCount(), 1) 132 | // @ts-expect-error - regenerate is a mock function 133 | assert.deepStrictEqual(request.session.regenerate.mock.calls[0].arguments, [['session']]) 134 | }) 135 | 136 | test('should call session.set function if no regenerate function provided and keepSessionInfo is true', async () => { 137 | const sessionManger = new SecureSessionManager( 138 | { clearSessionOnLogin: true }, 139 | ((id) => id) as unknown as SerializeFunction 140 | ) 141 | const user = { id: 'test' } 142 | const set = mock.fn() 143 | const request = { 144 | session: { set, data: () => {}, sessionValue: 'exist' } 145 | } as unknown as FastifyRequest 146 | await sessionManger.logIn(request, user, { keepSessionInfo: false }) 147 | assert.strictEqual(set.mock.callCount(), 1) 148 | assert.deepStrictEqual(set.mock.calls[0].arguments, ['passport', { id: 'test' }]) 149 | }) 150 | }) 151 | -------------------------------------------------------------------------------- /test/secure.key: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastify/fastify-passport/25fb23563793c8fd312f8328d8c9e742c69dfa12/test/secure.key -------------------------------------------------------------------------------- /test/session-isolation.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe, beforeEach } from 'node:test' 2 | import assert from 'node:assert' 3 | import { generateTestUser, getConfiguredTestServer, TestBrowserSession } from './helpers' 4 | 5 | function createServer () { 6 | const { server, fastifyPassport } = getConfiguredTestServer() 7 | 8 | server.get( 9 | '/protected', 10 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 11 | async () => 'hello!' 12 | ) 13 | server.get('/my-id', { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, async (request) => 14 | String((request.user as any).id) 15 | ) 16 | server.post( 17 | '/login', 18 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 19 | async () => 'success' 20 | ) 21 | 22 | server.post('/force-login', async (request, reply) => { 23 | await request.logIn(generateTestUser()) 24 | reply.send('logged in') 25 | }) 26 | 27 | server.post( 28 | '/logout', 29 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 30 | async (request, reply) => { 31 | await request.logout() 32 | reply.send('logged out') 33 | } 34 | ) 35 | return server 36 | } 37 | 38 | const testSuite = (sessionPluginName: string) => { 39 | process.env.SESSION_PLUGIN = sessionPluginName 40 | const server = createServer() 41 | describe(`${sessionPluginName} tests`, () => { 42 | const sessionOnlyTest = sessionPluginName === '@fastify/session' ? test : test.skip 43 | describe('session isolation', () => { 44 | let userA, userB, userC 45 | 46 | beforeEach(() => { 47 | userA = new TestBrowserSession(server) 48 | userB = new TestBrowserSession(server) 49 | userC = new TestBrowserSession(server) 50 | }) 51 | test('should return 401 Unauthorized if not logged in', async () => { 52 | await Promise.all( 53 | [userA, userB, userC].map(async (user) => { 54 | const response = await user.inject({ method: 'GET', url: '/protected' }) 55 | assert.strictEqual(response.statusCode, 401) 56 | }) 57 | ) 58 | 59 | await Promise.all( 60 | [userA, userB, userC].map(async (user) => { 61 | const response = await user.inject({ method: 'GET', url: '/protected' }) 62 | assert.strictEqual(response.statusCode, 401) 63 | }) 64 | ) 65 | }) 66 | 67 | test('logging in one user shouldn\'t log in the others', async () => { 68 | await Promise.all( 69 | [userA, userB, userC].map(async (user) => { 70 | const response = await user.inject({ method: 'GET', url: '/protected' }) 71 | assert.strictEqual(response.statusCode, 401) 72 | }) 73 | ) 74 | 75 | let response = await userA.inject({ 76 | method: 'POST', 77 | url: '/login', 78 | payload: { login: 'test', password: 'test' } 79 | }) 80 | assert.strictEqual(response.statusCode, 200) 81 | assert.strictEqual(response.body, 'success') 82 | 83 | response = await userA.inject({ method: 'GET', url: '/protected' }) 84 | assert.strictEqual(response.statusCode, 200) 85 | assert.strictEqual(response.body, 'hello!') 86 | 87 | await Promise.all( 88 | [userB, userC].map(async (user) => { 89 | const response = await user.inject({ method: 'GET', url: '/protected' }) 90 | assert.strictEqual(response.statusCode, 401) 91 | }) 92 | ) 93 | 94 | response = await userA.inject({ method: 'GET', url: '/protected' }) 95 | assert.strictEqual(response.statusCode, 200) 96 | assert.strictEqual(response.body, 'hello!') 97 | }) 98 | 99 | test('logging in each user should keep their sessions independent', async () => { 100 | await Promise.all( 101 | [userA, userB, userC].map(async (user) => { 102 | let response = await user.inject({ 103 | method: 'POST', 104 | url: '/login', 105 | payload: { login: 'test', password: 'test' } 106 | }) 107 | assert.strictEqual(response.statusCode, 200) 108 | assert.strictEqual(response.body, 'success') 109 | 110 | response = await user.inject({ method: 'GET', url: '/protected' }) 111 | assert.strictEqual(response.statusCode, 200) 112 | assert.strictEqual(response.body, 'hello!') 113 | }) 114 | ) 115 | 116 | const ids = await Promise.all( 117 | [userA, userB, userC].map(async (user) => { 118 | const response = await user.inject({ method: 'GET', url: '/my-id' }) 119 | assert.strictEqual(response.statusCode, 200) 120 | return response.body 121 | }) 122 | ) 123 | 124 | // assert.deepStrictEqual each returned ID to be unique 125 | assert.deepStrictEqual(Array.from(new Set(ids)).sort(), ids.sort()) 126 | }) 127 | 128 | test('logging out one user shouldn\'t log out the others', async () => { 129 | await Promise.all( 130 | [userA, userB, userC].map(async (user) => { 131 | let response = await user.inject({ 132 | method: 'POST', 133 | url: '/login', 134 | payload: { login: 'test', password: 'test' } 135 | }) 136 | assert.strictEqual(response.statusCode, 200) 137 | assert.strictEqual(response.body, 'success') 138 | 139 | response = await user.inject({ method: 'GET', url: '/protected' }) 140 | assert.strictEqual(response.statusCode, 200) 141 | assert.strictEqual(response.body, 'hello!') 142 | }) 143 | ) 144 | 145 | let response = await userB.inject({ 146 | url: '/logout', 147 | method: 'POST' 148 | }) 149 | assert.strictEqual(response.statusCode, 200) 150 | 151 | response = await userB.inject({ 152 | url: '/protected', 153 | method: 'GET' 154 | }) 155 | assert.strictEqual(response.statusCode, 401) 156 | 157 | await Promise.all( 158 | [userA, userC].map(async (user) => { 159 | const response = await user.inject({ method: 'GET', url: '/protected' }) 160 | assert.strictEqual(response.statusCode, 200) 161 | assert.strictEqual(response.body, 'hello!') 162 | }) 163 | ) 164 | }) 165 | 166 | test('force logging in users shouldn\'t change the login state of the others', async () => { 167 | await Promise.all( 168 | [userA, userB, userC].map(async (user) => { 169 | const response = await user.inject({ method: 'POST', url: '/force-login' }) 170 | assert.strictEqual(response.statusCode, 200) 171 | }) 172 | ) 173 | 174 | const ids = await Promise.all( 175 | [userA, userB, userC].map(async (user) => { 176 | const response = await user.inject({ method: 'GET', url: '/my-id' }) 177 | assert.strictEqual(response.statusCode, 200) 178 | return response.body 179 | }) 180 | ) 181 | 182 | // assert.deepStrictEqual each returned ID to be unique 183 | assert.deepStrictEqual(Array.from(new Set(ids)).sort(), ids.sort()) 184 | }) 185 | 186 | sessionOnlyTest('should regenerate session on login', async () => { 187 | assert.strictEqual(userA.cookies['sessionId'], undefined) 188 | await userA.inject({ method: 'GET', url: '/protected' }) 189 | assert.ok(userA.cookies['sessionId']) 190 | const prevSessionId = userA.cookies.sessionId 191 | await userA.inject({ 192 | method: 'POST', 193 | url: '/login', 194 | payload: { login: 'test', password: 'test' } 195 | }) 196 | assert.notStrictEqual(userA.cookies.sessionId, prevSessionId) 197 | }) 198 | }) 199 | }) 200 | delete process.env.SESSION_PLUGIN 201 | } 202 | 203 | testSuite('@fastify/session') 204 | testSuite('@fastify/secure-session') 205 | -------------------------------------------------------------------------------- /test/session-serialization.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe, mock } from 'node:test' 2 | import assert from 'node:assert' 3 | import { FastifyInstance } from 'fastify' 4 | import { FastifyRequest } from 'fastify/types/request' 5 | import Authenticator from '../src/Authenticator' 6 | import { getTestServer, TestDatabaseStrategy, TestStrategy } from './helpers' 7 | 8 | const testSuite = (sessionPluginName: string) => { 9 | describe(`${sessionPluginName} tests`, () => { 10 | describe('Authenticator session serialization', () => { 11 | test('it should roundtrip a user', async () => { 12 | const fastifyPassport = new Authenticator() 13 | 14 | fastifyPassport.registerUserSerializer(async (user) => JSON.stringify(user)) 15 | fastifyPassport.registerUserDeserializer(async (serialized: string) => JSON.parse(serialized)) 16 | 17 | const user = { name: 'foobar' } 18 | const request = {} as unknown as FastifyRequest 19 | assert.deepStrictEqual( 20 | await fastifyPassport.deserializeUser(await fastifyPassport.serializeUser(user, request), request), 21 | user 22 | ) 23 | }) 24 | 25 | const setupSerializationTestServer = async (fastifyPassport: Authenticator) => { 26 | const server = getTestServer() 27 | server.register(fastifyPassport.initialize()) 28 | server.register(fastifyPassport.secureSession()) 29 | server.get( 30 | '/', 31 | { preValidation: fastifyPassport.authenticate('test', { authInfo: false }) }, 32 | async () => 'hello world!' 33 | ) 34 | server.post( 35 | '/login', 36 | { preValidation: fastifyPassport.authenticate('test', { successRedirect: '/', authInfo: false }) }, 37 | () => {} 38 | ) 39 | server.get('/unprotected', async () => 'some content') 40 | return server 41 | } 42 | 43 | const verifySuccessfulLogin = async (server: FastifyInstance) => { 44 | const loginResponse = await server.inject({ 45 | method: 'POST', 46 | url: '/login', 47 | payload: { login: 'test', password: 'test' } 48 | }) 49 | 50 | assert.strictEqual(loginResponse.statusCode, 302) 51 | assert.strictEqual(loginResponse.headers.location, '/') 52 | 53 | const homeResponse = await server.inject({ 54 | url: '/', 55 | headers: { 56 | cookie: loginResponse.headers['set-cookie'] 57 | }, 58 | method: 'GET' 59 | }) 60 | 61 | assert.strictEqual(homeResponse.body, 'hello world!') 62 | assert.strictEqual(homeResponse.statusCode, 200) 63 | } 64 | 65 | test('should allow multiple user serializers and deserializers', async () => { 66 | const fastifyPassport = new Authenticator() 67 | fastifyPassport.use('test', new TestStrategy('test')) 68 | fastifyPassport.registerUserSerializer(async () => { 69 | throw 'pass' // eslint-disable-line no-throw-literal 70 | }) 71 | fastifyPassport.registerUserSerializer(async () => { 72 | throw 'pass' // eslint-disable-line no-throw-literal 73 | }) 74 | fastifyPassport.registerUserSerializer(async (user) => { 75 | return JSON.stringify(user) 76 | }) 77 | fastifyPassport.registerUserDeserializer(async () => { 78 | throw 'pass' // eslint-disable-line no-throw-literal 79 | }) 80 | fastifyPassport.registerUserDeserializer(async () => { 81 | throw 'pass' // eslint-disable-line no-throw-literal 82 | }) 83 | fastifyPassport.registerUserDeserializer(async (serialized: string) => JSON.parse(serialized)) 84 | const server = await setupSerializationTestServer(fastifyPassport) 85 | await verifySuccessfulLogin(server) 86 | }) 87 | 88 | test('should allow user serializers/deserializers that work like a database', async () => { 89 | const fastifyPassport = new Authenticator() 90 | const strategy = new TestDatabaseStrategy('test', { 1: { id: '1', login: 'test', password: 'test' } }) 91 | fastifyPassport.use('test', strategy) 92 | fastifyPassport.registerUserSerializer<{ id: string; name: string }, string>(async (user) => user.id) 93 | fastifyPassport.registerUserDeserializer(async (serialized: string) => strategy.database[serialized]) 94 | 95 | const server = await setupSerializationTestServer(fastifyPassport) 96 | await verifySuccessfulLogin(server) 97 | await verifySuccessfulLogin(server) 98 | }) 99 | 100 | test('should throw if user deserializers return undefined', async () => { 101 | // jest.spyOn(console, 'error').mockImplementation(jest.fn()) 102 | console.error = mock.fn() 103 | const fastifyPassport = new Authenticator() 104 | const strategy = new TestDatabaseStrategy('test', { 1: { id: '1', login: 'test', password: 'test' } }) 105 | fastifyPassport.use('test', strategy) 106 | fastifyPassport.registerUserSerializer<{ id: string; name: string }, string>(async (user) => user.id) 107 | fastifyPassport.registerUserDeserializer(async (serialized: string) => strategy.database[serialized]) 108 | 109 | const server = await setupSerializationTestServer(fastifyPassport) 110 | await verifySuccessfulLogin(server) 111 | 112 | const loginResponse = await server.inject({ 113 | method: 'POST', 114 | url: '/login', 115 | payload: { login: 'test', password: 'test' } 116 | }) 117 | 118 | assert.strictEqual(loginResponse.statusCode, 302) 119 | assert.strictEqual(loginResponse.headers.location, '/') 120 | 121 | // user id 1 is logged in now, simulate deleting them from the database while logged in 122 | delete strategy.database['1'] 123 | 124 | const homeResponse = await server.inject({ 125 | url: '/', 126 | headers: { 127 | cookie: loginResponse.headers['set-cookie'] 128 | }, 129 | method: 'GET' 130 | }) 131 | 132 | assert.strictEqual(homeResponse.statusCode, 500) 133 | assert.strictEqual( 134 | JSON.parse(homeResponse.body)?.message, 135 | 'Failed to deserialize user out of session. Tried 1 serializers.' 136 | ) 137 | 138 | // can't serve other requests either because the secure session decode fails, which would populate request.user even for unauthenticated requests 139 | const otherResponse = await server.inject({ 140 | url: '/unprotected', 141 | headers: { 142 | cookie: loginResponse.headers['set-cookie'] 143 | }, 144 | method: 'GET' 145 | }) 146 | 147 | assert.strictEqual(otherResponse.statusCode, 500) 148 | assert.strictEqual( 149 | JSON.parse(otherResponse.body)?.message, 150 | 'Failed to deserialize user out of session. Tried 1 serializers.' 151 | ) 152 | }) 153 | 154 | test('should deny access if user deserializers return null for logged in sessions', async () => { 155 | const fastifyPassport = new Authenticator() 156 | const strategy = new TestDatabaseStrategy('test', { 1: { id: '1', login: 'test', password: 'test' } }) 157 | fastifyPassport.use('test', strategy) 158 | fastifyPassport.registerUserSerializer<{ id: string; name: string }, string>(async (user) => user.id) 159 | fastifyPassport.registerUserDeserializer(async (serialized: string) => strategy.database[serialized] || null) 160 | 161 | const server = await setupSerializationTestServer(fastifyPassport) 162 | await verifySuccessfulLogin(server) 163 | 164 | const loginResponse = await server.inject({ 165 | method: 'POST', 166 | url: '/login', 167 | payload: { login: 'test', password: 'test' } 168 | }) 169 | 170 | assert.strictEqual(loginResponse.statusCode, 302) 171 | assert.strictEqual(loginResponse.headers.location, '/') 172 | 173 | // user id 1 is logged in now, simulate deleting them from the database while logged in 174 | delete strategy.database['1'] 175 | 176 | const homeResponse = await server.inject({ 177 | url: '/', 178 | headers: { 179 | cookie: loginResponse.headers['set-cookie'] 180 | }, 181 | method: 'GET' 182 | }) 183 | 184 | assert.strictEqual(homeResponse.statusCode, 401) 185 | 186 | // should still be able to serve unauthenticated requests just fine 187 | const otherResponse = await server.inject({ 188 | url: '/unprotected', 189 | headers: { 190 | cookie: loginResponse.headers['set-cookie'] 191 | }, 192 | method: 'GET' 193 | }) 194 | 195 | assert.strictEqual(otherResponse.statusCode, 200) 196 | assert.strictEqual(otherResponse.body, 'some content') 197 | }) 198 | }) 199 | }) 200 | } 201 | 202 | testSuite('@fastify/session') 203 | testSuite('@fastify/secure-session') 204 | -------------------------------------------------------------------------------- /test/session-strategy.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe } from 'node:test' 2 | import assert from 'node:assert' 3 | import { SerializeFunction } from '../src/Authenticator' 4 | import { SessionStrategy } from '../src/strategies/SessionStrategy' 5 | 6 | describe('SessionStrategy', () => { 7 | test('should throw an Error if no parameter was passed', () => { 8 | assert.throws( 9 | // @ts-expect-error.strictEqual-error expecting atleast a parameter 10 | () => new SessionStrategy(), 11 | (err) => { 12 | assert(err instanceof Error) 13 | assert.strictEqual( 14 | err.message, 15 | 'SessionStrategy#constructor must have a valid deserializeUser-function passed as a parameter' 16 | ) 17 | return true 18 | } 19 | ) 20 | }) 21 | 22 | test('should throw an Error if no deserializeUser-function was passed as second parameter', () => { 23 | assert.throws( 24 | // @ts-expect-error.strictEqual-error expecting a function as second parameter 25 | () => new SessionStrategy({}), 26 | (err) => { 27 | assert(err instanceof Error) 28 | assert.strictEqual( 29 | err.message, 30 | 'SessionStrategy#constructor must have a valid deserializeUser-function passed as a parameter' 31 | ) 32 | return true 33 | } 34 | ) 35 | }) 36 | 37 | test('should throw an Error if no deserializeUser-function was passed as second parameter', () => { 38 | assert.throws( 39 | // @ts-expect-error.strictEqual-error expecting a function as second parameter 40 | () => new SessionStrategy({}), 41 | (err) => { 42 | assert(err instanceof Error) 43 | assert.strictEqual( 44 | err.message, 45 | 'SessionStrategy#constructor must have a valid deserializeUser-function passed as a parameter' 46 | ) 47 | return true 48 | } 49 | ) 50 | }) 51 | 52 | test('should not throw an Error if no deserializeUser-function was passed as first parameter', () => { 53 | assert.doesNotThrow(() => new SessionStrategy(((id) => id) as unknown as SerializeFunction)) 54 | }) 55 | 56 | test('should not throw an Error if no deserializeUser-function was passed as second parameter', () => { 57 | assert.doesNotThrow(() => new SessionStrategy({}, ((id) => id) as unknown as SerializeFunction)) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /test/strategies-integration.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe } from 'node:test' 2 | import assert, { fail } from 'node:assert' 3 | import { Strategy as FacebookStrategy } from 'passport-facebook' 4 | import { Strategy as GitHubStrategy } from 'passport-github2' 5 | import { OAuth2Strategy as GoogleStrategy } from 'passport-google-oauth' 6 | import { Issuer as OpenIdIssuer, Strategy as OpenIdStrategy } from 'openid-client' 7 | import { getConfiguredTestServer, TestStrategy } from './helpers' 8 | 9 | const testSuite = (sessionPluginName: string) => { 10 | describe(`${sessionPluginName} tests`, () => { 11 | test('should initiate oauth with the google strategy from npm', async () => { 12 | const strategy: TestStrategy = new GoogleStrategy( 13 | { 14 | clientID: '384163122467-cq6dolrp53at1a3pa8j0f4stpa5gvouh.apps.googleusercontent.com', 15 | clientSecret: 'o15Chw0KIaXtx_2wRGxNdNSy', 16 | callbackURL: 'http://www.example.com/auth/google/callback' 17 | }, 18 | () => fail() 19 | ) 20 | 21 | const { server, fastifyPassport } = getConfiguredTestServer('google', strategy) 22 | 23 | server.get( 24 | '/', 25 | { preValidation: fastifyPassport.authenticate('google', { authInfo: false }) }, 26 | async () => 'hello world!' 27 | ) 28 | server.post( 29 | '/login', 30 | { preValidation: fastifyPassport.authenticate('google', { authInfo: false }) }, 31 | async () => 'hello' 32 | ) 33 | 34 | const response = await server.inject({ method: 'GET', url: '/' }) 35 | assert.strictEqual(response.statusCode, 302) 36 | }) 37 | 38 | test('should initiate oauth with the facebook strategy from npm', async () => { 39 | const strategy: TestStrategy = new FacebookStrategy( 40 | { 41 | clientID: 'foobar', 42 | clientSecret: 'baz', 43 | callbackURL: 'http://www.example.com/auth/facebook/callback' 44 | }, 45 | () => fail() 46 | ) 47 | 48 | const { server, fastifyPassport } = getConfiguredTestServer('facebook', strategy) 49 | 50 | server.get( 51 | '/', 52 | { preValidation: fastifyPassport.authenticate('facebook', { authInfo: false }) }, 53 | async () => 'hello world!' 54 | ) 55 | server.post( 56 | '/login', 57 | { preValidation: fastifyPassport.authenticate('facebook', { authInfo: false }) }, 58 | async () => 'hello' 59 | ) 60 | 61 | const response = await server.inject({ method: 'GET', url: '/' }) 62 | assert.strictEqual(response.statusCode, 302) 63 | }) 64 | 65 | test('should initiate oauth with the github strategy from npm', async () => { 66 | const strategy: TestStrategy = new GitHubStrategy( 67 | { 68 | clientID: 'foobar', 69 | clientSecret: 'baz', 70 | callbackURL: 'http://www.example.com/auth/facebook/callback' 71 | }, 72 | () => fail() 73 | ) 74 | 75 | const { server, fastifyPassport } = getConfiguredTestServer('github', strategy) 76 | 77 | server.get( 78 | '/', 79 | { preValidation: fastifyPassport.authenticate('github', { authInfo: false }) }, 80 | async () => 'hello world!' 81 | ) 82 | server.post( 83 | '/login', 84 | { preValidation: fastifyPassport.authenticate('github', { authInfo: false }) }, 85 | async () => 'hello' 86 | ) 87 | 88 | const response = await server.inject({ method: 'GET', url: '/' }) 89 | assert.strictEqual(response.statusCode, 302) 90 | }) 91 | 92 | test('should initiate oauth with the openid-client strategy from npm', async () => { 93 | const issuer = new OpenIdIssuer({ issuer: 'test_issuer', authorization_endpoint: 'http://www.example.com' }) 94 | 95 | const client = new issuer.Client({ 96 | client_id: 'identifier', 97 | client_secret: 'secure', 98 | redirect_uris: ['http://www.example.com/auth/openid-client/callback'] 99 | }) 100 | 101 | const strategy = new OpenIdStrategy( 102 | { 103 | client 104 | }, 105 | () => fail() 106 | ) as TestStrategy 107 | 108 | const { server, fastifyPassport } = getConfiguredTestServer('openid-client', strategy) 109 | 110 | server.get( 111 | '/', 112 | { preValidation: fastifyPassport.authenticate('openid-client', { authInfo: false }) }, 113 | async () => 'hello world!' 114 | ) 115 | server.post( 116 | '/login', 117 | { preValidation: fastifyPassport.authenticate('openid-client', { authInfo: false }) }, 118 | async () => 'hello' 119 | ) 120 | 121 | const response = await server.inject({ method: 'GET', url: '/' }) 122 | assert.strictEqual(response.statusCode, 302) 123 | }) 124 | }) 125 | } 126 | 127 | testSuite('@fastify/session') 128 | testSuite('@fastify/secure-session') 129 | -------------------------------------------------------------------------------- /test/strategy.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe } from 'node:test' 2 | import assert from 'node:assert' 3 | import Authenticator from '../src/Authenticator' 4 | import { getConfiguredTestServer, TestStrategy } from './helpers' 5 | import { Strategy } from '../src/strategies' 6 | 7 | const testSuite = (sessionPluginName: string) => { 8 | describe(`${sessionPluginName} tests`, () => { 9 | test('should be able to unuse strategy', () => { 10 | const fastifyPassport = new Authenticator() 11 | const testStrategy = new TestStrategy('test') 12 | fastifyPassport.use(testStrategy) 13 | fastifyPassport.unuse('test') 14 | }) 15 | 16 | test('should throw error if strategy has no name', () => { 17 | const fastifyPassport = new Authenticator() 18 | assert.throws(() => { 19 | fastifyPassport.use({} as Strategy) 20 | }) 21 | }) 22 | 23 | test('should catch synchronous strategy errors and fail authentication', async () => { 24 | class ErrorStrategy extends Strategy { 25 | authenticate (_request: any, _options?: { pauseStream?: boolean }) { 26 | throw new Error('the strategy threw an error') 27 | } 28 | } 29 | 30 | const { server, fastifyPassport } = getConfiguredTestServer('test', new ErrorStrategy('test')) 31 | server.get('/', { preValidation: fastifyPassport.authenticate('test') }, async () => 'hello world!') 32 | 33 | const response = await server.inject({ method: 'GET', url: '/' }) 34 | assert.strictEqual(response.statusCode, 500) 35 | assert.strictEqual(JSON.parse(response.body).message, 'the strategy threw an error') 36 | }) 37 | 38 | test('should catch asynchronous strategy errors and fail authentication', async () => { 39 | class ErrorStrategy extends Strategy { 40 | async authenticate (_request: any, _options?: { pauseStream?: boolean }) { 41 | await Promise.resolve() 42 | throw new Error('the strategy threw an error') 43 | } 44 | } 45 | 46 | const { server, fastifyPassport } = getConfiguredTestServer('test', new ErrorStrategy('test')) 47 | server.get('/', { preValidation: fastifyPassport.authenticate('test') }, async () => 'hello world!') 48 | 49 | const response = await server.inject({ method: 'GET', url: '/' }) 50 | assert.strictEqual(response.statusCode, 500) 51 | assert.strictEqual(JSON.parse(response.body).message, 'the strategy threw an error') 52 | }) 53 | 54 | test('should be able to fail with a failure flash message', async () => { 55 | class ErrorStrategy extends Strategy { 56 | async authenticate (_request: any, _options?: { pauseStream?: boolean }) { 57 | await Promise.resolve() 58 | this.fail({ message: 'The strategy failed with an error message' }, 401) 59 | } 60 | } 61 | 62 | const { server, fastifyPassport } = getConfiguredTestServer('test', new ErrorStrategy('test')) 63 | server.get( 64 | '/', 65 | { preValidation: fastifyPassport.authenticate('test', { failureFlash: true }) }, 66 | async () => 'hello world!' 67 | ) 68 | 69 | const response = await server.inject({ method: 'GET', url: '/' }) 70 | assert.strictEqual(response.statusCode, 401) 71 | }) 72 | 73 | test('should be able to fail without a failure flash message', async () => { 74 | class ErrorStrategy extends Strategy { 75 | async authenticate (_request: any, _options?: { pauseStream?: boolean }) { 76 | await Promise.resolve() 77 | this.fail(401) 78 | } 79 | } 80 | 81 | const { server, fastifyPassport } = getConfiguredTestServer('test', new ErrorStrategy('test')) 82 | server.get('/', { preValidation: fastifyPassport.authenticate('test') }, async () => 'hello world!') 83 | 84 | const response = await server.inject({ method: 'GET', url: '/' }) 85 | assert.strictEqual(response.statusCode, 401) 86 | }) 87 | }) 88 | } 89 | 90 | testSuite('@fastify/session') 91 | testSuite('@fastify/secure-session') 92 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "sourceMap": true, 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "declaration": true, 8 | "target": "es2017", 9 | "module": "commonjs", 10 | "pretty": true, 11 | "noEmitOnError": true, 12 | "strict": true, 13 | "noImplicitAny": false, 14 | "removeComments": true, 15 | "noUnusedLocals": true, 16 | "rootDir": "./src", 17 | "resolveJsonModule": true, 18 | "newLine": "lf", 19 | "noFallthroughCasesInSwitch": true, 20 | "isolatedModules": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "skipLibCheck": true, 23 | "lib": ["ESNext"] 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "sourceMap": true, 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "declaration": true, 8 | "target": "es2017", 9 | "module": "commonjs", 10 | "pretty": true, 11 | "noEmitOnError": true, 12 | "strict": true, 13 | "noImplicitAny": false, 14 | "removeComments": true, 15 | "noUnusedLocals": true, 16 | "rootDir": ".", 17 | "resolveJsonModule": true, 18 | "newLine": "lf", 19 | "noFallthroughCasesInSwitch": true, 20 | "isolatedModules": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "skipLibCheck": true, 23 | "lib": ["ESNext"] 24 | }, 25 | "include": ["src", "test"] 26 | } 27 | --------------------------------------------------------------------------------