From 851c553adbf2c4f8da6ce89ef07764fb50fae6f6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:31:10 +0000 Subject: [PATCH 01/15] Implement PKCE and CSRF protection Co-Authored-By: nick.nisi@workos.com --- package-lock.json | 699 ++--------------------------- package.json | 7 +- src/auth.spec.ts | 39 +- src/auth.ts | 25 +- src/authkit-callback-route.spec.ts | 146 +++++- src/authkit-callback-route.ts | 185 +++++--- src/get-authorization-url.spec.ts | 62 ++- src/get-authorization-url.ts | 63 ++- src/interfaces.ts | 44 +- src/pkce.ts | 115 +++++ src/session.spec.ts | 19 +- src/session.ts | 23 +- src/test-utils/test-helpers.ts | 43 ++ src/workos.spec.ts | 49 +- 14 files changed, 678 insertions(+), 841 deletions(-) create mode 100644 src/pkce.ts diff --git a/package-lock.json b/package-lock.json index 7726b01..f6aea30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,10 @@ "version": "0.10.0", "license": "MIT", "dependencies": { - "@workos-inc/node": "^7.41.0", "iron-session": "^8.0.1", - "jose": "^5.2.3" + "jose": "^5.2.3", + "tslib": "^2.8.1", + "valibot": "^1.2.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -19,7 +20,7 @@ "@types/jest": "^29.5.14", "@types/node": "^24.10.3", "@typescript-eslint/eslint-plugin": "^7.18.0", - "@workos-inc/node": "^7.77.0", + "@workos-inc/node": "^8.13.0", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-require-extensions": "^0.1.3", @@ -2724,45 +2725,6 @@ "node": ">= 8" } }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.13.tgz", - "integrity": "sha512-3Xq3a01WkHRZL8X04Zsfg//mGaA21xlL4tlVn4v2xGT0JStiztATRkMwa5b+f/HXmY2smsiLXYK46Gwgzvfg3g==", - "dev": true, - "dependencies": { - "asn1js": "^3.0.5", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2" - } - }, - "node_modules/@peculiar/json-schema": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", - "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", - "dev": true, - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@peculiar/webcrypto": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", - "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", - "dev": true, - "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/json-schema": "^1.1.12", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2", - "webcrypto-core": "^1.8.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2922,15 +2884,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/accepts": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", - "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2983,79 +2936,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/content-disposition": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.8.tgz", - "integrity": "sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==", - "dev": true - }, - "node_modules/@types/cookies": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.0.tgz", - "integrity": "sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==", - "dev": true, - "dependencies": { - "@types/connect": "*", - "@types/express": "*", - "@types/keygrip": "*", - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dev": true, - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-assert": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", - "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==", - "dev": true - }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3134,43 +3014,6 @@ "parse5": "^7.0.0" } }, - "node_modules/@types/keygrip": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", - "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", - "dev": true - }, - "node_modules/@types/koa": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.15.0.tgz", - "integrity": "sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==", - "dev": true, - "dependencies": { - "@types/accepts": "*", - "@types/content-disposition": "*", - "@types/cookies": "*", - "@types/http-assert": "*", - "@types/http-errors": "*", - "@types/keygrip": "*", - "@types/koa-compose": "*", - "@types/node": "*" - } - }, - "node_modules/@types/koa-compose": { - "version": "3.2.8", - "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.8.tgz", - "integrity": "sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==", - "dev": true, - "dependencies": { - "@types/koa": "*" - } - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true - }, "node_modules/@types/node": { "version": "24.10.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.3.tgz", @@ -3181,39 +3024,6 @@ "undici-types": "~7.16.0" } }, - "node_modules/@types/qs": { - "version": "6.9.17", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", - "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", - "dev": true - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true - }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3712,86 +3522,16 @@ ] }, "node_modules/@workos-inc/node": { - "version": "7.77.0", - "resolved": "https://registry.npmjs.org/@workos-inc/node/-/node-7.77.0.tgz", - "integrity": "sha512-6LGBAqih8kkzhHqmxueT9/xX93AJQxQhKekyNs0mqgWsrnqOPDiag1WIFzgxbQTvr564MqtEW502Tatfp1+x0Q==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/@workos-inc/node/-/node-8.13.0.tgz", + "integrity": "sha512-NgQKHpwh8AbT4KvAsW91Y+4f4jja2IvFPQ5atcy5NUxUMVRgXzRFEee3erawfXrTmiCVqJjd9PljHySKBXmHKQ==", "dev": true, "license": "MIT", "dependencies": { - "iron-session": "~6.3.1", - "jose": "~5.6.3", - "leb": "^1.0.0", - "qs": "6.14.0" + "eventemitter3": "^5.0.4" }, "engines": { - "node": ">=16" - } - }, - "node_modules/@workos-inc/node/node_modules/@types/cookie": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.4.tgz", - "integrity": "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==", - "dev": true - }, - "node_modules/@workos-inc/node/node_modules/@types/node": { - "version": "17.0.45", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", - "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", - "dev": true - }, - "node_modules/@workos-inc/node/node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@workos-inc/node/node_modules/iron-session": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-6.3.1.tgz", - "integrity": "sha512-3UJ7y2vk/WomAtEySmPgM6qtYF1cZ3tXuWX5GsVX4PJXAcs5y/sV9HuSfpjKS6HkTL/OhZcTDWJNLZ7w+Erx3A==", - "dev": true, - "dependencies": { - "@peculiar/webcrypto": "^1.4.0", - "@types/cookie": "^0.5.1", - "@types/express": "^4.17.13", - "@types/koa": "^2.13.5", - "@types/node": "^17.0.41", - "cookie": "^0.5.0", - "iron-webcrypto": "^0.2.5" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "express": ">=4", - "koa": ">=2", - "next": ">=10" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - }, - "koa": { - "optional": true - }, - "next": { - "optional": true - } - } - }, - "node_modules/@workos-inc/node/node_modules/iron-webcrypto": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-0.2.8.tgz", - "integrity": "sha512-YPdCvjFMOBjXaYuDj5tiHst5CEk6Xw84Jo8Y2+jzhMceclAnb3+vNPP/CTtb5fO2ZEuXEaO4N+w62Vfko757KA==", - "dev": true, - "dependencies": { - "buffer": "^6" - }, - "funding": { - "url": "https://github.com/sponsors/brc-dd" + "node": ">=20.15.0" } }, "node_modules/acorn": { @@ -3950,20 +3690,6 @@ "node": ">=8" } }, - "node_modules/asn1js": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", - "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", - "dev": true, - "dependencies": { - "pvtsutils": "^1.3.2", - "pvutils": "^1.1.3", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -4069,26 +3795,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/baseline-browser-mapping": { "version": "2.9.6", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.6.tgz", @@ -4176,30 +3882,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4207,37 +3889,6 @@ "dev": true, "license": "MIT" }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4591,21 +4242,6 @@ "dev": true, "peer": true }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4663,39 +4299,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4952,6 +4555,13 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5165,15 +4775,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5193,31 +4794,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -5228,20 +4804,6 @@ "node": ">=8.0.0" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -5332,19 +4894,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -5388,31 +4937,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -5484,26 +5008,6 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -8418,13 +7922,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/leb": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/leb/-/leb-1.0.0.tgz", - "integrity": "sha512-Y3c3QZfvKWHX60BVOQPhLCvVGmDYWyJEiINE3drOog6KCyN2AOwvuQQzlS3uJg1J85kzpILXIUwRXULWavir+w==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -8533,16 +8030,6 @@ "tmpl": "1.0.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8705,19 +8192,6 @@ "dev": true, "license": "MIT" }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -9088,40 +8562,6 @@ ], "license": "MIT" }, - "node_modules/pvtsutils": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", - "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", - "dev": true, - "dependencies": { - "tslib": "^2.6.1" - } - }, - "node_modules/pvutils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", - "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9386,82 +8826,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -9910,7 +9274,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -9950,7 +9314,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10109,6 +9473,20 @@ "node": ">=10.12.0" } }, + "node_modules/valibot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", + "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -10131,19 +9509,6 @@ "makeerror": "1.0.12" } }, - "node_modules/webcrypto-core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", - "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", - "dev": true, - "dependencies": { - "@peculiar/asn1-schema": "^2.3.13", - "@peculiar/json-schema": "^1.1.12", - "asn1js": "^3.0.5", - "pvtsutils": "^1.3.5", - "tslib": "^2.7.0" - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index 95d30a9..23942ad 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,10 @@ "format": "prettier --write \"{src,__tests__}/**/*.{js,ts,tsx}\"" }, "dependencies": { - "@workos-inc/node": "^7.41.0", "iron-session": "^8.0.1", - "jose": "^5.2.3" + "jose": "^5.2.3", + "tslib": "^2.8.1", + "valibot": "^1.2.0" }, "peerDependencies": { "react": "^18.0 || ^19.0.0", @@ -42,7 +43,7 @@ "@types/jest": "^29.5.14", "@types/node": "^24.10.3", "@typescript-eslint/eslint-plugin": "^7.18.0", - "@workos-inc/node": "^7.77.0", + "@workos-inc/node": "^8.13.0", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-require-extensions": "^0.1.3", diff --git a/src/auth.spec.ts b/src/auth.spec.ts index 9e00310..5d30e2c 100644 --- a/src/auth.spec.ts +++ b/src/auth.spec.ts @@ -45,11 +45,23 @@ jest.mock('react-router', () => { describe('auth', () => { beforeEach(() => { jest.spyOn(authorizationUrl, 'getAuthorizationUrl'); + getConfig.mockImplementation((key: string) => { + const map: Record = { + clientId: 'client_1234567890', + redirectUri: 'http://localhost:5173/callback', + cookiePassword: 'kR620keEzOIzPThfnMEAba8XYgKdQ5vg', + apiKey: 'sk_test_1234567890', + cookieName: 'wos-session', + }; + return map[key]; + }); }); describe('getSignInUrl', () => { - it('should return a URL', async () => { - expect(await getSignInUrl('/test')).toMatch(/^https:\/\/api\.workos\.com/); + it('returns a URL and a PKCE Set-Cookie header', async () => { + const result = await getSignInUrl('/test'); + expect(result.url).toMatch(/^https:\/\/api\.workos\.com/); + expect(result.headers['Set-Cookie']).toMatch(/^wos-auth-verifier-/); expect(authorizationUrl.getAuthorizationUrl).toHaveBeenCalledWith( expect.objectContaining({ returnPathname: '/test', screenHint: 'sign-in' }), ); @@ -57,8 +69,10 @@ describe('auth', () => { }); describe('getSignUpUrl', () => { - it('should return a URL', async () => { - expect(await getSignUpUrl()).toMatch(/^https:\/\/api\.workos\.com/); + it('returns a URL and a PKCE Set-Cookie header', async () => { + const result = await getSignUpUrl(); + expect(result.url).toMatch(/^https:\/\/api\.workos\.com/); + expect(result.headers['Set-Cookie']).toMatch(/^wos-auth-verifier-/); expect(authorizationUrl.getAuthorizationUrl).toHaveBeenCalledWith( expect.objectContaining({ screenHint: 'sign-up' }), ); @@ -187,36 +201,45 @@ describe('auth', () => { it('should redirect to authorization URL for SSO_required errors', async () => { const authUrl = 'https://api.workos.com/sso/authorize'; + const pkceHeaders = { 'Set-Cookie': 'wos-auth-verifier-abc=sealed; Path=/' }; const errorWithSSOCause = new Error('SSO Required', { cause: { error: 'sso_required' }, }); refreshSession.mockRejectedValueOnce(errorWithSSOCause); - (authorizationUrl.getAuthorizationUrl as jest.Mock).mockResolvedValueOnce(authUrl); + (authorizationUrl.getAuthorizationUrl as jest.Mock).mockResolvedValueOnce({ + url: authUrl, + headers: pkceHeaders, + }); const result = await switchToOrganization(request, organizationId); expect(authorizationUrl.getAuthorizationUrl).toHaveBeenCalled(); - expect(redirect).toHaveBeenCalledWith(authUrl); + expect(redirect).toHaveBeenCalledWith(authUrl, { headers: pkceHeaders }); assertIsResponse(result); expect(result.status).toBe(302); expect(result.headers.get('Location')).toBe(authUrl); + expect(result.headers.get('Set-Cookie')).toBe(pkceHeaders['Set-Cookie']); }); it('should handle mfa_enrollment errors', async () => { const authUrl = 'https://api.workos.com/sso/authorize'; + const pkceHeaders = { 'Set-Cookie': 'wos-auth-verifier-abc=sealed; Path=/' }; const errorWithMFACause = new Error('MFA Enrollment Required', { cause: { error: 'mfa_enrollment' }, }); refreshSession.mockRejectedValueOnce(errorWithMFACause); - (authorizationUrl.getAuthorizationUrl as jest.Mock).mockResolvedValueOnce(authUrl); + (authorizationUrl.getAuthorizationUrl as jest.Mock).mockResolvedValueOnce({ + url: authUrl, + headers: pkceHeaders, + }); const result = await switchToOrganization(request, organizationId); expect(authorizationUrl.getAuthorizationUrl).toHaveBeenCalled(); - expect(redirect).toHaveBeenCalledWith(authUrl); + expect(redirect).toHaveBeenCalledWith(authUrl, { headers: pkceHeaders }); assertIsResponse(result); expect(result.status).toBe(302); diff --git a/src/auth.ts b/src/auth.ts index 7014306..aa9ee1f 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,14 +1,30 @@ import { LoaderFunctionArgs, data, redirect } from 'react-router'; import { getAuthorizationUrl } from './get-authorization-url.js'; import { getClaimsFromAccessToken, getSessionFromCookie, refreshSession, terminateSession } from './session.js'; -import { NoUserInfo, UserInfo } from './interfaces.js'; +import { GetAuthURLResult, NoUserInfo, UserInfo } from './interfaces.js'; import { getConfig } from './config.js'; -export async function getSignInUrl(returnPathname?: string) { +/** + * Build a sign-in URL and the short-lived PKCE / CSRF cookie that must travel + * back to the browser on the redirect. + * + * @example + * const { url, headers } = await getSignInUrl('/dashboard'); + * return redirect(url, { headers }); + */ +export async function getSignInUrl(returnPathname?: string): Promise { return getAuthorizationUrl({ returnPathname, screenHint: 'sign-in' }); } -export async function getSignUpUrl(returnPathname?: string) { +/** + * Build a sign-up URL and the short-lived PKCE / CSRF cookie that must travel + * back to the browser on the redirect. + * + * @example + * const { url, headers } = await getSignUpUrl('/welcome'); + * return redirect(url, { headers }); + */ +export async function getSignUpUrl(returnPathname?: string): Promise { return getAuthorizationUrl({ returnPathname, screenHint: 'sign-up' }); } @@ -121,7 +137,8 @@ export async function switchToOrganization( // eslint-disable-next-line @typescript-eslint/no-explicit-any const errorCause: any = error instanceof Error ? error.cause : null; if (errorCause?.error === 'sso_required' || errorCause?.error === 'mfa_enrollment') { - return redirect(await getAuthorizationUrl({ organizationId })); + const { url, headers } = await getAuthorizationUrl({ organizationId }); + return redirect(url, { headers }); } return data( diff --git a/src/authkit-callback-route.spec.ts b/src/authkit-callback-route.spec.ts index ce37437..5818001 100644 --- a/src/authkit-callback-route.spec.ts +++ b/src/authkit-callback-route.spec.ts @@ -1,9 +1,11 @@ import { getWorkOS } from './workos.js'; import { authLoader } from './authkit-callback-route.js'; import { - createRequestWithSearchParams, - createAuthWithCodeResponse, assertIsResponse, + createAuthWithCodeResponse, + createRequestWithCookieAndParams, + createRequestWithSearchParams, + createSealedState, } from './test-utils/test-helpers.js'; import { configureSessionStorage } from './sessionStorage.js'; import { isDataWithResponseInit } from './utils.js'; @@ -25,6 +27,9 @@ jest.mock('./workos.js', () => ({ describe('authLoader', () => { let loader: ReturnType; let request: Request; + let sealedState: string; + let cookieHeader: string; + let codeVerifier: string; const workos = getWorkOS(); const authenticateWithCode = jest.mocked(workos.userManagement.authenticateWithCode); @@ -38,11 +43,14 @@ describe('authLoader', () => { const mockAuthResponse = createAuthWithCodeResponse(); authenticateWithCode.mockResolvedValue(mockAuthResponse); + ({ sealedState, cookieHeader, codeVerifier } = await createSealedState()); + loader = authLoader(); const url = new URL('http://example.com/callback'); - request = createRequestWithSearchParams(new Request(url), { + request = createRequestWithCookieAndParams(new Request(url), cookieHeader, { code: 'test-code', + state: sealedState, }); }); @@ -57,22 +65,71 @@ describe('authLoader', () => { expect(response).toBeUndefined(); }); - it('should handle authentication failure', async () => { - authenticateWithCode.mockRejectedValue(new Error('Auth failed')); - request = createRequestWithSearchParams(request, { code: 'invalid-code' }); + it('returns 500 when state is missing', async () => { + request = createRequestWithSearchParams(new Request('http://example.com/callback'), { + code: 'test-code', + }); const response = (await loader({ request, params: {}, context: {}, } as LoaderFunctionArgs)) as DataWithResponseInit; + + expect(isDataWithResponseInit(response)).toBeTruthy(); + expect(response?.init?.status).toBe(500); + }); + + it('returns 500 when PKCE cookie is missing (possible CSRF)', async () => { + request = createRequestWithSearchParams(new Request('http://example.com/callback'), { + code: 'test-code', + state: sealedState, + }); + const response = (await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs)) as DataWithResponseInit; + expect(isDataWithResponseInit(response)).toBeTruthy(); + expect(response?.init?.status).toBe(500); + // Still clears the PKCE cookie even when it wasn't present — harmless and + // matches the invariant that the cookie is always cleared post-callback. + expect(findSetCookie(response?.init?.headers, 'wos-auth-verifier-')).toMatch(/Max-Age=0/); + }); + + it('returns 500 when state does not match the PKCE cookie value', async () => { + // Valid cookie issued for a different flow + const other = await createSealedState({ nonce: 'other' }); + request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), other.cookieHeader, { + code: 'test-code', + state: sealedState, + }); + const response = (await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs)) as DataWithResponseInit; + expect(isDataWithResponseInit(response)).toBeTruthy(); expect(response?.init?.status).toBe(500); + expect(authenticateWithCode).not.toHaveBeenCalled(); + }); + + it('clears the PKCE cookie on authentication failure', async () => { + authenticateWithCode.mockRejectedValue(new Error('Auth failed')); + const response = (await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs)) as DataWithResponseInit; + + expect(isDataWithResponseInit(response)).toBeTruthy(); + expect(response?.init?.status).toBe(500); + expect(findSetCookie(response?.init?.headers, 'wos-auth-verifier-')).toMatch(/Max-Age=0/); }); it('should handle authentication failure with string error', async () => { authenticateWithCode.mockRejectedValue('Auth failed'); - request = createRequestWithSearchParams(request, { code: 'invalid-code' }); const response = (await loader({ request, params: {}, @@ -84,6 +141,16 @@ describe('authLoader', () => { }); }); + it('passes the PKCE code verifier to authenticateWithCode', async () => { + await loader({ request, params: {}, context: {} } as LoaderFunctionArgs); + + expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith({ + clientId: process.env.WORKOS_CLIENT_ID, + code: 'test-code', + codeVerifier, + }); + }); + it('returns a response when a code is present', async () => { const response = await loader({ request, @@ -91,16 +158,19 @@ describe('authLoader', () => { context: {}, } as LoaderFunctionArgs); - expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith({ - clientId: process.env.WORKOS_CLIENT_ID, - code: 'test-code', - }); - assertIsResponse(response); expect(response.status).toBe(302); expect(response.headers.get('Set-Cookie')).toBeDefined(); }); + it('clears the PKCE cookie on successful sign-in', async () => { + const response = await loader({ request, params: {}, context: {} } as LoaderFunctionArgs); + + assertIsResponse(response); + const setCookies = response.headers.getSetCookie(); + expect(setCookies.some((c) => c.startsWith('wos-auth-verifier-') && /Max-Age=0/.test(c))).toBe(true); + }); + it('should redirect to the returnPathname', async () => { loader = authLoader({ returnPathname: '/dashboard' }); const response = await loader({ @@ -139,11 +209,15 @@ describe('authLoader', () => { expect(onSuccess).toHaveBeenCalled(); }); - it('uses returnPathname from state when provided', async () => { + it('uses returnPathname from the sealed state when provided', async () => { + const scoped = await createSealedState({ returnPathname: '/profile' }); + request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { + code: 'test-code', + state: scoped.sealedState, + }); + const response = await loader({ - request: createRequestWithSearchParams(request, { - state: btoa(JSON.stringify({ returnPathname: '/profile' })), - }), + request, params: {}, context: {}, } as LoaderFunctionArgs); @@ -152,6 +226,20 @@ describe('authLoader', () => { expect(response.headers.get('Location')).toBe('http://example.com/profile'); }); + it('forwards customState from the sealed state to onSuccess', async () => { + const onSuccess = jest.fn(); + loader = authLoader({ onSuccess }); + + const scoped = await createSealedState({ customState: 'caller-state' }); + request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { + code: 'test-code', + state: scoped.sealedState, + }); + + await loader({ request, params: {}, context: {} } as LoaderFunctionArgs); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ state: 'caller-state' })); + }); + it('provides impersonator to onSuccess callback when provided', async () => { const onSuccess = jest.fn(); authenticateWithCode.mockResolvedValue( @@ -207,13 +295,15 @@ describe('authLoader', () => { process.env.WORKOS_REDIRECT_URI = 'https://example.com/callback'; try { - const request = createRequestWithSearchParams(new Request('http://example.com/callback'), { + const scoped = await createSealedState(); + const req = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { code: 'test-code-123', + state: scoped.sealedState, }); const loader = authLoader(); const response = await loader({ - request, + request: req, params: {}, context: {}, } as LoaderFunctionArgs); @@ -242,13 +332,19 @@ describe('authLoader', () => { process.env.WORKOS_REDIRECT_URI = 'https://example.com:8443/callback'; try { - const request = createRequestWithSearchParams(new Request('http://example.com:3000/callback'), { - code: 'test-code-123', - }); + const scoped = await createSealedState(); + const req = createRequestWithCookieAndParams( + new Request('http://example.com:3000/callback'), + scoped.cookieHeader, + { + code: 'test-code-123', + state: scoped.sealedState, + }, + ); const loader = authLoader(); const response = await loader({ - request, + request: req, params: {}, context: {}, } as LoaderFunctionArgs); @@ -272,3 +368,9 @@ describe('authLoader', () => { } }); }); + +function findSetCookie(headers: HeadersInit | undefined, prefix: string): string | undefined { + if (!headers) return undefined; + const h = new Headers(headers); + return h.getSetCookie().find((c) => c.startsWith(prefix)); +} diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index d318459..3a963f1 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -1,10 +1,21 @@ import { LoaderFunctionArgs, data, redirect } from 'react-router'; import { getConfig } from './config.js'; import { HandleAuthOptions } from './interfaces.js'; +import { getPKCECookieString, getStateFromPKCECookieValue, readPKCECookie } from './pkce.js'; import { encryptSession } from './session.js'; import { configureSessionStorage } from './sessionStorage.js'; import { getWorkOS } from './workos.js'; +/** + * Build a `Set-Cookie` header that clears the PKCE cookie associated with a + * given sealed state value. PKCE cookies are single-use — we always clear + * them, whether the exchange succeeded or failed, to prevent replays and + * stale cookies affecting future auth attempts. + */ +function clearPKCECookie(state: string): string { + return getPKCECookieString(state, /* expired */ true); +} + export function authLoader(options: HandleAuthOptions = {}) { return async function loader({ request }: LoaderFunctionArgs) { const { storage, cookie, returnPathname: returnPathnameOption = '/', onSuccess } = options; @@ -15,87 +26,123 @@ export function authLoader(options: HandleAuthOptions = {}) { const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); - let returnPathname = state && state !== 'null' ? JSON.parse(atob(state)).returnPathname : null; - - if (code) { - try { - const { accessToken, refreshToken, user, impersonator, oauthTokens, organizationId } = - await getWorkOS().userManagement.authenticateWithCode({ - clientId: getConfig('clientId'), - code, - }); - - // Clean up params - url.searchParams.delete('code'); - url.searchParams.delete('state'); - - // Redirect to the requested path and store the session - returnPathname = returnPathname ?? returnPathnameOption; - - // Extract the search params if they are present - if (returnPathname.includes('?')) { - const newUrl = new URL(returnPathname, 'https://example.com'); - url.pathname = newUrl.pathname; - - for (const [key, value] of newUrl.searchParams) { - url.searchParams.append(key, value); - } - } else { - url.pathname = returnPathname; - } - // The refreshToken should never be accesible publicly, hence why we encrypt it - // in the cookie session. Alternatively you could persist the refresh token in a - // backend database. - const encryptedSession = await encryptSession({ - accessToken, - refreshToken, - user, - impersonator, - headers: {}, + if (!code) { + return; + } + + // We always want to clear the PKCE cookie at the end of this handler, + // success or failure. `pkceClearCookie` is populated as soon as we know + // the state value and appended to every response below. + const pkceClearCookie = state ? clearPKCECookie(state) : null; + + try { + if (!state) { + throw new Error('Missing required auth parameter: state'); + } + + const pkceCookieValue = readPKCECookie(request.headers.get('Cookie'), state); + + // CSRF verification (double-submit cookie): both the cookie and the URL + // state must be present and identical. A missing cookie means either + // the browser never started this flow (forged link) or the cookie has + // been cleared (expired / tampered). + if (!pkceCookieValue) { + throw new Error( + 'Auth cookie missing — cannot verify OAuth state. Ensure Set-Cookie headers are propagated on the redirect that started this flow.', + ); + } + + if (state !== pkceCookieValue) { + throw new Error('OAuth state mismatch'); + } + + const { + codeVerifier, + customState, + returnPathname: returnPathnameState, + } = await getStateFromPKCECookieValue(pkceCookieValue); + + const { accessToken, refreshToken, user, impersonator, oauthTokens, authenticationMethod, organizationId } = + await getWorkOS().userManagement.authenticateWithCode({ + clientId: getConfig('clientId'), + code, + codeVerifier, }); - const session = await getSession(cookieName); + // Clean up params + url.searchParams.delete('code'); + url.searchParams.delete('state'); - session.set('jwt', encryptedSession); - const cookie = await commitSession(session); + const returnPathname = returnPathnameState ?? returnPathnameOption; - if (onSuccess) { - await onSuccess({ - accessToken, - impersonator: impersonator ?? null, - oauthTokens: oauthTokens ?? null, - refreshToken, - user, - organizationId: organizationId ?? null, - }); - } + // Extract the search params if they are present + if (returnPathname.includes('?')) { + const newUrl = new URL(returnPathname, 'https://example.com'); + url.pathname = newUrl.pathname; - // Fix protocol mismatch for load balancer scenarios - // If WORKOS_REDIRECT_URI is HTTPS but request is HTTP, use HTTPS for redirect - const redirectUri = getConfig('redirectUri'); - const configUrl = new URL(redirectUri); - if (configUrl.protocol === 'https:' && url.protocol === 'http:') { - url.protocol = 'https:'; + for (const [key, value] of newUrl.searchParams) { + url.searchParams.append(key, value); } + } else { + url.pathname = returnPathname; + } - return redirect(url.toString(), { - headers: { - 'Set-Cookie': cookie, - }, + // The refreshToken should never be accessible publicly, hence why we encrypt it + // in the cookie session. Alternatively you could persist the refresh token in a + // backend database. + const encryptedSession = await encryptSession({ + accessToken, + refreshToken, + user, + impersonator, + headers: {}, + }); + + const session = await getSession(cookieName); + session.set('jwt', encryptedSession); + const sessionCookie = await commitSession(session); + + if (onSuccess) { + await onSuccess({ + accessToken, + impersonator: impersonator ?? null, + oauthTokens: oauthTokens ?? null, + refreshToken, + user, + organizationId: organizationId ?? null, + authenticationMethod, + state: customState, }); - } catch (error) { - const errorRes = { - error: error instanceof Error ? error.message : String(error), - }; + } - console.error(errorRes); + // Fix protocol mismatch for load balancer scenarios + // If WORKOS_REDIRECT_URI is HTTPS but request is HTTP, use HTTPS for redirect + const redirectUri = getConfig('redirectUri'); + const configUrl = new URL(redirectUri); + if (configUrl.protocol === 'https:' && url.protocol === 'http:') { + url.protocol = 'https:'; + } - return errorResponse(); + const headers = new Headers(); + headers.append('Set-Cookie', sessionCookie); + if (pkceClearCookie) { + headers.append('Set-Cookie', pkceClearCookie); + } + + return redirect(url.toString(), { headers }); + } catch (error) { + const errorRes = { + error: error instanceof Error ? error.message : String(error), + }; + + console.error(errorRes); + + const headers = new Headers(); + if (pkceClearCookie) { + headers.append('Set-Cookie', pkceClearCookie); } - } - function errorResponse() { return data( { error: { @@ -103,7 +150,7 @@ export function authLoader(options: HandleAuthOptions = {}) { description: 'Couldn’t sign in. If you are not sure what happened, please contact your organization admin.', }, }, - { status: 500 }, + { status: 500, headers }, ); } }; diff --git a/src/get-authorization-url.spec.ts b/src/get-authorization-url.spec.ts index 1ad2620..5b657ba 100644 --- a/src/get-authorization-url.spec.ts +++ b/src/get-authorization-url.spec.ts @@ -1,26 +1,66 @@ +import { unsealData } from 'iron-session'; import { getAuthorizationUrl } from './get-authorization-url.js'; import { getConfig } from './config.js'; +import { getPKCECookieNameForState, PKCE_COOKIE_NAME } from './pkce.js'; +import type { State } from './interfaces.js'; describe('getAuthorizationUrl', () => { - it('should generate a valid WorkOS authorization URL', async () => { - const url = await getAuthorizationUrl(); + it('generates a valid WorkOS authorization URL with PKCE parameters', async () => { + const { url } = await getAuthorizationUrl(); expect(url).toMatch(/^https:\/\/api\.workos\.com\/user_management\/authorize\?/); expect(url).toContain(`client_id=${getConfig('clientId')}`); expect(url).toContain(`redirect_uri=${encodeURIComponent(getConfig('redirectUri'))}`); expect(url).toContain('provider=authkit'); + expect(url).toMatch(/code_challenge=[^&]+/); + expect(url).toContain('code_challenge_method=S256'); }); - it('should include envoded state when returnPathname is provided', async () => { - const returnPathname = '/dashboard'; - const url = await getAuthorizationUrl({ returnPathname }); - const expectedSstate = btoa(JSON.stringify({ returnPathname })); - expect(url).toContain(`state=${encodeURIComponent(expectedSstate)}`); + it('seals return-trip state into the OAuth state parameter', async () => { + const { url } = await getAuthorizationUrl({ returnPathname: '/dashboard' }); + const parsed = new URL(url); + const state = parsed.searchParams.get('state'); + expect(state).toBeTruthy(); + + const unsealed = await unsealData(state!, { password: getConfig('cookiePassword') }); + expect(unsealed.returnPathname).toBe('/dashboard'); + expect(unsealed.codeVerifier).toEqual(expect.any(String)); + expect(unsealed.nonce).toEqual(expect.any(String)); + }); + + it('emits a flow-specific PKCE cookie tied to the sealed state', async () => { + const { url, headers } = await getAuthorizationUrl(); + + const state = new URL(url).searchParams.get('state')!; + const setCookie = headers['Set-Cookie']; + expect(setCookie).toContain(`${getPKCECookieNameForState(state)}=${state}`); + expect(setCookie).toContain('Path=/'); + expect(setCookie).toContain('HttpOnly'); + expect(setCookie).toContain('SameSite=Lax'); + expect(setCookie).toMatch(/Max-Age=600\b/); + }); + + it('gives concurrent flows distinct cookie names', async () => { + const a = await getAuthorizationUrl(); + const b = await getAuthorizationUrl(); + + const aName = a.headers['Set-Cookie'].split('=')[0]; + const bName = b.headers['Set-Cookie'].split('=')[0]; + expect(aName).toMatch(new RegExp(`^${PKCE_COOKIE_NAME}-[0-9a-f]{8}$`)); + expect(bName).toMatch(new RegExp(`^${PKCE_COOKIE_NAME}-[0-9a-f]{8}$`)); + expect(aName).not.toBe(bName); + }); + + it('includes screenHint when provided', async () => { + const { url } = await getAuthorizationUrl({ screenHint: 'sign-up' }); + expect(url).toContain('screen_hint=sign-up'); }); - it('should include screenHint when provided', async () => { - const screenHint = 'sign-up'; - const url = await getAuthorizationUrl({ screenHint }); - expect(url).toContain(`screen_hint=${screenHint}`); + it('forwards caller-provided custom state through the sealed payload', async () => { + const { url } = await getAuthorizationUrl({ state: 'caller-state', returnPathname: '/foo' }); + const state = new URL(url).searchParams.get('state')!; + const unsealed = await unsealData(state, { password: getConfig('cookiePassword') }); + expect(unsealed.customState).toBe('caller-state'); + expect(unsealed.returnPathname).toBe('/foo'); }); }); diff --git a/src/get-authorization-url.ts b/src/get-authorization-url.ts index db6b34e..7fb7ea1 100644 --- a/src/get-authorization-url.ts +++ b/src/get-authorization-url.ts @@ -1,24 +1,65 @@ +import { sealData } from 'iron-session'; import { getConfig } from './config.js'; +import type { GetAuthURLOptions, GetAuthURLResult, State } from './interfaces.js'; +import { getPKCECookieString } from './pkce.js'; import { getWorkOS } from './workos.js'; -interface GetAuthURLOptions { - screenHint?: 'sign-up' | 'sign-in'; - returnPathname?: string; - organizationId?: string; - redirectUri?: string; - loginHint?: string; -} +/** + * Build an AuthKit authorization URL and the PKCE / CSRF cookie that must + * travel back with the user on the cross-site redirect. + * + * The caller attaches the returned `headers` to their redirect response: + * + * ```ts + * const { url, headers } = await getAuthorizationUrl({ returnPathname: '/dashboard' }); + * return redirect(url, { headers }); + * ``` + * + * Internally this: + * 1. Generates a PKCE verifier / challenge pair (RFC 7636, S256). + * 2. Seals `{ nonce, codeVerifier, customState, returnPathname }` with + * iron-session under the configured cookie password. + * 3. Sends the sealed value as the OAuth `state` parameter. + * 4. Sets an HTTP-only, flow-specific cookie (`wos-auth-verifier-`) + * with the same sealed value so the callback can: + * - prove the response came from a flow this browser initiated + * (CSRF: `cookie === state`); and + * - recover the `codeVerifier` to complete the PKCE exchange. + */ +export async function getAuthorizationUrl(options: GetAuthURLOptions = {}): Promise { + const { returnPathname, screenHint, organizationId, redirectUri, loginHint, prompt, state: customState } = options; + + const pkce = await getWorkOS().pkce.generate(); -export async function getAuthorizationUrl(options: GetAuthURLOptions = {}) { - const { returnPathname, screenHint, organizationId, redirectUri, loginHint } = options; + const state = { + nonce: crypto.randomUUID(), + codeVerifier: pkce.codeVerifier, + customState, + returnPathname, + } satisfies State; - return getWorkOS().userManagement.getAuthorizationUrl({ + const sealedState = await sealData(state, { + password: getConfig('cookiePassword'), + // Match the PKCE cookie's Max-Age so a stale sealed state can't be + // replayed after the cookie itself has expired. + ttl: 600, + }); + + const url = getWorkOS().userManagement.getAuthorizationUrl({ provider: 'authkit', clientId: getConfig('clientId'), redirectUri: redirectUri || getConfig('redirectUri'), - state: returnPathname ? btoa(JSON.stringify({ returnPathname })) : undefined, screenHint, organizationId, loginHint, + prompt, + state: sealedState, + codeChallenge: pkce.codeChallenge, + codeChallengeMethod: pkce.codeChallengeMethod, }); + + return { + url, + headers: { 'Set-Cookie': getPKCECookieString(sealedState) }, + }; } diff --git a/src/interfaces.ts b/src/interfaces.ts index 0424e53..54473d5 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,5 +1,6 @@ import type { SessionStorage, SessionIdStorageStrategy, data, SessionData } from 'react-router'; -import type { OauthTokens, User } from '@workos-inc/node'; +import type { AuthenticationResponse, OauthTokens, User } from '@workos-inc/node'; +import * as v from 'valibot'; export type DataWithResponseInit = ReturnType>; @@ -26,6 +27,8 @@ export interface AuthLoaderSuccessData { refreshToken: string; user: User; organizationId: string | null; + authenticationMethod?: AuthenticationResponse['authenticationMethod']; + state?: string; } export interface RefreshErrorOptions { @@ -93,8 +96,47 @@ export interface NoUserInfo { export interface GetAuthURLOptions { screenHint?: 'sign-up' | 'sign-in'; returnPathname?: string; + organizationId?: string; + redirectUri?: string; + loginHint?: string; + prompt?: 'consent'; + /** + * Custom state value echoed back to `onSuccess` after a successful callback. + * The library always generates its own internal OAuth `state` parameter so + * that PKCE and CSRF protection cannot be bypassed — this value is sealed + * alongside it for round-trip delivery only. + */ + state?: string; +} + +/** + * Result of building an authorization URL. The caller must attach `headers` + * to whatever redirect response they return so the short-lived PKCE / + * CSRF-binding cookie is set on the browser before WorkOS redirects back. + * + * @example + * const { url, headers } = await getSignInUrl('/dashboard'); + * return redirect(url, { headers }); + */ +export interface GetAuthURLResult { + url: string; + headers: { 'Set-Cookie': string }; } +/** + * Sealed state stored in the PKCE cookie and round-tripped through WorkOS as + * the OAuth `state` parameter. `codeVerifier` is the PKCE secret that binds + * the authorization code to this browser session. + */ +export const StateSchema = v.object({ + nonce: v.string(), + customState: v.optional(v.string()), + returnPathname: v.optional(v.string()), + codeVerifier: v.string(), +}); + +export type State = v.InferOutput; + export type AuthKitLoaderOptions = { ensureSignedIn?: boolean; debug?: boolean; diff --git a/src/pkce.ts b/src/pkce.ts new file mode 100644 index 0000000..92219da --- /dev/null +++ b/src/pkce.ts @@ -0,0 +1,115 @@ +import { unsealData } from 'iron-session'; +import * as v from 'valibot'; +import { getConfig } from './config.js'; +import { State, StateSchema } from './interfaces.js'; + +export const PKCE_COOKIE_NAME = 'wos-auth-verifier'; + +// 10 minutes. PKCE cookies are single-use and short-lived, and the OAuth +// authorization request should complete long before this expires. +const PKCE_COOKIE_MAX_AGE = 600; + +/** + * 32-bit FNV-1a non-cryptographic hash. Inlined here rather than pulled in as + * `@sindresorhus/fnv1a` because that package is ESM-only and this SDK ships + * CommonJS. FNV-1a is a well-known, ~15-line algorithm — see RFC draft-eastlake-fnv. + */ +function fnv1a32(input: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + // Force an unsigned 32-bit integer + return hash >>> 0; +} + +/** + * Short, deterministic hex fingerprint of an arbitrary string. + * Used to give each PKCE flow its own cookie name without depending on the + * internal format of the sealed state value. + */ +function shortHash(input: string): string { + return fnv1a32(input).toString(16).padStart(8, '0'); +} + +/** + * Derive a flow-specific cookie name so concurrent auth flows don't overwrite + * each other's PKCE cookies. Uses an FNV-1a hash of the full sealed state. + */ +export function getPKCECookieNameForState(state: string): string { + return `${PKCE_COOKIE_NAME}-${shortHash(state)}`; +} + +/** + * Build a `Set-Cookie` header string for the PKCE verifier cookie. + * + * `SameSite=Strict` would be stripped on the cross-site redirect back from + * WorkOS, so it is downgraded to `Lax`. `SameSite=None` is preserved for + * iframe / cross-origin embed flows. + */ +export function getPKCECookieString(sealedState: string, expired = false): string { + const name = getPKCECookieNameForState(sealedState); + const value = expired ? '' : sealedState; + + const redirectUri = getConfig('redirectUri'); + let secure = true; + try { + secure = new URL(redirectUri).protocol === 'https:'; + } catch { + secure = true; + } + + const parts = [ + `${name}=${value}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + `Max-Age=${expired ? 0 : PKCE_COOKIE_MAX_AGE}`, + ]; + if (secure) { + parts.push('Secure'); + } + return parts.join('; '); +} + +/** + * Read the PKCE cookie value for a given OAuth state from a Cookie header. + * Returns `undefined` when the browser didn't send back the cookie (which + * indicates either a brand-new user, a stolen authorization URL, or an + * unrelated cross-site redirect — all of which are CSRF-failure conditions). + */ +export function readPKCECookie(cookieHeader: string | null, state: string): string | undefined { + if (!cookieHeader) { + return undefined; + } + const name = getPKCECookieNameForState(state); + // Cookie header values are `name1=value1; name2=value2`. Split on `;` and + // match the first exact-name entry — cookie values themselves never contain + // `;` (they are percent-encoded) so this is safe. + for (const raw of cookieHeader.split(';')) { + const trimmed = raw.trim(); + if (trimmed.startsWith(`${name}=`)) { + return trimmed.slice(name.length + 1); + } + } + return undefined; +} + +/** + * Read and unseal the PKCE cookie, returning the code verifier, nonce, and + * any caller-supplied custom state and return pathname. + * + * Throws if the cookie was tampered with, encrypted under a different + * password, or is missing required fields. Runtime validation via valibot + * is an acceptable tradeoff here — this is not a hot path, and + * sealing/unsealing does not prove the unsealed payload has the expected + * shape. + */ +export async function getStateFromPKCECookieValue(cookieValue: string): Promise { + const unsealed = await unsealData(cookieValue, { + password: getConfig('cookiePassword'), + }); + + return v.parse(StateSchema, unsealed); +} diff --git a/src/session.spec.ts b/src/session.spec.ts index 1670c61..3789572 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -122,7 +122,10 @@ describe('session', () => { // Reset getAuthorizationUrl mock getAuthorizationUrlMock.mockReset(); - getAuthorizationUrlMock.mockResolvedValue('https://auth.workos.com/oauth/authorize'); + getAuthorizationUrlMock.mockResolvedValue({ + url: 'https://auth.workos.com/oauth/authorize', + headers: { 'Set-Cookie': 'wos-auth-verifier-default=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600' }, + }); }); describe('encryptSession', () => { @@ -309,7 +312,9 @@ describe('session', () => { assertIsResponse(response); expect(response.status).toBe(302); expect(response.headers.get('Location')).toMatch(/^https:\/\/auth\.workos\.com\/oauth/); - expect(response.headers.get('Set-Cookie')).toBe('destroyed-session-cookie'); + const setCookies = response.headers.getSetCookie(); + expect(setCookies).toContain('destroyed-session-cookie'); + expect(setCookies.some((c) => c.startsWith('wos-auth-verifier-'))).toBe(true); } }); @@ -617,7 +622,10 @@ describe('session', () => { authenticateWithRefreshToken.mockRejectedValue(new Error('Refresh token invalid')); // Setup the mock to return a URL with state parameter - getAuthorizationUrlMock.mockResolvedValue('https://auth.workos.com/oauth/authorize?state=abc123'); + getAuthorizationUrlMock.mockResolvedValue({ + url: 'https://auth.workos.com/oauth/authorize?state=abc123', + headers: { 'Set-Cookie': 'wos-auth-verifier-abc=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600' }, + }); try { const mockRequest = createMockRequest('test-cookie', 'https://app.example.com/dashboard/settings'); @@ -627,7 +635,10 @@ describe('session', () => { assertIsResponse(response); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('https://auth.workos.com/oauth/authorize?state=abc123'); - expect(response.headers.get('Set-Cookie')).toBe('destroyed-session-cookie'); + // The destroy cookie and the new PKCE cookie must both be present + const setCookies = response.headers.getSetCookie(); + expect(setCookies).toContain('destroyed-session-cookie'); + expect(setCookies).toContain('wos-auth-verifier-abc=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600'); // Verify getAuthorizationUrl was called with the correct returnPathname expect(getAuthorizationUrlMock).toHaveBeenCalledWith({ diff --git a/src/session.ts b/src/session.ts index ae34023..fbeb5c9 100644 --- a/src/session.ts +++ b/src/session.ts @@ -44,7 +44,8 @@ export async function refreshSession(request: Request, options: { organizationId const cookie = request.headers.get('Cookie'); const session = cookie ? await getSessionFromCookie(cookie) : null; if (!session) { - throw redirect(await getAuthorizationUrl()); + const { url, headers } = await getAuthorizationUrl(); + throw redirect(url, { headers }); } try { @@ -361,10 +362,12 @@ export async function authkitLoader( const returnPathname = getReturnPathname(request.url); const cookieSession = await getSession(request.headers.get('Cookie')); - throw redirect(await getAuthorizationUrl({ returnPathname }), { - headers: { - 'Set-Cookie': await destroySession(cookieSession), - }, + const { url, headers: authHeaders } = await getAuthorizationUrl({ returnPathname }); + throw redirect(url, { + headers: [ + ['Set-Cookie', await destroySession(cookieSession)], + ['Set-Cookie', authHeaders['Set-Cookie']], + ], }); } @@ -443,10 +446,12 @@ export async function authkitLoader( } const returnPathname = getReturnPathname(request.url); - throw redirect(await getAuthorizationUrl({ returnPathname }), { - headers: { - 'Set-Cookie': await destroySession(cookieSession), - }, + const { url, headers: authHeaders } = await getAuthorizationUrl({ returnPathname }); + throw redirect(url, { + headers: [ + ['Set-Cookie', await destroySession(cookieSession)], + ['Set-Cookie', authHeaders['Set-Cookie']], + ], }); } diff --git a/src/test-utils/test-helpers.ts b/src/test-utils/test-helpers.ts index e7dce37..d474ed6 100644 --- a/src/test-utils/test-helpers.ts +++ b/src/test-utils/test-helpers.ts @@ -1,6 +1,10 @@ /* istanbul ignore file */ import type { User } from '@workos-inc/node'; +import { sealData } from 'iron-session'; +import { getConfig } from '../config.js'; +import { getPKCECookieNameForState } from '../pkce.js'; +import type { State } from '../interfaces.js'; type SearchParamsModifier = Record | ((params: URLSearchParams) => void); @@ -40,6 +44,45 @@ export function createRequestWithSearchParams(request: Request, modifier: Search * @param overrides - Any properties to override in the mock response. * @returns A mock WorkOS authentication response object. */ +/** + * Build a sealed PKCE state value and matching Cookie header, the way + * `getAuthorizationUrl` would emit them on the outbound redirect. + * + * Returns `{ sealedState, cookieHeader, codeVerifier }` — pass `sealedState` + * as the URL's `state` search param and `cookieHeader` as the inbound + * `Cookie` header in the callback request. + */ +export async function createSealedState( + overrides: Partial = {}, +): Promise<{ sealedState: string; cookieHeader: string; codeVerifier: string }> { + const state: State = { + nonce: overrides.nonce ?? 'test-nonce', + codeVerifier: overrides.codeVerifier ?? 'test-code-verifier', + customState: overrides.customState, + returnPathname: overrides.returnPathname, + }; + const sealedState = await sealData(state, { password: getConfig('cookiePassword') }); + const cookieHeader = `${getPKCECookieNameForState(sealedState)}=${sealedState}`; + return { sealedState, cookieHeader, codeVerifier: state.codeVerifier }; +} + +/** + * Mutate an existing Request to include the given `Cookie` header plus the + * given search params, returning a fresh Request instance. + */ +export function createRequestWithCookieAndParams( + request: Request, + cookieHeader: string, + modifier: SearchParamsModifier, +): Request { + const next = createRequestWithSearchParams(request, modifier); + const headers = new Headers(next.headers); + // Append rather than set so callers can stack cookies + const existing = headers.get('Cookie'); + headers.set('Cookie', existing ? `${existing}; ${cookieHeader}` : cookieHeader); + return new Request(next.url, { ...next, headers, body: next.body }); +} + export function createAuthWithCodeResponse(overrides: Record = {}) { return { accessToken: 'access123', diff --git a/src/workos.spec.ts b/src/workos.spec.ts index eb59570..9ac6381 100644 --- a/src/workos.spec.ts +++ b/src/workos.spec.ts @@ -1,4 +1,3 @@ -import type { WorkOS as WorkOSType } from '@workos-inc/node'; import type { AuthKitConfig } from './interfaces.js'; describe('workos', () => { @@ -11,55 +10,41 @@ describe('workos', () => { apiHostname: 'api.workos.com', } as const; - const options = { - apiHostname: config.apiHostname, - https: true, - port: undefined, - appInfo: { - name: 'authkit-react-router', - version: expect.any(String), - }, - } as const; - - let getWorkOS: () => WorkOSType; - let WorkOS: typeof WorkOSType; let configure: (config: Partial) => void; - beforeEach(() => { + beforeEach(async () => { jest.resetModules(); - ({ configure } = require('./config.js')); + ({ configure } = await import('./config.js')); }); - it('should initialize WorkOS with correct API key', async () => { + it('should initialize WorkOS with correct API key and options', async () => { configure({ ...config }); - jest.mock('@workos-inc/node', () => ({ WorkOS: jest.fn() })); - ({ getWorkOS } = await import('./workos.js')); - ({ WorkOS } = await import('@workos-inc/node')); + const { getWorkOS } = await import('./workos.js'); const workos = getWorkOS(); - expect(WorkOS).toHaveBeenCalledWith(config.apiKey, expect.objectContaining(options)); expect(workos).toBeDefined(); + expect(workos.options.apiHostname).toBe(config.apiHostname); + expect(workos.options.https).toBe(true); + expect(workos.options.port).toBeUndefined(); + expect(workos.options.appInfo).toEqual({ + name: 'authkit-react-router', + version: expect.any(String), + }); }); - it('sets https when WORKOS_API_HTTPS is set', async () => { + it('sets https when apiHttps is set', async () => { configure({ ...config, apiHttps: false }); - jest.mock('@workos-inc/node', () => ({ WorkOS: jest.fn() })); - ({ getWorkOS } = await import('./workos.js')); - ({ WorkOS } = await import('@workos-inc/node')); + const { getWorkOS } = await import('./workos.js'); const workos = getWorkOS(); - expect(WorkOS).toHaveBeenCalledWith(config.apiKey, expect.objectContaining({ ...options, https: false })); - expect(workos).toBeDefined(); + expect(workos.options.https).toBe(false); }); - it('does not set the port when not provided', async () => { + it('sets the port when provided', async () => { configure({ ...config, apiPort: 3000 }); - jest.mock('@workos-inc/node', () => ({ WorkOS: jest.fn() })); - ({ getWorkOS } = await import('./workos.js')); - ({ WorkOS } = await import('@workos-inc/node')); + const { getWorkOS } = await import('./workos.js'); const workos = getWorkOS(); - expect(WorkOS).toHaveBeenCalledWith(config.apiKey, expect.objectContaining({ ...options, port: 3000 })); - expect(workos).toBeDefined(); + expect(workos.options.port).toBe(3000); }); }); From 7bd2c834997cc29acf7e3bb2ac8a67467e4ee5d9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:37:20 +0000 Subject: [PATCH 02/15] Restore @workos-inc/node to runtime dependencies Co-Authored-By: nick.nisi@workos.com --- package-lock.json | 3 ++- package.json | 3 ++- src/pkce.ts | 5 +++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index f6aea30..4130c3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.10.0", "license": "MIT", "dependencies": { + "@workos-inc/node": "^8.9.0", "iron-session": "^8.0.1", "jose": "^5.2.3", "tslib": "^2.8.1", @@ -20,7 +21,7 @@ "@types/jest": "^29.5.14", "@types/node": "^24.10.3", "@typescript-eslint/eslint-plugin": "^7.18.0", - "@workos-inc/node": "^8.13.0", + "@workos-inc/node": "^8.9.0", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-require-extensions": "^0.1.3", diff --git a/package.json b/package.json index 23942ad..274f7ee 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "format": "prettier --write \"{src,__tests__}/**/*.{js,ts,tsx}\"" }, "dependencies": { + "@workos-inc/node": "^8.9.0", "iron-session": "^8.0.1", "jose": "^5.2.3", "tslib": "^2.8.1", @@ -43,7 +44,7 @@ "@types/jest": "^29.5.14", "@types/node": "^24.10.3", "@typescript-eslint/eslint-plugin": "^7.18.0", - "@workos-inc/node": "^8.13.0", + "@workos-inc/node": "^8.9.0", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-require-extensions": "^0.1.3", diff --git a/src/pkce.ts b/src/pkce.ts index 92219da..e7a9c8e 100644 --- a/src/pkce.ts +++ b/src/pkce.ts @@ -13,6 +13,11 @@ const PKCE_COOKIE_MAX_AGE = 600; * 32-bit FNV-1a non-cryptographic hash. Inlined here rather than pulled in as * `@sindresorhus/fnv1a` because that package is ESM-only and this SDK ships * CommonJS. FNV-1a is a well-known, ~15-line algorithm — see RFC draft-eastlake-fnv. + * + * Note: this hashes UTF-16 code units (via `charCodeAt`) rather than UTF-8 + * bytes. Callers only feed this ASCII-safe iron-session seals (base64url), so + * the distinction is irrelevant in practice — but don't reuse the function for + * non-ASCII input without re-encoding to bytes first. */ function fnv1a32(input: string): number { let hash = 0x811c9dc5; From 1218911dbf0b5c8f7bed61a76edee294ea544099 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:44:40 +0000 Subject: [PATCH 03/15] Restore JSDoc above createAuthWithCodeResponse Co-Authored-By: nick.nisi@workos.com --- src/test-utils/test-helpers.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test-utils/test-helpers.ts b/src/test-utils/test-helpers.ts index d474ed6..64335a3 100644 --- a/src/test-utils/test-helpers.ts +++ b/src/test-utils/test-helpers.ts @@ -39,11 +39,6 @@ export function createRequestWithSearchParams(request: Request, modifier: Search return new Request(url, request); } -/** - * Creates a mock WorkOS authentication response object. - * @param overrides - Any properties to override in the mock response. - * @returns A mock WorkOS authentication response object. - */ /** * Build a sealed PKCE state value and matching Cookie header, the way * `getAuthorizationUrl` would emit them on the outbound redirect. @@ -83,6 +78,11 @@ export function createRequestWithCookieAndParams( return new Request(next.url, { ...next, headers, body: next.body }); } +/** + * Creates a mock WorkOS authentication response object. + * @param overrides - Any properties to override in the mock response. + * @returns A mock WorkOS authentication response object. + */ export function createAuthWithCodeResponse(overrides: Record = {}) { return { accessToken: 'access123', From 0c4956a6efed124977bd8cd70ba5ac946405c410 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:32:02 +0000 Subject: [PATCH 04/15] Thread request through PKCE cookie Secure detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply review feedback from the PKCE/CSRF PR: - Derive the PKCE cookie's Secure attribute from the live request protocol when a Request is available, falling back to the configured redirectUri and warning on unparseable URIs. Fixes local dev on http://localhost with an https:// WORKOS_REDIRECT_URI silently dropping the cookie. - Replace the hand-rolled FNV-1a cookie-name fingerprint with a SHA-256 slice from node:crypto. No new deps, shorter, and no collision-tradeoff caveat to document. - Drop the misleading 'SameSite=None for iframes' comment on a code path that hardcodes Lax. Iframe/embed flows aren't supported; if we ever add them, it belongs in its own change. - Let getSignInUrl/getSignUpUrl accept the incoming Request as a second argument so loaders can thread it through transparently. - Stop emitting an empty Set-Cookie: '' header from switchToOrganization when refreshSession returns no cookie — omit the header entirely. Co-Authored-By: nick.nisi@workos.com --- src/auth.spec.ts | 19 ++----- src/auth.ts | 51 +++++++++-------- src/authkit-callback-route.ts | 6 +- src/get-authorization-url.ts | 13 ++++- src/interfaces.ts | 14 ++++- src/pkce.spec.ts | 100 ++++++++++++++++++++++++++++++++++ src/pkce.ts | 89 ++++++++++++++++++------------ src/session.spec.ts | 10 +++- src/session.ts | 6 +- 9 files changed, 225 insertions(+), 83 deletions(-) create mode 100644 src/pkce.spec.ts diff --git a/src/auth.spec.ts b/src/auth.spec.ts index 5d30e2c..3e583f3 100644 --- a/src/auth.spec.ts +++ b/src/auth.spec.ts @@ -283,7 +283,7 @@ describe('auth', () => { ); }); - it('should handle when Set-Cookie header is missing', async () => { + it('omits the Set-Cookie response header when refreshSession returns none', async () => { // Create a mock without the Set-Cookie header const mockResponseWithoutCookie = { ...mockAuthResponse, @@ -293,17 +293,10 @@ describe('auth', () => { await switchToOrganization(request, organizationId); - expect(data).toHaveBeenCalledWith( - { success: true, auth: mockResponseWithoutCookie }, - { - headers: { - 'Set-Cookie': '', - }, - }, - ); + expect(data).toHaveBeenCalledWith({ success: true, auth: mockResponseWithoutCookie }, undefined); }); - it('should handle when returnTo is provided but Set-Cookie header is missing', async () => { + it('omits the Set-Cookie response header on returnTo when refreshSession returns none', async () => { // Create a mock without the Set-Cookie header const mockResponseWithoutCookie = { ...mockAuthResponse, @@ -313,11 +306,7 @@ describe('auth', () => { await switchToOrganization(request, organizationId, { returnTo: '/dashboard' }); - expect(redirect).toHaveBeenCalledWith('/dashboard', { - headers: { - 'Set-Cookie': '', - }, - }); + expect(redirect).toHaveBeenCalledWith('/dashboard', undefined); }); }); diff --git a/src/auth.ts b/src/auth.ts index aa9ee1f..3cc77dc 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -8,24 +8,35 @@ import { getConfig } from './config.js'; * Build a sign-in URL and the short-lived PKCE / CSRF cookie that must travel * back to the browser on the redirect. * + * Pass `request` when calling from a loader so the cookie's `Secure` + * attribute matches the live protocol (important for local dev on + * `http://` against an `https://` redirect URI). + * * @example - * const { url, headers } = await getSignInUrl('/dashboard'); - * return redirect(url, { headers }); + * export async function loader({ request }: LoaderFunctionArgs) { + * const { url, headers } = await getSignInUrl('/dashboard', request); + * return redirect(url, { headers }); + * } */ -export async function getSignInUrl(returnPathname?: string): Promise { - return getAuthorizationUrl({ returnPathname, screenHint: 'sign-in' }); +export async function getSignInUrl(returnPathname?: string, request?: Request): Promise { + return getAuthorizationUrl({ returnPathname, screenHint: 'sign-in', request }); } /** * Build a sign-up URL and the short-lived PKCE / CSRF cookie that must travel * back to the browser on the redirect. * + * Pass `request` when calling from a loader so the cookie's `Secure` + * attribute matches the live protocol. + * * @example - * const { url, headers } = await getSignUpUrl('/welcome'); - * return redirect(url, { headers }); + * export async function loader({ request }: LoaderFunctionArgs) { + * const { url, headers } = await getSignUpUrl('/welcome', request); + * return redirect(url, { headers }); + * } */ -export async function getSignUpUrl(returnPathname?: string): Promise { - return getAuthorizationUrl({ returnPathname, screenHint: 'sign-up' }); +export async function getSignUpUrl(returnPathname?: string, request?: Request): Promise { + return getAuthorizationUrl({ returnPathname, screenHint: 'sign-up', request }); } export async function signOut(request: Request, options?: { returnTo?: string }) { @@ -111,24 +122,20 @@ export async function switchToOrganization( try { const auth = await refreshSession(request, { organizationId }); + // `refreshSession` always returns a `Set-Cookie` header for a successful + // refresh; guard with `as Record` to satisfy the wider + // typing on AuthLoaderSuccessData without silently emitting an empty + // `Set-Cookie` header if the invariant ever changes. + const setCookie = (auth.headers as Record | undefined)?.['Set-Cookie']; + const responseHeaders = setCookie ? { 'Set-Cookie': setCookie } : undefined; + // if returnTo is provided, redirect to there if (returnTo) { - return redirect(returnTo, { - headers: { - 'Set-Cookie': auth.headers?.['Set-Cookie'] ?? '', - }, - }); + return redirect(returnTo, responseHeaders ? { headers: responseHeaders } : undefined); } // otherwise return the updated auth data - return data( - { success: true, auth }, - { - headers: { - 'Set-Cookie': auth.headers?.['Set-Cookie'] ?? '', - }, - }, - ); + return data({ success: true, auth }, responseHeaders ? { headers: responseHeaders } : undefined); } catch (error) { if (error instanceof Response && error.status === 302) { throw error; @@ -137,7 +144,7 @@ export async function switchToOrganization( // eslint-disable-next-line @typescript-eslint/no-explicit-any const errorCause: any = error instanceof Error ? error.cause : null; if (errorCause?.error === 'sso_required' || errorCause?.error === 'mfa_enrollment') { - const { url, headers } = await getAuthorizationUrl({ organizationId }); + const { url, headers } = await getAuthorizationUrl({ organizationId, request }); return redirect(url, { headers }); } diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index 3a963f1..4db46e6 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -12,8 +12,8 @@ import { getWorkOS } from './workos.js'; * them, whether the exchange succeeded or failed, to prevent replays and * stale cookies affecting future auth attempts. */ -function clearPKCECookie(state: string): string { - return getPKCECookieString(state, /* expired */ true); +function clearPKCECookie(state: string, request: Request): string { + return getPKCECookieString(state, { expired: true, request }); } export function authLoader(options: HandleAuthOptions = {}) { @@ -34,7 +34,7 @@ export function authLoader(options: HandleAuthOptions = {}) { // We always want to clear the PKCE cookie at the end of this handler, // success or failure. `pkceClearCookie` is populated as soon as we know // the state value and appended to every response below. - const pkceClearCookie = state ? clearPKCECookie(state) : null; + const pkceClearCookie = state ? clearPKCECookie(state, request) : null; try { if (!state) { diff --git a/src/get-authorization-url.ts b/src/get-authorization-url.ts index 7fb7ea1..93b4362 100644 --- a/src/get-authorization-url.ts +++ b/src/get-authorization-url.ts @@ -27,7 +27,16 @@ import { getWorkOS } from './workos.js'; * - recover the `codeVerifier` to complete the PKCE exchange. */ export async function getAuthorizationUrl(options: GetAuthURLOptions = {}): Promise { - const { returnPathname, screenHint, organizationId, redirectUri, loginHint, prompt, state: customState } = options; + const { + returnPathname, + screenHint, + organizationId, + redirectUri, + loginHint, + prompt, + state: customState, + request, + } = options; const pkce = await getWorkOS().pkce.generate(); @@ -60,6 +69,6 @@ export async function getAuthorizationUrl(options: GetAuthURLOptions = {}): Prom return { url, - headers: { 'Set-Cookie': getPKCECookieString(sealedState) }, + headers: { 'Set-Cookie': getPKCECookieString(sealedState, { request }) }, }; } diff --git a/src/interfaces.ts b/src/interfaces.ts index 54473d5..0393298 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -107,6 +107,13 @@ export interface GetAuthURLOptions { * alongside it for round-trip delivery only. */ state?: string; + /** + * The incoming `Request`, if available. When provided the PKCE cookie's + * `Secure` attribute is derived from the live request protocol rather than + * the configured `redirectUri` — necessary so local dev on `http://` with + * a `https://` redirect URI still sets a cookie the browser will keep. + */ + request?: Request; } /** @@ -114,8 +121,13 @@ export interface GetAuthURLOptions { * to whatever redirect response they return so the short-lived PKCE / * CSRF-binding cookie is set on the browser before WorkOS redirects back. * + * The concrete `{ 'Set-Cookie': string }` shape is still assignable to + * `HeadersInit` (via `Record`), so callers can spread it + * directly into a `new Headers({ ...headers, 'Cache-Control': 'no-store' })` + * or pass it straight to `redirect(url, { headers })`. + * * @example - * const { url, headers } = await getSignInUrl('/dashboard'); + * const { url, headers } = await getSignInUrl('/dashboard', request); * return redirect(url, { headers }); */ export interface GetAuthURLResult { diff --git a/src/pkce.spec.ts b/src/pkce.spec.ts new file mode 100644 index 0000000..e63e177 --- /dev/null +++ b/src/pkce.spec.ts @@ -0,0 +1,100 @@ +import { getPKCECookieString } from './pkce.js'; +import * as configModule from './config.js'; + +jest.mock('./config', () => ({ + getConfig: jest.fn(), +})); + +const getConfig = jest.mocked(configModule.getConfig); + +function cookieAttrs(cookie: string): Set { + return new Set(cookie.split(';').map((s) => s.trim())); +} + +describe('getPKCECookieString', () => { + const sealedState = 'sealed-state-value'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Secure attribute', () => { + it('uses the live request protocol over the configured redirectUri', () => { + // Simulate the footgun: redirect URI is https but dev server is http. + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { + request: new Request('http://localhost:5173/login'), + }); + + expect(cookieAttrs(cookie)).not.toContain('Secure'); + }); + + it('marks the cookie Secure when the request is https', () => { + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'http://localhost/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { + request: new Request('https://app.example.com/login'), + }); + + expect(cookieAttrs(cookie)).toContain('Secure'); + }); + + it('falls back to redirectUri when no request is supplied', () => { + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + expect(cookieAttrs(getPKCECookieString(sealedState))).toContain('Secure'); + + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'http://localhost/callback' : undefined, + ); + + expect(cookieAttrs(getPKCECookieString(sealedState))).not.toContain('Secure'); + }); + + it('honors an explicit secure override over both request and redirectUri', () => { + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { + request: new Request('https://app.example.com/login'), + secure: false, + }); + + expect(cookieAttrs(cookie)).not.toContain('Secure'); + }); + + it('defaults to Secure=true and warns when redirectUri is unparseable', () => { + getConfig.mockImplementation((key: string) => (key === 'redirectUri' ? 'not a url' : undefined)); + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const cookie = getPKCECookieString(sealedState); + expect(cookieAttrs(cookie)).toContain('Secure'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('redirectUri')); + } finally { + warn.mockRestore(); + } + }); + }); + + describe('expired variant', () => { + it('emits Max-Age=0 and an empty value so the browser clears the cookie', () => { + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { expired: true }); + + expect(cookie).toMatch(/^wos-auth-verifier-[0-9a-f]{8}=;/); + expect(cookieAttrs(cookie)).toContain('Max-Age=0'); + }); + }); +}); diff --git a/src/pkce.ts b/src/pkce.ts index e7a9c8e..4842890 100644 --- a/src/pkce.ts +++ b/src/pkce.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { unsealData } from 'iron-session'; import * as v from 'valibot'; import { getConfig } from './config.js'; @@ -9,61 +10,81 @@ export const PKCE_COOKIE_NAME = 'wos-auth-verifier'; // authorization request should complete long before this expires. const PKCE_COOKIE_MAX_AGE = 600; -/** - * 32-bit FNV-1a non-cryptographic hash. Inlined here rather than pulled in as - * `@sindresorhus/fnv1a` because that package is ESM-only and this SDK ships - * CommonJS. FNV-1a is a well-known, ~15-line algorithm — see RFC draft-eastlake-fnv. - * - * Note: this hashes UTF-16 code units (via `charCodeAt`) rather than UTF-8 - * bytes. Callers only feed this ASCII-safe iron-session seals (base64url), so - * the distinction is irrelevant in practice — but don't reuse the function for - * non-ASCII input without re-encoding to bytes first. - */ -function fnv1a32(input: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < input.length; i++) { - hash ^= input.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - // Force an unsigned 32-bit integer - return hash >>> 0; -} - /** * Short, deterministic hex fingerprint of an arbitrary string. * Used to give each PKCE flow its own cookie name without depending on the - * internal format of the sealed state value. + * internal format of the sealed state value. Collision resistance is not + * security-critical here (cookie values are still integrity-checked via + * iron-session); the fingerprint only needs to spread concurrent flows + * across distinct cookie names. */ function shortHash(input: string): string { - return fnv1a32(input).toString(16).padStart(8, '0'); + return createHash('sha256').update(input).digest('hex').slice(0, 8); } /** * Derive a flow-specific cookie name so concurrent auth flows don't overwrite - * each other's PKCE cookies. Uses an FNV-1a hash of the full sealed state. + * each other's PKCE cookies. */ export function getPKCECookieNameForState(state: string): string { return `${PKCE_COOKIE_NAME}-${shortHash(state)}`; } /** - * Build a `Set-Cookie` header string for the PKCE verifier cookie. + * Decide whether the PKCE cookie should carry the `Secure` attribute. * - * `SameSite=Strict` would be stripped on the cross-site redirect back from - * WorkOS, so it is downgraded to `Lax`. `SameSite=None` is preserved for - * iframe / cross-origin embed flows. + * Preference order: + * 1. An explicit `secure` override from the caller. + * 2. The live request protocol — the cookie is set on *this* response, and + * the browser will drop `Secure` cookies on http:// pages even if the + * configured redirect URI is https://. + * 3. Fall back to the configured redirectUri's protocol. + * + * A misconfigured redirectUri (unparseable URL) is not a fatal error here; + * we default to `Secure=true` and warn so the misconfiguration is visible. */ -export function getPKCECookieString(sealedState: string, expired = false): string { - const name = getPKCECookieNameForState(sealedState); - const value = expired ? '' : sealedState; +function resolveSecure({ secure, request }: { secure?: boolean; request?: Request } = {}): boolean { + if (typeof secure === 'boolean') return secure; + + if (request) { + try { + return new URL(request.url).protocol === 'https:'; + } catch { + // fall through to redirectUri-based detection + } + } const redirectUri = getConfig('redirectUri'); - let secure = true; try { - secure = new URL(redirectUri).protocol === 'https:'; + return new URL(redirectUri).protocol === 'https:'; } catch { - secure = true; + console.warn( + `[AuthKit] Could not parse redirectUri (${JSON.stringify(redirectUri)}); defaulting PKCE cookie to Secure=true.`, + ); + return true; } +} + +/** + * Build a `Set-Cookie` header string for the PKCE verifier cookie. + * + * `SameSite=Strict` would be stripped on the cross-site redirect back from + * WorkOS, so it is set to `Lax` — the minimum that survives a top-level + * cross-site navigation back to our origin. + * + * Callers that have the incoming `Request` in hand should pass it via + * `options.request` so the `Secure` attribute reflects the actual protocol + * in use rather than the configured redirect URI's — otherwise running + * `npm run dev` on http:// while `WORKOS_REDIRECT_URI=https://…` would mint + * a `Secure` cookie the browser silently drops. + */ +export function getPKCECookieString( + sealedState: string, + options: { expired?: boolean; request?: Request; secure?: boolean } = {}, +): string { + const { expired = false, request, secure } = options; + const name = getPKCECookieNameForState(sealedState); + const value = expired ? '' : sealedState; const parts = [ `${name}=${value}`, @@ -72,7 +93,7 @@ export function getPKCECookieString(sealedState: string, expired = false): strin 'SameSite=Lax', `Max-Age=${expired ? 0 : PKCE_COOKIE_MAX_AGE}`, ]; - if (secure) { + if (resolveSecure({ secure, request })) { parts.push('Secure'); } return parts.join('; '); diff --git a/src/session.spec.ts b/src/session.spec.ts index 3789572..be42168 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -641,9 +641,13 @@ describe('session', () => { expect(setCookies).toContain('wos-auth-verifier-abc=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600'); // Verify getAuthorizationUrl was called with the correct returnPathname - expect(getAuthorizationUrlMock).toHaveBeenCalledWith({ - returnPathname: '/dashboard/settings', - }); + // and the request is threaded through for Secure-attribute detection. + expect(getAuthorizationUrlMock).toHaveBeenCalledWith( + expect.objectContaining({ + returnPathname: '/dashboard/settings', + request: expect.any(Request), + }), + ); } }); diff --git a/src/session.ts b/src/session.ts index fbeb5c9..728377d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -44,7 +44,7 @@ export async function refreshSession(request: Request, options: { organizationId const cookie = request.headers.get('Cookie'); const session = cookie ? await getSessionFromCookie(cookie) : null; if (!session) { - const { url, headers } = await getAuthorizationUrl(); + const { url, headers } = await getAuthorizationUrl({ request }); throw redirect(url, { headers }); } @@ -362,7 +362,7 @@ export async function authkitLoader( const returnPathname = getReturnPathname(request.url); const cookieSession = await getSession(request.headers.get('Cookie')); - const { url, headers: authHeaders } = await getAuthorizationUrl({ returnPathname }); + const { url, headers: authHeaders } = await getAuthorizationUrl({ returnPathname, request }); throw redirect(url, { headers: [ ['Set-Cookie', await destroySession(cookieSession)], @@ -446,7 +446,7 @@ export async function authkitLoader( } const returnPathname = getReturnPathname(request.url); - const { url, headers: authHeaders } = await getAuthorizationUrl({ returnPathname }); + const { url, headers: authHeaders } = await getAuthorizationUrl({ returnPathname, request }); throw redirect(url, { headers: [ ['Set-Cookie', await destroySession(cookieSession)], From 72f3137c62ed5353725397648e72fadf22dd0c78 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:32:10 +0000 Subject: [PATCH 05/15] Rewrite README and add migration guide for 0.10.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSignInUrl/getSignUpUrl now return { url, headers } and the Set-Cookie must travel on the redirect that starts the OAuth flow. The old pattern of loading a URL into page data and rendering it in a no longer works — the cookie and the URL must leave the server on the same response. Replace the broken example with dedicated /login and /signup redirect routes, call that pattern out as the way to offer sign-in/sign-up from any page, and add a 'Migrating from 0.4.x' section documenting the return-shape change, the required redirect-route pattern, the new request threading for Secure detection, and the @workos-inc/node ^8.9.0 minimum. Also add CHANGELOG.md to track behavioral changes. Co-Authored-By: nick.nisi@workos.com --- CHANGELOG.md | 42 ++++++++++++++++ README.md | 136 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..07df7b8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to `@workos-inc/authkit-react-router` are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +While the package is pre-1.0, minor version bumps (e.g. `0.4.x → 0.10.0`) are +used to signal breaking changes. + +## [Unreleased] + +### Added + +- **PKCE + CSRF protection** on the authorization-code flow. Each sign-in / + sign-up redirect now sets a short-lived (10 minute), flow-specific + `wos-auth-verifier-` cookie containing a sealed OAuth `state` value, + and the callback verifies the round-tripped `state` matches the cookie + (double-submit) before exchanging the code plus PKCE verifier. Concurrent + flows get distinct cookie names so stacked sign-in attempts don't + overwrite each other. +- `getSignInUrl` / `getSignUpUrl` / `getAuthorizationUrl` now accept the + incoming `Request` so the PKCE cookie's `Secure` attribute reflects the + live request protocol (fixes local-dev with `http://localhost` and an + `https://` `WORKOS_REDIRECT_URI`). + +### Changed + +- **Breaking:** `getSignInUrl`, `getSignUpUrl`, and `getAuthorizationUrl` + now return `{ url, headers }` instead of a bare URL string. The + `headers` include a `Set-Cookie` that **must** travel to the browser on + the redirect response; otherwise the callback will reject the flow as + a CSRF failure. See the + [migration guide](./README.md#migrating-from-04x) for the redirect-route + pattern that replaces the old "render a sign-in URL in a ``" + approach. +- **Breaking:** Minimum `@workos-inc/node` is now `^8.9.0` (for the + `pkce` namespace). +- `authkitLoader` and `switchToOrganization` automatically forward the + new PKCE cookie on redirects they initiate, so most consumers don't + need to thread it through manually. +- `switchToOrganization` no longer emits an empty `Set-Cookie: ''` header + when `refreshSession` returns without one. diff --git a/README.md b/README.md index 95f7a5a..ee8d66d 100644 --- a/README.md +++ b/README.md @@ -142,10 +142,10 @@ import { authkitLoader } from '@workos-inc/authkit-react-router'; export const loader = (args: LoaderFunctionArgs) => authkitLoader(args); export function App() { - // Retrieves the user from the session or returns `null` if no user is signed in + // Retrieves the user from the session or returns `null` if no user is signed in. // Other supported values include `sessionId`, `organizationId`, // `role`, `permissions`, `entitlements`, `featureFlags`, and `impersonator`. - const { user, signInUrl, signUpUrl } = useLoaderData(); + const { user } = useLoaderData(); return (
@@ -155,33 +155,66 @@ export function App() { } ``` -For pages where you want to display a signed-in and signed-out view, use `authkitLoader` to retrieve the user profile from WorkOS. You can pass in additional data by providing a loader function directly to `authkitLoader`. +### Sign-in and sign-up routes + +`getSignInUrl` and `getSignUpUrl` return a `{ url, headers }` pair. The +`headers` contain a short-lived `Set-Cookie` used for PKCE + CSRF +protection, which **must** travel to the browser on the same redirect +response that sends the user to AuthKit. Create dedicated redirect routes +for sign-in and sign-up and link to those routes from your pages: + +```ts +// app/routes/login.ts +import { redirect, type LoaderFunctionArgs } from 'react-router'; +import { getSignInUrl } from '@workos-inc/authkit-react-router'; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const { url: authUrl, headers } = await getSignInUrl(url.searchParams.get('returnTo') ?? undefined, request); + return redirect(authUrl, { headers }); +} +``` + +```ts +// app/routes/signup.ts +import { redirect, type LoaderFunctionArgs } from 'react-router'; +import { getSignUpUrl } from '@workos-inc/authkit-react-router'; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const { url: authUrl, headers } = await getSignUpUrl(url.searchParams.get('returnTo') ?? undefined, request); + return redirect(authUrl, { headers }); +} +``` + +Passing `request` ensures the `Secure` attribute on the PKCE cookie +matches your app's live protocol (important in local dev, where the app +runs on `http://localhost` even if `WORKOS_REDIRECT_URI` is an `https://` +URL). + +Then link to those routes from any page where you want to offer sign-in +or sign-up: ```tsx -import { type ActionFunctionArgs, type LoaderFunctionArgs, data, Form, Link, useLoaderData } from 'react-router'; -import { getSignInUrl, getSignUpUrl, signOut, authkitLoader } from '@workos-inc/authkit-react-router'; +// app/routes/_index.tsx +import { type ActionFunctionArgs, type LoaderFunctionArgs, Form, Link, useLoaderData } from 'react-router'; +import { signOut, authkitLoader } from '@workos-inc/authkit-react-router'; -export const loader = (args: LoaderFunctionArgs) => - authkitLoader(args, async ({ request, auth }) => { - return data({ - signInUrl: await getSignInUrl(), - signUpUrl: await getSignUpUrl(), - }); - }); +export const loader = (args: LoaderFunctionArgs) => authkitLoader(args); export async function action({ request }: ActionFunctionArgs) { return await signOut(request); } export default function HomePage() { - const { user, signInUrl, signUpUrl } = useLoaderData(); + const { user } = useLoaderData(); if (!user) { return ( <> - Log in + Log in
- Sign Up + Sign Up ); } @@ -195,6 +228,13 @@ export default function HomePage() { } ``` +> [!NOTE] +> +> Prior to `0.10.0`, `getSignInUrl` / `getSignUpUrl` returned a bare URL +> string that could be rendered directly in a ``. That pattern is +> no longer supported — see [Migrating from 0.4.x](#migrating-from-04x) +> below. + ### Requiring auth For pages where a signed-in user is mandatory, you can use the `ensureSignedIn` option: @@ -497,3 +537,69 @@ export const loader = (args) => > When deploying to serverless environments like AWS Lambda, ensure you pass the same storage configuration to both your main routes and the callback route to handle cold starts properly. AuthKit works with any session storage that implements React Router's `SessionStorage` interface, including Redis-based or database-backed implementations. + +## Migrating from 0.4.x + +`0.10.0` is a breaking release that adds PKCE and CSRF protection to the +authorization-code flow. Upgrading from `0.4.x` requires small changes to +any route that builds a sign-in or sign-up URL. + +### 1. `getSignInUrl` / `getSignUpUrl` now return `{ url, headers }` + +They used to return a bare URL string. They now return an object with a +`url` and a `Set-Cookie` header that **must** travel to the browser on +the redirect that starts the OAuth flow, so that the callback can verify +the response came from this browser (CSRF) and recover the PKCE code +verifier. + +```ts +// 0.4.x +const signInUrl = await getSignInUrl(); +return redirect(signInUrl); + +// 0.10.0+ +const { url, headers } = await getSignInUrl('/dashboard', request); +return redirect(url, { headers }); +``` + +### 2. Use a dedicated redirect route for sign-in / sign-up + +The old "load the URL into your page data and render it in a ``" +pattern no longer works: the cookie and the URL must leave the server on +the same response. Move the URL generation into a loader that returns a +redirect, and link to that loader's path from your page: + +```ts +// app/routes/login.ts +export async function loader({ request }: LoaderFunctionArgs) { + const { url, headers } = await getSignInUrl(undefined, request); + return redirect(url, { headers }); +} +``` + +```tsx +// Any page: +Log in +``` + +See [Sign-in and sign-up routes](#sign-in-and-sign-up-routes) above for +the full pattern. + +### 3. Pass `request` when calling from a loader + +`getSignInUrl` / `getSignUpUrl` (and `getAuthorizationUrl`) accept the +incoming `Request` as their second argument. Pass it when available so +the PKCE cookie's `Secure` attribute reflects the live request protocol +rather than the configured `redirectUri`'s — otherwise local dev on +`http://localhost` with `WORKOS_REDIRECT_URI=https://…` mints a `Secure` +cookie the browser silently drops, and the callback fails with +`Auth cookie missing`. + +### 4. `@workos-inc/node` minimum is `^8.9.0` + +PKCE is implemented in `@workos-inc/node`'s `pkce` namespace, which +requires `^8.9.0`. If your app pins an older version, upgrade: + +```bash +npm install @workos-inc/node@^8.9.0 +``` From 4fe11738d82bd7c3e626f731e35a4f38831c583d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:33:30 +0000 Subject: [PATCH 06/15] Format pkce.spec.ts Co-Authored-By: nick.nisi@workos.com --- src/pkce.spec.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/pkce.spec.ts b/src/pkce.spec.ts index e63e177..808d72b 100644 --- a/src/pkce.spec.ts +++ b/src/pkce.spec.ts @@ -33,9 +33,7 @@ describe('getPKCECookieString', () => { }); it('marks the cookie Secure when the request is https', () => { - getConfig.mockImplementation((key: string) => - key === 'redirectUri' ? 'http://localhost/callback' : undefined, - ); + getConfig.mockImplementation((key: string) => (key === 'redirectUri' ? 'http://localhost/callback' : undefined)); const cookie = getPKCECookieString(sealedState, { request: new Request('https://app.example.com/login'), @@ -51,9 +49,7 @@ describe('getPKCECookieString', () => { expect(cookieAttrs(getPKCECookieString(sealedState))).toContain('Secure'); - getConfig.mockImplementation((key: string) => - key === 'redirectUri' ? 'http://localhost/callback' : undefined, - ); + getConfig.mockImplementation((key: string) => (key === 'redirectUri' ? 'http://localhost/callback' : undefined)); expect(cookieAttrs(getPKCECookieString(sealedState))).not.toContain('Secure'); }); From 4806fc7f7e8acd86238c26b6833f35aececb8689 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:40:50 +0000 Subject: [PATCH 07/15] Address follow-up review on PKCE cookie Secure detection - Raise engines.node floor to >=20.15.0 to match @workos-inc/node@^8.13.x, whose engines declaration was stricter than ours. Users on 20.0-20.14 would otherwise see unsupported-engine install failures despite our metadata advertising support. - Honor X-Forwarded-Proto (leftmost value when chained) ahead of request.url when deriving the PKCE cookie's Secure attribute, so deployments that terminate TLS upstream and forward http:// internally still emit Secure cookies for the public https:// site. - Thread the per-call redirectUri override from getAuthorizationUrl through to resolveSecure so a caller passing getAuthorizationUrl({ redirectUri }) without a Request gets a cookie whose Secure attribute matches the override's scheme rather than the unrelated global WORKOS_REDIRECT_URI. Co-Authored-By: nick.nisi@workos.com --- package.json | 2 +- src/get-authorization-url.ts | 2 +- src/pkce.spec.ts | 58 ++++++++++++++++++++++++++++++++++++ src/pkce.ts | 42 ++++++++++++++++++-------- 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 274f7ee..e05fcde 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "main": "./dist/cjs/index.js", "types": "./dist/cjs/index.d.ts", "engines": { - "node": ">=20.0.0" + "node": ">=20.15.0" }, "files": [ "dist", diff --git a/src/get-authorization-url.ts b/src/get-authorization-url.ts index 93b4362..9652b94 100644 --- a/src/get-authorization-url.ts +++ b/src/get-authorization-url.ts @@ -69,6 +69,6 @@ export async function getAuthorizationUrl(options: GetAuthURLOptions = {}): Prom return { url, - headers: { 'Set-Cookie': getPKCECookieString(sealedState, { request }) }, + headers: { 'Set-Cookie': getPKCECookieString(sealedState, { request, redirectUri }) }, }; } diff --git a/src/pkce.spec.ts b/src/pkce.spec.ts index 808d72b..a81e7c8 100644 --- a/src/pkce.spec.ts +++ b/src/pkce.spec.ts @@ -54,6 +54,64 @@ describe('getPKCECookieString', () => { expect(cookieAttrs(getPKCECookieString(sealedState))).not.toContain('Secure'); }); + it('honors X-Forwarded-Proto=https behind a TLS-terminating proxy', () => { + // TLS terminator forwards a plain http:// request upstream but the + // public site is https://. The PKCE cookie must still be Secure. + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { + request: new Request('http://internal.lb:8080/login', { + headers: { 'X-Forwarded-Proto': 'https' }, + }), + }); + + expect(cookieAttrs(cookie)).toContain('Secure'); + }); + + it('honors X-Forwarded-Proto=http even when request.url is https', () => { + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { + request: new Request('https://internal.lb/login', { + headers: { 'X-Forwarded-Proto': 'http' }, + }), + }); + + expect(cookieAttrs(cookie)).not.toContain('Secure'); + }); + + it('uses the leftmost entry of a chained X-Forwarded-Proto header', () => { + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'http://localhost/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { + request: new Request('http://internal.lb/login', { + headers: { 'X-Forwarded-Proto': 'https, http' }, + }), + }); + + expect(cookieAttrs(cookie)).toContain('Secure'); + }); + + it('prefers a per-call redirectUri override over the global config when no request is supplied', () => { + // Global config says https, but this specific flow is being initiated + // against a different redirect URI (e.g. a dev tunnel on http). + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { + redirectUri: 'http://localhost:5173/callback', + }); + + expect(cookieAttrs(cookie)).not.toContain('Secure'); + }); + it('honors an explicit secure override over both request and redirectUri', () => { getConfig.mockImplementation((key: string) => key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, diff --git a/src/pkce.ts b/src/pkce.ts index 4842890..20252cc 100644 --- a/src/pkce.ts +++ b/src/pkce.ts @@ -35,18 +35,34 @@ export function getPKCECookieNameForState(state: string): string { * * Preference order: * 1. An explicit `secure` override from the caller. - * 2. The live request protocol — the cookie is set on *this* response, and - * the browser will drop `Secure` cookies on http:// pages even if the - * configured redirect URI is https://. - * 3. Fall back to the configured redirectUri's protocol. + * 2. `X-Forwarded-Proto` from the incoming request — the canonical signal + * when TLS is terminated upstream (load balancer, reverse proxy) and + * the app receives a plain http:// request for an https:// site. + * 3. The live request protocol — the cookie is set on *this* response, + * and the browser will drop `Secure` cookies on http:// pages even if + * the configured redirect URI is https://. + * 4. The per-call `redirectUri` override, when provided, so that a caller + * passing `getAuthorizationUrl({ redirectUri })` without a request can + * still control the cookie's Secure attribute for that specific flow. + * 5. Fall back to the configured redirectUri's protocol. * - * A misconfigured redirectUri (unparseable URL) is not a fatal error here; - * we default to `Secure=true` and warn so the misconfiguration is visible. + * A misconfigured / unparseable redirectUri is not fatal; we default to + * `Secure=true` and warn so the misconfiguration is visible. */ -function resolveSecure({ secure, request }: { secure?: boolean; request?: Request } = {}): boolean { +function resolveSecure({ + secure, + request, + redirectUri, +}: { secure?: boolean; request?: Request; redirectUri?: string } = {}): boolean { if (typeof secure === 'boolean') return secure; if (request) { + const forwardedProto = request.headers.get('x-forwarded-proto'); + if (forwardedProto) { + // X-Forwarded-Proto may be a comma-separated list when multiple proxies + // chain; the leftmost value is the client-facing protocol. + return forwardedProto.split(',')[0].trim().toLowerCase() === 'https'; + } try { return new URL(request.url).protocol === 'https:'; } catch { @@ -54,12 +70,12 @@ function resolveSecure({ secure, request }: { secure?: boolean; request?: Reques } } - const redirectUri = getConfig('redirectUri'); + const uri = redirectUri ?? getConfig('redirectUri'); try { - return new URL(redirectUri).protocol === 'https:'; + return new URL(uri).protocol === 'https:'; } catch { console.warn( - `[AuthKit] Could not parse redirectUri (${JSON.stringify(redirectUri)}); defaulting PKCE cookie to Secure=true.`, + `[AuthKit] Could not parse redirectUri (${JSON.stringify(uri)}); defaulting PKCE cookie to Secure=true.`, ); return true; } @@ -80,9 +96,9 @@ function resolveSecure({ secure, request }: { secure?: boolean; request?: Reques */ export function getPKCECookieString( sealedState: string, - options: { expired?: boolean; request?: Request; secure?: boolean } = {}, + options: { expired?: boolean; request?: Request; secure?: boolean; redirectUri?: string } = {}, ): string { - const { expired = false, request, secure } = options; + const { expired = false, request, secure, redirectUri } = options; const name = getPKCECookieNameForState(sealedState); const value = expired ? '' : sealedState; @@ -93,7 +109,7 @@ export function getPKCECookieString( 'SameSite=Lax', `Max-Age=${expired ? 0 : PKCE_COOKIE_MAX_AGE}`, ]; - if (resolveSecure({ secure, request })) { + if (resolveSecure({ secure, request, redirectUri })) { parts.push('Secure'); } return parts.join('; '); From 9e92ec7077aedfa60d1df38dc1ec410d6fe412d4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:42:01 +0000 Subject: [PATCH 08/15] Format pkce.spec.ts Co-Authored-By: nick.nisi@workos.com --- src/pkce.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pkce.spec.ts b/src/pkce.spec.ts index a81e7c8..0057019 100644 --- a/src/pkce.spec.ts +++ b/src/pkce.spec.ts @@ -85,9 +85,7 @@ describe('getPKCECookieString', () => { }); it('uses the leftmost entry of a chained X-Forwarded-Proto header', () => { - getConfig.mockImplementation((key: string) => - key === 'redirectUri' ? 'http://localhost/callback' : undefined, - ); + getConfig.mockImplementation((key: string) => (key === 'redirectUri' ? 'http://localhost/callback' : undefined)); const cookie = getPKCECookieString(sealedState, { request: new Request('http://internal.lb/login', { From 0c3f9a4ab7dc8b5719475546eae49e3d9f1d5777 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:06:25 +0000 Subject: [PATCH 09/15] Sanitize returnPathname and preserve hash on callback Port two defensive improvements from the nicknisi/pkce branch: 1. Introduce sanitizeReturnPathname, a shared utility that restricts a user-supplied return target to a same-origin pathname. Rejects absolute URLs, protocol-relative paths, backslash-prefixed paths, CRLF header-injection attempts, dot-segment traversal, URL-encoded bypasses, and oversized inputs; returns '/' on rejection. 2. Apply the sanitizer in two places: - getAuthorizationUrl sanitizes returnPathname before sealing so a hostile caller cannot plant a malicious return target in the encrypted state. - authkit-callback-route sanitizes both the sealed-state value and the configured default independently on the callback response, so a tampered sealed state cannot erase a legitimate default. 3. Replace the pathname-or-pathname+search branching in the callback with a single URL-based reconstruction that preserves pathname, search params, and fragment together. /dashboard#section now stays /dashboard#section instead of being percent-encoded. Coverage: the new return-pathname.spec.ts mirrors the sanitization suite from nicknisi/pkce (accepted paths; rejected absolute URLs, protocol-relative, CRLF, dot segments, encoded bypasses, oversized, non-string inputs). The callback spec adds tests for fragment preservation, search+fragment together, and fall-back to the configured default when a hostile sealed state is received. Co-Authored-By: nick.nisi@workos.com --- src/authkit-callback-route.spec.ts | 68 ++++++++++++++++++++++++++++++ src/authkit-callback-route.ts | 29 +++++++------ src/get-authorization-url.ts | 6 ++- src/return-pathname.spec.ts | 50 ++++++++++++++++++++++ src/return-pathname.ts | 28 ++++++++++++ 5 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 src/return-pathname.spec.ts create mode 100644 src/return-pathname.ts diff --git a/src/authkit-callback-route.spec.ts b/src/authkit-callback-route.spec.ts index 5818001..1271216 100644 --- a/src/authkit-callback-route.spec.ts +++ b/src/authkit-callback-route.spec.ts @@ -197,6 +197,74 @@ describe('authLoader', () => { expect(response.headers.get('Location')).toBe('http://example.com/dashboard?foo=bar'); }); + it('preserves the fragment on the returnPathname', async () => { + loader = authLoader({ returnPathname: '/dashboard#section' }); + const response = await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs); + + assertIsResponse(response); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('http://example.com/dashboard#section'); + }); + + it('preserves search params and fragment together', async () => { + loader = authLoader({ returnPathname: '/dashboard?foo=bar#section' }); + const response = await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs); + + assertIsResponse(response); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('http://example.com/dashboard?foo=bar#section'); + }); + + it('falls back to the configured returnPathname when the sealed state value is hostile', async () => { + // Simulate a tampered / hand-forged sealed state that bypasses the + // sanitization in getAuthorizationUrl. The callback must reject the + // hostile value and fall back to the configured option rather than + // redirecting the user to an attacker-controlled destination. + loader = authLoader({ returnPathname: '/dashboard' }); + const scoped = await createSealedState({ returnPathname: '//evil.com/pwn' }); + request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { + code: 'test-code', + state: scoped.sealedState, + }); + + const response = await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs); + + assertIsResponse(response); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('http://example.com/dashboard'); + }); + + it('rejects a CRLF-smuggled returnPathname in the sealed state', async () => { + loader = authLoader({ returnPathname: '/dashboard' }); + const scoped = await createSealedState({ returnPathname: '/foo\r\nSet-Cookie: bad' }); + request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { + code: 'test-code', + state: scoped.sealedState, + }); + + const response = await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs); + + assertIsResponse(response); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('http://example.com/dashboard'); + }); + it('handles calling onSuccess when provided', async () => { const onSuccess = jest.fn(); loader = authLoader({ onSuccess }); diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index 4db46e6..6a4f27e 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -2,6 +2,7 @@ import { LoaderFunctionArgs, data, redirect } from 'react-router'; import { getConfig } from './config.js'; import { HandleAuthOptions } from './interfaces.js'; import { getPKCECookieString, getStateFromPKCECookieValue, readPKCECookie } from './pkce.js'; +import { sanitizeReturnPathname } from './return-pathname.js'; import { encryptSession } from './session.js'; import { configureSessionStorage } from './sessionStorage.js'; import { getWorkOS } from './workos.js'; @@ -74,19 +75,23 @@ export function authLoader(options: HandleAuthOptions = {}) { url.searchParams.delete('code'); url.searchParams.delete('state'); - const returnPathname = returnPathnameState ?? returnPathnameOption; - - // Extract the search params if they are present - if (returnPathname.includes('?')) { - const newUrl = new URL(returnPathname, 'https://example.com'); - url.pathname = newUrl.pathname; - - for (const [key, value] of newUrl.searchParams) { - url.searchParams.append(key, value); - } - } else { - url.pathname = returnPathname; + // Sanitize each candidate separately so a hostile sealed-state value + // (e.g. an absolute URL, CRLF smuggle, or encoded traversal) can't + // erase a legitimate configured default. `sanitizeReturnPathname` + // returns '/' on rejection, which we treat as "fall back to the option". + const safeFromState = returnPathnameState ? sanitizeReturnPathname(returnPathnameState) : '/'; + const safeFromOption = sanitizeReturnPathname(returnPathnameOption); + const returnPathname = safeFromState !== '/' ? safeFromState : safeFromOption; + + // Reconstruct pathname + search + hash together. Using `.pathname = ...` + // on a raw string with a fragment would percent-encode the `#`, so we + // parse the return target and reassign each piece. + const parsedReturn = new URL(returnPathname, 'https://placeholder.invalid'); + url.pathname = parsedReturn.pathname; + for (const [key, value] of parsedReturn.searchParams) { + url.searchParams.append(key, value); } + url.hash = parsedReturn.hash; // The refreshToken should never be accessible publicly, hence why we encrypt it // in the cookie session. Alternatively you could persist the refresh token in a diff --git a/src/get-authorization-url.ts b/src/get-authorization-url.ts index 9652b94..e771f4f 100644 --- a/src/get-authorization-url.ts +++ b/src/get-authorization-url.ts @@ -2,6 +2,7 @@ import { sealData } from 'iron-session'; import { getConfig } from './config.js'; import type { GetAuthURLOptions, GetAuthURLResult, State } from './interfaces.js'; import { getPKCECookieString } from './pkce.js'; +import { sanitizeReturnPathname } from './return-pathname.js'; import { getWorkOS } from './workos.js'; /** @@ -44,7 +45,10 @@ export async function getAuthorizationUrl(options: GetAuthURLOptions = {}): Prom nonce: crypto.randomUUID(), codeVerifier: pkce.codeVerifier, customState, - returnPathname, + // Sanitize before sealing so a hostile caller can't plant a malicious + // return target (absolute URL, CRLF smuggle, dot-segment traversal, etc.) + // that the callback would later redirect to. + returnPathname: returnPathname !== undefined ? sanitizeReturnPathname(returnPathname) : undefined, } satisfies State; const sealedState = await sealData(state, { diff --git a/src/return-pathname.spec.ts b/src/return-pathname.spec.ts new file mode 100644 index 0000000..469d0ff --- /dev/null +++ b/src/return-pathname.spec.ts @@ -0,0 +1,50 @@ +import { sanitizeReturnPathname } from './return-pathname.js'; + +describe('sanitizeReturnPathname', () => { + describe('accepts', () => { + it.each(['/', '/foo', '/foo/bar', '/foo?bar=1', '/foo?a=1&b=2', '/foo#baz', '/foo?bar=1#baz', '/a-b_c.d~e'])( + '%s', + (input) => { + expect(sanitizeReturnPathname(input)).toBe(input); + }, + ); + }); + + describe('rejects', () => { + it.each([ + ['non-leading-slash', 'foo'], + ['empty', ''], + ['protocol-relative //evil.com', '//evil.com'], + ['protocol-relative with path', '//evil.com/foo'], + ['absolute http', 'http://evil.com'], + ['absolute https', 'https://evil.com/path'], + ['backslash prefix', '/\\evil.com'], + ['CR injection', '/foo\rbar'], + ['LF injection', '/foo\nbar'], + ['CRLF Set-Cookie smuggle', '/foo\r\nSet-Cookie: bad'], + ['dot-segment traversal', '/app/../admin'], + ['dot-segment same-dir', '/app/./x'], + ['URL-encoded absolute', '/%2F%2Fevil.com'], + ['URL-encoded protocol', '//%65vil.com'], + ['URL-encoded backslash', '/%5Cevil.com'], + ['malformed percent encoding', '/%ZZ'], + ])('%s → /', (_label, input) => { + expect(sanitizeReturnPathname(input)).toBe('/'); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ['number', 42], + ['object', { pathname: '/foo' }], + ['array', ['/foo']], + ['boolean', true], + ])('non-string %s → /', (_label, input) => { + expect(sanitizeReturnPathname(input)).toBe('/'); + }); + + it('oversized string (>2048 chars) → /', () => { + expect(sanitizeReturnPathname('/' + 'a'.repeat(2048))).toBe('/'); + }); + }); +}); diff --git a/src/return-pathname.ts b/src/return-pathname.ts new file mode 100644 index 0000000..2506e7d --- /dev/null +++ b/src/return-pathname.ts @@ -0,0 +1,28 @@ +const PLACEHOLDER = 'https://placeholder.invalid'; + +/** + * Restrict a user-supplied return target to a same-origin pathname. + * Rejects absolute URLs, protocol-relative paths, backslash-prefixed paths, CRLF + * header-injection attempts, dot-segment traversal, URL-encoded bypasses, and + * oversized inputs. Returns the input on success, '/' on rejection. + */ +export function sanitizeReturnPathname(input: unknown): string { + if (typeof input !== 'string' || input.length === 0) return '/'; + if (input.length > 2048) return '/'; + if (!input.startsWith('/')) return '/'; + if (input.startsWith('//')) return '/'; + if (input.startsWith('/\\')) return '/'; + if (/[\r\n]/.test(input)) return '/'; + + try { + const u = new URL(input, PLACEHOLDER); + if (u.origin !== PLACEHOLDER) return '/'; + const normalized = u.pathname + u.search + u.hash; + if (normalized !== input) return '/'; + const decoded = decodeURIComponent(u.pathname); + if (decoded.startsWith('//') || decoded.startsWith('/\\')) return '/'; + } catch { + return '/'; + } + return input; +} From 697e81e19f8e8313fe0e0ed84c927fbe2573e90f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:13:44 +0000 Subject: [PATCH 10/15] Preserve explicit returnPathname='/' in callback sanitizeReturnPathname returns '/' both on legitimate '/' input and on rejection, which made the previous callback logic treat any sealed- state value of '/' as a rejection and fall through to the configured returnPathname option. A caller who explicitly asked to return home would instead land on the handler's default (e.g. '/dashboard'). Detect rejection by comparing the sanitized result against the raw input: state is only rejected when sanitization produced '/' from a non-'/' input. Adds a callback test covering an explicit '/' in the sealed state with a non-root configured option. Co-Authored-By: nick.nisi@workos.com --- src/authkit-callback-route.spec.ts | 23 +++++++++++++++++++++++ src/authkit-callback-route.ts | 16 ++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/authkit-callback-route.spec.ts b/src/authkit-callback-route.spec.ts index 1271216..4c3190a 100644 --- a/src/authkit-callback-route.spec.ts +++ b/src/authkit-callback-route.spec.ts @@ -246,6 +246,29 @@ describe('authLoader', () => { expect(response.headers.get('Location')).toBe('http://example.com/dashboard'); }); + it("honors an explicit returnPathname='/' from the sealed state over the configured option", async () => { + // The sanitizer collapses rejection and legitimate '/' into the same + // result, so the callback must disambiguate by comparing against the + // raw input — otherwise a caller who explicitly asks to return home + // would be silently redirected to the configured default instead. + loader = authLoader({ returnPathname: '/dashboard' }); + const scoped = await createSealedState({ returnPathname: '/' }); + request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { + code: 'test-code', + state: scoped.sealedState, + }); + + const response = await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs); + + assertIsResponse(response); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('http://example.com/'); + }); + it('rejects a CRLF-smuggled returnPathname in the sealed state', async () => { loader = authLoader({ returnPathname: '/dashboard' }); const scoped = await createSealedState({ returnPathname: '/foo\r\nSet-Cookie: bad' }); diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index 6a4f27e..81bbed8 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -78,10 +78,18 @@ export function authLoader(options: HandleAuthOptions = {}) { // Sanitize each candidate separately so a hostile sealed-state value // (e.g. an absolute URL, CRLF smuggle, or encoded traversal) can't // erase a legitimate configured default. `sanitizeReturnPathname` - // returns '/' on rejection, which we treat as "fall back to the option". - const safeFromState = returnPathnameState ? sanitizeReturnPathname(returnPathnameState) : '/'; - const safeFromOption = sanitizeReturnPathname(returnPathnameOption); - const returnPathname = safeFromState !== '/' ? safeFromState : safeFromOption; + // collapses both rejection and a legitimate '/' input into the same + // '/' result, so we detect rejection by comparing against the raw + // input — that way an explicit `returnPathname: '/'` from the caller + // still wins over a configured option like `'/dashboard'`. + let returnPathname: string; + if (returnPathnameState !== undefined) { + const sanitizedState = sanitizeReturnPathname(returnPathnameState); + const stateWasRejected = sanitizedState === '/' && returnPathnameState !== '/'; + returnPathname = stateWasRejected ? sanitizeReturnPathname(returnPathnameOption) : sanitizedState; + } else { + returnPathname = sanitizeReturnPathname(returnPathnameOption); + } // Reconstruct pathname + search + hash together. Using `.pathname = ...` // on a raw string with a fragment would percent-encode the `#`, so we From f94893370a8f636e0e9858cfc1d5f52ec1ab4733 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:18:14 +0000 Subject: [PATCH 11/15] Clear PKCE cookies on sign-out; doc sign-in endpoint Co-Authored-By: nick.nisi@workos.com --- CHANGELOG.md | 14 ++++++++++++++ README.md | 37 ++++++++++++++++++++++++++++++++++++ src/pkce.ts | 38 +++++++++++++++++++++++++++++++++++++ src/session.spec.ts | 46 +++++++++++++++++++++++++++++++++++++++++++++ src/session.ts | 18 ++++++++++++++---- 5 files changed, 149 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07df7b8..dd8a0cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,3 +40,17 @@ used to signal breaking changes. need to thread it through manually. - `switchToOrganization` no longer emits an empty `Set-Cookie: ''` header when `refreshSession` returns without one. +- `signOut` / `terminateSession` now also clears any orphan + `wos-auth-verifier-*` cookies left behind by abandoned OAuth flows + (tabs closed mid-sign-in, etc.) so they don't accumulate under the + browser's per-domain cookie cap. + +### Docs + +- New **Sign-in endpoint** section documenting the `initiate_login_uri` + dashboard setting, including a callout that a configured sign-in + endpoint is required for dashboard impersonation to work. +- New **Troubleshooting** entry for the + `Missing required auth parameter` error surfaced when an + impersonation flow reaches the callback without routing through the + sign-in endpoint. diff --git a/README.md b/README.md index ee8d66d..2d30fde 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,28 @@ export default function HomePage() { > no longer supported — see [Migrating from 0.4.x](#migrating-from-04x) > below. +### Sign-in endpoint + +The sign-in route above doubles as your **Sign-in endpoint** (also known +as `initiate_login_uri`) — the URL WorkOS redirects to when it needs to +start an authentication flow on your app's behalf (for example, when an +admin impersonates a user from the dashboard, or when a password-reset +email lands on a device that is not already signed in). + +In the [WorkOS dashboard](https://dashboard.workos.com), go to +**Redirects** and set the **Sign-in endpoint** to the public URL of the +route (e.g., `http://localhost:5173/login` in development, +`https://your-app.com/login` in production). + +> [!IMPORTANT] +> A configured Sign-in endpoint is required for +> [impersonation](https://workos.com/docs/user-management/impersonation) +> to work. Without it, WorkOS-initiated flows (such as impersonating a +> user from the dashboard) redirect directly to your callback URL +> without a `state` parameter and fail the PKCE/CSRF verification this +> library enforces on every callback, surfacing as a +> `Missing required auth parameter` error. + ### Requiring auth For pages where a signed-in user is mandatory, you can use the `ensureSignedIn` option: @@ -538,6 +560,21 @@ export const loader = (args) => AuthKit works with any session storage that implements React Router's `SessionStorage` interface, including Redis-based or database-backed implementations. +## Troubleshooting + +### `Missing required auth parameter` when impersonating from the WorkOS dashboard + +This error occurs when WorkOS-initiated flows (such as dashboard +impersonation) redirect directly to your callback URL without going +through your application's sign-in flow. Because this library enforces +PKCE/CSRF verification on every callback, the request is rejected when +the required `state` parameter is missing. + +**Fix:** Configure a [sign-in endpoint](#sign-in-endpoint) in your +WorkOS dashboard so that impersonation flows route through your app +first, allowing the PKCE verifier and CSRF state to be set up before +redirecting to WorkOS. + ## Migrating from 0.4.x `0.10.0` is a breaking release that adds PKCE and CSRF protection to the diff --git a/src/pkce.ts b/src/pkce.ts index 20252cc..6bee74b 100644 --- a/src/pkce.ts +++ b/src/pkce.ts @@ -138,6 +138,44 @@ export function readPKCECookie(cookieHeader: string | null, state: string): stri return undefined; } +/** + * Build one `Set-Cookie: =; Max-Age=0` string for every PKCE verifier + * cookie present on the incoming request. Used on sign-out so abandoned + * flows (tabs closed mid-OAuth, browser killed before the callback, etc.) + * don't leave orphan `wos-auth-verifier-` cookies accumulating under + * the browser's per-domain cookie cap. + * + * Returns an empty array when no PKCE cookies are present, so callers can + * compose the result into a `Set-Cookie` array without a length check. + */ +export function getPKCECleanupCookieStrings( + cookieHeader: string | null, + options: { request?: Request; secure?: boolean; redirectUri?: string } = {}, +): string[] { + if (!cookieHeader) return []; + + const names = new Set(); + for (const raw of cookieHeader.split(';')) { + const trimmed = raw.trim(); + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const name = trimmed.slice(0, eq); + // Include the bare legacy name and every per-flow variant (current + // scheme uses `-`; guard the prefix form in case the scheme + // ever changes). + if (name === PKCE_COOKIE_NAME || name.startsWith(`${PKCE_COOKIE_NAME}-`)) { + names.add(name); + } + } + + const secure = resolveSecure({ ...options }); + return Array.from(names).map((name) => { + const parts = [`${name}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0']; + if (secure) parts.push('Secure'); + return parts.join('; '); + }); +} + /** * Read and unseal the PKCE cookie, returning the code verifier, nonce, and * any caller-supplied custom state and return pathname. diff --git a/src/session.spec.ts b/src/session.spec.ts index be42168..7d189e7 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -233,6 +233,52 @@ describe('session', () => { expect(getLogoutUrl).not.toHaveBeenCalled(); }); + it('clears orphan wos-auth-verifier cookies from abandoned OAuth flows', async () => { + const mockSession = createMockSession({ + has: jest.fn().mockReturnValue(true), + get: jest.fn().mockReturnValue('encrypted-jwt'), + }); + getSession.mockResolvedValueOnce(mockSession); + unsealData.mockResolvedValueOnce({ + accessToken: 'token.without.sessionid', + refreshToken: 'refresh-token', + user: { id: 'user-id' }, + impersonator: null, + }); + (jose.decodeJwt as jest.Mock).mockReturnValueOnce({}); + + const request = createMockRequest( + 'wos-session=value; wos-auth-verifier-aaaaaaaa=sealed1; wos-auth-verifier-bbbbbbbb=sealed2; other=ignored', + ); + const response = await terminateSession(request); + + const setCookies = response.headers.getSetCookie(); + expect(setCookies).toContain('destroyed-session-cookie'); + expect(setCookies.some((c) => c.startsWith('wos-auth-verifier-aaaaaaaa=;') && /Max-Age=0/.test(c))).toBe(true); + expect(setCookies.some((c) => c.startsWith('wos-auth-verifier-bbbbbbbb=;') && /Max-Age=0/.test(c))).toBe(true); + expect(setCookies.every((c) => !c.startsWith('other='))).toBe(true); + }); + + it('emits no PKCE cleanup headers when no orphan cookies are present', async () => { + const mockSession = createMockSession({ + has: jest.fn().mockReturnValue(true), + get: jest.fn().mockReturnValue('encrypted-jwt'), + }); + getSession.mockResolvedValueOnce(mockSession); + unsealData.mockResolvedValueOnce({ + accessToken: 'token.without.sessionid', + refreshToken: 'refresh-token', + user: { id: 'user-id' }, + impersonator: null, + }); + (jose.decodeJwt as jest.Mock).mockReturnValueOnce({}); + + const response = await terminateSession(createMockRequest('wos-session=value; other=still-ignored')); + + const setCookies = response.headers.getSetCookie(); + expect(setCookies).toEqual(['destroyed-session-cookie']); + }); + it('should redirect to WorkOS logout URL when valid session exists', async () => { // Setup a session with jwt const mockSession = createMockSession({ diff --git a/src/session.ts b/src/session.ts index 728377d..afcaa25 100644 --- a/src/session.ts +++ b/src/session.ts @@ -14,6 +14,7 @@ import { getWorkOS } from './workos.js'; import { sealData, unsealData } from 'iron-session'; import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; import { getConfig } from './config.js'; +import { getPKCECleanupCookieStrings } from './pkce.js'; import { configureSessionStorage, getSessionStorage } from './sessionStorage.js'; import { isDataWithResponseInit, isJsonResponse, isRedirect, isResponse } from './utils.js'; import type { AuthenticationResponse } from '@workos-inc/node'; @@ -536,17 +537,26 @@ async function handleAuthLoader( export async function terminateSession(request: Request, { returnTo }: { returnTo?: string } = {}) { const { getSession, destroySession } = await getSessionStorage(); - const encryptedSession = await getSession(request.headers.get('Cookie')); + const cookieHeader = request.headers.get('Cookie'); + const encryptedSession = await getSession(cookieHeader); const { accessToken } = (await getSessionFromCookie( - request.headers.get('Cookie') as string, + cookieHeader as string, encryptedSession, )) as Session; const { sessionId } = getClaimsFromAccessToken(accessToken); - const headers = { + // Destroy the session cookie plus any orphan `wos-auth-verifier-*` cookies + // from abandoned OAuth flows — the per-flow cookie scheme means an + // unfinished flow leaves a cookie behind that the browser will keep + // sending until its 10-minute Max-Age expires, and stacking enough of + // them can exceed the per-domain cookie cap. + const headers = new Headers({ 'Set-Cookie': await destroySession(encryptedSession), - }; + }); + for (const cleanup of getPKCECleanupCookieStrings(cookieHeader, { request })) { + headers.append('Set-Cookie', cleanup); + } if (sessionId) { return redirect(getWorkOS().userManagement.getLogoutUrl({ sessionId, returnTo }), { From 001edb6d4f3f58d91d9bf46298f589a5503db895 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:19:14 +0000 Subject: [PATCH 12/15] Format session.ts Co-Authored-By: nick.nisi@workos.com --- src/session.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/session.ts b/src/session.ts index afcaa25..75d37db 100644 --- a/src/session.ts +++ b/src/session.ts @@ -539,10 +539,7 @@ export async function terminateSession(request: Request, { returnTo }: { returnT const { getSession, destroySession } = await getSessionStorage(); const cookieHeader = request.headers.get('Cookie'); const encryptedSession = await getSession(cookieHeader); - const { accessToken } = (await getSessionFromCookie( - cookieHeader as string, - encryptedSession, - )) as Session; + const { accessToken } = (await getSessionFromCookie(cookieHeader as string, encryptedSession)) as Session; const { sessionId } = getClaimsFromAccessToken(accessToken); From 5fcbbb940a61a6749c9a7cc95539f4709fb6b8ec Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:39:32 +0000 Subject: [PATCH 13/15] Validate iss claim on access token Co-Authored-By: nick.nisi@workos.com --- CHANGELOG.md | 5 +++++ src/session.spec.ts | 10 ++++++++++ src/session.ts | 14 +++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8a0cc..7cd66c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,11 @@ used to signal breaking changes. `wos-auth-verifier-*` cookies left behind by abandoned OAuth flows (tabs closed mid-sign-in, etc.) so they don't accumulate under the browser's per-domain cookie cap. +- `verifyAccessToken` now validates the JWT `iss` claim against + `https://api.workos.com` (the fixed issuer WorkOS stamps on every + access token) in addition to the signature, so a token signed by a + different WorkOS project whose JWKS happens to resolve to the same + keys is rejected. ### Docs diff --git a/src/session.spec.ts b/src/session.spec.ts index 7d189e7..d406fd8 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -456,6 +456,16 @@ describe('session', () => { jsonSpy.mockRestore(); }); + it('validates the access token issuer claim against https://api.workos.com', async () => { + await authkitLoader(createLoaderArgs(createMockRequest())); + + expect(jwtVerify).toHaveBeenCalled(); + for (const call of jwtVerify.mock.calls) { + expect(call[0]).toBe('valid.jwt.token'); + expect(call[2]).toEqual({ issuer: 'https://api.workos.com' }); + } + }); + it('should return authorized data with session claims', async () => { const { data } = await authkitLoader(createLoaderArgs(createMockRequest())); diff --git a/src/session.ts b/src/session.ts index 75d37db..b88e9f3 100644 --- a/src/session.ts +++ b/src/session.ts @@ -607,10 +607,22 @@ export async function getSessionFromCookie(cookie: string, session?: SessionData } } +// WorkOS access tokens carry a fixed `iss` claim regardless of environment +// or client id; see +// https://workos.com/docs/reference/user-management/session-tokens/access-token. +// Validating it defends against tokens signed by a different WorkOS project +// whose JWKS happens to resolve to the same keys, and matches the team's +// "always validate iss" JWT rule. +// +// WorkOS access tokens do not carry a standard `aud` claim — the target +// client is encoded as `client_id` instead — so we do not pass `audience` +// to jwtVerify here; doing so would reject every token. +const WORKOS_JWT_ISSUER = 'https://api.workos.com'; + async function verifyAccessToken(accessToken: string) { const JWKS = createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(getConfig('clientId')))); try { - await jwtVerify(accessToken, JWKS); + await jwtVerify(accessToken, JWKS, { issuer: WORKOS_JWT_ISSUER }); return true; } catch (e) { return false; From 11626f6c4ba44c7899253a19e4aa0151fc8346fc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:44:23 +0000 Subject: [PATCH 14/15] Clear PKCE cookie on error callback Co-Authored-By: nick.nisi@workos.com --- CHANGELOG.md | 4 ++++ src/authkit-callback-route.spec.ts | 21 +++++++++++++++++++++ src/authkit-callback-route.ts | 15 ++++++++++----- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cd66c8..5fdfbcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,10 @@ used to signal breaking changes. `wos-auth-verifier-*` cookies left behind by abandoned OAuth flows (tabs closed mid-sign-in, etc.) so they don't accumulate under the browser's per-domain cookie cap. +- The callback now clears the PKCE verifier cookie on WorkOS error + callbacks (`?error=…&state=…` with no `code`) instead of only on + success/exception paths, so abandoned flows don't leave orphan + cookies until the 10-minute TTL expires. - `verifyAccessToken` now validates the JWT `iss` claim against `https://api.workos.com` (the fixed issuer WorkOS stamps on every access token) in addition to the signature, so a token signed by a diff --git a/src/authkit-callback-route.spec.ts b/src/authkit-callback-route.spec.ts index 4c3190a..9e468b3 100644 --- a/src/authkit-callback-route.spec.ts +++ b/src/authkit-callback-route.spec.ts @@ -65,6 +65,27 @@ describe('authLoader', () => { expect(response).toBeUndefined(); }); + it('clears the PKCE cookie when WorkOS returns an error callback without a code', async () => { + // Simulates `?error=access_denied&state=sealedState` — WorkOS error + // callbacks reach us with `state` but no `code`. The PKCE verifier + // cookie should be cleared so an abandoned flow doesn't linger in + // the browser until its 10-minute TTL expires. + request = createRequestWithSearchParams(new Request('http://example.com/callback'), { + state: 'sealed-state', + error: 'access_denied', + }); + const response = (await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs)) as Response; + + expect(response).toBeInstanceOf(Response); + const setCookie = response.headers.get('Set-Cookie') ?? ''; + expect(setCookie).toMatch(/^wos-auth-verifier-[0-9a-f]+=;/); + expect(setCookie).toMatch(/Max-Age=0/); + }); + it('returns 500 when state is missing', async () => { request = createRequestWithSearchParams(new Request('http://example.com/callback'), { code: 'test-code', diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index 81bbed8..bc189df 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -28,15 +28,20 @@ export function authLoader(options: HandleAuthOptions = {}) { const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); - if (!code) { - return; - } - // We always want to clear the PKCE cookie at the end of this handler, // success or failure. `pkceClearCookie` is populated as soon as we know - // the state value and appended to every response below. + // the state value and appended to every response below — including + // early exits for WorkOS error callbacks (`?error=…&state=…`) that + // would otherwise leave an orphan verifier cookie in the browser. const pkceClearCookie = state ? clearPKCECookie(state, request) : null; + if (!code) { + if (pkceClearCookie) { + return new Response(null, { headers: { 'Set-Cookie': pkceClearCookie } }); + } + return; + } + try { if (!state) { throw new Error('Missing required auth parameter: state'); From 45d5024123088014cbf03a1bf01e3cecfbf88bba Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 23 Apr 2026 14:04:49 -0700 Subject: [PATCH 15/15] Simplify PKCE callback comments and cleanup helper Inline the one-call `clearPKCECookie` wrapper, trim over-narrated comments on the callback handler and returnPathname disambiguation, fix the inaccurate "legacy name" comment in getPKCECleanupCookieStrings (the bare name has always been a prefix only), drop an unused spread, and remove narrative preambles that duplicated the test titles. --- src/authkit-callback-route.spec.ts | 8 -------- src/authkit-callback-route.ts | 30 +++++++----------------------- src/pkce.ts | 19 +++++++------------ 3 files changed, 14 insertions(+), 43 deletions(-) diff --git a/src/authkit-callback-route.spec.ts b/src/authkit-callback-route.spec.ts index 9e468b3..62e2f97 100644 --- a/src/authkit-callback-route.spec.ts +++ b/src/authkit-callback-route.spec.ts @@ -66,10 +66,6 @@ describe('authLoader', () => { }); it('clears the PKCE cookie when WorkOS returns an error callback without a code', async () => { - // Simulates `?error=access_denied&state=sealedState` — WorkOS error - // callbacks reach us with `state` but no `code`. The PKCE verifier - // cookie should be cleared so an abandoned flow doesn't linger in - // the browser until its 10-minute TTL expires. request = createRequestWithSearchParams(new Request('http://example.com/callback'), { state: 'sealed-state', error: 'access_denied', @@ -268,10 +264,6 @@ describe('authLoader', () => { }); it("honors an explicit returnPathname='/' from the sealed state over the configured option", async () => { - // The sanitizer collapses rejection and legitimate '/' into the same - // result, so the callback must disambiguate by comparing against the - // raw input — otherwise a caller who explicitly asks to return home - // would be silently redirected to the configured default instead. loader = authLoader({ returnPathname: '/dashboard' }); const scoped = await createSealedState({ returnPathname: '/' }); request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index bc189df..14fc56d 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -7,16 +7,6 @@ import { encryptSession } from './session.js'; import { configureSessionStorage } from './sessionStorage.js'; import { getWorkOS } from './workos.js'; -/** - * Build a `Set-Cookie` header that clears the PKCE cookie associated with a - * given sealed state value. PKCE cookies are single-use — we always clear - * them, whether the exchange succeeded or failed, to prevent replays and - * stale cookies affecting future auth attempts. - */ -function clearPKCECookie(state: string, request: Request): string { - return getPKCECookieString(state, { expired: true, request }); -} - export function authLoader(options: HandleAuthOptions = {}) { return async function loader({ request }: LoaderFunctionArgs) { const { storage, cookie, returnPathname: returnPathnameOption = '/', onSuccess } = options; @@ -28,12 +18,10 @@ export function authLoader(options: HandleAuthOptions = {}) { const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); - // We always want to clear the PKCE cookie at the end of this handler, - // success or failure. `pkceClearCookie` is populated as soon as we know - // the state value and appended to every response below — including - // early exits for WorkOS error callbacks (`?error=…&state=…`) that - // would otherwise leave an orphan verifier cookie in the browser. - const pkceClearCookie = state ? clearPKCECookie(state, request) : null; + // Cleared on every exit (success, failure, and WorkOS error callbacks + // where `?error=…&state=…` arrives with no code) so abandoned flows + // don't leave orphan verifier cookies until their 10-minute TTL expires. + const pkceClearCookie = state ? getPKCECookieString(state, { expired: true, request }) : null; if (!code) { if (pkceClearCookie) { @@ -80,13 +68,9 @@ export function authLoader(options: HandleAuthOptions = {}) { url.searchParams.delete('code'); url.searchParams.delete('state'); - // Sanitize each candidate separately so a hostile sealed-state value - // (e.g. an absolute URL, CRLF smuggle, or encoded traversal) can't - // erase a legitimate configured default. `sanitizeReturnPathname` - // collapses both rejection and a legitimate '/' input into the same - // '/' result, so we detect rejection by comparing against the raw - // input — that way an explicit `returnPathname: '/'` from the caller - // still wins over a configured option like `'/dashboard'`. + // `sanitizeReturnPathname` maps both rejection and a legitimate '/' + // to '/'; disambiguate by comparing to the raw input so an explicit + // '/' from the caller still beats the configured option. let returnPathname: string; if (returnPathnameState !== undefined) { const sanitizedState = sanitizeReturnPathname(returnPathnameState); diff --git a/src/pkce.ts b/src/pkce.ts index 6bee74b..741612f 100644 --- a/src/pkce.ts +++ b/src/pkce.ts @@ -139,14 +139,10 @@ export function readPKCECookie(cookieHeader: string | null, state: string): stri } /** - * Build one `Set-Cookie: =; Max-Age=0` string for every PKCE verifier - * cookie present on the incoming request. Used on sign-out so abandoned - * flows (tabs closed mid-OAuth, browser killed before the callback, etc.) - * don't leave orphan `wos-auth-verifier-` cookies accumulating under - * the browser's per-domain cookie cap. - * - * Returns an empty array when no PKCE cookies are present, so callers can - * compose the result into a `Set-Cookie` array without a length check. + * Build a `Set-Cookie: =; Max-Age=0` string for every PKCE verifier + * cookie on the incoming request. Used on sign-out so abandoned flows + * (tabs closed mid-OAuth, etc.) don't leave orphan `wos-auth-verifier-*` + * cookies accumulating under the browser's per-domain cookie cap. */ export function getPKCECleanupCookieStrings( cookieHeader: string | null, @@ -160,15 +156,14 @@ export function getPKCECleanupCookieStrings( const eq = trimmed.indexOf('='); if (eq <= 0) continue; const name = trimmed.slice(0, eq); - // Include the bare legacy name and every per-flow variant (current - // scheme uses `-`; guard the prefix form in case the scheme - // ever changes). + // Match both the bare constant and every per-flow `-` variant, + // in case the naming scheme ever changes. if (name === PKCE_COOKIE_NAME || name.startsWith(`${PKCE_COOKIE_NAME}-`)) { names.add(name); } } - const secure = resolveSecure({ ...options }); + const secure = resolveSecure(options); return Array.from(names).map((name) => { const parts = [`${name}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0']; if (secure) parts.push('Secure');