├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── deno.json ├── deps.ts ├── egg.json ├── mod.ts ├── src └── jwtMiddleware.ts └── tests ├── integration.test.ts └── unit.test.ts /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This is a community project where every contribution is appreciated! 4 | 5 | There is a simple process to follow no matter if you are facing an bug, have an 6 | idea, or simply have a question. 7 | 8 | ## Process of issue filing 9 | 10 | Check if there is an existing issue covering your intention. 11 | 12 | 1. if you have the same problem but the outcome is different (or vice versa), 13 | add a comment stating the difference 14 | 2. if you have the exact same problem add a like (:+1:) 15 | 3. otherwise create a new issue 16 | 17 | ### Bugs 18 | 19 | When facing a bug, give steps to reproduce as well as the error. If you have a 20 | hypothesis to why this issue erupted, mention this. If you have already isolated 21 | the issue and have found a fix, you can open a PR. 22 | 23 | When you have a problem, these steps will often help: 24 | 25 | - Make sure you use the latest version of Deno 26 | - Add the `-r` or `--reload` flag to the Deno command to reload the cache 27 | 28 | ### Ideas 29 | 30 | Ideas are always welcome, and if there is a good reason and/or many users agree, 31 | there is a good chance it will be incorporated. Before making a PR, get feedback 32 | from the maintainers and comunity. 33 | 34 | ### Questions 35 | 36 | If you can't find the answers in one of the open (or closed) issues, create a 37 | new one. If this project gains enough traction, a discord server will be 38 | created, until then you can use the issues. 39 | 40 | ## When creating a PR 41 | 42 | Before pushing commits, go through this checklist: 43 | 44 | - You have run `deno fmt` 45 | - All tests are running successfully 46 | 47 | For a PR to be accepted, the following needs to be applied: 48 | 49 | - Add tests where applicable 50 | - Pipeline is green 51 | - Nothing more than what the PR is supposed to solve is changed (unless 52 | discussed and approved) 53 | 54 | ## Testing 55 | 56 | Run `deno test` 57 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: halvardm 4 | github: halvardssm 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | name: Bug Report description: File a bug report title: "[Bug]: " labels: [bug] 2 | body: 3 | 4 | - type: input attributes: label: Operating System description: What operating 5 | system are you using? placeholder: macOS Big Sur 11.4 validations: required: 6 | true 7 | - type: input attributes: label: Deno version description: What version of Deno 8 | are you using? placeholder: 1.0.0 validations: required: true 9 | - type: input attributes: label: Oak version description: What version of Oak 10 | are you using? placeholder: 8.0.0 validations: required: true 11 | - type: textarea attributes: label: Bug description description: Describe the 12 | bug placeholder: A clear and concise description of what the bug is. 13 | validations: required: true 14 | - type: textarea attributes: label: Steps to reproduce description: Add steps to 15 | reproduce the issue placeholder: | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | validations: required: true 22 | - type: textarea attributes: label: Aditional information description: Add any 23 | aditional information you belive could help in fixing this issue validations: 24 | required: false 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | name: Feature request description: Suggest an idea for this project title: 2 | "[IDEA]: " labels: [idea] body: 3 | 4 | - type: textarea attributes: label: The Idea description: Add your idea, and be 5 | as descriptive as possible. validations: required: true 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | env: 4 | DENO_VERSION: 1.37.0 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | tests: 16 | name: Run tests 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Clone repo 21 | uses: actions/checkout@v4 22 | - name: Install deno 23 | uses: denoland/setup-deno@v1 24 | with: 25 | deno-version: ${{env.DENO_VERSION}} 26 | - name: Check formatting 27 | run: deno fmt --check 28 | - name: Check linting 29 | run: deno lint 30 | - name: Run tests 31 | run: deno task test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.gitignore.io/api/visualstudiocode,macos,jetbrains+all,linux,node,windows 4 | # Edit at https://www.gitignore.io/?templates=visualstudiocode,macos,jetbrains+all,linux,node,windows 5 | 6 | ### JetBrains+all ### 7 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 8 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 9 | 10 | # User-specific stuff 11 | .idea/**/workspace.xml 12 | .idea/**/tasks.xml 13 | .idea/**/usage.statistics.xml 14 | .idea/**/dictionaries 15 | .idea/**/shelf 16 | 17 | # Generated files 18 | .idea/**/contentModel.xml 19 | 20 | # Sensitive or high-churn files 21 | .idea/**/dataSources/ 22 | .idea/**/dataSources.ids 23 | .idea/**/dataSources.local.xml 24 | .idea/**/sqlDataSources.xml 25 | .idea/**/dynamic.xml 26 | .idea/**/uiDesigner.xml 27 | .idea/**/dbnavigator.xml 28 | 29 | # Gradle 30 | .idea/**/gradle.xml 31 | .idea/**/libraries 32 | 33 | # Gradle and Maven with auto-import 34 | # When using Gradle or Maven with auto-import, you should exclude module files, 35 | # since they will be recreated, and may cause churn. Uncomment if using 36 | # auto-import. 37 | # .idea/modules.xml 38 | # .idea/*.iml 39 | # .idea/modules 40 | # *.iml 41 | # *.ipr 42 | 43 | # CMake 44 | cmake-build-*/ 45 | 46 | # Mongo Explorer plugin 47 | .idea/**/mongoSettings.xml 48 | 49 | # File-based project format 50 | *.iws 51 | 52 | # IntelliJ 53 | out/ 54 | 55 | # mpeltonen/sbt-idea plugin 56 | .idea_modules/ 57 | 58 | # JIRA plugin 59 | atlassian-ide-plugin.xml 60 | 61 | # Cursive Clojure plugin 62 | .idea/replstate.xml 63 | 64 | # Crashlytics plugin (for Android Studio and IntelliJ) 65 | com_crashlytics_export_strings.xml 66 | crashlytics.properties 67 | crashlytics-build.properties 68 | fabric.properties 69 | 70 | # Editor-based Rest Client 71 | .idea/httpRequests 72 | 73 | # Android studio 3.1+ serialized cache file 74 | .idea/caches/build_file_checksums.ser 75 | 76 | ### JetBrains+all Patch ### 77 | # Ignores the whole .idea folder and all .iml files 78 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 79 | 80 | .idea/ 81 | 82 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 83 | 84 | *.iml 85 | modules.xml 86 | .idea/misc.xml 87 | *.ipr 88 | 89 | # Sonarlint plugin 90 | .idea/sonarlint 91 | 92 | ### Linux ### 93 | *~ 94 | 95 | # temporary files which can be created if a process still has a handle open of a deleted file 96 | .fuse_hidden* 97 | 98 | # KDE directory preferences 99 | .directory 100 | 101 | # Linux trash folder which might appear on any partition or disk 102 | .Trash-* 103 | 104 | # .nfs files are created when an open file is removed but is still being accessed 105 | .nfs* 106 | 107 | ### macOS ### 108 | # General 109 | .DS_Store 110 | .AppleDouble 111 | .LSOverride 112 | 113 | # Icon must end with two \r 114 | Icon 115 | 116 | # Thumbnails 117 | ._* 118 | 119 | # Files that might appear in the root of a volume 120 | .DocumentRevisions-V100 121 | .fseventsd 122 | .Spotlight-V100 123 | .TemporaryItems 124 | .Trashes 125 | .VolumeIcon.icns 126 | .com.apple.timemachine.donotpresent 127 | 128 | # Directories potentially created on remote AFP share 129 | .AppleDB 130 | .AppleDesktop 131 | Network Trash Folder 132 | Temporary Items 133 | .apdisk 134 | 135 | ### Node ### 136 | # Logs 137 | logs 138 | *.log 139 | npm-debug.log* 140 | yarn-debug.log* 141 | yarn-error.log* 142 | lerna-debug.log* 143 | 144 | # Diagnostic reports (https://nodejs.org/api/report.html) 145 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 146 | 147 | # Runtime data 148 | pids 149 | *.pid 150 | *.seed 151 | *.pid.lock 152 | 153 | # Directory for instrumented libs generated by jscoverage/JSCover 154 | lib-cov 155 | 156 | # Coverage directory used by tools like istanbul 157 | coverage 158 | *.lcov 159 | 160 | # nyc test coverage 161 | .nyc_output 162 | 163 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 164 | .grunt 165 | 166 | # Bower dependency directory (https://bower.io/) 167 | bower_components 168 | 169 | # node-waf configuration 170 | .lock-wscript 171 | 172 | # Compiled binary addons (https://nodejs.org/api/addons.html) 173 | build/Release 174 | 175 | # Dependency directories 176 | node_modules/ 177 | jspm_packages/ 178 | 179 | # TypeScript v1 declaration files 180 | typings/ 181 | 182 | # TypeScript cache 183 | *.tsbuildinfo 184 | 185 | # Optional npm cache directory 186 | .npm 187 | 188 | # Optional eslint cache 189 | .eslintcache 190 | 191 | # Optional REPL history 192 | .node_repl_history 193 | 194 | # Output of 'npm pack' 195 | *.tgz 196 | 197 | # Yarn Integrity file 198 | .yarn-integrity 199 | 200 | # dotenv environment variables file 201 | .env 202 | .env.test 203 | 204 | # parcel-bundler cache (https://parceljs.org/) 205 | .cache 206 | 207 | # next.js build output 208 | .next 209 | 210 | # nuxt.js build output 211 | .nuxt 212 | 213 | # rollup.js default build output 214 | dist/ 215 | 216 | # Uncomment the public line if your project uses Gatsby 217 | # https://nextjs.org/blog/next-9-1#public-directory-support 218 | # https://create-react-app.dev/docs/using-the-public-folder/#docsNav 219 | # public 220 | 221 | # Storybook build outputs 222 | .out 223 | .storybook-out 224 | 225 | # vuepress build output 226 | .vuepress/dist 227 | 228 | # Serverless directories 229 | .serverless/ 230 | 231 | # FuseBox cache 232 | .fusebox/ 233 | 234 | # DynamoDB Local files 235 | .dynamodb/ 236 | 237 | # Temporary folders 238 | tmp/ 239 | temp/ 240 | 241 | ### VisualStudioCode ### 242 | .vscode/* 243 | !.vscode/settings.json 244 | !.vscode/tasks.json 245 | !.vscode/launch.json 246 | !.vscode/extensions.json 247 | 248 | ### VisualStudioCode Patch ### 249 | # Ignore all local history of files 250 | .history 251 | 252 | ### Windows ### 253 | # Windows thumbnail cache files 254 | Thumbs.db 255 | Thumbs.db:encryptable 256 | ehthumbs.db 257 | ehthumbs_vista.db 258 | 259 | # Dump file 260 | *.stackdump 261 | 262 | # Folder config file 263 | [Dd]esktop.ini 264 | 265 | # Recycle Bin used on file shares 266 | $RECYCLE.BIN/ 267 | 268 | # Windows Installer files 269 | *.cab 270 | *.msi 271 | *.msix 272 | *.msm 273 | *.msp 274 | 275 | # Windows shortcuts 276 | *.lnk 277 | 278 | # End of https://www.gitignore.io/api/visualstudiocode,macos,jetbrains+all,linux,node,windows 279 | 280 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 281 | .vscode 282 | data 283 | deno.lock 284 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2020] [Halvard Mørstad] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oak Middleware JWT 2 | 3 | [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/halvardssm/oak-middleware-jwt?logo=deno&style=flat-square)](https://github.com/halvardssm/oak-middleware-jwt) 4 | [![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/halvardssm/oak-middleware-jwt/CI/master?style=flat-square&logo=github)](https://github.com/halvardssm/oak-middleware-jwt/actions?query=branch%3Amaster+workflow%3ACI) 5 | [![(Deno)](https://img.shields.io/badge/deno-v1.18.2-green.svg?style=flat-square&logo=deno)](https://deno.land) 6 | [![(Deno)](https://img.shields.io/badge/oak-v10.2.0-orange.svg?style=flat-square&logo=deno)](https://github.com/oakserver/oak) 7 | [![(Deno)](https://img.shields.io/badge/djwt-v2.4-orange.svg?style=flat-square&logo=deno)](https://github.com/timonson/djwt) 8 | [![deno doc](https://img.shields.io/badge/deno-doc-blue.svg?style=flat-square&logo=deno)](https://doc.deno.land/https/raw.githubusercontent.com/halvardssm/oak-middleware-jwt/master/mod.ts) 9 | [![nest badge](https://nest.land/badge-block.svg)](https://nest.land/package/oak-middleware-jwt) 10 | 11 | Oak middleware for JWT using Djwt 12 | 13 | ## Usage 14 | 15 | - As an application middleware 16 | 17 | ```ts 18 | import { jwtMiddleware } from "https://raw.githubusercontent.com/halvardssm/oak-middleware-jwt/master/mod.ts"; 19 | import { Middleware } from "https://deno.land/x/oak/mod.ts"; 20 | 21 | const app = new Application(); 22 | 23 | app.use(jwtMiddleware({ key: "foo" })); 24 | 25 | await app.listen(appOptions); 26 | ``` 27 | 28 | - As a router middleware 29 | 30 | ```ts 31 | import { jwtMiddleware, OnSuccessHandler } from "https://raw.githubusercontent.com/halvardssm/oak-middleware-jwt/master/mod.ts" 32 | import { RouterMiddleware } from "https://deno.land/x/oak/mod.ts"; 33 | 34 | interface ApplicationState { 35 | userId: string 36 | } 37 | 38 | const router = new Router(); 39 | const app = new Application(); 40 | 41 | const onSuccess: OnSuccessHandler = (ctx, jwtPayload) => { 42 | ctx.state.userId = jwtPayload.userId 43 | } 44 | 45 | router 46 | .get("/bar", jwtMiddleware({ key:"foo", onSuccess }), async (ctx) => { 47 | const callerId = ctx.state.userId 48 | ... 49 | }) 50 | 51 | app.use(router.routes()); 52 | 53 | await app.listen(appOptions); 54 | ``` 55 | 56 | - With ignore patterns 57 | 58 | ```ts 59 | import { 60 | IgnorePattern, 61 | jwtMiddleware, 62 | OnSuccessHandler, 63 | } from "https://raw.githubusercontent.com/halvardssm/oak-middleware-jwt/master/mod.ts"; 64 | import { RouterMiddleware } from "https://deno.land/x/oak/mod.ts"; 65 | 66 | const app = new Application(); 67 | 68 | const ignorePatterns: IgnorePattern[] = ["/baz", /buz/, { 69 | path: "/biz", 70 | methods: ["GET"], 71 | }]; 72 | 73 | app.use(jwtMiddleware({ key: "foo", ignorePatterns })); 74 | 75 | await app.listen(appOptions); 76 | ``` 77 | 78 | ## Options 79 | 80 | - key: string; // See the djwt module for Validation options 81 | - algorithm: AlgorithmInput ; // See the djwt module for Validation options 82 | - customMessages?: ErrorMessages; // Custom error messages 83 | - ignorePatterns?: Array; // Pattern to ignore e.g. 84 | `/authenticate`, can be a RegExp, Pattern object or string. When passing a 85 | string, the string will be matched with the path `===` 86 | - onSuccess?: OnSuccessHandler; // Optional callback for successfull validation, 87 | passes the Context and the Payload object from djwt module 88 | - onFailure?: OnFailureHandler; // Optional callback for unsuccessfull 89 | validation, passes the Context and the Error encountered while validating the 90 | jwt 91 | 92 | ## Error Handling 93 | 94 | All errors originating from this middleware is of class `JWTMiddlewareError` 95 | which is exported. To handle `JWTMiddlewareError`s you can do such: 96 | 97 | ```ts 98 | ... 99 | } catch(e){ 100 | if(e instanceof JWTMiddlewareError){ 101 | //do something 102 | } 103 | } 104 | ``` 105 | 106 | ## Migrating from v1.0.0 107 | 108 | - Change the previous `algorithm` parameter's type from `Algorithm` to 109 | `AlgorithmInput` 110 | 111 | ```ts 112 | import { AlgorithmInput } from "https://raw.githubusercontent.com/halvardssm/oak-middleware-jwt/master/mod.ts"; 113 | 114 | const algorithm: AlgorithmInput = "HS512"; 115 | 116 | app.use(jwtMiddleware({ key: "foo", algorithm })); 117 | ``` 118 | 119 | - Change the onFailure and onSuccess callbacks. 120 | - `onSuccess` gets an object of type `Payload` as a second argument (check 121 | https://github.com/timonson/djwt#decode) 122 | - `onFailure` gets an object of type `Error` as a second argument, should 123 | return `true` if the error should be thrown instead of returning as a 124 | response. 125 | 126 | ```ts 127 | const onFailure = (ctx, error: Error) => { 128 | console.log(error.message); 129 | }; 130 | 131 | const onSuccess = (ctx, payload: Payload) => { 132 | console.log(payload.userId); 133 | }; 134 | ``` 135 | 136 | - The expired token bug was fixed. This module will now throw an error (and call 137 | `onFailure` callback) if the token sent is expired. Can cause problems in 138 | implementations that weren't expecting that 139 | 140 | ## Contributing 141 | 142 | All contributions are welcome, make sure to read the 143 | [contributing guidelines](./.github/CONTRIBUTING.md). 144 | 145 | ## Uses 146 | 147 | - [Oak](https://deno.land/x/oak/) 148 | - [djwt](https://deno.land/x/djwt) 149 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "test": "deno test --allow-net", 4 | "test:unit": "deno test --filter=unit", 5 | "test:integration": "deno test --allow-net --filter=integration" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | // std 2 | export { 3 | assert, 4 | assertEquals, 5 | assertRejects, 6 | } from "https://deno.land/std@0.202.0/assert/mod.ts"; 7 | export { 8 | afterEach, 9 | beforeEach, 10 | describe, 11 | it, 12 | } from "https://deno.land/std@0.202.0/testing/bdd.ts"; 13 | 14 | // Djwt 15 | export type { Payload } from "https://deno.land/x/djwt@v2.9.1/mod.ts"; 16 | export { 17 | create, 18 | getNumericDate, 19 | verify, 20 | } from "https://deno.land/x/djwt@v2.9.1/mod.ts"; 21 | export type { Algorithm } from "https://deno.land/x/djwt@v2.9.1/algorithm.ts"; 22 | 23 | // Oak 24 | export type { 25 | HTTPMethods, 26 | Middleware, 27 | RouterContext, 28 | RouterMiddleware, 29 | } from "https://deno.land/x/oak@v12.6.1/mod.ts"; 30 | export { 31 | Application, 32 | Context, 33 | createHttpError, 34 | Status, 35 | } from "https://deno.land/x/oak@v12.6.1/mod.ts"; 36 | -------------------------------------------------------------------------------- /egg.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://x.nest.land/eggs@0.3.8/src/schema.json", 3 | "name": "oak-middleware-jwt", 4 | "description": "Oak middleware for JWT validation using djwt", 5 | "version": "2.2.0", 6 | "stable": true, 7 | "repository": "https://github.com/halvardssm/oak-middleware-jwt", 8 | "files": [ 9 | "./mod.ts", 10 | "./deps.ts", 11 | "./src/**/*", 12 | "./README.md", 13 | "./LICENSE" 14 | ], 15 | "entry": "./mod.ts", 16 | "ignore": [], 17 | "unlisted": false 18 | } 19 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./src/jwtMiddleware.ts"; 2 | -------------------------------------------------------------------------------- /src/jwtMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Algorithm, 3 | Context, 4 | HTTPMethods, 5 | Middleware, 6 | Payload, 7 | RouterContext, 8 | RouterMiddleware, 9 | Status, 10 | verify, 11 | } from "../deps.ts"; 12 | 13 | export type Pattern = { path: string | RegExp; methods?: HTTPMethods[] }; 14 | export type IgnorePattern = string | RegExp | Pattern; 15 | export type ErrorMessagesKeys = 16 | | "ERROR_INVALID_AUTH" 17 | | "AUTHORIZATION_HEADER_NOT_PRESENT" 18 | | "AUTHORIZATION_HEADER_INVALID"; 19 | export type ErrorMessages = Partial>; 20 | export type OnSuccessHandler = ( 21 | ctx: Context | RouterContext, 22 | payload: Payload, 23 | ) => void; 24 | export type OnFailureHandler = ( 25 | ctx: Context | RouterContext, 26 | error: Error, 27 | ) => boolean; 28 | 29 | export type { Algorithm, Payload }; 30 | 31 | export interface JwtMiddlewareOptions { 32 | /** Custom error messages */ 33 | customMessages?: ErrorMessages; 34 | 35 | /** Pattern to ignore e.g. `/authenticate`, can be a RegExp, Pattern object or string. 36 | * 37 | * When passing a string, the string will be matched with the path `===`. 38 | */ 39 | ignorePatterns?: Array; 40 | 41 | /** Optional callback for successfull validation, passes the Context and the JwtValidation object */ 42 | onSuccess?: OnSuccessHandler; 43 | 44 | /** Optional callback for unsuccessfull validation, passes the Context and JwtValidation if the JWT is present in the header. 45 | * 46 | * When not used, will throw HTTPError using custom (or default) messages. 47 | * If you want the failure to be ignored and to call the next middleware, return true. 48 | */ 49 | onFailure?: OnFailureHandler; 50 | 51 | /** See the djwt module for Validation options */ 52 | key: CryptoKey | null; 53 | algorithm: Algorithm; 54 | } 55 | 56 | export class JWTMiddlewareError extends Error { 57 | name = this.constructor.name; 58 | } 59 | 60 | const errorMessages: ErrorMessages = { 61 | ERROR_INVALID_AUTH: "Authentication failed", 62 | AUTHORIZATION_HEADER_NOT_PRESENT: "Authorization header is not present", 63 | AUTHORIZATION_HEADER_INVALID: "Invalid Authorization header", 64 | }; 65 | 66 | // deno-lint-ignore no-explicit-any 67 | const isPattern = (obj: any): obj is Pattern => { 68 | return typeof obj === "object" && obj.path; 69 | }; 70 | 71 | const ignorePath = >( 72 | ctx: T, 73 | patterns: Array, 74 | ): boolean => { 75 | // deno-lint-ignore no-explicit-any 76 | const testString = (pattern: any) => 77 | typeof pattern === "string" && pattern === ctx.request.url.pathname; 78 | // deno-lint-ignore no-explicit-any 79 | const testRegExp = (pattern: any) => 80 | pattern instanceof RegExp && pattern.test(ctx.request.url.pathname); 81 | 82 | for (const pattern of patterns) { 83 | if ( 84 | testString(pattern) || 85 | testRegExp(pattern) || 86 | ( 87 | isPattern(pattern) && 88 | (testString(pattern.path) || testRegExp(pattern.path)) && 89 | (!pattern.methods || pattern.methods?.includes(ctx.request.method)) 90 | ) 91 | ) { 92 | return true; 93 | } 94 | } 95 | 96 | return false; 97 | }; 98 | 99 | export const jwtMiddleware = < 100 | T extends RouterMiddleware | Middleware = Middleware, 101 | >({ 102 | key, 103 | customMessages = {}, 104 | ignorePatterns, 105 | onSuccess = () => {}, 106 | onFailure = () => true, 107 | }: JwtMiddlewareOptions): T => { 108 | Object.assign(customMessages, errorMessages); 109 | 110 | const core: RouterMiddleware = async (ctx, next) => { 111 | const onUnauthorized = async ( 112 | jwtValidation: Error, 113 | isJwtValidationError = false, 114 | ) => { 115 | const shouldThrow = onFailure(ctx, jwtValidation); 116 | if (shouldThrow) { 117 | ctx.throw( 118 | Status.Unauthorized, 119 | isJwtValidationError 120 | ? jwtValidation.message 121 | : customMessages?.ERROR_INVALID_AUTH, 122 | ); 123 | } 124 | 125 | await next(); 126 | }; 127 | 128 | // If request matches ignore, call next early 129 | if (ignorePatterns && ignorePath(ctx, ignorePatterns)) { 130 | await next(); 131 | 132 | return; 133 | } 134 | 135 | // No Authorization header 136 | if (!ctx.request.headers.has("Authorization")) { 137 | await onUnauthorized( 138 | new JWTMiddlewareError(errorMessages.ERROR_INVALID_AUTH), 139 | ); 140 | 141 | return; 142 | } 143 | 144 | // Authorization header has no Bearer or no token 145 | const authHeader = ctx.request.headers.get("Authorization")!; 146 | if (!authHeader.startsWith("Bearer ") || authHeader.length <= 7) { 147 | await onUnauthorized( 148 | new JWTMiddlewareError(errorMessages.AUTHORIZATION_HEADER_INVALID), 149 | ); 150 | 151 | return; 152 | } 153 | 154 | const jwt = authHeader.slice(7); 155 | try { 156 | onSuccess(ctx, await verify(jwt, key)); 157 | } catch (e) { 158 | await onUnauthorized(e, true); 159 | } 160 | await next(); 161 | }; 162 | 163 | return core as T; 164 | }; 165 | 166 | export default { jwtMiddleware }; 167 | -------------------------------------------------------------------------------- /tests/integration.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | afterEach, 3 | Algorithm, 4 | Application, 5 | assertEquals, 6 | beforeEach, 7 | create, 8 | describe, 9 | getNumericDate, 10 | it, 11 | Middleware, 12 | } from "../deps.ts"; 13 | import { jwtMiddleware } from "../mod.ts"; 14 | 15 | const SECRET = await crypto.subtle.generateKey( 16 | { name: "HMAC", hash: "SHA-512" }, 17 | true, 18 | ["sign", "verify"], 19 | ); 20 | const ALGORITHM: Algorithm = "HS512"; 21 | const INVALID_JWT = 22 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; 23 | const PORT = 8001; 24 | 25 | const getJWT = ({ expirationDate }: { expirationDate?: Date } = {}) => { 26 | return create({ alg: ALGORITHM, typ: "jwt" }, { 27 | exp: getNumericDate(expirationDate || 60 * 60), 28 | }, SECRET); 29 | }; 30 | 31 | // Spawns an application with middleware instantiated 32 | const createApplicationAndClient = async () => { 33 | const controller = new AbortController(); 34 | const app = new Application({ logErrors: false }); 35 | 36 | app.use( 37 | jwtMiddleware({ 38 | algorithm: ALGORITHM, 39 | key: SECRET, 40 | }), 41 | ); 42 | 43 | app.use((ctx) => { 44 | ctx.response.body = "hello-world"; 45 | ctx.response.status = 200; 46 | }); 47 | 48 | const listen = app.listen({ 49 | port: PORT, 50 | hostname: "localhost", 51 | signal: controller.signal, 52 | }); 53 | 54 | await new Promise((resolve) => { 55 | setTimeout(resolve, 1000); 56 | }); 57 | 58 | return { 59 | listen, 60 | controller, 61 | request: (options: RequestInit) => { 62 | return fetch(`http://localhost:${PORT}`, options); 63 | }, 64 | }; 65 | }; 66 | 67 | describe("jwtMiddleware integration test", () => { 68 | let testCtx: Awaited>; 69 | 70 | beforeEach(async () => { 71 | testCtx = await createApplicationAndClient(); 72 | }); 73 | 74 | afterEach(async () => { 75 | testCtx.controller.abort(); 76 | await testCtx.listen; 77 | }); 78 | 79 | it("error with invalid Authorization", async () => { 80 | const headers = new Headers(); 81 | headers.set("Authorization", "Noth"); 82 | const response = await testCtx.request({ headers }); 83 | 84 | assertEquals(response.status, 401); 85 | assertEquals(response.statusText, "Unauthorized"); 86 | assertEquals(await response.text(), "Authentication failed"); 87 | }); 88 | 89 | it("error with invalid Bearer", async () => { 90 | const headers = new Headers(); 91 | headers.set("Authorization", "Bearer 123"); 92 | 93 | const response = await testCtx.request({ headers }); 94 | 95 | assertEquals(response.status, 401); 96 | assertEquals(response.statusText, "Unauthorized"); 97 | assertEquals( 98 | await response.text(), 99 | "The serialization of the jwt is invalid.", 100 | ); 101 | }); 102 | 103 | it("success with valid token", async () => { 104 | const headers = new Headers(); 105 | headers.set("Authorization", `Bearer ${await getJWT()}`); 106 | 107 | const response = await testCtx.request({ headers }); 108 | 109 | assertEquals(response.status, 200); 110 | assertEquals(await response.text(), "hello-world"); 111 | }); 112 | 113 | it("failure with invalid token", async () => { 114 | const headers = new Headers(); 115 | headers.set("Authorization", `Bearer ${INVALID_JWT}`); 116 | 117 | const response = await testCtx.request({ headers }); 118 | 119 | assertEquals(response.status, 401); 120 | assertEquals( 121 | await response.text(), 122 | "The jwt's alg 'HS256' does not match the key's algorithm.", 123 | ); 124 | }); 125 | 126 | it("failure with expired token", async () => { 127 | const headers = new Headers(); 128 | const expiredJwt = await getJWT({ 129 | expirationDate: new Date(2000, 0, 0), 130 | }); 131 | 132 | headers.set("Authorization", `Bearer ${expiredJwt}`); 133 | 134 | const response = await testCtx.request({ headers }); 135 | 136 | assertEquals(response.status, 401); 137 | assertEquals(await response.text(), "The jwt is expired."); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /tests/unit.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Algorithm, 3 | assert, 4 | assertRejects, 5 | create, 6 | createHttpError, 7 | describe, 8 | getNumericDate, 9 | it, 10 | Payload, 11 | RouterContext, 12 | } from "../deps.ts"; 13 | import { jwtMiddleware, JwtMiddlewareOptions } from "../mod.ts"; 14 | 15 | const SECRET = await crypto.subtle.generateKey( 16 | { name: "HMAC", hash: "SHA-512" }, 17 | true, 18 | ["sign", "verify"], 19 | ); 20 | const ALGORITHM: Algorithm = "HS512"; 21 | const jwtOptions: JwtMiddlewareOptions = { 22 | key: SECRET, 23 | algorithm: ALGORITHM, 24 | }; 25 | 26 | const mockContext = (token?: string): RouterContext => 27 | ({ 28 | request: { 29 | headers: new Headers( 30 | token ? { "Authorization": `Bearer ${token}` } : undefined, 31 | ), 32 | url: new URL("http://foo.bar/baz"), 33 | method: "GET", 34 | }, 35 | throw: (status: number, msg: string) => { 36 | throw createHttpError(status, msg); 37 | }, 38 | }) as RouterContext; 39 | 40 | const mockNext = () => { 41 | return new Promise((resolve) => { 42 | resolve(); 43 | }); 44 | }; 45 | 46 | describe("jwtMiddleware unit test", () => { 47 | it("succeeds", async () => { 48 | let jwtObj: Payload = {}; 49 | 50 | const payload = { test: "test" }; 51 | const mockJwt = await create( 52 | { alg: ALGORITHM, typ: "jwt" }, 53 | payload, 54 | SECRET, 55 | ); 56 | 57 | const mw = jwtMiddleware({ 58 | ...jwtOptions, 59 | onSuccess: (_ctx, payload) => { 60 | jwtObj = payload; 61 | }, 62 | }); 63 | 64 | await mw(mockContext(mockJwt), mockNext); 65 | 66 | assert(jwtObj.test === payload.test); 67 | }); 68 | 69 | it("fails with expired token", async () => { 70 | const mockJwt = await create( 71 | { alg: ALGORITHM, typ: "jwt" }, 72 | { exp: getNumericDate(new Date(2000, 1, 0)) }, 73 | SECRET, 74 | ); 75 | 76 | const mw = jwtMiddleware(jwtOptions); 77 | 78 | await assertRejects( 79 | async () => await mw(mockContext(mockJwt), mockNext), 80 | Error, 81 | "The jwt is expired.", 82 | ); 83 | }); 84 | 85 | it("fails with no header", async () => { 86 | const mw = jwtMiddleware(jwtOptions); 87 | 88 | await assertRejects( 89 | async () => await mw(mockContext(), mockNext), 90 | Error, 91 | "Authentication failed", 92 | ); 93 | }); 94 | 95 | it("fails with invalid header", async () => { 96 | const mw = jwtMiddleware(jwtOptions); 97 | 98 | await assertRejects( 99 | async () => await mw(mockContext(""), mockNext), 100 | Error, 101 | "Authentication failed", 102 | ); 103 | }); 104 | 105 | it("fails with invalid token", async () => { 106 | const mw = jwtMiddleware(jwtOptions); 107 | 108 | await assertRejects( 109 | async () => 110 | await mw( 111 | mockContext( 112 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 113 | ), 114 | mockNext, 115 | ), 116 | Error, 117 | "The jwt's alg 'HS256' does not match the key's algorithm.", 118 | ); 119 | }); 120 | 121 | it("pattern ignore string", async () => { 122 | const mw = jwtMiddleware(Object.assign({}, jwtOptions, { 123 | ignorePatterns: ["/baz"], 124 | })); 125 | 126 | await mw(mockContext(), mockNext); 127 | 128 | assert(true); 129 | }); 130 | 131 | it("pattern ignore regex", async () => { 132 | const mw = jwtMiddleware(Object.assign({}, jwtOptions, { 133 | ignorePatterns: [/baz/], 134 | })); 135 | 136 | await mw(mockContext(), mockNext); 137 | 138 | assert(true); 139 | }); 140 | 141 | it("pattern ignore object string", async () => { 142 | const mw = jwtMiddleware(Object.assign({}, jwtOptions, { 143 | ignorePatterns: [{ path: "/baz" }], 144 | })); 145 | 146 | await mw(mockContext(), mockNext); 147 | 148 | assert(true); 149 | }); 150 | 151 | it("pattern ignore object regex", async () => { 152 | const mw = jwtMiddleware(Object.assign({}, jwtOptions, { 153 | ignorePatterns: [{ path: /baz/ }], 154 | })); 155 | 156 | await mw(mockContext(), mockNext); 157 | 158 | assert(true); 159 | }); 160 | 161 | it("fails with pattern ignore object string wrong method", async () => { 162 | const mw = jwtMiddleware({ 163 | ...jwtOptions, 164 | ignorePatterns: [{ path: "/baz", methods: ["PUT"] }], 165 | }); 166 | 167 | await assertRejects(async () => await mw(mockContext(), mockNext)); 168 | }); 169 | 170 | it("succeeds with pattern ignore object string correct method", async () => { 171 | const mw = jwtMiddleware({ 172 | ...jwtOptions, 173 | ignorePatterns: [{ path: "/baz", methods: ["GET"] }], 174 | }); 175 | 176 | await mw(mockContext(), mockNext); 177 | 178 | assert(true); 179 | }); 180 | 181 | it("succeeds with pattern ignore multiple", async () => { 182 | const mw = jwtMiddleware({ 183 | ...jwtOptions, 184 | ignorePatterns: ["/baz", /buz/, { path: "/biz", methods: ["GET"] }], 185 | }); 186 | 187 | await mw(mockContext(), mockNext); 188 | 189 | assert(true); 190 | }); 191 | 192 | it("fails with onSuccess is not called on invalid jwt", async () => { 193 | const mw = jwtMiddleware({ 194 | ...jwtOptions, 195 | onSuccess: () => { 196 | assert(false, "onSuccess is not called"); 197 | }, 198 | }); 199 | 200 | await assertRejects( 201 | async () => 202 | await mw( 203 | mockContext( 204 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 205 | ), 206 | mockNext, 207 | ), 208 | Error, 209 | "The jwt's alg 'HS256' does not match the key's algorithm.", 210 | ); 211 | }); 212 | 213 | it("fails with onFailure is called", async () => { 214 | const mw = jwtMiddleware({ 215 | ...jwtOptions, 216 | onFailure: () => { 217 | assert(true, "onFailure is called"); 218 | 219 | return false; 220 | }, 221 | }); 222 | 223 | await mw( 224 | mockContext( 225 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 226 | ), 227 | mockNext, 228 | ); 229 | }); 230 | }); 231 | --------------------------------------------------------------------------------