diff --git a/.changeset/bright-gorillas-crash.md b/.changeset/bright-gorillas-crash.md new file mode 100644 index 00000000000..c3cc6bf6721 --- /dev/null +++ b/.changeset/bright-gorillas-crash.md @@ -0,0 +1,12 @@ +--- +'@clerk/express': patch +--- + +Introduce [Express](https://expressjs.com/) specific Clerk SDK called `@clerk/express`. The SDK exposes the following APIs: + +- `clerkClient`: Default [`@clerk/backend`](https://clerk.com/docs/references/backend/overview) client initialized from environment variables and used to make backend API requests +- `clerkMiddleware`: Centralized middleware that authenticates all requests without blocking them (also triggers handshake mechanism) +- `getAuth`: Utility to retrieve the auth state from a request (requires `clerkMiddleware` to be executed) +- `requireAuth`: Middleware that returns HTTP 401 response when request is signed-out + +Also all the top level exports from `@clerk/backend` are re-exported from `@clerk/express`. \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml index a38ef7e4567..836baf3e6ec 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -12,6 +12,9 @@ clerk-js: elements: - packages/elements/** +express: + - packages/express/** + expo: - packages/expo/** diff --git a/.jit/config.yml b/.jit/config.yml index bfb4be53d67..52a89383506 100644 --- a/.jit/config.yml +++ b/.jit/config.yml @@ -32,3 +32,6 @@ folders: - ./**/*.test.ts - path: /packages/themes - path: /packages/types + - path: /packages/express + exclude: + - ./**/*.test.ts diff --git a/integration/.keys.json.sample b/integration/.keys.json.sample index 5f29828daf6..a3bf1201b67 100644 --- a/integration/.keys.json.sample +++ b/integration/.keys.json.sample @@ -10,5 +10,9 @@ "with-custom-roles": { "pk": "", "sk": "" + }, + "core-2-all-enabled": { + "pk": "", + "sk": "" } } diff --git a/package-lock.json b/package-lock.json index 01446d2d770..889fd64a38d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2964,6 +2964,10 @@ "resolved": "packages/elements", "link": true }, + "node_modules/@clerk/express": { + "resolved": "packages/express", + "link": true + }, "node_modules/@clerk/fastify": { "resolved": "packages/fastify", "link": true @@ -10936,6 +10940,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true + }, "node_modules/@types/cors": { "version": "2.8.14", "dev": true, @@ -11181,6 +11191,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true + }, "node_modules/@types/mime": { "version": "1.3.2", "dev": true, @@ -11381,6 +11397,27 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.6.tgz", + "integrity": "sha512-yzBOv+6meEHSzV2NThYYOA6RtqvPr3Hbob9ZLp3i07SH27CrYVfm8CrF7ydTmidtelsFiKx2I4gZAiAOamGgvQ==", + "dev": true, + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.2.tgz", + "integrity": "sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==", + "dev": true, + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/testing-library__jest-dom": { "version": "5.14.6", "dev": true, @@ -14422,6 +14459,7 @@ }, "node_modules/camelcase-keys": { "version": "6.2.2", + "dev": true, "license": "MIT", "dependencies": { "camelcase": "^5.3.1", @@ -14437,6 +14475,7 @@ }, "node_modules/camelcase-keys/node_modules/camelcase": { "version": "5.3.1", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -15273,6 +15312,15 @@ "node": ">=0.10.0" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/component-type": { "version": "1.2.2", "dev": true, @@ -15507,8 +15555,9 @@ }, "node_modules/connect": { "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "debug": "2.6.9", @@ -15735,6 +15784,12 @@ "dev": true, "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true + }, "node_modules/cookies": { "version": "0.8.0", "license": "MIT", @@ -17254,6 +17309,16 @@ "dev": true, "license": "0BSD" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "5.1.0", "dev": true, @@ -20698,6 +20763,21 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", + "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", + "dev": true, + "dependencies": { + "dezalgo": "^1.0.4", + "hexoid": "^1.0.0", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "license": "MIT", @@ -22962,6 +23042,15 @@ "tslib": "^2.0.3" } }, + "node_modules/hexoid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "license": "BSD-3-Clause", @@ -31490,6 +31579,7 @@ }, "node_modules/quick-lru": { "version": "4.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -34538,6 +34628,66 @@ "dev": true, "license": "MIT" }, + "node_modules/superagent": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", + "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", + "dev": true, + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/superagent/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", + "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", + "dev": true, + "dependencies": { + "methods": "^1.1.2", + "superagent": "^8.1.2" + }, + "engines": { + "node": ">=6.4.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "license": "MIT", @@ -35253,10 +35403,6 @@ "node": ">=4" } }, - "node_modules/to-no-case": { - "version": "1.0.2", - "license": "MIT" - }, "node_modules/to-regex-range": { "version": "5.0.1", "license": "MIT", @@ -35267,20 +35413,6 @@ "node": ">=8.0" } }, - "node_modules/to-snake-case": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "to-space-case": "^1.0.0" - } - }, - "node_modules/to-space-case": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "to-no-case": "^1.0.0" - } - }, "node_modules/toggle-selection": { "version": "1.0.6", "license": "MIT" @@ -39064,6 +39196,191 @@ "dev": true, "license": "ISC" }, + "packages/express": { + "name": "@clerk/express", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@clerk/backend": "^1.0.0-beta.28", + "@clerk/shared": "^2.0.0-beta.19", + "tslib": "2.4.1" + }, + "devDependencies": { + "@clerk/types": "4.0.0-beta.18", + "@types/express": "^4.17", + "@types/node": "^18.17.0", + "@types/supertest": "^6.0.2", + "eslint-config-custom": "*", + "express": "^4.19.2", + "supertest": "^6.3.4", + "tsup": "*", + "typescript": "*" + }, + "engines": { + "node": ">=18.17.0" + } + }, + "packages/express/node_modules/@clerk/types": { + "version": "4.0.0-beta.18", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.0.0-beta.18.tgz", + "integrity": "sha512-GgpTYv6kTnf4nCtdP/nmEzJlxzGiy6IEfzELBl6Xk1UQTGh3tN6cOGYPAPsn0VN5osMsq8UGwujcbDkoFclBiQ==", + "dev": true, + "dependencies": { + "csstype": "3.1.1" + }, + "engines": { + "node": ">=18.17.0" + } + }, + "packages/express/node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "packages/express/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "packages/express/node_modules/csstype": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", + "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", + "dev": true + }, + "packages/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "packages/express/node_modules/express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dev": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "packages/express/node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "packages/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "packages/express/node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true + }, + "packages/express/node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "packages/express/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "packages/express/node_modules/tslib": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", + "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" + }, "packages/fastify": { "name": "@clerk/fastify", "version": "1.0.0-beta.39", @@ -39316,8 +39633,6 @@ "dependencies": { "@clerk/backend": "1.0.0-beta.32", "@clerk/shared": "2.0.0-beta.21", - "camelcase-keys": "6.2.2", - "snakecase-keys": "3.2.1", "tslib": "2.4.1" }, "devDependencies": { @@ -39335,17 +39650,6 @@ "node": ">=18.17.0" } }, - "packages/sdk-node/node_modules/snakecase-keys": { - "version": "3.2.1", - "license": "MIT", - "dependencies": { - "map-obj": "^4.1.0", - "to-snake-case": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, "packages/sdk-node/node_modules/tslib": { "version": "2.4.1", "license": "0BSD" diff --git a/packages/express/.eslintrc.js b/packages/express/.eslintrc.js new file mode 100644 index 00000000000..41785d00428 --- /dev/null +++ b/packages/express/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ['custom/node', 'custom/typescript', 'custom/jest'], +}; diff --git a/packages/express/.gitignore b/packages/express/.gitignore new file mode 100644 index 00000000000..6a53db05dea --- /dev/null +++ b/packages/express/.gitignore @@ -0,0 +1,8 @@ +*.log +.DS_Store +.idea +node_modules +dist +coverage +.env +.env.local diff --git a/packages/express/.npmignore b/packages/express/.npmignore new file mode 100644 index 00000000000..8b2767ff3b3 --- /dev/null +++ b/packages/express/.npmignore @@ -0,0 +1,2 @@ +* +!/dist/**/* \ No newline at end of file diff --git a/packages/express/CHANGELOG.md b/packages/express/CHANGELOG.md new file mode 100644 index 00000000000..420e6f23d0e --- /dev/null +++ b/packages/express/CHANGELOG.md @@ -0,0 +1 @@ +# Change Log diff --git a/packages/express/LICENSE b/packages/express/LICENSE new file mode 100644 index 00000000000..5713d0938b3 --- /dev/null +++ b/packages/express/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Clerk, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/express/README.md b/packages/express/README.md new file mode 100644 index 00000000000..46ba4f6ac5b --- /dev/null +++ b/packages/express/README.md @@ -0,0 +1,99 @@ +

+ + + + + + +
+

+ +# @clerk/express + +
+ +[![Chat on Discord](https://img.shields.io/discord/856971667393609759.svg?logo=discord)](https://clerk.com/discord) +[![Clerk documentation](https://img.shields.io/badge/documentation-clerk-green.svg)](https://beta.clerk.com/docs?utm_source=github&utm_medium=express) +[![Follow on Twitter](https://img.shields.io/twitter/follow/ClerkDev?style=social)](https://twitter.com/intent/follow?screen_name=ClerkDev) + +[Changelog](https://github.com/clerk/javascript/blob/main/packages/express/CHANGELOG.md) +· +[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml) +· +[Request a Feature](https://feedback.clerk.com/roadmap) +· +[Ask a Question](https://github.com/clerk/javascript/discussions) + +
+ +--- + +## Overview + +[Clerk](https://clerk.com?utm_source=github&utm_medium=express) is the easiest way to add authentication and user management to your Node.js application. To gain a better understanding of the Express SDK and Clerk Backend API, refer to +the Express SDK and Backend API documentation. + +## Getting started + +### Prerequisites + +- Node.js `>=18.17.0` or later +- Express installed (follow their [Getting started](https://expressjs.com/en/starter/installing.html) guide) + +## Installation + +```sh +npm install @clerk/express +``` + +To build the package locally with the TypeScript compiler, run: + +```sh +npm run build +``` + +## Usage + +Retrieve your Backend API key from the [API Keys](https://dashboard.clerk.com/last-active?path=api-keys) screen in your Clerk dashboard and set it as an environment variable in a `.env` file: + +```sh +CLERK_PUBLISHABLE_KEY=pk_******* +CLERK_SECRET_KEY=sk_****** +``` + +You will then be able to access all the available methods. + +```js +import 'dotenv/config'; // To read CLERK_SECRET_KEY +import { clerkClient } from '@clerk/express'; + +const { data: userList } = await clerkClient.users.getUserList(); +``` + +_For further details and examples, please refer to our [Documentation](https://beta.clerk.com/docs/references/express/overview?utm_source=github&utm_medium=express)._ + +## Support + +You can get in touch with us in any of the following ways: + +- Join our official community [Discord server](https://clerk.com/discord) +- Create a [GitHub Discussion](https://github.com/clerk/javascript/discussions) +- Contact options listed on [our Support page](https://clerk.com/support?utm_source=github&utm_medium=express) + +## Contributing + +We're open to all community contributions! If you'd like to contribute in any way, please read [our contribution guidelines](https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md). + +## Security + +`@clerk/express` follows good practices of security, but 100% security cannot be assured. + +`@clerk/express` is provided **"as is"** without any **warranty**. Use at your own risk. + +_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._ + +## License + +This project is licensed under the **MIT license**. + +See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/express/LICENSE) for more information. diff --git a/packages/express/jest.config.js b/packages/express/jest.config.js new file mode 100644 index 00000000000..71b21269826 --- /dev/null +++ b/packages/express/jest.config.js @@ -0,0 +1,18 @@ +const { name } = require('./package.json'); + +module.exports = { + displayName: name.replace('@clerk', ''), + globals: { + PACKAGE_NAME: '@clerk/express', + PACKAGE_VERSION: '0.0.0-test', + }, + roots: ['/src'], + testMatch: ['**/?(*.)+(test).+(ts)'], + transform: { + '^.+\\.ts$': 'ts-jest', + }, + setupFiles: ['/jest.setup.js'], + collectCoverage: true, + coveragePathIgnorePatterns: ['__tests__'], + testPathIgnorePatterns: ['/node_modules/', '/coverage', '/dist/'], +}; diff --git a/packages/express/jest.setup.js b/packages/express/jest.setup.js new file mode 100644 index 00000000000..cf6bc7526d0 --- /dev/null +++ b/packages/express/jest.setup.js @@ -0,0 +1,2 @@ +process.env.CLERK_SECRET_KEY = 'sk_test_....'; +process.env.CLERK_PUBLISHABLE_KEY = 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA'; diff --git a/packages/express/package.json b/packages/express/package.json new file mode 100644 index 00000000000..4d41f76d2eb --- /dev/null +++ b/packages/express/package.json @@ -0,0 +1,77 @@ +{ + "name": "@clerk/express", + "version": "1.0.0", + "description": "Clerk server SDK for usage with Express", + "keywords": [ + "clerk", + "sdk", + "express" + ], + "homepage": "https://clerk.com/", + "bugs": { + "url": "https://github.com/clerk/javascript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/express" + }, + "license": "MIT", + "author": { + "name": "Clerk, Inc.", + "email": "support@clerk.com", + "url": "git+https://github.com/clerk/javascript.git" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "files": [ + "dist" + ], + "scripts": { + "build": "npm run clean && tsup", + "clean": "rimraf ./dist", + "dev": "tsup --watch", + "dev:publish": "npm run dev -- --env.publish", + "lint": "eslint src/", + "lint:attw": "attw --pack .", + "lint:publint": "publint", + "publish:local": "npx yalc push --replace --sig", + "test": "jest", + "test:cache:clear": "jest --clearCache --useStderr", + "test:ci": "jest --maxWorkers=70%" + }, + "dependencies": { + "@clerk/backend": "^1.0.0-beta.28", + "@clerk/shared": "^2.0.0-beta.19", + "tslib": "2.4.1" + }, + "devDependencies": { + "@clerk/types": "4.0.0-beta.18", + "@types/express": "^4.17", + "@types/node": "^18.17.0", + "@types/supertest": "^6.0.2", + "eslint-config-custom": "*", + "express": "^4.19.2", + "supertest": "^6.3.4", + "tsup": "*", + "typescript": "*" + }, + "engines": { + "node": ">=18.17.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/express/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/express/src/__tests__/__snapshots__/exports.test.ts.snap new file mode 100644 index 00000000000..406f539783d --- /dev/null +++ b/packages/express/src/__tests__/__snapshots__/exports.test.ts.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`module exports should not change unless explicitly set 1`] = ` +[ + "clerkClient", + "clerkMiddleware", + "createClerkClient", + "getAuth", + "requireAuth", + "verifyToken", +] +`; diff --git a/packages/express/src/__tests__/clerkMiddleware.test.ts b/packages/express/src/__tests__/clerkMiddleware.test.ts new file mode 100644 index 00000000000..39f39f56ca8 --- /dev/null +++ b/packages/express/src/__tests__/clerkMiddleware.test.ts @@ -0,0 +1,138 @@ +import type { RequestHandler } from 'express'; + +import { clerkMiddleware } from '../clerkMiddleware'; +import { getAuth } from '../getAuth'; +import { requireAuth } from '../requireAuth'; +import { assertNoDebugHeaders, assertSignedOutDebugHeaders, runMiddleware } from './helpers'; + +describe('clerkMiddleware', () => { + // TODO(dimkl): Fix issue that makes test order matter, until then keep this test suite first + describe('without secretKey env', () => { + const sk = process.env.CLERK_SECRET_KEY; + + beforeEach(() => { + delete process.env.CLERK_SECRET_KEY; + }); + + afterEach(() => { + process.env.CLERK_SECRET_KEY = sk; + }); + + it('throws error if secretKey is not passed as parameter', async () => { + const response = await runMiddleware(clerkMiddleware()).expect(500); + + assertNoDebugHeaders(response); + }); + + it('works if secretKey is passed as parameter', async () => { + const options = { secretKey: 'sk_test_....' }; + + const response = await runMiddleware(clerkMiddleware(options)).expect(200, 'Hello world!'); + + assertSignedOutDebugHeaders(response); + }); + }); + + // TODO(dimkl): Fix issue that makes test order matter, until then keep this test suite second + describe('without publishableKey env', () => { + const pk = process.env.CLERK_PUBLISHABLE_KEY; + + beforeEach(() => { + delete process.env.CLERK_PUBLISHABLE_KEY; + }); + + afterEach(() => { + process.env.CLERK_PUBLISHABLE_KEY = pk; + }); + + it('throws error if publishableKey is not passed as parameter', async () => { + const response = await runMiddleware(clerkMiddleware()).expect(500); + + assertNoDebugHeaders(response); + }); + + it('works if publishableKey is passed as parameter', async () => { + const options = { publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k' }; + + const response = await runMiddleware(clerkMiddleware(options)).expect(200, 'Hello world!'); + + assertSignedOutDebugHeaders(response); + }); + }); + + it.todo('supports usage without invocation: app.use(clerkMiddleware)'); + + it('supports usage without parameters: app.use(clerkMiddleware())', async () => { + const response = await runMiddleware(clerkMiddleware()).expect(200, 'Hello world!'); + + assertSignedOutDebugHeaders(response); + }); + + it('supports usage with parameters: app.use(clerkMiddleware(options))', async () => { + const options = { publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k' }; + + const response = await runMiddleware(clerkMiddleware(options)).expect(200, 'Hello world!'); + + assertSignedOutDebugHeaders(response); + }); + + it('supports usage with request handler: app.use(clerkMiddleware(requestHandler))', async () => { + const handler: RequestHandler = (_req, res, next) => { + res.setHeader('x-clerk-auth-custom', 'custom-value'); + return next(); + }; + + const response = await runMiddleware(clerkMiddleware(handler)).expect(200, 'Hello world!'); + + expect(response.header).toHaveProperty('x-clerk-auth-custom', 'custom-value'); + assertSignedOutDebugHeaders(response); + }); + + it('supports usage with parameters and request handler: app.use(clerkMiddleware(requestHandler, options))', async () => { + const handler: RequestHandler = (_req, res, next) => { + res.setHeader('x-clerk-auth-custom', 'custom-value'); + return next(); + }; + const options = { publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k' }; + + const response = await runMiddleware(clerkMiddleware(handler, options)).expect(200, 'Hello world!'); + + expect(response.header).toHaveProperty('x-clerk-auth-custom', 'custom-value'); + assertSignedOutDebugHeaders(response); + }); + + it('throws error if clerkMiddleware is not executed before requireAuth', async () => { + const customMiddleware: RequestHandler = (_request, response, next) => { + response.setHeader('x-custom-middleware', 'custom'); + return next(); + }; + + const response = await runMiddleware([requireAuth, customMiddleware]).expect(500); + + assertNoDebugHeaders(response); + expect(response.header).not.toHaveProperty('x-clerk-auth-custom', 'custom-value'); + }); + + it('throws error if clerkMiddleware is not executed before getAuth', async () => { + const customMiddleware: RequestHandler = (request, response, next) => { + const auth = getAuth(request); + response.setHeader('x-custom-middleware', auth.userId || ''); + return next(); + }; + + const response = await runMiddleware([customMiddleware]).expect(500); + + assertNoDebugHeaders(response); + expect(response.header).not.toHaveProperty('x-clerk-auth-custom', 'custom-value'); + }); + + it('supports handshake flow', async () => { + const response = await runMiddleware(clerkMiddleware(), { + Cookie: '__client_uat=1711618859;', + 'Sec-Fetch-Dest': 'document', + }).expect(307); + + expect(response.header).toHaveProperty('x-clerk-auth-status', 'handshake'); + expect(response.header).toHaveProperty('location', expect.stringContaining('/v1/client/handshake?redirect_url=')); + }); +}); diff --git a/packages/express/src/__tests__/exports.test.ts b/packages/express/src/__tests__/exports.test.ts new file mode 100644 index 00000000000..632b0e0fcbf --- /dev/null +++ b/packages/express/src/__tests__/exports.test.ts @@ -0,0 +1,7 @@ +import * as publicExports from '..'; + +describe('module exports', () => { + it('should not change unless explicitly set', () => { + expect(Object.keys(publicExports).sort()).toMatchSnapshot(); + }); +}); diff --git a/packages/express/src/__tests__/getAuth.test.ts b/packages/express/src/__tests__/getAuth.test.ts new file mode 100644 index 00000000000..eaa3e6337ba --- /dev/null +++ b/packages/express/src/__tests__/getAuth.test.ts @@ -0,0 +1,17 @@ +import { getAuth } from '../getAuth'; +import { mockRequest, mockRequestWithAuth } from './helpers'; + +describe('getAuth', () => { + it('throws error if clerkMiddleware is not executed before getAuth', async () => { + expect(() => getAuth(mockRequest())).toThrow(/The "clerkMiddleware" should be registered before using "getAuth"/); + }); + + it('returns auth from request for signed-out request', async () => { + expect(getAuth(mockRequestWithAuth())).toHaveProperty('userId', null); + }); + + it('returns auth from request', async () => { + const req = mockRequestWithAuth({ userId: 'user_12345' }); + expect(getAuth(req)).toHaveProperty('userId', 'user_12345'); + }); +}); diff --git a/packages/express/src/__tests__/helpers.ts b/packages/express/src/__tests__/helpers.ts new file mode 100644 index 00000000000..45a6480b95f --- /dev/null +++ b/packages/express/src/__tests__/helpers.ts @@ -0,0 +1,57 @@ +import type { AuthObject } from '@clerk/backend/internal'; +import type { Application, Request as ExpressRequest, RequestHandler, Response as ExpressResponse } from 'express'; +import express from 'express'; +import supertest from 'supertest'; + +import type { ExpressRequestWithAuth } from '../types'; + +// Inspired by https://github.com/helmetjs/helmet/blob/main/test/helpers.ts +export function runMiddleware(middleware: RequestHandler | RequestHandler[], headers: Record = {}) { + const app: Application = express(); + app.use(middleware); + app.use((_req, res, _next) => res.end('Hello world!')); + + return supertest(app).get('/').set(headers); +} + +export function mockResponse(): ExpressResponse { + return { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + } as unknown as ExpressResponse; +} + +export function mockRequest(): ExpressRequest { + return {} as ExpressRequest; +} + +export function mockRequestWithAuth(auth: Partial = {}): ExpressRequestWithAuth { + return { + auth: { + sessionClaims: null, + sessionId: null, + actor: null, + userId: null, + orgId: null, + orgRole: null, + orgSlug: null, + orgPermissions: null, + getToken: async () => '', + has: () => false, + debug: () => ({}), + ...auth, + }, + } as unknown as ExpressRequestWithAuth; +} + +export function assertSignedOutDebugHeaders(response: any) { + expect(response.header).toHaveProperty('x-clerk-auth-status', 'signed-out'); + expect(response.header).toHaveProperty('x-clerk-auth-reason', 'session-token-and-uat-missing'); + expect(response.header).not.toHaveProperty('x-clerk-auth-message'); +} + +export function assertNoDebugHeaders(response: any) { + expect(response.header).not.toHaveProperty('x-clerk-auth-status'); + expect(response.header).not.toHaveProperty('x-clerk-auth-reason'); + expect(response.header).not.toHaveProperty('x-clerk-auth-message'); +} diff --git a/packages/express/src/__tests__/requireAuth.test.ts b/packages/express/src/__tests__/requireAuth.test.ts new file mode 100644 index 00000000000..afc554c5d25 --- /dev/null +++ b/packages/express/src/__tests__/requireAuth.test.ts @@ -0,0 +1,31 @@ +import { requireAuth } from '../requireAuth'; +import { mockRequest, mockRequestWithAuth, mockResponse } from './helpers'; + +describe('requireAuth', () => { + it('throws error if clerkMiddleware is not executed before this middleware', async () => { + expect(() => requireAuth(mockRequest(), mockResponse(), () => undefined)).toThrow( + /The "clerkMiddleware" should be registered before using "requireAuth"/, + ); + }); + + it('make application require auth - returns 401 Unauthorized for signed-out', async () => { + const response = mockResponse(); + const nextFn = jest.fn(); + + requireAuth(mockRequestWithAuth(), response, nextFn); + + expect(response.status).toBeCalledWith(401); + expect(nextFn).not.toBeCalled(); + }); + + it('make application require auth - proceed with next middlewares for signed-in', async () => { + const response = mockResponse(); + const nextFn = jest.fn(); + const request = mockRequestWithAuth({ userId: 'user_1234' }); + + requireAuth(request, response, nextFn); + + expect(response.status).not.toBeCalled(); + expect(nextFn).toBeCalled(); + }); +}); diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts new file mode 100644 index 00000000000..57849e6630b --- /dev/null +++ b/packages/express/src/authenticateRequest.ts @@ -0,0 +1,97 @@ +import type { RequestState } from '@clerk/backend/internal'; +import { AuthStatus, createClerkRequest } from '@clerk/backend/internal'; +import { handleValueOrFn } from '@clerk/shared/handleValueOrFn'; +import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; +import { isHttpOrHttps, isProxyUrlRelative, isValidProxyUrl } from '@clerk/shared/proxy'; +import type { Response } from 'express'; +import type { IncomingMessage } from 'http'; + +import { satelliteAndMissingProxyUrlAndDomain, satelliteAndMissingSignInUrl } from './errors'; +import type { AuthenticateRequestParams } from './types'; +import { loadApiEnv, loadClientEnv } from './utils'; + +export const authenticateRequest = (opts: AuthenticateRequestParams) => { + const { clerkClient, request, options } = opts; + const { jwtKey, authorizedParties, audience } = options || {}; + + const clerkRequest = createClerkRequest(incomingMessageToRequest(request)); + const env = { ...loadApiEnv(), ...loadClientEnv() }; + + const secretKey = options?.secretKey || env.secretKey; + const publishableKey = options?.publishableKey || env.publishableKey; + + const isSatellite = handleValueOrFn(options?.isSatellite, clerkRequest.clerkUrl, env.isSatellite); + const domain = handleValueOrFn(options?.domain, clerkRequest.clerkUrl) || env.domain; + const signInUrl = options?.signInUrl || env.signInUrl; + const proxyUrl = absoluteProxyUrl( + handleValueOrFn(options?.proxyUrl, clerkRequest.clerkUrl, env.proxyUrl), + clerkRequest.clerkUrl.toString(), + ); + + if (isSatellite && !proxyUrl && !domain) { + throw new Error(satelliteAndMissingProxyUrlAndDomain); + } + + if (isSatellite && !isHttpOrHttps(signInUrl) && isDevelopmentFromSecretKey(secretKey || '')) { + throw new Error(satelliteAndMissingSignInUrl); + } + + return clerkClient.authenticateRequest(clerkRequest, { + audience, + secretKey, + publishableKey, + jwtKey, + authorizedParties, + proxyUrl, + isSatellite, + domain, + signInUrl, + }); +}; + +const incomingMessageToRequest = (req: IncomingMessage): Request => { + const headers = Object.keys(req.headers).reduce((acc, key) => Object.assign(acc, { [key]: req?.headers[key] }), {}); + // @ts-ignore Optimistic attempt to get the protocol in case + // req extends IncomingMessage in a useful way. No guarantee + // it'll work. + const protocol = req.connection?.encrypted ? 'https' : 'http'; + const dummyOriginReqUrl = new URL(req.url || '', `${protocol}://clerk-dummy`); + return new Request(dummyOriginReqUrl, { + method: req.method, + headers: new Headers(headers), + }); +}; + +export const setResponseHeaders = (requestState: RequestState, res: Response): Error | undefined => { + if (requestState.headers) { + requestState.headers.forEach((value, key) => res.appendHeader(key, value)); + } + return setResponseForHandshake(requestState, res); +}; + +/** + * Depending on the auth state of the request, handles applying redirects and validating that a handshake state was properly handled. + * + * Returns an error if state is handshake without a redirect, otherwise returns undefined. res.writableEnded should be checked after this method is called. + */ +const setResponseForHandshake = (requestState: RequestState, res: Response): Error | undefined => { + const hasLocationHeader = requestState.headers.get('location'); + if (hasLocationHeader) { + // triggering a handshake redirect + res.status(307).end(); + return; + } + + if (requestState.status === AuthStatus.Handshake) { + return new Error('Clerk: unexpected handshake without redirect'); + } + + return; +}; + +const absoluteProxyUrl = (relativeOrAbsoluteUrl: string, baseUrl: string): string => { + if (!relativeOrAbsoluteUrl || !isValidProxyUrl(relativeOrAbsoluteUrl) || !isProxyUrlRelative(relativeOrAbsoluteUrl)) { + return relativeOrAbsoluteUrl; + } + return new URL(relativeOrAbsoluteUrl, baseUrl).toString(); +}; diff --git a/packages/express/src/clerkClient.ts b/packages/express/src/clerkClient.ts new file mode 100644 index 00000000000..eb8ee20a512 --- /dev/null +++ b/packages/express/src/clerkClient.ts @@ -0,0 +1,28 @@ +import type { ClerkClient } from '@clerk/backend'; +import { createClerkClient } from '@clerk/backend'; + +import { loadApiEnv, loadClientEnv } from './utils'; + +let clerkClientSingleton = {} as unknown as ClerkClient; + +export const clerkClient = new Proxy(clerkClientSingleton, { + get(_target, property: keyof ClerkClient) { + if (property in clerkClientSingleton) { + return clerkClientSingleton[property]; + } + + const env = { ...loadApiEnv(), ...loadClientEnv() }; + const client = createClerkClient({ ...env, userAgent: `${PACKAGE_NAME}@${PACKAGE_VERSION}` }); + + // if the client is initialized properly, cache it to a singleton instance variable + // in the next invocation the guard at the top will be triggered instead of creating another instance + if (env.secretKey) { + clerkClientSingleton = client; + } + + return client[property]; + }, + set() { + return false; + }, +}); diff --git a/packages/express/src/clerkMiddleware.ts b/packages/express/src/clerkMiddleware.ts new file mode 100644 index 00000000000..de622a3f7ac --- /dev/null +++ b/packages/express/src/clerkMiddleware.ts @@ -0,0 +1,57 @@ +import type { RequestHandler } from 'express'; + +import { authenticateRequest, setResponseHeaders } from './authenticateRequest'; +import { clerkClient as defaultClerkClient } from './clerkClient'; +import { middlewareNotInvoked } from './errors'; +import type { ClerkMiddleware, ClerkMiddlewareOptions } from './types'; +import { defaultHandler } from './utils'; + +const usedWithoutInvocation = (args: unknown[]) => { + return ( + args.length === 3 && typeof args[0] === 'object' && typeof args[1] === 'object' && typeof args[2] === 'function' + ); +}; + +const parseHandlerAndOptions = (args: unknown[]) => { + return [ + typeof args[0] === 'function' ? args[0] : undefined, + (args.length === 2 ? args[1] : typeof args[0] === 'function' ? {} : args[0]) || {}, + ] as [RequestHandler | undefined, ClerkMiddlewareOptions]; +}; + +export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { + const [handler, options] = parseHandlerAndOptions(args); + + const clerkClient = options.clerkClient || defaultClerkClient; + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + const middleware: RequestHandler = async (request, response, next) => { + try { + const requestState = await authenticateRequest({ + clerkClient, + request, + options, + }); + + const err = setResponseHeaders(requestState, response); + if (err || response.writableEnded) { + if (err) { + next(err); + } + return; + } + + Object.assign(request, { auth: requestState.toAuth() }); + + return next(); + } catch (err) { + next(err); + } + }; + + if (usedWithoutInvocation(args)) { + throw new Error(middlewareNotInvoked); + } + + return [middleware, handler || defaultHandler]; +}; diff --git a/packages/express/src/errors.ts b/packages/express/src/errors.ts new file mode 100644 index 00000000000..0bbb461990e --- /dev/null +++ b/packages/express/src/errors.ts @@ -0,0 +1,28 @@ +const createErrorMessage = (msg: string) => { + return `🔒 Clerk: ${msg.trim()} + + For more info, check out the docs: https://clerk.com/docs, + or come say hi in our discord server: https://clerk.com/discord + `; +}; + +export const middlewareRequired = (fnName: string) => + createErrorMessage(`The "clerkMiddleware" should be registered before using "${fnName}". +Example: + +import express from 'express'; +import { clerkMiddleware } from '@clerk/express'; + +const app = express(); +app.use(clerkMiddleware()); +`); + +export const middlewareNotInvoked = createErrorMessage( + `The "clerkMiddleware" should be invoked. Use "clerkMiddleware()"`, +); + +export const satelliteAndMissingProxyUrlAndDomain = + 'Missing domain and proxyUrl. A satellite application needs to specify a domain or a proxyUrl'; +export const satelliteAndMissingSignInUrl = ` +Invalid signInUrl. A satellite application requires a signInUrl for development instances. +Check if signInUrl is missing from your configuration or if it is not an absolute URL.`; diff --git a/packages/express/src/getAuth.ts b/packages/express/src/getAuth.ts new file mode 100644 index 00000000000..41a9b0e0c7d --- /dev/null +++ b/packages/express/src/getAuth.ts @@ -0,0 +1,20 @@ +import type { AuthObject } from '@clerk/backend/internal'; +import type { Request as ExpressRequest } from 'express'; + +import { middlewareRequired } from './errors'; +import { requestHasAuthObject } from './utils'; + +/** + * Retrieves the Clerk AuthObject using the current request object. + * + * @param {ExpressRequest} req - The current request object. + * @returns {AuthObject} Object with information about the request state and claims. + * @throws {Error} `clerkMiddleware` is required to be set in the middleware chain before this util is used. + */ +export const getAuth = (req: ExpressRequest): AuthObject => { + if (!requestHasAuthObject(req)) { + throw new Error(middlewareRequired('getAuth')); + } + + return req.auth; +}; diff --git a/packages/express/src/globals.d.ts b/packages/express/src/globals.d.ts new file mode 100644 index 00000000000..f14f854fd7d --- /dev/null +++ b/packages/express/src/globals.d.ts @@ -0,0 +1,6 @@ +export {}; + +declare global { + const PACKAGE_NAME: string; + const PACKAGE_VERSION: string; +} diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts new file mode 100644 index 00000000000..99e44657280 --- /dev/null +++ b/packages/express/src/index.ts @@ -0,0 +1,8 @@ +export * from '@clerk/backend'; + +export { clerkClient } from './clerkClient'; + +export type { ClerkMiddleware, ExpressRequestWithAuth } from './types'; +export { clerkMiddleware } from './clerkMiddleware'; +export { getAuth } from './getAuth'; +export { requireAuth } from './requireAuth'; diff --git a/packages/express/src/requireAuth.ts b/packages/express/src/requireAuth.ts new file mode 100644 index 00000000000..3aacb20aeb6 --- /dev/null +++ b/packages/express/src/requireAuth.ts @@ -0,0 +1,39 @@ +import type { RequestHandler } from 'express'; + +import { middlewareRequired } from './errors'; +import { getAuth } from './getAuth'; +import { requestHasAuthObject } from './utils'; + +/** + * Middleware to require auth requests for user authenticated or authorized requests. + * An HTTP 401 status code is returned for unauthenticated requests. + * + * @example + * router.get('/path', requireAuth, getHandler) + * //or + * router.use(requireAuth) + * @example + * hasPermission = (request, response, next) => { + * const auth = getAuth(request); + * if (!auth.has({ permission: 'permission' })) { + * response.status(403).send('Forbidden'); + * return; + * } + * return next(); + * } + * router.get('/path', requireAuth, hasPermission, getHandler) + * + * @throws {Error} `clerkMiddleware` is required to be set in the middleware chain before this util is used. + */ +export const requireAuth: RequestHandler = (request, response, next) => { + if (!requestHasAuthObject(request)) { + throw new Error(middlewareRequired('requireAuth')); + } + + if (!getAuth(request).userId) { + response.status(401).send('Unauthorized'); + return; + } + + return next(); +}; diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts new file mode 100644 index 00000000000..afe420298ed --- /dev/null +++ b/packages/express/src/types.ts @@ -0,0 +1,47 @@ +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { createClerkClient } from '@clerk/backend'; +import type { AuthenticateRequestOptions, AuthObject } from '@clerk/backend/internal'; +import type { Request as ExpressRequest, RequestHandler } from 'express'; + +export type ExpressRequestWithAuth = ExpressRequest & { auth: AuthObject }; + +export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { + debug?: boolean; + clerkClient?: ClerkClient; +}; + +/** + * Middleware for Express that handles authentication and authorization with Clerk. + * For more details, please refer to the docs: https://beta.clerk.com/docs/references/express/overview?utm_source=github&utm_medium=expres + */ +export interface ClerkMiddleware { + /** + * @example + * const handler = (request, response, next) => { + * ...; + * // if next is not called the request will be terminated or hung. + * return next(); + * } + * app.use(clerkMiddleware(handler, options)); + */ + (handler: RequestHandler, options?: ClerkMiddlewareOptions): RequestHandler[]; + /** + * @example + * app.use(clerkMiddleware(options)); + * + * @example + * const clerkClient = createClerkClient({ ... }); + * app.use(clerkMiddleware({ clerkClient })); + * + * @example + * app.use(clerkMiddleware()); + */ + (options?: ClerkMiddlewareOptions): RequestHandler[]; +} + +type ClerkClient = ReturnType; +export type AuthenticateRequestParams = { + clerkClient: ClerkClient; + request: ExpressRequest; + options?: ClerkMiddlewareOptions; +}; diff --git a/packages/express/src/utils.ts b/packages/express/src/utils.ts new file mode 100644 index 00000000000..bc045f952f4 --- /dev/null +++ b/packages/express/src/utils.ts @@ -0,0 +1,36 @@ +import { isTruthy } from '@clerk/shared/underscore'; +import type { Request as ExpressRequest, RequestHandler } from 'express'; + +import type { ExpressRequestWithAuth } from './types'; + +export const requestHasAuthObject = (req: ExpressRequest): req is ExpressRequestWithAuth => { + return 'auth' in req; +}; + +export const loadClientEnv = () => { + return { + publishableKey: process.env.CLERK_PUBLISHABLE_KEY || '', + clerkJSUrl: process.env.CLERK_JS || '', + clerkJSVersion: process.env.CLERK_JS_VERSION || '', + }; +}; + +export const loadApiEnv = () => { + return { + secretKey: process.env.CLERK_SECRET_KEY || '', + apiUrl: process.env.CLERK_API_URL || 'https://api.clerk.com', + apiVersion: process.env.CLERK_API_VERSION || 'v1', + domain: process.env.CLERK_DOMAIN || '', + proxyUrl: process.env.CLERK_PROXY_URL || '', + signInUrl: process.env.CLERK_SIGN_IN_URL || '', + isSatellite: isTruthy(process.env.CLERK_IS_SATELLITE), + jwtKey: process.env.CLERK_JWT_KEY || '', + sdkMetadata: { + name: PACKAGE_NAME, + version: PACKAGE_VERSION, + environment: process.env.NODE_ENV, + }, + }; +}; + +export const defaultHandler: RequestHandler = (_req, _res, next) => next(); diff --git a/packages/express/tsconfig.json b/packages/express/tsconfig.json new file mode 100644 index 00000000000..0f6fe206f62 --- /dev/null +++ b/packages/express/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "importHelpers": true, + "isolatedModules": true, + "moduleResolution": "NodeNext", + "module": "NodeNext", + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "sourceMap": false, + "strict": true, + "target": "ES2020", + "outDir": "dist", + "types": ["jest"] + }, + "exclude": ["node_modules"], + "include": ["src/index.ts", "src/globals.d.ts"] +} diff --git a/packages/express/tsconfig.lint.json b/packages/express/tsconfig.lint.json new file mode 100644 index 00000000000..9b624091911 --- /dev/null +++ b/packages/express/tsconfig.lint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/express/tsup.config.ts b/packages/express/tsup.config.ts new file mode 100644 index 00000000000..f402b39bbd5 --- /dev/null +++ b/packages/express/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsup'; + +import { name, version } from './package.json'; + +export default defineConfig(overrideOptions => { + const isWatch = !!overrideOptions.watch; + + return { + entry: ['./src/index.ts'], + format: ['cjs', 'esm'], + bundle: true, + clean: true, + minify: false, + sourcemap: true, + dts: true, + define: { + PACKAGE_NAME: `"${name}"`, + PACKAGE_VERSION: `"${version}"`, + __DEV__: `${isWatch}`, + }, + }; +}); diff --git a/packages/express/turbo.json b/packages/express/turbo.json new file mode 100644 index 00000000000..258eb4d8b2f --- /dev/null +++ b/packages/express/turbo.json @@ -0,0 +1,19 @@ +{ + "extends": ["//"], + "pipeline": { + "build": { + "inputs": [ + "*.d.ts", + "**/package.json", + "src/**", + "tsconfig.json", + "tsup.config.ts", + + "!**/__tests__/**", + "!**/__snapshots__/**", + "!coverage/**", + "!node_modules/**" + ] + } + } +} diff --git a/packages/sdk-node/package.json b/packages/sdk-node/package.json index ce726c1f54a..05b207ac3a7 100644 --- a/packages/sdk-node/package.json +++ b/packages/sdk-node/package.json @@ -55,8 +55,6 @@ "dependencies": { "@clerk/backend": "1.0.0-beta.32", "@clerk/shared": "2.0.0-beta.21", - "camelcase-keys": "6.2.2", - "snakecase-keys": "3.2.1", "tslib": "2.4.1" }, "devDependencies": { diff --git a/packages/sdk-node/src/index.ts b/packages/sdk-node/src/index.ts index 7aa1b40e041..6de071dd83c 100644 --- a/packages/sdk-node/src/index.ts +++ b/packages/sdk-node/src/index.ts @@ -12,7 +12,7 @@ import type { export * from '@clerk/backend'; /** - * The order of these exports is important, as we want Clerk from clerk/sdk-node + * The order of these exports is important, as we want Clerk from clerk/clerk-sdk-node * to shadow the Clerk export from clerk/backend, because it needs to support * 2 additional apis: clerk.expressWithAuth, clerk.expressRequireAuth */ diff --git a/playground/express/package.json b/playground/express/package.json index cfe498c0dcc..4fd175d0623 100644 --- a/playground/express/package.json +++ b/playground/express/package.json @@ -4,14 +4,14 @@ "private": true, "scripts": { "start": "ts-node ./src/server.ts", - "yalc:add": "yalc add -- @clerk/types @clerk/backend @clerk/clerk-sdk-node", + "yalc:add": "yalc add -- @clerk/express @clerk/types @clerk/backend @clerk/shared", "dev:fromlocal": " nodemon --watch .yalc --watch src --exec \"npm run yalc:add && npm run start\"" }, "author": "", "license": "ISC", "dependencies": { "@clerk/backend": "file:.yalc/@clerk/backend", - "@clerk/clerk-sdk-node": "file:.yalc/@clerk/clerk-sdk-node", + "@clerk/express": "file:.yalc/@clerk/express", "@clerk/shared": "file:.yalc/@clerk/shared", "@clerk/types": "file:.yalc/@clerk/types", "dotenv": "^16.0.3", @@ -26,4 +26,4 @@ "tslib": "^2.5.0", "typescript": "4.8.4" } -} \ No newline at end of file +} diff --git a/playground/express/src/routes/private.ts b/playground/express/src/routes/private.ts index 9dcbdff9f47..a76c7d91193 100644 --- a/playground/express/src/routes/private.ts +++ b/playground/express/src/routes/private.ts @@ -1,22 +1,23 @@ import type { Response } from 'express'; -import { clerkClient } from '@clerk/clerk-sdk-node'; -import { Router } from 'express'; +import { getAuth, requireAuth } from '@clerk/express'; +import { Router, Request as ExpressRequest } from 'express'; const router = Router(); -router.use((...args)=>clerkClient.expressRequireAuth()(...args)); +router.use(requireAuth); -router.get('/me', async (req, reply: Response) => { - return reply.json({ auth: req.auth }); +router.get('/me', async (req: ExpressRequest, reply: Response) => { + return reply.json({ auth: getAuth(req) }); }); router.get('/private', async (req, reply: Response) => { - if (!req.auth.userId) { + const auth = getAuth(req); + if (!auth.userId) { return reply.status(403).end(); } - return reply.json({ hello: 'world', auth: req.auth }); + return reply.json({ hello: 'world', auth }); }); export const privateRoutes = router; diff --git a/playground/express/src/routes/public.ts b/playground/express/src/routes/public.ts index 47797d9a747..fe4c62486ac 100644 --- a/playground/express/src/routes/public.ts +++ b/playground/express/src/routes/public.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router, RequestHandler} from 'express'; const router = Router(); @@ -6,13 +6,16 @@ router.get('/public', async (_req, reply) => { return reply.json({ hello: 'world' }); }); -router.get('/home', function (_req, res) { +const homeHandler: RequestHandler = function (_req, res) { return res.render('home.ejs', { publishableKey: process.env.CLERK_PUBLISHABLE_KEY, domain: process.env.CLERK_DOMAIN, isSatellite: process.env.CLERK_IS_SATELLITE, signInUrl: process.env.CLERK_SIGN_IN_URL, }); -}); +}; + +router.get('/home', homeHandler); +router.get('/', homeHandler); export const publicRoutes = router; diff --git a/playground/express/src/server.ts b/playground/express/src/server.ts index 2b3f27af550..c9efee78acc 100644 --- a/playground/express/src/server.ts +++ b/playground/express/src/server.ts @@ -1,6 +1,6 @@ -import type { StrictAuthProp } from '@clerk/clerk-sdk-node'; import type { Application, Request, Response, NextFunction } from 'express'; +import { clerkMiddleware } from '@clerk/express'; import * as express from 'express'; import { privateRoutes, publicRoutes } from './routes'; @@ -9,15 +9,11 @@ import './loadEnv'; const port = process.env.PORT || 3000; const app: Application = express(); -declare global { - namespace Express { - interface Request extends StrictAuthProp {} - } -} - app.set('view engine', 'ejs'); app.set('views', 'src/views'); +app.use(clerkMiddleware()); + app.use(publicRoutes); app.use(privateRoutes); diff --git a/scripts/canary.mjs b/scripts/canary.mjs index 7335ead77ce..3823d94e666 100755 --- a/scripts/canary.mjs +++ b/scripts/canary.mjs @@ -20,6 +20,7 @@ const snapshot = `--- '@clerk/remix': patch '@clerk/types': patch '@clerk/clerk-expo': patch +'@clerk/express': patch --- Canary release diff --git a/scripts/snapshot.mjs b/scripts/snapshot.mjs index 9d5f6e85d3c..a2327b61eee 100755 --- a/scripts/snapshot.mjs +++ b/scripts/snapshot.mjs @@ -19,6 +19,7 @@ const snapshot = `--- '@clerk/remix': patch '@clerk/types': patch '@clerk/clerk-expo': patch +'@clerk/express': patch --- Snapshot release