From 46a3e3bc5ca3ad7e2f55fb2edd7b9d7922dcef42 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 17:16:42 -0600 Subject: [PATCH 01/22] Add OIDC authentication support with Arctic library Implement complete OIDC authentication flow including: - Server-side OIDC client using Arctic library with PKCE support - Login, callback, logout, and token refresh endpoints - Updated hooks.server.ts to handle OIDC authentication mode - Modified subscribable stores and effects for token management - Request utilities updated for authenticated API calls --- .env | 16 ++ package-lock.json | 252 ++++++++++++++++- package.json | 4 + src/hooks.server.ts | 111 ++++---- src/lib/server/oidc.ts | 327 +++++++++++++++++++++++ src/lib/stores/oidc.ts | 175 ++++++++++++ src/lib/types/oidc.ts | 14 + src/routes/oidc/callback/+page.server.ts | 76 ++++++ src/routes/oidc/login/+page.server.ts | 33 +++ src/routes/oidc/logout/+server.ts | 33 +++ src/routes/oidc/refresh/+server.ts | 40 +++ src/utilities/effects.ts | 2 + src/utilities/login.ts | 25 +- src/utilities/requests.ts | 1 - 14 files changed, 1044 insertions(+), 65 deletions(-) create mode 100644 src/lib/server/oidc.ts create mode 100644 src/lib/stores/oidc.ts create mode 100644 src/lib/types/oidc.ts create mode 100644 src/routes/oidc/callback/+page.server.ts create mode 100644 src/routes/oidc/login/+page.server.ts create mode 100644 src/routes/oidc/logout/+server.ts create mode 100644 src/routes/oidc/refresh/+server.ts diff --git a/.env b/.env index 04a0310d75..caf8fdc087 100644 --- a/.env +++ b/.env @@ -13,3 +13,19 @@ PUBLIC_LIBRARY_SEQUENCES_ENABLED=false PUBLIC_COMMAND_EXPANSION_MODE=typescript # VITE_HOST=localhost.jpl.nasa.gov # VITE_HTTPS=true + +PUBLIC_AUTH_OIDC_ENABLED=false +OIDC_WELL_KNOWN_URL= +OIDC_AUTHORIZATION_URL= +OIDC_TOKEN_URL= +OIDC_LOGOUT_URL= +OIDC_JWKS_URL= +OIDC_SCOPES= +OIDC_CLIENT_ID= + +# (likely not used, but can be in future implementations) +# OIDC_CLIENT_SECRET= + +OIDC_REDIRECT_URI= +OIDC_AUDIENCE= +OIDC_ISSUER= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 30fc5d131e..a60e0d0d18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@tanstack/svelte-virtual": "^3.11.2", "ag-grid-community": "32.2.0", "ajv": "^8.12.0", + "arctic": "^3.7.0", "codemirror": "^6.0.1", "cookie": "^0.6.0", "d3-array": "^3.2.4", @@ -49,7 +50,9 @@ "fastest-levenshtein": "^1.0.16", "graphql-ws": "^5.16.2", "json-source-map": "^0.6.1", + "jsonwebtoken": "^9.0.1", "jszip": "^3.10.1", + "jwks-rsa": "^3.2.0", "jwt-decode": "^4.0.0", "lodash-es": "^4.17.21", "monaco-editor": "0.47.0", @@ -84,6 +87,7 @@ "@types/node": "^20.11.30", "@types/picomatch": "^2.3.0", "@types/toastify-js": "^1.11.1", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "@vitejs/plugin-basic-ssl": "^1.1.0", @@ -1656,6 +1660,52 @@ "node": ">= 8" } }, + "node_modules/@oslojs/asn1": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz", + "integrity": "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==", + "license": "MIT", + "dependencies": { + "@oslojs/binary": "1.0.0" + } + }, + "node_modules/@oslojs/binary": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oslojs/binary/-/binary-1.0.0.tgz", + "integrity": "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==", + "license": "MIT" + }, + "node_modules/@oslojs/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@oslojs/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==", + "license": "MIT", + "dependencies": { + "@oslojs/asn1": "1.0.0", + "@oslojs/binary": "1.0.0" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@oslojs/jwt": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@oslojs/jwt/-/jwt-0.2.0.tgz", + "integrity": "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==", + "license": "MIT", + "dependencies": { + "@oslojs/encoding": "0.4.1" + } + }, + "node_modules/@oslojs/jwt/node_modules/@oslojs/encoding": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-0.4.1.tgz", + "integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2377,6 +2427,16 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "optional": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.16", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz", @@ -2392,6 +2452,12 @@ "@types/lodash": "*" } }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.17.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", @@ -2445,6 +2511,16 @@ "integrity": "sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==", "optional": true }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typeschema/class-validator": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@typeschema/class-validator/-/class-validator-0.3.0.tgz", @@ -3059,6 +3135,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/arctic": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/arctic/-/arctic-3.7.0.tgz", + "integrity": "sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw==", + "license": "MIT", + "dependencies": { + "@oslojs/crypto": "1.0.1", + "@oslojs/encoding": "1.1.0", + "@oslojs/jwt": "0.2.0" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -3341,6 +3428,12 @@ "node": ">=8.0.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4363,6 +4456,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/effect": { "version": "3.19.12", "resolved": "https://registry.npmjs.org/effect/-/effect-3.19.12.tgz", @@ -6123,6 +6225,15 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6222,6 +6333,28 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -6239,6 +6372,43 @@ "integrity": "sha512-1IynUYEc/HAwxhi3WDpIpxJbZpMCvvrrmZVqvj9EhpvbH8lls7HhdhiByjL7DkAaWlLIzpC0Xc/VPvy/UxLNjA==", "license": "MIT" }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/jwt-decode": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", @@ -6315,6 +6485,11 @@ "node": ">=10" } }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6361,12 +6536,60 @@ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -6387,6 +6610,28 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/lucide-svelte": { "version": "0.561.0", "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.561.0.tgz", @@ -8077,7 +8322,6 @@ "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -10119,6 +10363,12 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", diff --git a/package.json b/package.json index 8980c7477d..46fb930b49 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@tanstack/svelte-virtual": "^3.11.2", "ag-grid-community": "32.2.0", "ajv": "^8.12.0", + "arctic": "^3.7.0", "codemirror": "^6.0.1", "cookie": "^0.6.0", "d3-array": "^3.2.4", @@ -79,7 +80,9 @@ "fastest-levenshtein": "^1.0.16", "graphql-ws": "^5.16.2", "json-source-map": "^0.6.1", + "jsonwebtoken": "^9.0.1", "jszip": "^3.10.1", + "jwks-rsa": "^3.2.0", "jwt-decode": "^4.0.0", "lodash-es": "^4.17.21", "monaco-editor": "0.47.0", @@ -114,6 +117,7 @@ "@types/node": "^20.11.30", "@types/picomatch": "^2.3.0", "@types/toastify-js": "^1.11.1", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "@vitejs/plugin-basic-ssl": "^1.1.0", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 6bd34c88a0..0bd154a484 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,11 +1,11 @@ import { base } from '$app/paths'; import { env } from '$env/dynamic/public'; -import type { Handle } from '@sveltejs/kit'; +import * as auth from '$lib/server/oidc'; +import { error, type Handle } from '@sveltejs/kit'; import { parse, type CookieSerializeOptions } from 'cookie'; -import { jwtDecode } from 'jwt-decode'; -import type { BaseUser, ParsedUserToken, User } from './types/app'; +import type { BaseUser } from './types/app'; import type { ReqValidateSSOResponse } from './types/auth'; -import effects from './utilities/effects'; +import { computeRolesFromCookies, computeRolesFromJWT } from './utilities/auth'; import { reqGatewayForwardCookies } from './utilities/requests'; export const handle: Handle = async ({ event, resolve }) => { @@ -14,15 +14,62 @@ export const handle: Handle = async ({ event, resolve }) => { if (event.url.pathname.startsWith('/.well-known/appspecific/com.chrome.')) { return new Response(null, { status: 404 }); } + if (event.url.pathname.includes('error') || event.url.pathname.includes('oidc')) { + // don't want hooks running on an error page + return await resolve(event); + } + if ( + env.PUBLIC_AUTH_OIDC_ENABLED === 'true' && + !event.url.pathname.includes('changeRole') && + event.url.pathname.includes('auth') + ) { + error( + 500, + `Attempting to access /auth endpoint "${event.url.pathname}" while OIDC enabled (env.PUBLIC_AUTH_OIDC_ENABLED='true').`, + ); + } try { - if (env.PUBLIC_AUTH_SSO_ENABLED === 'true') { + if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { + return await handleOIDCAuth({ event, resolve }); + } else if (env.PUBLIC_AUTH_SSO_ENABLED === 'true') { return await handleSSOAuth({ event, resolve }); } else { return await handleJWTAuth({ event, resolve }); } } catch (e) { - console.log(e); + event.locals.user = null; + } + + return await resolve(event); +}; + +/** + * Sets local user to the decoded access token enriched with additional + * fine-grained query-related permissions. + */ +const handleOIDCAuth: Handle = async ({ event, resolve }) => { + event = await auth.handler(event); + + // the above handler doesn't impact the event.request.headers, but it does + // impact the cookies object. we only gain information by using that... + // so let's use it! + const activeRole = event.cookies.get('activeRole') ?? null; + const token = event.cookies.get('accessToken'); + + if (token) { + const user: BaseUser = { id: null, token }; + event.locals.user = await computeRolesFromJWT(user, activeRole); + + // If the active role cookie is not in the list of allowed roles, then set + // it to the user's default role. + if (event.locals.user && !event.locals.user.allowedRoles.includes(activeRole || '')) { + event.cookies.set('activeRole', event.locals.user.defaultRole, { + httpOnly: false, + path: `${base}/`, + }); + } + } else { event.locals.user = null; } @@ -95,21 +142,14 @@ const handleSSOAuth: Handle = async ({ event, resolve }) => { const roles = await computeRolesFromJWT(user, activeRole); + // create and set activeRole cookie if (roles) { - // create and set cookies - const userStr = JSON.stringify(user); - const userCookie = Buffer.from(userStr).toString('base64'); const cookieOpts: CookieSerializeOptions & { path: string } = { httpOnly: false, path: `${base}/`, sameSite: 'none', }; - // if logout just cleared user cookie, don't re-set it - if (!event.url.pathname.includes('/auth/logout')) { - event.cookies.set('user', userCookie, cookieOpts); - } - // don't overwrite existing activeRole, unless it doesn't exist anymore if (!activeRoleCookie || activeRoleCookie === 'deleted' || !roles.allowedRoles.includes(activeRoleCookie)) { event.cookies.set('activeRole', roles.defaultRole, cookieOpts); @@ -120,46 +160,3 @@ const handleSSOAuth: Handle = async ({ event, resolve }) => { return await resolve(event); }; - -async function computeRolesFromCookies( - userCookie: string | null, - activeRoleCookie: string | null, -): Promise { - const userBuffer = Buffer.from(userCookie ?? '', 'base64'); - const userStr = userBuffer.toString('utf-8'); - - try { - const baseUser: BaseUser = JSON.parse(userStr); - return computeRolesFromJWT(baseUser, activeRoleCookie); - } catch { - return null; - } -} - -export async function computeRolesFromJWT(baseUser: BaseUser, activeRole: string | null): Promise { - const { success } = await effects.session(baseUser); - if (!success) { - return null; - } - - const decodedToken: ParsedUserToken = jwtDecode(baseUser.token); - - const allowedRoles = decodedToken['https://hasura.io/jwt/claims']['x-hasura-allowed-roles']; - const defaultRole = decodedToken['https://hasura.io/jwt/claims']['x-hasura-default-role']; - - const user: User = { - ...baseUser, - activeRole: activeRole ?? defaultRole, - allowedRoles, - defaultRole, - permissibleQueries: null, - rolePermissions: null, - }; - const permissibleQueries = await effects.getUserQueries(user); - const rolePermissions = await effects.getRolePermissions(user); - return { - ...user, - permissibleQueries, - rolePermissions, - }; -} diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts new file mode 100644 index 0000000000..4545bba5e6 --- /dev/null +++ b/src/lib/server/oidc.ts @@ -0,0 +1,327 @@ +import { browser } from '$app/environment'; +import * as env from '$env/static/private'; +import type { HasuraToken, MaybeToken, Rule } from '$lib/types/oidc'; +import { type Cookies, type RequestEvent } from '@sveltejs/kit'; +import * as arctic from 'arctic'; +import jwt from 'jsonwebtoken'; +import { JwksClient } from 'jwks-rsa'; +import type { User } from '../../types/app'; +import { reqHasura } from '../../utilities/requests'; + +const DEFAULT_JWKS_CLIENT = (() => { + if (env.OIDC_JWKS_URL) { + return new JwksClient({ jwksUri: env.OIDC_JWKS_URL }); + } +})(); + +const DEFAULT_VERIFY_OPTS: jwt.VerifyOptions = { + algorithms: ['RS256'], + ignoreExpiration: false, + issuer: env.OIDC_ISSUER, +}; + +/** + * Remove invalid tokens, refresh if appropriate, and set locals for tokens and roles. + * Only invoked on page refresh. Does not execute behavior if cookies expire and page doesn't refresh (see cookieStoreListener() for that) + * + * Will log but not raise any errors. + * + * @param {RequestEvent} event - The SvelteKit request event containing cookies. + */ +export async function handler(event: RequestEvent): Promise { + return sanitize(event).then(refresh); +} + +/** + * Removes invalid access or id tokens. + * Only invoked in handler. + * + * Note: This **may** mutate the given event. + * + * @param evt + * @returns RequestEvent + */ +async function sanitize(evt: RequestEvent) { + await verify(evt.cookies.get('accessToken')).catch(_ => evt.cookies.delete('accessToken', { path: '/' })); + await verify(evt.cookies.get('idToken')).catch(_ => evt.cookies.delete('idToken', { path: '/' })); + return evt; +} + +/** + * Refreshes tokens iff access or id token is missing. + * Only invoked in handler. + * + * Note: This **may** mutate the given event. + * + * @param evt + * @returns RequestEvent + */ +async function refresh(evt: RequestEvent) { + if (!evt.cookies.get('accessToken') || !evt.cookies.get('idToken')) { + const refreshToken: string | undefined = evt.cookies.get('refreshToken'); + if (refreshToken) { + // unconditionally clear refreshToken. if it was invalid, we don't want it, and if it's valid, it will be replaced! + evt.cookies.delete('refreshToken', { path: '/' }); + const tokens = await Client.instance.refresh(refreshToken); + await updateWithNewTokens(evt.cookies, tokens); + } + } + return evt; +} + +/** + * Verify ensures raw token values are signed by the expected issuer and haven't expired. + * + * @param token - The raw base64 encoded JWT token to verify. If null, the function will return null. + * @param opts - Verification options to pass to jsonwebtoken. Defaults to sensible defaults. + * @returns The decoded JWT payload if verification is successful, otherwise throws an error. + * @throws {Error} If the token is invalid, expired, or if there are issues + */ +export async function verify( + token: string | undefined, + client = DEFAULT_JWKS_CLIENT, + opts: jwt.VerifyOptions = DEFAULT_VERIFY_OPTS, +): Promise { + if (!token) { + return undefined; + } + if (!client) { + throw new Error('Cannot verify JWT without a configured JWKS Client'); + } + if (client) { + const header = jwt.decode(token, { complete: true })?.header; + if (!header) { + throw new Error('Malformed JWT token: no header present.'); + } + const key = await client.getSigningKey(header.kid); + return jwt.verify(token, key.getPublicKey(), opts) as MaybeToken; + } +} + +/** + * Client is a singleton that manages OAuth2/OIDC interactions. + * + * It avoids re-fetching OIDC configuration by caching values on first use. + * + */ +export class Client { + private static _instance: Client; + + private authorizationEndpoint: string; + private client: arctic.OAuth2Client; + private clientId: string; + private clientSecret: string | null; + private logoutEndpoint: string; + private redirectEndpoint: string; + private scopes: string[]; + private tokenEndpoint: string; + + private constructor() { + if (env.OIDC_WELL_KNOWN_URL) { + fetch(env.OIDC_WELL_KNOWN_URL) + .then(res => res.json()) + .then(data => { + this.authorizationEndpoint ??= data.authorizationEndpoint ?? data.authorization_endpoint; + this.tokenEndpoint ??= data.tokenEndpoint ?? data.token_endpoint; + this.logoutEndpoint ??= data.endSessionEndpoint ?? data.end_session_endpoint; + }) + .catch(err => { + console.error('Error fetching OIDC configuration:', err); + }); + } + + // ??= is used to preserve any values set from the well-known URL. + this.authorizationEndpoint ??= env.OIDC_AUTHORIZATION_URL; + this.tokenEndpoint ??= env.OIDC_TOKEN_URL; + this.redirectEndpoint ??= env.OIDC_REDIRECT_URI; + this.logoutEndpoint ??= env.OIDC_LOGOUT_URL; + this.clientId ??= env.OIDC_CLIENT_ID; + this.clientSecret ??= env.OIDC_CLIENT_SECRET || null; + this.scopes ??= env.OIDC_SCOPES ? env.OIDC_SCOPES.split(' ') : ['openid', 'profile', 'email']; + + // The entire client configuration is validated here, this should help + // people understand everything they need to set without having to fix + // one problem... then another... then another... + const problems = this.validateConfiguration(); + + if (problems.length > 0) { + throw new Error('OAuth2 client configuration is incomplete.', { cause: problems }); + } else { + this.client = new arctic.OAuth2Client(this.clientId, this.clientSecret, this.redirectEndpoint); + } + } + + static get instance() { + this._instance ??= new Client(); + return this._instance; + } + + createAuthorizationURLWithPKCE(): { authorizationUrl: URL; state: string; verifier: string } { + const verifier: string = arctic.generateCodeVerifier(); + const state: string = arctic.generateState(); + const authorizationUrl: URL = this.client.createAuthorizationURLWithPKCE( + this.authorizationEndpoint, + state, + arctic.CodeChallengeMethod.S256, + verifier, + this.scopes, + ); + return { authorizationUrl, state, verifier }; + } + + /** + * Exchange an authorization code (and verifier) for tokens. + * + * @param code + * @param verifier + * @returns + */ + async exchange(code: string, verifier: string): Promise { + return this.client.validateAuthorizationCode(this.tokenEndpoint, code, verifier); + } + + // arctic handles token revocation, but not logout, as described here https://blog.elest.io/keycloak-token-management-expiration-revocation-and-renewal/, which is what we want to end the session + getLogoutEndpoint(): string { + return this.logoutEndpoint; + } + + getRedirectEndpoint(): string { + return this.redirectEndpoint; + } + + /** + * Request new tokens using a refresh token. + * + * @param token - The refresh token to use to obtain new tokens. + * @returns + */ + async refresh(token: string): Promise { + return this.client.refreshAccessToken(this.tokenEndpoint, token, this.scopes); + } + + private validateConfiguration(): string[] { + const problems: string[] = []; + + if (!this.authorizationEndpoint) { + problems.push('Missing OIDC authorization endpoint. Check OIDC_WELL_KNOWN_URL or OIDC_AUTHORIZATION_URL.'); + } + + if (!this.tokenEndpoint) { + problems.push('Missing OIDC token endpoint. Check OIDC_WELL_KNOWN_URL or OIDC_TOKEN_URL.'); + } + + if (!this.redirectEndpoint) { + problems.push('Missing OIDC redirect URI. Check OIDC_WELL_KNOWN_URL or OIDC_REDIRECT_URI.'); + } + + if (!this.clientId) { + problems.push('Missing OIDC client ID. Check OIDC_CLIENT_ID.'); + } + + if (this.scopes.length === 0) { + problems.push('Missing OIDC scopes. Check OIDC_SCOPES environment variable.'); + } + + if (!this.scopes.includes('openid')) { + problems.push('OIDC scopes must include "openid". Check OIDC_SCOPES environment variable.'); + } + + return problems; + } +} + +const mutation = `mutation InsertUser($input: users_insert_input!) { + insert_users_one( + object: $input, + on_conflict: { + constraint: users_pkey, + update_columns: default_role + } + ) { + username + } +}`; // TODO: update other user tables in permissions schema? + +async function upsertUser(decodedAccessToken: HasuraToken, accessToken: string): Promise { + const username = decodedAccessToken['https://hasura.io/jwt/claims']['x-hasura-user-id']; + // const defaultRole = decodedAccessToken['https://hasura.io/jwt/claims']['x-hasura-default-role']; + const allowedRoles = decodedAccessToken['https://hasura.io/jwt/claims']['x-hasura-allowed-roles']; + + // set the active and default role manually: + let defaultRole = 'viewer'; + switch (true) { + case allowedRoles.includes('aerie_admin'): + defaultRole = 'aerie_admin'; + break; + case allowedRoles.includes('user'): + defaultRole = 'user'; + break; + default: + defaultRole = 'viewer'; + } + + const input = { default_role: defaultRole, username }; + const user: User = { + activeRole: defaultRole, // TODO: check allowed roles and pick highest. forget about default role. + allowedRoles, + defaultRole, + id: username, // TODO: not exactly. I think this is supposed to be decodedAccessToken.sub. but we don't even use it. + permissibleQueries: null, + rolePermissions: null, + token: accessToken, + }; + console.log('Registering user:', user); + const result = await reqHasura(mutation, { input }, user); + console.log('Registered user: ', result); +} + +export async function updateWithNewTokens(cookies: Cookies, tokens: arctic.OAuth2Tokens): Promise { + console.log('Persisting tokens following a refresh...', browser); + + // Check token validity. + const accessJwt = await verify(tokens.accessToken()); + const idJwt = await verify(tokens.idToken()); + + if (accessJwt && idJwt) { + cookies.set('accessToken', tokens.accessToken(), { httpOnly: false, path: '/' }); + cookies.set('idToken', tokens.idToken(), { httpOnly: false, path: '/' }); + cookies.set('refreshToken', tokens.refreshToken(), { httpOnly: true, path: '/' }); + + // sort of an edge case, but if default role does change at the idp, it wouldn't hurt to update the local entry + // TODO: should this be here? Where else could it go? + await upsertUser(accessJwt as HasuraToken, tokens.accessToken()); + return true; + } + + return false; +} + +/* + * This function provides developers with a way to evaluate their own rule + * against an access token in +page.server.ts or +layout.server.ts + * + * It is **NOT** responsible for decoding the token, refreshing it, or + * validating it. + * + * https://svelte.dev/docs/kit/load#Implications-for-authentication + * + * There are a few possible strategies to ensure an auth check occurs before protected code. + * + * To prevent data waterfalls and preserve layout load caches: + * + * Use hooks to protect multiple routes before any load functions run + * + * Use auth guards directly in +page.server.js load functions for route specific protection + * Putting an auth guard in +layout.server.js requires all child pages to call + * await parent() before protected code. Unless every child page depends on + * returned data from await parent(), the other options will be more performant. + */ + +export function enforce(user: User | null, rule: Rule): boolean { + // Any value other than 'true' is considered a failure. This is intentional. + if (rule(user) === true) { + return true; + } else { + throw new Error('Unauthorized access: Rule evaluation failed'); + } +} diff --git a/src/lib/stores/oidc.ts b/src/lib/stores/oidc.ts new file mode 100644 index 0000000000..8891414e73 --- /dev/null +++ b/src/lib/stores/oidc.ts @@ -0,0 +1,175 @@ +import { jwtDecode } from 'jwt-decode'; +import { derived, get, type Readable } from 'svelte/store'; +import type { BaseUser, User } from '../../types/app'; +import { computeRolesFromJWT } from '../../utilities/auth'; +import { showFailureToast } from '../../utilities/toast'; +import type { MaybeToken } from '../types/oidc'; +import { userStore } from './auth'; + +type CookieChanged = { + domain: string; + expires: Date; + name: string; + value: string; +}; + +type CookieDeleted = { + domain: string; + name: string; +}; + +interface CookieChangeEvent extends Event { + changed: CookieChanged[]; + deleted: CookieDeleted[]; +} + +type CookieStore = { + addEventListener: Window['addEventListener']; + removeEventListener: Window['removeEventListener']; +}; + +declare global { + interface Window { + cookieStore: CookieStore; + addEventListener(type: string, listener: (this: Window, ev: CookieChangeEvent) => void, useCapture?: boolean): void; + } +} + +export function cookieStoreListener() { + if (window && 'cookieStore' in window) { + window.cookieStore.addEventListener('change', handleCookieStoreChange); + console.log('Added cookie store change listener.'); + } else { + console.error('Cookie store is not available in this environment. It is *required* for automatic refresh of JWT.'); + } + + // Delay is a `derived` value, ultimately from the user store... (see below). + // Whenever the delay changes, any prior timeout is cancelled and a new timeout + // is created (using the new value of delay). + // + // We track an unsubscribe function to remove the cookie store change listener + // when the component is unmounted. + const unsubscribe = delay.subscribe(value => { + if (value) { + console.log(`Delay changed to ${value}ms`); + prior = reschedule(refresh, value, prior); + } + }); + + // Return a cleanup function to remove the cookie store change listener + // and unsubscribe from the delay store. + return () => { + console.log('Removing cookie store change listener.'); + window.cookieStore.removeEventListener('change', handleCookieStoreChange); + unsubscribe(); + }; +} + +// The decoded access token contains a timestamp that indicates when +// it will expire. +export const accessTokenDecoded: Readable = derived(userStore, $userStore => { + if ($userStore && $userStore.token) { + return jwtDecode($userStore.token) as MaybeToken; + } + return null; +}); + +// We convert the expiration time to a javascript date value. +export const expiresAt = derived(accessTokenDecoded, $accessTokenDecoded => { + return $accessTokenDecoded?.exp ? new Date($accessTokenDecoded?.exp * 1000) : null; +}); + +// We calculate a refresh time that is 10 seconds before the expiration time. +export const refreshAt = derived(expiresAt, $expiresAt => { + return $expiresAt ? new Date($expiresAt.getTime() - 10 * 1000) : null; +}); + +// The delay is used to schedule a timeout. +export const delay = derived(refreshAt, $refreshAt => { + const $expiresAt = get(expiresAt); + if ($expiresAt && $refreshAt && $refreshAt > new Date()) { + return Math.max(0, $refreshAt.getTime() - Date.now()); + } else { + return 0; + } +}); + +// This number is the result of calling setTimeout. +let prior: number | null = null; + +/// Private Helpers. + +export async function refresh(): Promise { + console.log('Refreshing tokens...'); + const res = await fetch('/oidc/refresh', { credentials: 'include', method: 'POST' }); + if (res.ok) { + console.info('Access token refresh succeeded.'); + } else { + const errorMessage = await res.json(); + console.error('Access token refresh failed, refresh token is probably expired.'); + throw new Error(`Refresh failed, with the following message: ${JSON.stringify(errorMessage)}`); + } +} + +function reschedule(fn: () => Promise, delay: number, prior: number | null): any { + if (prior) { + console.log(`Clearing previous timeout. ${prior}`); + clearTimeout(prior); + } + console.log(`Scheduling ${fn.name} in ${delay}ms`); + return setTimeout(async () => { + try { + await fn(); + } catch (err) { + console.error('Error in rescheduled function:', err); + + // TODO: show a modal? + showFailureToast('Failed to refresh your credentials, please login again.'); + } + }, delay); +} + +/** + * Handles changes and deletions to the cookie store. + * + * @param event: CookieChangeEvent - The event containing the changed or deleted cookies. + */ +const handleCookieStoreChange = async (ev: Event) => { + const event = ev as CookieChangeEvent; + + console.log(`Cookie store change detected.`, event); + event.changed.forEach(async ({ name, value }) => { + console.log(`Cookie changed: ${name}`); + if (name === 'accessToken') { + // set user store + const baseUser: BaseUser = { id: null, token: value }; // id can be null because any time this function is used, its in the context of oidc, and we specifically catch id being null for oidc in computeRolesFromJWT + const user: User | null = await computeRolesFromJWT(baseUser, null); // null role because if after a refresh a user has been demoted, wouldn't want to retain an invalid role + userStore.set(user); + } + if (name === 'idToken') { + const decoded = jwtDecode(value); + // update user store + userStore.update(user => { + if (user && decoded.sub) { + return { + ...user, + id: decoded.sub, + }; + } + return user; + }); + } + if (name === 'activeRole') { + // update the user store + userStore.update(user => { + if (user) { + user.activeRole = value; + } + return user; + }); + } + }); + event.deleted.forEach(({ name }) => { + console.log(`Cookie deleted: ${name}`); + }); +}; diff --git a/src/lib/types/oidc.ts b/src/lib/types/oidc.ts new file mode 100644 index 0000000000..b9a8ef998e --- /dev/null +++ b/src/lib/types/oidc.ts @@ -0,0 +1,14 @@ +import type { JwtPayload } from 'jsonwebtoken'; +import type { User } from '../../types/app'; + +export type MaybeToken = JwtPayload | undefined | null; + +export type HasuraToken = JwtPayload & { + 'https://hasura.io/jwt/claims': { + 'x-hasura-allowed-roles': string[]; + 'x-hasura-default-role': string; + 'x-hasura-user-id': string; + }; +}; + +export type Rule = (user: User | null) => boolean; diff --git a/src/routes/oidc/callback/+page.server.ts b/src/routes/oidc/callback/+page.server.ts new file mode 100644 index 0000000000..c6ab2ad036 --- /dev/null +++ b/src/routes/oidc/callback/+page.server.ts @@ -0,0 +1,76 @@ +import * as auth from '$lib/server/oidc'; +import { error, redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +/** + * The callback page exchanges the authorization code for tokens. + * + * It is critical to implement the following security measures: + * + * 1. **State Parameter**: The state parameter is used to prevent CSRF attacks + * 2. **PKCE**: The Proof Key for Code Exchange (PKCE) is used to enhance security in public clients. + * 3. **Secure Cookies**: Cookies should be set with `httpOnly`, `secure`, and `sameSite` attributes to prevent XSS and CSRF attacks. + * 4. **Validate iss, aud, and exp claims** to ensure it is issued by the expected identity provider and is not expired. + * + */ + +export const load: PageServerLoad = async ({ cookies, url }) => { + console.debug('/oidc/callback load'); + + const client = auth.Client.instance; + const verifier = cookies.get('verifier'); + const code = url.searchParams.get('code'); + const expectedState = cookies.get('oidc_state'); + const returnedState = url.searchParams.get('state'); + const back = cookies.get('back') || '/'; + + // These cookies are only used during this step of the OIDC flow, if the exchange fails for + // any reason, the flow will need to be reinitiated. So they are unconditionally deleted. + cookies.delete('verifier', { path: '/' }); + cookies.delete('back', { path: '/' }); + cookies.delete('oidc_state', { path: '/' }); + + if (!code) { + const errorMsg = url.searchParams.get('error_description') || 'No code provided'; + const message = `Authorization server returned an error: ${errorMsg}`; + error(401, message); + } + + try { + const problems = check(verifier, code, expectedState, returnedState); + if (problems.size > 0) { + throw new Error(`Encountered the following problems with the callback state: \n${[...problems].join('\n')}`); + } + + const tokens = await client.exchange(code, verifier as string); + if (!tokens) { + throw new Error(`Could not exchange authorization code for tokens.`); + } + + const success = await auth.updateWithNewTokens(cookies, tokens); + if (!success) { + throw new Error(`Failed to validate token ${tokens.accessToken()}`); + } + } catch (err) { + console.error(err); + const message = `Failed to handle OIDC callback: ${err}`; + error(401, message); + } + + redirect(302, back); +}; + +function check( + verifier: string | undefined, + code: string | null, + expectedState: string | undefined, + returnedState: string | null, +) { + const problems = new Set(); + void (expectedState || problems.add('Missing expected state')); + void (returnedState || problems.add('Missing returned state')); + void (expectedState === returnedState || problems.add('State parameter mismatch')); + void (verifier || problems.add('Missing verifier')); + void (code || problems.add('Missing code')); + return problems; +} diff --git a/src/routes/oidc/login/+page.server.ts b/src/routes/oidc/login/+page.server.ts new file mode 100644 index 0000000000..832e62c6ac --- /dev/null +++ b/src/routes/oidc/login/+page.server.ts @@ -0,0 +1,33 @@ +import * as auth from '$lib/server/oidc'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +const shortLivedCookieOptions = { + httpOnly: true, + maxAge: 300, + path: '/', + sameSite: 'lax', + secure: true, +} as const; + +/** + * The login page produces a code verifier and an authorization URL. + */ +export const load: PageServerLoad = async ({ cookies, url }) => { + console.debug('/oidc/login load'); + + // Other pages in this app may redirect to the login page with a `back` query parameter. + // This allows the login page to redirect back to the original page after a successful login. + // If no `back` parameter is provided, it defaults to the root path. + const back = url.searchParams.get('back') || '/'; + cookies.set('back', back, { + httpOnly: true, + path: '/', + }); + + const client = auth.Client.instance; + const { verifier, state, authorizationUrl } = client.createAuthorizationURLWithPKCE(); + cookies.set('verifier', verifier, shortLivedCookieOptions); + cookies.set('oidc_state', state, shortLivedCookieOptions); + redirect(302, authorizationUrl.toString()); +}; diff --git a/src/routes/oidc/logout/+server.ts b/src/routes/oidc/logout/+server.ts new file mode 100644 index 0000000000..ddc666b856 --- /dev/null +++ b/src/routes/oidc/logout/+server.ts @@ -0,0 +1,33 @@ +import { ORIGIN } from '$env/static/private'; +import * as auth from '$lib/server/oidc'; +import { redirect } from '@sveltejs/kit'; + +/** + * Submits the id token to the IDP, and uses that to end the SSO session. Also destroys the session locally. + * + * @param { cookies } - Expected to contain an 'idToken' cookie, as well as the 'refreshToken' and 'accessToken' cookies. + * @returns a redirection to the IDP session destruction endpoint. + */ + +export const GET = async ({ cookies }) => { + console.debug('/oidc/logout (GET)'); + + const client = auth.Client.instance; + const idToken = cookies.get('idToken') ?? ''; + + // delete cookies here + cookies.delete('accessToken', { path: '/' }); + cookies.delete('idToken', { path: '/' }); + cookies.delete('refreshToken', { path: '/' }); + + cookies.delete('activeRole', { path: '/' }); + + // redirect browser to logout page (SSO session destroy) + const logoutUrl = new URL(client.getLogoutEndpoint()); + + logoutUrl.searchParams.set('post_logout_redirect_uri', `${ORIGIN}`); + logoutUrl.searchParams.set('id_token_hint', idToken); + + // redirect to the logout endpoint + redirect(302, logoutUrl.toString()); +}; diff --git a/src/routes/oidc/refresh/+server.ts b/src/routes/oidc/refresh/+server.ts new file mode 100644 index 0000000000..f5cf1a75f3 --- /dev/null +++ b/src/routes/oidc/refresh/+server.ts @@ -0,0 +1,40 @@ +import * as auth from '$lib/server/oidc'; +import { json } from '@sveltejs/kit'; + +/** + * Requests a new access and refresh token. + * + * This endpoint is intended to be called from the client at a regular interval. + * + * @param { cookies } - Expected to contain a 'refreshToken' cookie. + * @returns JSON response with new access token or error. + */ +export const POST = async ({ cookies }) => { + console.debug('/oidc/refresh'); + + const refreshToken = cookies.get('refreshToken'); + + if (!refreshToken) { + throw new Error(`Error refreshing token - user is unauthenticated.`); + } + + const client = auth.Client.instance; + const tokens = await client.refresh(refreshToken); + + if (!tokens) { + // okay to throw here, since it's a POST, not a GET. + console.error('Tokens came back null.'); + throw new Error('Tokens came back null.'); + } + + if (await auth.updateWithNewTokens(cookies, tokens)) { + // Tokens are returned as JSON for convenience. The client is able to extract tokens from + // cookie values, not JSON. + return json({ + accessToken: tokens.accessToken(), + idToken: tokens.idToken(), + }); + } else { + throw new Error(`Failed to verify new access token after refresh.`); + } +}; diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index 56e65fc0ce..483d8a250e 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -5211,6 +5211,7 @@ const effects = { } }, + // NOTE: may want to move this out of effects async getRolePermissions(user: User | null): Promise { try { const roleData = await reqHasura(gql.GET_ROLE_PERMISSIONS, {}, user, undefined); @@ -5565,6 +5566,7 @@ const effects = { } }, + // NOTE: may want to move this out of effects async getUserQueries(user: User | null): Promise { try { const data = await reqHasura(gql.GET_PERMISSIBLE_QUERIES, {}, user, undefined); diff --git a/src/utilities/login.ts b/src/utilities/login.ts index 467195cfa7..87dec65a53 100644 --- a/src/utilities/login.ts +++ b/src/utilities/login.ts @@ -10,13 +10,26 @@ export function shouldRedirectToLogin(user: User | null) { } export async function logout(reason?: string) { - if (browser) { - await fetch(`${base}/auth/logout`, { method: 'POST' }); - if (env.PUBLIC_AUTH_SSO_ENABLED === 'true') { - // hooks will handle SSO redirect - await goto(base, { invalidateAll: true }); + if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { + if (browser) { + await goto(`${base}/oidc/logout`); } else { - await goto(`${base}/login${reason ? '?reason=' + reason : ''}`, { invalidateAll: true }); + console.error( + `Logout triggered from server. NOTE - this is exceptional behavior and this logout handling exists to avoid a crash. Cited reason: ${reason}:`, + reason, + ); + + throw new Error(`Logout triggered server-side.\nCited Reason: ${reason}.`); + } + } else { + if (browser) { + await fetch(`${base}/auth/logout`, { method: 'POST' }); + if (env.PUBLIC_AUTH_SSO_ENABLED === 'true') { + // hooks will handle SSO redirect + await goto(base, { invalidateAll: true }); + } else { + await goto(`${base}/login${reason ? '?reason=' + reason : ''}`, { invalidateAll: true }); + } } } } diff --git a/src/utilities/requests.ts b/src/utilities/requests.ts index 5bc574287a..b97eeec303 100644 --- a/src/utilities/requests.ts +++ b/src/utilities/requests.ts @@ -4,7 +4,6 @@ import type { BaseUser, User } from '../types/app'; import type { BaseError, LogMessage } from '../types/errors'; import type { ExtensionPayload, ExtensionResponse } from '../types/extension'; import type { QueryVariables } from '../types/subscribable'; -import { logout } from '../utilities/login'; import { INVALID_JWT } from '../utilities/permissions'; import { ErrorTypes } from './errors'; From 5c1637917d46aa706b29f57da61a7cda58269e58 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 17:18:56 -0600 Subject: [PATCH 02/22] Replace PageData.user with reactive userStore across application Migrate from passing user through PageData to using a centralized userStore for authentication state. This change: - Removes user parameter threading through page components - Updates all stores to access user from centralized auth store - Refactors route layouts to use reactive auth state - Removes unnecessary +page.ts files that only passed user data - Enables role changes to propagate without full page reload # Conflicts: # src/components/plan/PlanMergeReview.svelte # src/routes/+layout.server.ts # src/routes/+layout.svelte # src/routes/constraints/+layout.svelte # src/routes/constraints/+page.svelte # src/routes/constraints/edit/[id]/+page.svelte # src/routes/constraints/new/+page.svelte # src/routes/dictionaries/+page.svelte # src/routes/expansion/+layout.svelte # src/routes/expansion/rules/+page.svelte # src/routes/expansion/rules/edit/[id]/+page.svelte # src/routes/expansion/rules/new/+page.svelte # src/routes/expansion/runs/+page.svelte # src/routes/expansion/sets/+page.svelte # src/routes/expansion/sets/new/+page.svelte # src/routes/external-sources/+layout.svelte # src/routes/external-sources/sources/+page.svelte # src/routes/external-sources/types/+page.svelte # src/routes/models/+layout.svelte # src/routes/models/+page.svelte # src/routes/models/[id]/+page.svelte # src/routes/parcels/+layout.svelte # src/routes/parcels/+page.svelte # src/routes/parcels/edit/[id]/+page.svelte # src/routes/parcels/new/+page.svelte # src/routes/plans/+page.svelte # src/routes/plans/[id]/+page.svelte # src/routes/plans/[id]/merge/+page.svelte # src/routes/scheduling/+layout.svelte # src/routes/scheduling/+page.svelte # src/routes/scheduling/conditions/edit/[id]/+page.svelte # src/routes/scheduling/conditions/new/+page.svelte # src/routes/scheduling/goals/edit/[id]/+page.svelte # src/routes/scheduling/goals/new/+page.svelte # src/routes/sequence-templates/+layout.svelte # src/routes/sequence-templates/+page.svelte # src/routes/tags/+page.svelte # src/routes/workspaces/+layout.svelte # src/routes/workspaces/+page.svelte # src/routes/workspaces/[workspaceId]/+layout@.svelte # src/routes/workspaces/[workspaceId]/actions/+layout@.svelte # src/routes/workspaces/[workspaceId]/actions/runs/[runId]/+layout@.svelte # src/routes/workspaces/[workspaceId]/actions/runs/[runId]/+page.svelte # src/stores/sequencing.ts # src/stores/tags.ts --- src/lib/stores/auth.ts | 7 ++ src/routes/+layout.server.ts | 19 ++++- src/routes/+layout.svelte | 15 +++- src/routes/+layout.ts | 11 +++ src/routes/constraints/+page.ts | 9 --- src/routes/constraints/edit/[id]/+page.ts | 1 - src/routes/constraints/new/+page.ts | 9 --- src/routes/dictionaries/+page.ts | 7 -- src/routes/expansion/+layout.ts | 6 -- src/routes/expansion/rules/+page.ts | 7 -- src/routes/expansion/rules/edit/[id]/+page.ts | 1 - src/routes/expansion/rules/new/+page.ts | 7 -- src/routes/expansion/sets/+page.ts | 7 -- src/routes/expansion/sets/new/+page.ts | 2 +- src/routes/external-sources/+layout.ts | 6 -- src/routes/external-sources/sources/+page.ts | 9 --- src/routes/external-sources/types/+page.ts | 9 --- src/routes/login/+page.svelte | 53 ++++++++----- src/routes/models/+page.ts | 1 - src/routes/models/[id]/+page.ts | 1 - src/routes/parcels/+page.ts | 7 -- src/routes/parcels/edit/[id]/+page.ts | 1 - src/routes/parcels/new/+page.ts | 7 -- src/routes/plans/+page.ts | 1 - src/routes/plans/[id]/+page.ts | 1 - src/routes/plans/[id]/merge/+page.ts | 1 - src/routes/scheduling/+page.ts | 9 --- .../scheduling/conditions/edit/[id]/+page.ts | 1 - src/routes/scheduling/conditions/new/+page.ts | 9 --- .../scheduling/goals/edit/[id]/+page.ts | 1 - src/routes/scheduling/goals/new/+page.ts | 9 --- src/routes/sequence-templates/+page.ts | 7 -- src/routes/tags/+page.ts | 1 - src/routes/workspaces/+page.ts | 7 -- .../workspaces/[workspaceId]/+layout.ts | 1 - .../[workspaceId]/actions/+layout.ts | 1 - .../[workspaceId]/actions/+page.svelte | 5 +- .../workspaces/[workspaceId]/actions/+page.ts | 6 -- .../actions/runs/[runId]/+layout.ts | 2 - src/types/app.ts | 4 +- src/utilities/auth.ts | 74 +++++++++++++++++++ 41 files changed, 166 insertions(+), 176 deletions(-) create mode 100644 src/lib/stores/auth.ts delete mode 100644 src/routes/constraints/+page.ts delete mode 100644 src/routes/constraints/new/+page.ts delete mode 100644 src/routes/dictionaries/+page.ts delete mode 100644 src/routes/expansion/+layout.ts delete mode 100644 src/routes/expansion/rules/+page.ts delete mode 100644 src/routes/expansion/rules/new/+page.ts delete mode 100644 src/routes/expansion/sets/+page.ts delete mode 100644 src/routes/external-sources/+layout.ts delete mode 100644 src/routes/external-sources/sources/+page.ts delete mode 100644 src/routes/external-sources/types/+page.ts delete mode 100644 src/routes/parcels/+page.ts delete mode 100644 src/routes/parcels/new/+page.ts delete mode 100644 src/routes/scheduling/+page.ts delete mode 100644 src/routes/scheduling/conditions/new/+page.ts delete mode 100644 src/routes/scheduling/goals/new/+page.ts delete mode 100644 src/routes/sequence-templates/+page.ts delete mode 100644 src/routes/workspaces/+page.ts delete mode 100644 src/routes/workspaces/[workspaceId]/actions/+page.ts create mode 100644 src/utilities/auth.ts diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts new file mode 100644 index 0000000000..9c0e9796f0 --- /dev/null +++ b/src/lib/stores/auth.ts @@ -0,0 +1,7 @@ +import { type Client } from 'graphql-ws'; +import { writable, type Writable } from 'svelte/store'; +import type { User } from '../../types/app'; + +// no need for id token because that's only used serverside. no need for activeRole cookie because that's part of the user...we just need to make sure that when it is updated, it is reflected in user store +export const userStore: Writable = writable(); +export const gqlWsClient: Writable = writable(); // TODO: add more robust handling for if this is null diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 6e04658178..314cc97588 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,10 +1,25 @@ import { base } from '$app/paths'; +import { env } from '$env/dynamic/public'; import { redirect } from '@sveltejs/kit'; -import { shouldRedirectToLogin } from '../utilities/login'; +import { enforce } from '../lib/server/oidc'; +import { userIsDefined } from '../lib/server/rule'; import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = async ({ locals, url }) => { - if (!url.pathname.includes('login') && shouldRedirectToLogin(locals.user)) { + const nonProtectedPage: boolean = + url.pathname.includes('error') || + url.pathname.includes('oidc') || + url.pathname.includes('login') || + url.pathname.includes('auth'); + if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true' && !nonProtectedPage) { + try { + enforce(locals?.user, userIsDefined); + } catch (error) { + console.log(error); + const redirectTo = encodeURIComponent(url.pathname + url.search); + redirect(302, `${base}/login?redirectTo=${redirectTo}`); + } + } else if (!nonProtectedPage && !locals.user) { const redirectTo = encodeURIComponent(url.pathname + url.search); redirect(302, `${base}/login?redirectTo=${redirectTo}`); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6522dbfc9b..2d6df06862 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,6 +3,7 @@
@@ -80,20 +83,36 @@ -
- - -
+ {#if isOidcEnabled()} +
+
+ +
+
+ {:else} +
+ + +
-
- - -
+
+ + +
-
- -
+
+ +
+ {/if}
diff --git a/src/routes/models/+page.ts b/src/routes/models/+page.ts index f9d826137f..5940cfc523 100644 --- a/src/routes/models/+page.ts +++ b/src/routes/models/+page.ts @@ -8,6 +8,5 @@ export const load: PageLoad = async ({ parent }) => { return { initialModels, - user, }; }; diff --git a/src/routes/models/[id]/+page.ts b/src/routes/models/[id]/+page.ts index 3c2634c70e..1e3004b3b9 100644 --- a/src/routes/models/[id]/+page.ts +++ b/src/routes/models/[id]/+page.ts @@ -15,7 +15,6 @@ export const load: PageLoad = async ({ parent, params }) => { if (initialModel) { return { initialModel, - user, }; } } diff --git a/src/routes/parcels/+page.ts b/src/routes/parcels/+page.ts deleted file mode 100644 index 35cf52612a..0000000000 --- a/src/routes/parcels/+page.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PageLoad } from './$types'; - -export const load: PageLoad = async ({ parent }) => { - const { user } = await parent(); - - return { user }; -}; diff --git a/src/routes/parcels/edit/[id]/+page.ts b/src/routes/parcels/edit/[id]/+page.ts index 3b1a3adbb7..68fddf846a 100644 --- a/src/routes/parcels/edit/[id]/+page.ts +++ b/src/routes/parcels/edit/[id]/+page.ts @@ -19,7 +19,6 @@ export const load: PageLoad = async ({ parent, params }) => { if (initialParcel !== null) { return { initialParcel, - user, }; } } diff --git a/src/routes/parcels/new/+page.ts b/src/routes/parcels/new/+page.ts deleted file mode 100644 index 35cf52612a..0000000000 --- a/src/routes/parcels/new/+page.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PageLoad } from './$types'; - -export const load: PageLoad = async ({ parent }) => { - const { user } = await parent(); - - return { user }; -}; diff --git a/src/routes/plans/+page.ts b/src/routes/plans/+page.ts index 37fa8da7de..5258a6254c 100644 --- a/src/routes/plans/+page.ts +++ b/src/routes/plans/+page.ts @@ -9,6 +9,5 @@ export const load: PageLoad = async ({ parent }) => { return { models, plans, - user, }; }; diff --git a/src/routes/plans/[id]/+page.ts b/src/routes/plans/[id]/+page.ts index def902121e..96bbdcc84d 100644 --- a/src/routes/plans/[id]/+page.ts +++ b/src/routes/plans/[id]/+page.ts @@ -74,7 +74,6 @@ export const load: PageLoad = async ({ parent, params, url }) => { initialPlanSnapshotId, initialPlanTags, initialView, - user, }; } } diff --git a/src/routes/plans/[id]/merge/+page.ts b/src/routes/plans/[id]/merge/+page.ts index f79047112e..6c48fa971e 100644 --- a/src/routes/plans/[id]/merge/+page.ts +++ b/src/routes/plans/[id]/merge/+page.ts @@ -41,7 +41,6 @@ export const load: PageLoad = async ({ parent, params }) => { initialMergeRequest, initialNonConflictingActivities, initialPlan, - user, }; } } diff --git a/src/routes/scheduling/+page.ts b/src/routes/scheduling/+page.ts deleted file mode 100644 index ee8329053d..0000000000 --- a/src/routes/scheduling/+page.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PageLoad } from './$types'; - -export const load: PageLoad = async ({ parent }) => { - const { user } = await parent(); - - return { - user, - }; -}; diff --git a/src/routes/scheduling/conditions/edit/[id]/+page.ts b/src/routes/scheduling/conditions/edit/[id]/+page.ts index 93f52b05f0..095b08ba2e 100644 --- a/src/routes/scheduling/conditions/edit/[id]/+page.ts +++ b/src/routes/scheduling/conditions/edit/[id]/+page.ts @@ -17,7 +17,6 @@ export const load: PageLoad = async ({ parent, params }) => { if (initialCondition !== null) { return { initialCondition, - user, }; } } diff --git a/src/routes/scheduling/conditions/new/+page.ts b/src/routes/scheduling/conditions/new/+page.ts deleted file mode 100644 index ee8329053d..0000000000 --- a/src/routes/scheduling/conditions/new/+page.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PageLoad } from './$types'; - -export const load: PageLoad = async ({ parent }) => { - const { user } = await parent(); - - return { - user, - }; -}; diff --git a/src/routes/scheduling/goals/edit/[id]/+page.ts b/src/routes/scheduling/goals/edit/[id]/+page.ts index bd092ab8c8..9b1f2597ad 100644 --- a/src/routes/scheduling/goals/edit/[id]/+page.ts +++ b/src/routes/scheduling/goals/edit/[id]/+page.ts @@ -18,7 +18,6 @@ export const load: PageLoad = async ({ parent, params }) => { if (initialGoal !== null) { return { initialGoal, - user, }; } } diff --git a/src/routes/scheduling/goals/new/+page.ts b/src/routes/scheduling/goals/new/+page.ts deleted file mode 100644 index ee8329053d..0000000000 --- a/src/routes/scheduling/goals/new/+page.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PageLoad } from './$types'; - -export const load: PageLoad = async ({ parent }) => { - const { user } = await parent(); - - return { - user, - }; -}; diff --git a/src/routes/sequence-templates/+page.ts b/src/routes/sequence-templates/+page.ts deleted file mode 100644 index b688e42745..0000000000 --- a/src/routes/sequence-templates/+page.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PageLoad } from '../$types'; - -export const load: PageLoad = async ({ parent }) => { - const { user } = await parent(); - - return { user }; -}; diff --git a/src/routes/tags/+page.ts b/src/routes/tags/+page.ts index 40c006aefc..6a0beef5ff 100644 --- a/src/routes/tags/+page.ts +++ b/src/routes/tags/+page.ts @@ -8,6 +8,5 @@ export const load: PageLoad = async ({ parent }) => { return { initialTags, - user, }; }; diff --git a/src/routes/workspaces/+page.ts b/src/routes/workspaces/+page.ts deleted file mode 100644 index 35cf52612a..0000000000 --- a/src/routes/workspaces/+page.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PageLoad } from './$types'; - -export const load: PageLoad = async ({ parent }) => { - const { user } = await parent(); - - return { user }; -}; diff --git a/src/routes/workspaces/[workspaceId]/+layout.ts b/src/routes/workspaces/[workspaceId]/+layout.ts index c4cc251dac..cac181678e 100644 --- a/src/routes/workspaces/[workspaceId]/+layout.ts +++ b/src/routes/workspaces/[workspaceId]/+layout.ts @@ -10,6 +10,5 @@ export const load: LayoutLoad = async ({ parent, params }) => { return { initialWorkspace, - user, }; }; diff --git a/src/routes/workspaces/[workspaceId]/actions/+layout.ts b/src/routes/workspaces/[workspaceId]/actions/+layout.ts index 560a844375..445477efaf 100644 --- a/src/routes/workspaces/[workspaceId]/actions/+layout.ts +++ b/src/routes/workspaces/[workspaceId]/actions/+layout.ts @@ -10,6 +10,5 @@ export const load: LayoutLoad = async ({ parent, params }) => { return { initialWorkspace, - user, }; }; diff --git a/src/routes/workspaces/[workspaceId]/actions/+page.svelte b/src/routes/workspaces/[workspaceId]/actions/+page.svelte index d947c4979c..86fd178150 100644 --- a/src/routes/workspaces/[workspaceId]/actions/+page.svelte +++ b/src/routes/workspaces/[workspaceId]/actions/+page.svelte @@ -3,13 +3,14 @@ - + diff --git a/src/routes/workspaces/[workspaceId]/actions/+page.ts b/src/routes/workspaces/[workspaceId]/actions/+page.ts deleted file mode 100644 index 8d544d24f3..0000000000 --- a/src/routes/workspaces/[workspaceId]/actions/+page.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { PageLoad } from './$types'; - -export const load: PageLoad = async ({ parent }) => { - // Get data from parent layout which includes initialWorkspace and user - return await parent(); -}; diff --git a/src/routes/workspaces/[workspaceId]/actions/runs/[runId]/+layout.ts b/src/routes/workspaces/[workspaceId]/actions/runs/[runId]/+layout.ts index fa2533eab2..3660edb7ba 100644 --- a/src/routes/workspaces/[workspaceId]/actions/runs/[runId]/+layout.ts +++ b/src/routes/workspaces/[workspaceId]/actions/runs/[runId]/+layout.ts @@ -14,13 +14,11 @@ export const load: LayoutLoad = async ({ parent, params }) => { return { initialActionRun, initialWorkspace, - user, }; } return { initialActionRun: null, initialWorkspace, - user, }; }; diff --git a/src/types/app.ts b/src/types/app.ts index ff83482298..03e29963da 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -21,6 +21,7 @@ export type User = BaseUser & { export type UserStore = Writable; export type ParsedUserToken = { + email: string; exp: number; 'https://hasura.io/jwt/claims': { 'x-hasura-allowed-roles': UserRole[]; @@ -28,7 +29,8 @@ export type ParsedUserToken = { 'x-hasura-user-id': string; }; iat: number; - username: string; + oid: string; + sub: string; }; export type Version = { diff --git a/src/utilities/auth.ts b/src/utilities/auth.ts new file mode 100644 index 0000000000..8289d565f2 --- /dev/null +++ b/src/utilities/auth.ts @@ -0,0 +1,74 @@ +import { env } from '$env/dynamic/public'; +import { jwtDecode } from 'jwt-decode'; +import type { BaseUser, ParsedUserToken, User } from '../types/app'; +import effects from './effects'; + +export async function computeRolesFromCookies( + userCookie: string | null, + activeRoleCookie: string | null, +): Promise { + const userBuffer = Buffer.from(userCookie ?? '', 'base64'); + const userStr = userBuffer.toString('utf-8'); + + try { + const baseUser: BaseUser = JSON.parse(userStr); + return computeRolesFromJWT(baseUser, activeRoleCookie); + } catch (err) { + console.error(err); + return null; + } +} + +/** + * Consult Aerie Gateway to obtain fine grained permissions; + */ +export async function computeRolesFromJWT(baseUser: BaseUser, activeRole: string | null): Promise { + const { success, message } = await effects.session(baseUser); + if (!success) { + console.error( + `Could not verify token and retrieve roles in Aerie-Gateway using the given JWT access token: ${message}`, + ); + if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { + console.error( + `OIDC is enabled, please ensure Aerie-Gateway's "HASURA_GRAPHQL_JWT_SECRET" environment variable specifies the same jwks_url as Aerie UI.`, + ); + } + + return null; // expect to return in non-oidc case + } + + const decodedToken: ParsedUserToken = jwtDecode(baseUser.token); + + if (baseUser.id === null && env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { + // since our scope is always one that includes email, and that's also a unique id, we can use that + // BUT sub is the one that matches hasura's expected x-hasura-user-id, which is important. + baseUser.id = decodedToken.sub; + } + + const allowedRoles = decodedToken['https://hasura.io/jwt/claims']['x-hasura-allowed-roles']; + const defaultRole = decodedToken['https://hasura.io/jwt/claims']['x-hasura-default-role']; + + const user: User = { + ...baseUser, + activeRole: activeRole && allowedRoles.includes(activeRole) ? activeRole : defaultRole, // check to make sure whatever was passed in as activeRole if not null is still in allowedRoles + allowedRoles, + defaultRole, + permissibleQueries: null, + rolePermissions: null, + }; + const permissibleQueries = await effects.getUserQueries(user); + const rolePermissions = await effects.getRolePermissions(user); + return { + ...user, + permissibleQueries, + rolePermissions, + }; +} + +export function goToLogin() { + if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { + document.location.href = '/oidc/login'; + } else { + document.location.href = '/login'; + } +} From be6e13518634906806e5ba8ed534468b58b91c0d Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 17:19:13 -0600 Subject: [PATCH 03/22] Add server-side rule enforcement infrastructure Create rule.ts module for enforcing authentication requirements at the route level, enabling consistent access control across the application. --- src/lib/server/rule.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/lib/server/rule.ts diff --git a/src/lib/server/rule.ts b/src/lib/server/rule.ts new file mode 100644 index 0000000000..60235a8daa --- /dev/null +++ b/src/lib/server/rule.ts @@ -0,0 +1,10 @@ +import type { Rule } from '$lib/types/oidc'; +import type { User } from '../../types/app'; + +export const userIsDefined: Rule = (u: User | null) => { + return !!u; +}; + +export const userIsAdmin: Rule = (u: User | null) => { + return u?.activeRole === 'aerie_admin'; +}; From 47959b42df8850753908b09597c1f38c4ff3421a Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 17:19:33 -0600 Subject: [PATCH 04/22] Add E2E tests for OIDC authentication flow Implement Playwright tests for OIDC authentication including: - OIDC fixture for handling auth flows in tests - Login/logout test scenarios - Token refresh verification - Updated helpers and AppNav fixture for OIDC support --- e2e-tests/fixtures/AppNav.ts | 6 + e2e-tests/fixtures/OIDC.ts | 213 +++++++++++++++++++++++++++++++++ e2e-tests/tests/oidc.test.ts | 100 ++++++++++++++++ e2e-tests/utilities/helpers.ts | 3 + playwright.config.ts | 10 +- 5 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 e2e-tests/fixtures/OIDC.ts create mode 100644 e2e-tests/tests/oidc.test.ts diff --git a/e2e-tests/fixtures/AppNav.ts b/e2e-tests/fixtures/AppNav.ts index 2d277b6f9e..910f03d0f7 100644 --- a/e2e-tests/fixtures/AppNav.ts +++ b/e2e-tests/fixtures/AppNav.ts @@ -29,6 +29,12 @@ export class AppNav { await this.pageLoadingLocator.waitFor({ state: 'detached' }); } + async show() { + await this.appMenuButton.click(); + await this.appMenu.waitFor({ state: 'attached' }); + await this.appMenu.waitFor({ state: 'visible' }); + } + updatePage(page: Page): void { this.aboutModal = page.locator(`.modal:has-text("About")`); this.aboutModalCloseButton = page.locator(`.modal:has-text("About") >> button:has-text("Close")`); diff --git a/e2e-tests/fixtures/OIDC.ts b/e2e-tests/fixtures/OIDC.ts new file mode 100644 index 0000000000..9057d15724 --- /dev/null +++ b/e2e-tests/fixtures/OIDC.ts @@ -0,0 +1,213 @@ +import { expect, Locator, Page } from '@playwright/test'; +import { decode, JwtPayload } from 'jsonwebtoken'; +import { AppNav } from './AppNav'; + +type HasuraToken = JwtPayload & { + 'https://hasura.io/jwt/claims': { + 'x-hasura-allowed-roles': string[]; + 'x-hasura-default-role': string; + 'x-hasura-user-id': string; + }; +}; + +// OIDC spans several pages. +// As such, we will define a class for each of the pages, +// and then incorporate them as members into an overall +// OIDC class. +class AerieLogin { + loginButton: Locator; + + constructor(public page: Page) { + this.updatePage(page); + } + + async login() { + await this.page.goto('/plans', { waitUntil: 'load' }); + const loginButton = this.page.getByText('Login Using OIDC'); + + await loginButton.waitFor(); + + let buttonClicked: boolean = false; + await loginButton.click(); + while (!buttonClicked) { + // this button has required variable numbers of tries + try { + await this.page.waitForURL('**/realms/aerie-dev/**', { timeout: 2000 }); + buttonClicked = true; + } catch { + // means it timed out, no new page + await loginButton.click(); + } + } + } + + updatePage(page: Page) { + this.loginButton = page.getByText('Login Using OIDC'); + } +} + +class IdPLogin { + passwordSlot: Locator; + signInButton: Locator; + usernameSlot: Locator; + + constructor(public page: Page) { + this.updatePage(page); + } + + async login(username: string, password: string) { + await this.usernameSlot.waitFor(); + await this.passwordSlot.waitFor(); + await this.signInButton.waitFor(); + + await this.usernameSlot.fill(username); + await this.passwordSlot.fill(password); + + await this.signInButton.click(); + + await this.page.waitForURL('**/plans'); + } + + updatePage(page: Page) { + this.usernameSlot = page.locator('#username'); + this.passwordSlot = page.locator('#password'); + this.signInButton = page.getByText('Sign In').last(); + } +} + +export class OIDC { + expectedDefaultRole: string; + expectedRoles: string[]; + + constructor( + public page: Page, + public username: string, + public password: string, + ) { + switch (username) { + case 'AerieAdmin': + this.expectedRoles = ['1-aerie_admin', '2-user', '3-viewer']; + break; + case 'AerieUser': + this.expectedRoles = ['2-user', '3-viewer']; + break; + default: // AerieViewer + this.expectedRoles = ['3-viewer']; + } + this.expectedDefaultRole = this.expectedRoles[0]; + } + + async checkCookieRoles() { + const { accessToken } = await this.extractTokens(); + + if (accessToken) { + // otherwise it is considered potentailly undefined despite the above expect + const decoded = decode(accessToken); // TODO: extract this into its own method ? + + const allowedRoles = (decoded as HasuraToken)['https://hasura.io/jwt/claims']['x-hasura-allowed-roles']; + for (const expectedRole of this.expectedRoles) { + expect(allowedRoles.includes(expectedRole)); + } + } + } + + async checkCurrentRole() { + // while this element shows up in Plan.ts, it is too cumbersome to define that object here. + // if it would make things more consistent and clean, a local class for the plans page for + // just elements like this (and cookies too?) can be created. + const currentRole = this.page.getByRole('combobox').filter({ hasText: '-' }); + await expect(currentRole).toBeVisible(); + await expect(currentRole).toHaveText(this.expectedDefaultRole); + } + + async expectNoCookies() { + const cookies = await this.page.context().cookies(); + + console.log(cookies.map(c => c.name)); + + const cookieNames = cookies.map(c => c.name); + expect(cookieNames.includes('accessToken')).toBeFalsy(); + expect(cookieNames.includes('idToken')).toBeFalsy(); + expect(cookieNames.includes('refreshToken')).toBeFalsy(); + } + + async extractTokens() { + const cookies = await this.page.context().cookies(); + + // check presence of accessToken, idToken, and refreshToken + const cookieNames = cookies.map(c => c.name); + expect(cookieNames.includes('accessToken')).toBeTruthy(); + expect(cookieNames.includes('idToken')).toBeTruthy(); + expect(cookieNames.includes('refreshToken')).toBeTruthy(); + + // then pull them out + const accessToken = cookies.find(c => c.name === 'accessToken')?.value; + const idToken = cookies.find(c => c.name === 'idToken')?.value; + const refreshToken = cookies.find(c => c.name === 'refreshToken')?.value; + + return { + accessToken, + idToken, + refreshToken, + }; + } + + async login() { + // log in on AERIE end of things + const aerieLogin = new AerieLogin(this.page); + await aerieLogin.login(); + + // then, IdP Login + const idpLogin = new IdPLogin(this.page); + await idpLogin.login(this.username, this.password); + } + + async logout() { + const appNav = new AppNav(this.page); + + await appNav.show(); + await appNav.appMenuItemLogout.click(); + + await this.page.waitForURL('**/login'); + + await this.expectNoCookies(); + } + + // should run this iff already logged in. + async refresh() { + // get old cookies + const { + accessToken: oldAccessToken, + idToken: oldIdToken, + refreshToken: oldRefreshToken, + } = await this.extractTokens(); + + // wait for timeout (set to 600 seconds by default in our Keycloak deployment) + // NOTE: since the timer is set in the UI, the token needn't actually expire + // to prompt a refresh. we just need to skip that time HERE and it'll know to refresh. + // It pre-emptively refreshes 10 seconds before refresh time, so we will + // skip to 1 second before that, i.e. we will timeskip 589 seconds. + // await this.page.clock.fastForward(1 * 1000); + // TURNS OUT MESSING WITH PAGE TIMER SERIOUSLY THROWS OFF DELAYS AND RESULTS IN A REFRESH LOOP! + + // now it'll refresh, so we want this test itself to wait for 5 seconds + await this.page.waitForTimeout(11000); + + // get new cookies + const { + accessToken: newAccessToken, + idToken: newIdToken, + refreshToken: newRefreshToken, + } = await this.extractTokens(); + + console.log('OLD ACCESS TOKEN, NEW ACCESS TOKEN', oldAccessToken, newAccessToken); + console.log('OLD ID TOKEN, NEW ID TOKEN', oldIdToken, newIdToken); + console.log('OLD REFRESH TOKEN, NEW REFRESH TOKEN', oldRefreshToken, newRefreshToken); + + expect(oldAccessToken).not.toEqual(newAccessToken); + expect(oldIdToken).not.toEqual(newIdToken); + expect(oldRefreshToken).not.toEqual(newRefreshToken); + + await this.checkCookieRoles(); // should still be right! + } +} diff --git a/e2e-tests/tests/oidc.test.ts b/e2e-tests/tests/oidc.test.ts new file mode 100644 index 0000000000..70223d8713 --- /dev/null +++ b/e2e-tests/tests/oidc.test.ts @@ -0,0 +1,100 @@ +import test, { type BrowserContext, type Page } from '@playwright/test'; +import { OIDC } from '../fixtures/OIDC'; + +let context: BrowserContext; +let page: Page; + +const users = [ + { + password: 'password', + username: 'AerieAdmin', + }, + { + password: 'password', + username: 'AerieUser', + }, + { + password: 'password', + username: 'AerieViewer', + }, +]; + +test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); +}); + +test.afterAll(async () => { + await page.close(); + await context.close(); +}); + +test.describe('Different Logins', () => { + // need to destroy everything between test runs + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + }); + + test.afterAll(async () => { + await page.close(); + await context.close(); + }); + + test('Login as admin', async () => { + const { username, password } = users[0]; + + const oidc = new OIDC(page, username, password); + await oidc.login(); + await oidc.checkCookieRoles(); + await oidc.checkCurrentRole(); + }); + test('Login as user', async () => { + const { username, password } = users[1]; + + const oidc = new OIDC(page, username, password); + await oidc.login(); + await oidc.checkCookieRoles(); + await oidc.checkCurrentRole(); + }); + test('Login as viewer', async () => { + const { username, password } = users[2]; + + const oidc = new OIDC(page, username, password); + await oidc.login(); + await oidc.checkCookieRoles(); + + // the current role box/option won't be visible + }); +}); + +test.describe('Refresh Functionality', () => { + test('Refresh as any user', async () => { + // user doesn't matter, so pick randomly + const { username, password } = users[Math.floor(Math.random() * 3)]; + + const oidc = new OIDC(page, username, password); + + // you might be thinking - why essentially re-test login? why not just inject an access token? + // the reason is that the logic required to get an access token that always works + // requires a fair bit of extra work and logic to make sure it always works, which would + // require forging a token from scratch to ensure time properties and all were correct (requiring + // experimentation here AS WELL AS some modification of the keycloak configuration itself to + // ensure there is a fixed, predictable JWT key...simply re-logging in seems like the easier + // option implementationwise but we can explore the other option if this is too cumbersome) + await oidc.login(); + await oidc.refresh(); + }); +}); + +test.describe('Logout Functionality', () => { + test('Logout as any user', async () => { + // user doesn't matter, so pick randomly + const { username, password } = users[Math.floor(Math.random() * 3)]; + + const oidc = new OIDC(page, username, password); + await oidc.login(); + await page.waitForTimeout(2000); // wait for a sec + await oidc.logout(); + }); +}); diff --git a/e2e-tests/utilities/helpers.ts b/e2e-tests/utilities/helpers.ts index af6790bb64..d84adf4380 100644 --- a/e2e-tests/utilities/helpers.ts +++ b/e2e-tests/utilities/helpers.ts @@ -2,6 +2,9 @@ import { Cookie, Locator, Page } from '@playwright/test'; import { adjectives, animals, colors, uniqueNamesGenerator } from 'unique-names-generator'; export function getUserCookieValue(cookies: Cookie[]): string | undefined { + if (process.env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { + return cookies.find(cookie => cookie.name === 'accessToken')?.value; + } for (const cookie of cookies) { if (cookie.name === 'user') { return JSON.parse(atob(cookie.value)).token; diff --git a/playwright.config.ts b/playwright.config.ts index 04b2bb60ae..55ca68a56e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -41,12 +41,20 @@ const config: PlaywrightTestConfig = { name: 'e2e tests', teardown: 'teardown', testDir: './e2e-tests', - testIgnore: /.*\/sequence-templates\.test\.ts/, + testIgnore: /.*\/(sequence-templates)|(oidc)\.test\.ts/, // TODO: make this also skip over the oidc stuff use: { baseURL: MAIN_TEST_SUITE_BASE_URL, storageState: STORAGE_STATE, }, }, + { + name: 'oidc tests', + testDir: './e2e-tests', + testMatch: /.*\/oidc\.test\.ts/, + use: { + baseURL: MAIN_TEST_SUITE_BASE_URL, + }, + }, { dependencies: ['setup-auth', 'setup-jar'], name: 'e2e sequence template tests', From 48790ccbfb3883d0b2fd1eb1c6702392b3e194ec Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 17:19:53 -0600 Subject: [PATCH 05/22] Update non-OIDC auth routes to use event-based cookie handling Align legacy auth routes (login, logout, changeRole) with OIDC implementation by using SvelteKit's event-based cookie API instead of manual header manipulation. Reduces code duplication and improves consistency. --- src/routes/auth/changeRole/+server.ts | 1 - src/routes/auth/login/+server.ts | 8 +++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/routes/auth/changeRole/+server.ts b/src/routes/auth/changeRole/+server.ts index 1434089fe4..66ed4b3708 100644 --- a/src/routes/auth/changeRole/+server.ts +++ b/src/routes/auth/changeRole/+server.ts @@ -1,4 +1,3 @@ -import { base } from '$app/paths'; import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; import type { CookieSerializeOptions } from 'cookie'; diff --git a/src/routes/auth/login/+server.ts b/src/routes/auth/login/+server.ts index 75874805ed..77c30a521e 100644 --- a/src/routes/auth/login/+server.ts +++ b/src/routes/auth/login/+server.ts @@ -1,4 +1,3 @@ -import { base } from '$app/paths'; import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; import { jwtDecode } from 'jwt-decode'; @@ -21,10 +20,9 @@ export const POST: RequestHandler = async event => { const parsedUserToken: ParsedUserToken = jwtDecode(user.token); const defaultRole = parsedUserToken['https://hasura.io/jwt/claims']['x-hasura-default-role']; - return json( - { success: true, user }, - { headers: { 'set-cookie': `activeRole=${defaultRole}; path=${base}/,user=${userCookie}; Path=${base}/` } }, - ); + event.cookies.set('activeRole', defaultRole, { httpOnly: false, path: '/' }); + event.cookies.set('user', userCookie, { httpOnly: false, path: '/' }); + return json({ success: true, user }); } else { return json({ message, success: false }); } From 2dba59af7316d8a13970bb405b1262136f95c416 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 17:24:58 -0600 Subject: [PATCH 06/22] Fix race condition in OIDC well-known configuration fetch The Client singleton was initiating a fetch for the well-known configuration but not awaiting it, causing endpoint values to be undefined when the constructor completed before the fetch. Changes: - Convert Client.instance to async getter returning Promise - Move initialization to async init() method that awaits well-known fetch - Update all call sites to await Client.instance - Uncomment OIDC_CLIENT_SECRET env var to fix type error --- .env | 2 +- src/lib/server/oidc.ts | 66 ++++++++++++++---------- src/routes/oidc/callback/+page.server.ts | 2 +- src/routes/oidc/login/+page.server.ts | 2 +- src/routes/oidc/logout/+server.ts | 2 +- src/routes/oidc/refresh/+server.ts | 2 +- 6 files changed, 44 insertions(+), 32 deletions(-) diff --git a/.env b/.env index caf8fdc087..061dad4a9d 100644 --- a/.env +++ b/.env @@ -24,7 +24,7 @@ OIDC_SCOPES= OIDC_CLIENT_ID= # (likely not used, but can be in future implementations) -# OIDC_CLIENT_SECRET= +OIDC_CLIENT_SECRET= OIDC_REDIRECT_URI= OIDC_AUDIENCE= diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index 4545bba5e6..a8a2ae0caf 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -62,7 +62,8 @@ async function refresh(evt: RequestEvent) { if (refreshToken) { // unconditionally clear refreshToken. if it was invalid, we don't want it, and if it's valid, it will be replaced! evt.cookies.delete('refreshToken', { path: '/' }); - const tokens = await Client.instance.refresh(refreshToken); + const client = await Client.instance; + const tokens = await client.refresh(refreshToken); await updateWithNewTokens(evt.cookies, tokens); } } @@ -106,38 +107,43 @@ export async function verify( */ export class Client { private static _instance: Client; + private static _initPromise: Promise; - private authorizationEndpoint: string; - private client: arctic.OAuth2Client; - private clientId: string; - private clientSecret: string | null; - private logoutEndpoint: string; - private redirectEndpoint: string; - private scopes: string[]; - private tokenEndpoint: string; + private authorizationEndpoint!: string; + private client!: arctic.OAuth2Client; + private clientId!: string; + private clientSecret!: string | null; + private logoutEndpoint!: string; + private redirectEndpoint!: string; + private scopes!: string[]; + private tokenEndpoint!: string; private constructor() { + // Use init() for async initialization + } + + private async init(): Promise { + // Fetch well-known configuration first if URL is provided if (env.OIDC_WELL_KNOWN_URL) { - fetch(env.OIDC_WELL_KNOWN_URL) - .then(res => res.json()) - .then(data => { - this.authorizationEndpoint ??= data.authorizationEndpoint ?? data.authorization_endpoint; - this.tokenEndpoint ??= data.tokenEndpoint ?? data.token_endpoint; - this.logoutEndpoint ??= data.endSessionEndpoint ?? data.end_session_endpoint; - }) - .catch(err => { - console.error('Error fetching OIDC configuration:', err); - }); + try { + const res = await fetch(env.OIDC_WELL_KNOWN_URL); + const data = await res.json(); + this.authorizationEndpoint = data.authorization_endpoint ?? data.authorizationEndpoint; + this.tokenEndpoint = data.token_endpoint ?? data.tokenEndpoint; + this.logoutEndpoint = data.end_session_endpoint ?? data.endSessionEndpoint; + } catch (err) { + console.error('Error fetching OIDC configuration:', err); + } } - // ??= is used to preserve any values set from the well-known URL. + // Fall back to explicit env vars if not set from well-known this.authorizationEndpoint ??= env.OIDC_AUTHORIZATION_URL; this.tokenEndpoint ??= env.OIDC_TOKEN_URL; - this.redirectEndpoint ??= env.OIDC_REDIRECT_URI; + this.redirectEndpoint = env.OIDC_REDIRECT_URI; this.logoutEndpoint ??= env.OIDC_LOGOUT_URL; - this.clientId ??= env.OIDC_CLIENT_ID; - this.clientSecret ??= env.OIDC_CLIENT_SECRET || null; - this.scopes ??= env.OIDC_SCOPES ? env.OIDC_SCOPES.split(' ') : ['openid', 'profile', 'email']; + this.clientId = env.OIDC_CLIENT_ID; + this.clientSecret = env.OIDC_CLIENT_SECRET || null; + this.scopes = env.OIDC_SCOPES ? env.OIDC_SCOPES.split(' ') : ['openid', 'profile', 'email']; // The entire client configuration is validated here, this should help // people understand everything they need to set without having to fix @@ -151,9 +157,15 @@ export class Client { } } - static get instance() { - this._instance ??= new Client(); - return this._instance; + static get instance(): Promise { + if (!this._initPromise) { + const client = new Client(); + this._initPromise = client.init().then(() => { + this._instance = client; + return client; + }); + } + return this._initPromise; } createAuthorizationURLWithPKCE(): { authorizationUrl: URL; state: string; verifier: string } { diff --git a/src/routes/oidc/callback/+page.server.ts b/src/routes/oidc/callback/+page.server.ts index c6ab2ad036..f331e25797 100644 --- a/src/routes/oidc/callback/+page.server.ts +++ b/src/routes/oidc/callback/+page.server.ts @@ -17,7 +17,7 @@ import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ cookies, url }) => { console.debug('/oidc/callback load'); - const client = auth.Client.instance; + const client = await auth.Client.instance; const verifier = cookies.get('verifier'); const code = url.searchParams.get('code'); const expectedState = cookies.get('oidc_state'); diff --git a/src/routes/oidc/login/+page.server.ts b/src/routes/oidc/login/+page.server.ts index 832e62c6ac..bc131e5262 100644 --- a/src/routes/oidc/login/+page.server.ts +++ b/src/routes/oidc/login/+page.server.ts @@ -25,7 +25,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { path: '/', }); - const client = auth.Client.instance; + const client = await auth.Client.instance; const { verifier, state, authorizationUrl } = client.createAuthorizationURLWithPKCE(); cookies.set('verifier', verifier, shortLivedCookieOptions); cookies.set('oidc_state', state, shortLivedCookieOptions); diff --git a/src/routes/oidc/logout/+server.ts b/src/routes/oidc/logout/+server.ts index ddc666b856..1c7f2f5d42 100644 --- a/src/routes/oidc/logout/+server.ts +++ b/src/routes/oidc/logout/+server.ts @@ -12,7 +12,7 @@ import { redirect } from '@sveltejs/kit'; export const GET = async ({ cookies }) => { console.debug('/oidc/logout (GET)'); - const client = auth.Client.instance; + const client = await auth.Client.instance; const idToken = cookies.get('idToken') ?? ''; // delete cookies here diff --git a/src/routes/oidc/refresh/+server.ts b/src/routes/oidc/refresh/+server.ts index f5cf1a75f3..106bd6d876 100644 --- a/src/routes/oidc/refresh/+server.ts +++ b/src/routes/oidc/refresh/+server.ts @@ -18,7 +18,7 @@ export const POST = async ({ cookies }) => { throw new Error(`Error refreshing token - user is unauthenticated.`); } - const client = auth.Client.instance; + const client = await auth.Client.instance; const tokens = await client.refresh(refreshToken); if (!tokens) { From 7e958b838d1c1d2a34733811ed4b2d8e1dc67736 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 17:28:35 -0600 Subject: [PATCH 07/22] Add OIDC nonce parameter for replay attack protection The nonce parameter binds the ID token to a specific authentication request, preventing attackers from reusing previously issued tokens. Changes: - Add generateNonce() function using crypto.randomBytes - Include nonce in authorization URL via createAuthorizationURLWithPKCE() - Store nonce in httpOnly cookie during login - Add verifyNonce() to validate ID token nonce claim on callback - Update callback check() to require nonce presence --- src/lib/server/oidc.ts | 37 ++++++++++++++++++++++-- src/routes/oidc/callback/+page.server.ts | 14 +++++++-- src/routes/oidc/login/+page.server.ts | 3 +- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index a8a2ae0caf..21a455a5e1 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -3,11 +3,20 @@ import * as env from '$env/static/private'; import type { HasuraToken, MaybeToken, Rule } from '$lib/types/oidc'; import { type Cookies, type RequestEvent } from '@sveltejs/kit'; import * as arctic from 'arctic'; +import crypto from 'crypto'; import jwt from 'jsonwebtoken'; import { JwksClient } from 'jwks-rsa'; import type { User } from '../../types/app'; import { reqHasura } from '../../utilities/requests'; +/** + * Generate a cryptographically secure nonce for OIDC. + * The nonce prevents replay attacks by binding the ID token to a specific authentication request. + */ +export function generateNonce(): string { + return crypto.randomBytes(16).toString('base64url'); +} + const DEFAULT_JWKS_CLIENT = (() => { if (env.OIDC_JWKS_URL) { return new JwksClient({ jwksUri: env.OIDC_JWKS_URL }); @@ -99,6 +108,27 @@ export async function verify( } } +/** + * Verify that the nonce in an ID token matches the expected nonce. + * This prevents replay attacks where an attacker reuses a previously issued ID token. + * + * @param idToken - The raw ID token string + * @param expectedNonce - The nonce that was sent in the authorization request + * @throws {Error} If the nonce doesn't match or is missing + */ +export function verifyNonce(idToken: string, expectedNonce: string): void { + const decoded = jwt.decode(idToken) as { nonce?: string } | null; + if (!decoded) { + throw new Error('Failed to decode ID token for nonce verification'); + } + if (!decoded.nonce) { + throw new Error('ID token is missing nonce claim'); + } + if (decoded.nonce !== expectedNonce) { + throw new Error('ID token nonce does not match expected nonce (possible replay attack)'); + } +} + /** * Client is a singleton that manages OAuth2/OIDC interactions. * @@ -168,9 +198,10 @@ export class Client { return this._initPromise; } - createAuthorizationURLWithPKCE(): { authorizationUrl: URL; state: string; verifier: string } { + createAuthorizationURLWithPKCE(): { authorizationUrl: URL; nonce: string; state: string; verifier: string } { const verifier: string = arctic.generateCodeVerifier(); const state: string = arctic.generateState(); + const nonce: string = generateNonce(); const authorizationUrl: URL = this.client.createAuthorizationURLWithPKCE( this.authorizationEndpoint, state, @@ -178,7 +209,9 @@ export class Client { verifier, this.scopes, ); - return { authorizationUrl, state, verifier }; + // Add nonce parameter for OIDC replay attack protection + authorizationUrl.searchParams.set('nonce', nonce); + return { authorizationUrl, nonce, state, verifier }; } /** diff --git a/src/routes/oidc/callback/+page.server.ts b/src/routes/oidc/callback/+page.server.ts index f331e25797..122165f85c 100644 --- a/src/routes/oidc/callback/+page.server.ts +++ b/src/routes/oidc/callback/+page.server.ts @@ -9,8 +9,9 @@ import type { PageServerLoad } from './$types'; * * 1. **State Parameter**: The state parameter is used to prevent CSRF attacks * 2. **PKCE**: The Proof Key for Code Exchange (PKCE) is used to enhance security in public clients. - * 3. **Secure Cookies**: Cookies should be set with `httpOnly`, `secure`, and `sameSite` attributes to prevent XSS and CSRF attacks. - * 4. **Validate iss, aud, and exp claims** to ensure it is issued by the expected identity provider and is not expired. + * 3. **Nonce**: The nonce parameter prevents replay attacks by binding the ID token to this specific request. + * 4. **Secure Cookies**: Cookies should be set with `httpOnly`, `secure`, and `sameSite` attributes to prevent XSS and CSRF attacks. + * 5. **Validate iss, aud, and exp claims** to ensure it is issued by the expected identity provider and is not expired. * */ @@ -21,6 +22,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const verifier = cookies.get('verifier'); const code = url.searchParams.get('code'); const expectedState = cookies.get('oidc_state'); + const expectedNonce = cookies.get('oidc_nonce'); const returnedState = url.searchParams.get('state'); const back = cookies.get('back') || '/'; @@ -29,6 +31,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { cookies.delete('verifier', { path: '/' }); cookies.delete('back', { path: '/' }); cookies.delete('oidc_state', { path: '/' }); + cookies.delete('oidc_nonce', { path: '/' }); if (!code) { const errorMsg = url.searchParams.get('error_description') || 'No code provided'; @@ -37,7 +40,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { } try { - const problems = check(verifier, code, expectedState, returnedState); + const problems = check(verifier, code, expectedState, expectedNonce, returnedState); if (problems.size > 0) { throw new Error(`Encountered the following problems with the callback state: \n${[...problems].join('\n')}`); } @@ -47,6 +50,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => { throw new Error(`Could not exchange authorization code for tokens.`); } + // Verify the nonce in the ID token matches what we sent + auth.verifyNonce(tokens.idToken(), expectedNonce as string); + const success = await auth.updateWithNewTokens(cookies, tokens); if (!success) { throw new Error(`Failed to validate token ${tokens.accessToken()}`); @@ -64,12 +70,14 @@ function check( verifier: string | undefined, code: string | null, expectedState: string | undefined, + expectedNonce: string | undefined, returnedState: string | null, ) { const problems = new Set(); void (expectedState || problems.add('Missing expected state')); void (returnedState || problems.add('Missing returned state')); void (expectedState === returnedState || problems.add('State parameter mismatch')); + void (expectedNonce || problems.add('Missing expected nonce')); void (verifier || problems.add('Missing verifier')); void (code || problems.add('Missing code')); return problems; diff --git a/src/routes/oidc/login/+page.server.ts b/src/routes/oidc/login/+page.server.ts index bc131e5262..f348f06ede 100644 --- a/src/routes/oidc/login/+page.server.ts +++ b/src/routes/oidc/login/+page.server.ts @@ -26,8 +26,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => { }); const client = await auth.Client.instance; - const { verifier, state, authorizationUrl } = client.createAuthorizationURLWithPKCE(); + const { verifier, state, nonce, authorizationUrl } = client.createAuthorizationURLWithPKCE(); cookies.set('verifier', verifier, shortLivedCookieOptions); cookies.set('oidc_state', state, shortLivedCookieOptions); + cookies.set('oidc_nonce', nonce, shortLivedCookieOptions); redirect(302, authorizationUrl.toString()); }; From 34d49f11e582e63f229e6fca71cdc5989bb17841 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 17:33:20 -0600 Subject: [PATCH 08/22] Add audience claim validation to JWT verification Validate the 'aud' claim in tokens to prevent confused deputy attacks where tokens issued for other applications could be accepted. Set OIDC_AUDIENCE env var to enable validation (recommended for production). --- src/lib/server/oidc.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index 21a455a5e1..35a085bbd6 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -25,6 +25,7 @@ const DEFAULT_JWKS_CLIENT = (() => { const DEFAULT_VERIFY_OPTS: jwt.VerifyOptions = { algorithms: ['RS256'], + audience: env.OIDC_AUDIENCE || undefined, ignoreExpiration: false, issuer: env.OIDC_ISSUER, }; From 4848409788c6d8e5d1190dd5479f0dd1921bbefe Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 18:25:13 -0600 Subject: [PATCH 09/22] Validate audience only on ID token per OIDC spec - Split JWT verification options: BASE_VERIFY_OPTS (no audience) for access tokens, ID_TOKEN_VERIFY_OPTS (with audience) for ID tokens - Access tokens are treated as opaque per OIDC spec - audience validation is the resource server's responsibility - ID tokens require audience validation to prevent confused deputy attacks - Also fixes secure cookie flag for local development (secure: !dev) --- src/lib/server/oidc.ts | 29 ++++++++++++++++++++++----- src/routes/oidc/login/+page.server.ts | 3 ++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index 35a085bbd6..c4c3eb49cf 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -23,13 +23,26 @@ const DEFAULT_JWKS_CLIENT = (() => { } })(); -const DEFAULT_VERIFY_OPTS: jwt.VerifyOptions = { +/** + * Base verification options for all tokens (signature, issuer, expiration). + * Access tokens are treated as opaque by OIDC clients - audience validation + * is only required for ID tokens per the OIDC spec. + */ +const BASE_VERIFY_OPTS: jwt.VerifyOptions = { algorithms: ['RS256'], - audience: env.OIDC_AUDIENCE || undefined, ignoreExpiration: false, issuer: env.OIDC_ISSUER, }; +/** + * ID token verification includes audience validation per OIDC spec. + * The audience must match the client ID that requested the token. + */ +const ID_TOKEN_VERIFY_OPTS: jwt.VerifyOptions = { + ...BASE_VERIFY_OPTS, + audience: env.OIDC_AUDIENCE || undefined, +}; + /** * Remove invalid tokens, refresh if appropriate, and set locals for tokens and roles. * Only invoked on page refresh. Does not execute behavior if cookies expire and page doesn't refresh (see cookieStoreListener() for that) @@ -52,8 +65,12 @@ export async function handler(event: RequestEvent): Promise { * @returns RequestEvent */ async function sanitize(evt: RequestEvent) { + // Access tokens use base verification (no audience check - treated as opaque per OIDC spec) await verify(evt.cookies.get('accessToken')).catch(_ => evt.cookies.delete('accessToken', { path: '/' })); - await verify(evt.cookies.get('idToken')).catch(_ => evt.cookies.delete('idToken', { path: '/' })); + // ID tokens require audience validation per OIDC spec + await verify(evt.cookies.get('idToken'), DEFAULT_JWKS_CLIENT, ID_TOKEN_VERIFY_OPTS).catch(_ => + evt.cookies.delete('idToken', { path: '/' }), + ); return evt; } @@ -91,7 +108,7 @@ async function refresh(evt: RequestEvent) { export async function verify( token: string | undefined, client = DEFAULT_JWKS_CLIENT, - opts: jwt.VerifyOptions = DEFAULT_VERIFY_OPTS, + opts: jwt.VerifyOptions = BASE_VERIFY_OPTS, ): Promise { if (!token) { return undefined; @@ -325,8 +342,10 @@ export async function updateWithNewTokens(cookies: Cookies, tokens: arctic.OAuth console.log('Persisting tokens following a refresh...', browser); // Check token validity. + // Access tokens use base verification (no audience check - treated as opaque per OIDC spec) const accessJwt = await verify(tokens.accessToken()); - const idJwt = await verify(tokens.idToken()); + // ID tokens require audience validation per OIDC spec + const idJwt = await verify(tokens.idToken(), DEFAULT_JWKS_CLIENT, ID_TOKEN_VERIFY_OPTS); if (accessJwt && idJwt) { cookies.set('accessToken', tokens.accessToken(), { httpOnly: false, path: '/' }); diff --git a/src/routes/oidc/login/+page.server.ts b/src/routes/oidc/login/+page.server.ts index f348f06ede..9378fdac7d 100644 --- a/src/routes/oidc/login/+page.server.ts +++ b/src/routes/oidc/login/+page.server.ts @@ -1,3 +1,4 @@ +import { dev } from '$app/environment'; import * as auth from '$lib/server/oidc'; import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; @@ -7,7 +8,7 @@ const shortLivedCookieOptions = { maxAge: 300, path: '/', sameSite: 'lax', - secure: true, + secure: !dev, // Only require secure in production (HTTPS) } as const; /** From 684a9dfe5ad6ed39f42431a13d8a80687f3190a8 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 18:26:01 -0600 Subject: [PATCH 10/22] Fix open redirect vulnerability in back parameter Validate the `back` query parameter in the OIDC login route to prevent open redirect attacks. Only allow relative paths starting with '/' but not '//' (protocol-relative URLs). Rejected examples: 'https://evil.com', '//evil.com', 'javascript:alert(1)' --- src/routes/oidc/login/+page.server.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/routes/oidc/login/+page.server.ts b/src/routes/oidc/login/+page.server.ts index 9378fdac7d..7d94fd90f1 100644 --- a/src/routes/oidc/login/+page.server.ts +++ b/src/routes/oidc/login/+page.server.ts @@ -20,7 +20,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => { // Other pages in this app may redirect to the login page with a `back` query parameter. // This allows the login page to redirect back to the original page after a successful login. // If no `back` parameter is provided, it defaults to the root path. - const back = url.searchParams.get('back') || '/'; + // + // SECURITY: Validate the back parameter to prevent open redirect attacks. + // Only allow relative paths that start with '/' but not '//' (protocol-relative URLs). + // Examples of rejected values: 'https://evil.com', '//evil.com', 'javascript:alert(1)' + const rawBack = url.searchParams.get('back') || '/'; + const back = rawBack.startsWith('/') && !rawBack.startsWith('//') ? rawBack : '/'; cookies.set('back', back, { httpOnly: true, path: '/', From 216a19bf640bed31603fc567aa7ff5aca4bd9cab Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 18:31:04 -0600 Subject: [PATCH 11/22] Add secure flag to all authentication cookies Ensures auth cookies are only transmitted over HTTPS in production. Uses `secure: !dev` pattern to allow HTTP in local development. Updated files: - oidc.ts: accessToken, idToken, refreshToken - hooks.server.ts: activeRole (OIDC and SSO handlers) - oidc/login/+page.server.ts: back cookie - auth/login/+server.ts: activeRole, user - auth/changeRole/+server.ts: activeRole --- src/hooks.server.ts | 3 +++ src/lib/server/oidc.ts | 11 +++++++---- src/routes/auth/changeRole/+server.ts | 1 + src/routes/auth/login/+server.ts | 5 +++-- src/routes/oidc/login/+page.server.ts | 1 + 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0bd154a484..5798612287 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,3 +1,4 @@ +import { dev } from '$app/environment'; import { base } from '$app/paths'; import { env } from '$env/dynamic/public'; import * as auth from '$lib/server/oidc'; @@ -67,6 +68,7 @@ const handleOIDCAuth: Handle = async ({ event, resolve }) => { event.cookies.set('activeRole', event.locals.user.defaultRole, { httpOnly: false, path: `${base}/`, + secure: !dev, }); } } else { @@ -148,6 +150,7 @@ const handleSSOAuth: Handle = async ({ event, resolve }) => { httpOnly: false, path: `${base}/`, sameSite: 'none', + secure: !dev, }; // don't overwrite existing activeRole, unless it doesn't exist anymore diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index c4c3eb49cf..fcd7eebf3d 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -1,4 +1,4 @@ -import { browser } from '$app/environment'; +import { browser, dev } from '$app/environment'; import * as env from '$env/static/private'; import type { HasuraToken, MaybeToken, Rule } from '$lib/types/oidc'; import { type Cookies, type RequestEvent } from '@sveltejs/kit'; @@ -348,9 +348,12 @@ export async function updateWithNewTokens(cookies: Cookies, tokens: arctic.OAuth const idJwt = await verify(tokens.idToken(), DEFAULT_JWKS_CLIENT, ID_TOKEN_VERIFY_OPTS); if (accessJwt && idJwt) { - cookies.set('accessToken', tokens.accessToken(), { httpOnly: false, path: '/' }); - cookies.set('idToken', tokens.idToken(), { httpOnly: false, path: '/' }); - cookies.set('refreshToken', tokens.refreshToken(), { httpOnly: true, path: '/' }); + // SECURITY: secure flag ensures cookies are only sent over HTTPS in production + // httpOnly: false for accessToken/idToken because client JS needs them for Hasura requests + // httpOnly: true for refreshToken to protect it from XSS + cookies.set('accessToken', tokens.accessToken(), { httpOnly: false, path: '/', secure: !dev }); + cookies.set('idToken', tokens.idToken(), { httpOnly: false, path: '/', secure: !dev }); + cookies.set('refreshToken', tokens.refreshToken(), { httpOnly: true, path: '/', secure: !dev }); // sort of an edge case, but if default role does change at the idp, it wouldn't hurt to update the local entry // TODO: should this be here? Where else could it go? diff --git a/src/routes/auth/changeRole/+server.ts b/src/routes/auth/changeRole/+server.ts index 66ed4b3708..541bd51d7d 100644 --- a/src/routes/auth/changeRole/+server.ts +++ b/src/routes/auth/changeRole/+server.ts @@ -1,3 +1,4 @@ +import { dev } from '$app/environment'; import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; import type { CookieSerializeOptions } from 'cookie'; diff --git a/src/routes/auth/login/+server.ts b/src/routes/auth/login/+server.ts index 77c30a521e..7405dbb211 100644 --- a/src/routes/auth/login/+server.ts +++ b/src/routes/auth/login/+server.ts @@ -1,3 +1,4 @@ +import { dev } from '$app/environment'; import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; import { jwtDecode } from 'jwt-decode'; @@ -20,8 +21,8 @@ export const POST: RequestHandler = async event => { const parsedUserToken: ParsedUserToken = jwtDecode(user.token); const defaultRole = parsedUserToken['https://hasura.io/jwt/claims']['x-hasura-default-role']; - event.cookies.set('activeRole', defaultRole, { httpOnly: false, path: '/' }); - event.cookies.set('user', userCookie, { httpOnly: false, path: '/' }); + event.cookies.set('activeRole', defaultRole, { httpOnly: false, path: '/', secure: !dev }); + event.cookies.set('user', userCookie, { httpOnly: false, path: '/', secure: !dev }); return json({ success: true, user }); } else { return json({ message, success: false }); diff --git a/src/routes/oidc/login/+page.server.ts b/src/routes/oidc/login/+page.server.ts index 7d94fd90f1..d3303102df 100644 --- a/src/routes/oidc/login/+page.server.ts +++ b/src/routes/oidc/login/+page.server.ts @@ -29,6 +29,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { cookies.set('back', back, { httpOnly: true, path: '/', + secure: !dev, }); const client = await auth.Client.instance; From 623b39e2f58562865235a5aae224ac70d550cd73 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 18:35:50 -0600 Subject: [PATCH 12/22] Add SameSite=Lax to all authentication cookies Using 'lax' instead of 'strict' because: - OIDC flow requires cookies to persist across the redirect back from Keycloak - 'lax' allows cookies on top-level GET navigations (safe for redirects) - 'lax' still blocks cross-site POST requests (CSRF protection) Note: SSO handler keeps sameSite='none' for cross-site SSO compatibility. --- src/hooks.server.ts | 1 + src/lib/server/oidc.ts | 15 +++++++++------ src/routes/auth/login/+server.ts | 4 ++-- src/routes/oidc/login/+page.server.ts | 1 + 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5798612287..a022b043bf 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -68,6 +68,7 @@ const handleOIDCAuth: Handle = async ({ event, resolve }) => { event.cookies.set('activeRole', event.locals.user.defaultRole, { httpOnly: false, path: `${base}/`, + sameSite: 'lax', secure: !dev, }); } diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index fcd7eebf3d..f72767afae 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -348,12 +348,15 @@ export async function updateWithNewTokens(cookies: Cookies, tokens: arctic.OAuth const idJwt = await verify(tokens.idToken(), DEFAULT_JWKS_CLIENT, ID_TOKEN_VERIFY_OPTS); if (accessJwt && idJwt) { - // SECURITY: secure flag ensures cookies are only sent over HTTPS in production - // httpOnly: false for accessToken/idToken because client JS needs them for Hasura requests - // httpOnly: true for refreshToken to protect it from XSS - cookies.set('accessToken', tokens.accessToken(), { httpOnly: false, path: '/', secure: !dev }); - cookies.set('idToken', tokens.idToken(), { httpOnly: false, path: '/', secure: !dev }); - cookies.set('refreshToken', tokens.refreshToken(), { httpOnly: true, path: '/', secure: !dev }); + // SECURITY: Cookie settings explained: + // - secure: only sent over HTTPS in production + // - sameSite: 'lax' allows cookies on top-level navigations (needed for OIDC redirect back) + // but blocks cross-site POST requests (CSRF protection) + // - httpOnly: false for accessToken/idToken because client JS needs them for Hasura requests + // - httpOnly: true for refreshToken to protect it from XSS + cookies.set('accessToken', tokens.accessToken(), { httpOnly: false, path: '/', sameSite: 'lax', secure: !dev }); + cookies.set('idToken', tokens.idToken(), { httpOnly: false, path: '/', sameSite: 'lax', secure: !dev }); + cookies.set('refreshToken', tokens.refreshToken(), { httpOnly: true, path: '/', sameSite: 'lax', secure: !dev }); // sort of an edge case, but if default role does change at the idp, it wouldn't hurt to update the local entry // TODO: should this be here? Where else could it go? diff --git a/src/routes/auth/login/+server.ts b/src/routes/auth/login/+server.ts index 7405dbb211..3f7aebea73 100644 --- a/src/routes/auth/login/+server.ts +++ b/src/routes/auth/login/+server.ts @@ -21,8 +21,8 @@ export const POST: RequestHandler = async event => { const parsedUserToken: ParsedUserToken = jwtDecode(user.token); const defaultRole = parsedUserToken['https://hasura.io/jwt/claims']['x-hasura-default-role']; - event.cookies.set('activeRole', defaultRole, { httpOnly: false, path: '/', secure: !dev }); - event.cookies.set('user', userCookie, { httpOnly: false, path: '/', secure: !dev }); + event.cookies.set('activeRole', defaultRole, { httpOnly: false, path: '/', sameSite: 'lax', secure: !dev }); + event.cookies.set('user', userCookie, { httpOnly: false, path: '/', sameSite: 'lax', secure: !dev }); return json({ success: true, user }); } else { return json({ message, success: false }); diff --git a/src/routes/oidc/login/+page.server.ts b/src/routes/oidc/login/+page.server.ts index d3303102df..23275f205b 100644 --- a/src/routes/oidc/login/+page.server.ts +++ b/src/routes/oidc/login/+page.server.ts @@ -29,6 +29,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { cookies.set('back', back, { httpOnly: true, path: '/', + sameSite: 'lax', secure: !dev, }); From 5148894590c84c70776bd7ed2821ba65b47e7d10 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 18:41:44 -0600 Subject: [PATCH 13/22] Add configurable JWT signing algorithms via OIDC_ALGORITHMS env var Allows operators to specify supported JWT signing algorithms via space-separated OIDC_ALGORITHMS environment variable (e.g., "RS256 RS384"). Defaults to RS256, the most common algorithm for OIDC providers. --- src/lib/server/oidc.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index f72767afae..95a9a13a2e 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -23,13 +23,17 @@ const DEFAULT_JWKS_CLIENT = (() => { } })(); +// Supported JWT signing algorithms. RS256 is the most common for OIDC. +// Can be overridden via OIDC_ALGORITHMS env var (space-separated, e.g., "RS256 RS384 RS512") +const SUPPORTED_ALGORITHMS = (env.OIDC_ALGORITHMS?.split(' ') || ['RS256']) as jwt.Algorithm[]; + /** * Base verification options for all tokens (signature, issuer, expiration). * Access tokens are treated as opaque by OIDC clients - audience validation * is only required for ID tokens per the OIDC spec. */ const BASE_VERIFY_OPTS: jwt.VerifyOptions = { - algorithms: ['RS256'], + algorithms: SUPPORTED_ALGORITHMS, ignoreExpiration: false, issuer: env.OIDC_ISSUER, }; From 10f35c9ebe8af0ed0e3dc1d4eca739c68d2cccbe Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 18:46:38 -0600 Subject: [PATCH 14/22] Remove sensitive data from OIDC logs - Remove token values from user registration logs (only log username) - Remove event object from cookie change log (contained token values) - Remove token from error messages in callback - Log only error messages, not full error objects that may contain tokens - Downgrade verbose logs to console.debug --- .env | 6 ++---- src/lib/server/oidc.ts | 6 +++--- src/lib/stores/oidc.ts | 13 ++++++++----- src/routes/oidc/callback/+page.server.ts | 7 ++++--- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.env b/.env index 061dad4a9d..73a48b431f 100644 --- a/.env +++ b/.env @@ -22,10 +22,8 @@ OIDC_LOGOUT_URL= OIDC_JWKS_URL= OIDC_SCOPES= OIDC_CLIENT_ID= - -# (likely not used, but can be in future implementations) OIDC_CLIENT_SECRET= - OIDC_REDIRECT_URI= OIDC_AUDIENCE= -OIDC_ISSUER= \ No newline at end of file +OIDC_ISSUER= +OIDC_ALGORITHMS= diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index 95a9a13a2e..c32a9fa4f1 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -337,13 +337,13 @@ async function upsertUser(decodedAccessToken: HasuraToken, accessToken: string): rolePermissions: null, token: accessToken, }; - console.log('Registering user:', user); + console.log('Registering user:', username); const result = await reqHasura(mutation, { input }, user); - console.log('Registered user: ', result); + console.log('Registered user:', username); } export async function updateWithNewTokens(cookies: Cookies, tokens: arctic.OAuth2Tokens): Promise { - console.log('Persisting tokens following a refresh...', browser); + console.debug('Persisting tokens following a refresh...'); // Check token validity. // Access tokens use base verification (no audience check - treated as opaque per OIDC spec) diff --git a/src/lib/stores/oidc.ts b/src/lib/stores/oidc.ts index 8891414e73..e116b38ba5 100644 --- a/src/lib/stores/oidc.ts +++ b/src/lib/stores/oidc.ts @@ -137,9 +137,15 @@ function reschedule(fn: () => Promise, delay: number, prior: number | null const handleCookieStoreChange = async (ev: Event) => { const event = ev as CookieChangeEvent; - console.log(`Cookie store change detected.`, event); + // Only log cookie names, never values (which may contain tokens) + console.debug( + 'Cookie store change detected:', + 'changed:', + event.changed.map(c => c.name), + 'deleted:', + event.deleted.map(c => c.name), + ); event.changed.forEach(async ({ name, value }) => { - console.log(`Cookie changed: ${name}`); if (name === 'accessToken') { // set user store const baseUser: BaseUser = { id: null, token: value }; // id can be null because any time this function is used, its in the context of oidc, and we specifically catch id being null for oidc in computeRolesFromJWT @@ -169,7 +175,4 @@ const handleCookieStoreChange = async (ev: Event) => { }); } }); - event.deleted.forEach(({ name }) => { - console.log(`Cookie deleted: ${name}`); - }); }; diff --git a/src/routes/oidc/callback/+page.server.ts b/src/routes/oidc/callback/+page.server.ts index 122165f85c..cd2d0bbf87 100644 --- a/src/routes/oidc/callback/+page.server.ts +++ b/src/routes/oidc/callback/+page.server.ts @@ -55,11 +55,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const success = await auth.updateWithNewTokens(cookies, tokens); if (!success) { - throw new Error(`Failed to validate token ${tokens.accessToken()}`); + throw new Error(`Failed to validate tokens.`); } } catch (err) { - console.error(err); - const message = `Failed to handle OIDC callback: ${err}`; + // Log error message only - avoid logging full error object which may contain tokens + console.error('OIDC callback error:', err instanceof Error ? err.message : 'Unknown error'); + const message = `Failed to handle OIDC callback: ${err instanceof Error ? err.message : 'Unknown error'}`; error(401, message); } From d9f266e4b0118a9c5279236afabf28ba2a53bea1 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 18:49:49 -0600 Subject: [PATCH 15/22] Standardize OIDC logging and fix additional sensitive data leaks - Change informational logs from console.log to console.debug for consistency - Remove response body from refresh error (may contain sensitive details) - Only log error messages, not full error objects in scheduled refresh - Remove timeout ID from log output (not useful for debugging) - Improve log message clarity ("Scheduling token refresh" vs "Delay changed") --- src/lib/server/oidc.ts | 4 ++-- src/lib/stores/oidc.ts | 23 +++++++++++------------ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index c32a9fa4f1..97c08a9256 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -337,9 +337,9 @@ async function upsertUser(decodedAccessToken: HasuraToken, accessToken: string): rolePermissions: null, token: accessToken, }; - console.log('Registering user:', username); + console.debug('Registering user:', username); const result = await reqHasura(mutation, { input }, user); - console.log('Registered user:', username); + console.debug('Registered user:', username); } export async function updateWithNewTokens(cookies: Cookies, tokens: arctic.OAuth2Tokens): Promise { diff --git a/src/lib/stores/oidc.ts b/src/lib/stores/oidc.ts index e116b38ba5..edc35ceca5 100644 --- a/src/lib/stores/oidc.ts +++ b/src/lib/stores/oidc.ts @@ -38,7 +38,7 @@ declare global { export function cookieStoreListener() { if (window && 'cookieStore' in window) { window.cookieStore.addEventListener('change', handleCookieStoreChange); - console.log('Added cookie store change listener.'); + console.debug('Added cookie store change listener.'); } else { console.error('Cookie store is not available in this environment. It is *required* for automatic refresh of JWT.'); } @@ -51,7 +51,7 @@ export function cookieStoreListener() { // when the component is unmounted. const unsubscribe = delay.subscribe(value => { if (value) { - console.log(`Delay changed to ${value}ms`); + console.debug(`Scheduling token refresh in ${value}ms`); prior = reschedule(refresh, value, prior); } }); @@ -59,7 +59,7 @@ export function cookieStoreListener() { // Return a cleanup function to remove the cookie store change listener // and unsubscribe from the delay store. return () => { - console.log('Removing cookie store change listener.'); + console.debug('Removing cookie store change listener.'); window.cookieStore.removeEventListener('change', handleCookieStoreChange); unsubscribe(); }; @@ -100,30 +100,29 @@ let prior: number | null = null; /// Private Helpers. export async function refresh(): Promise { - console.log('Refreshing tokens...'); + console.debug('Refreshing tokens...'); const res = await fetch('/oidc/refresh', { credentials: 'include', method: 'POST' }); if (res.ok) { - console.info('Access token refresh succeeded.'); + console.debug('Access token refresh succeeded.'); } else { - const errorMessage = await res.json(); + // Don't log or include response body - it may contain sensitive error details console.error('Access token refresh failed, refresh token is probably expired.'); - throw new Error(`Refresh failed, with the following message: ${JSON.stringify(errorMessage)}`); + throw new Error('Token refresh failed'); } } function reschedule(fn: () => Promise, delay: number, prior: number | null): any { if (prior) { - console.log(`Clearing previous timeout. ${prior}`); + console.debug(`Clearing previous timeout.`); clearTimeout(prior); } - console.log(`Scheduling ${fn.name} in ${delay}ms`); + console.debug(`Scheduling ${fn.name} in ${delay}ms`); return setTimeout(async () => { try { await fn(); } catch (err) { - console.error('Error in rescheduled function:', err); - - // TODO: show a modal? + // Only log error message, not full object (may contain sensitive data) + console.error('Error in scheduled refresh:', err instanceof Error ? err.message : 'Unknown error'); showFailureToast('Failed to refresh your credentials, please login again.'); } }, delay); From b4b1beb72771db84b7633f2ed59d7ff10198f761 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 18:58:02 -0600 Subject: [PATCH 16/22] Add Content Security Policy headers (report-only mode) Adds CSP and other security headers to all responses: - Content-Security-Policy-Report-Only: monitors violations without blocking - X-Content-Type-Options: nosniff - X-Frame-Options: DENY - Referrer-Policy: strict-origin-when-cross-origin CSP directives configured for: - Scripts: self + unsafe-inline + unsafe-eval (Monaco editor requires these) - Styles: self + unsafe-inline (Svelte scoped styles) - Connect: self + Hasura + Gateway + Action + Workspace URLs - Workers: self + blob (Monaco editor) - Images/Fonts: self + data + blob Change header to 'Content-Security-Policy' to enforce after testing. --- src/hooks.server.ts | 64 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index a022b043bf..0014e876f7 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -9,6 +9,59 @@ import type { ReqValidateSSOResponse } from './types/auth'; import { computeRolesFromCookies, computeRolesFromJWT } from './utilities/auth'; import { reqGatewayForwardCookies } from './utilities/requests'; +/** + * Build Content Security Policy directives. + * CSP helps prevent XSS attacks by restricting where scripts/resources can be loaded from. + */ +function buildCSPDirectives(): string { + // Extract hostnames from URLs for connect-src + const connectSources = [ + "'self'", + env.PUBLIC_HASURA_CLIENT_URL, + env.PUBLIC_HASURA_WEB_SOCKET_URL, + env.PUBLIC_GATEWAY_CLIENT_URL, + env.PUBLIC_ACTION_CLIENT_URL, + env.PUBLIC_WORKSPACE_CLIENT_URL, + ].filter(Boolean); + + return [ + "default-src 'self'", + // 'unsafe-inline' needed for Svelte's scoped styles and Monaco editor + // 'unsafe-eval' needed for Monaco editor's syntax highlighting + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + // Workers needed for Monaco editor + "worker-src 'self' blob:", + `connect-src ${connectSources.join(' ')}`, + "frame-ancestors 'none'", + "form-action 'self'", + "base-uri 'self'", + "object-src 'none'", + ].join('; '); +} + +/** + * Add security headers to response. + * Uses Report-Only mode initially to gather violations without breaking functionality. + * Change to 'Content-Security-Policy' to enforce after testing. + */ +function addSecurityHeaders(response: Response): Response { + const csp = buildCSPDirectives(); + + // Use Report-Only mode to monitor violations without blocking + // Change to 'Content-Security-Policy' to enforce after testing + response.headers.set('Content-Security-Policy-Report-Only', csp); + + // Additional security headers + response.headers.set('X-Content-Type-Options', 'nosniff'); + response.headers.set('X-Frame-Options', 'DENY'); + response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + return response; +} + export const handle: Handle = async ({ event, resolve }) => { // Ignore Chrome DevTools requests to prevent noisy 404 logs // See https://svelte.dev/docs/cli/devtools-json#Alternatives @@ -17,7 +70,8 @@ export const handle: Handle = async ({ event, resolve }) => { } if (event.url.pathname.includes('error') || event.url.pathname.includes('oidc')) { // don't want hooks running on an error page - return await resolve(event); + const response = await resolve(event); + return addSecurityHeaders(response); } if ( env.PUBLIC_AUTH_OIDC_ENABLED === 'true' && @@ -32,17 +86,17 @@ export const handle: Handle = async ({ event, resolve }) => { try { if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { - return await handleOIDCAuth({ event, resolve }); + return addSecurityHeaders(await handleOIDCAuth({ event, resolve })); } else if (env.PUBLIC_AUTH_SSO_ENABLED === 'true') { - return await handleSSOAuth({ event, resolve }); + return addSecurityHeaders(await handleSSOAuth({ event, resolve })); } else { - return await handleJWTAuth({ event, resolve }); + return addSecurityHeaders(await handleJWTAuth({ event, resolve })); } } catch (e) { event.locals.user = null; } - return await resolve(event); + return addSecurityHeaders(await resolve(event)); }; /** From da15bd843eb444a79c5eaf6472541e286622a694 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 19:19:20 -0600 Subject: [PATCH 17/22] Verify ID token before using as logout hint Add verifyIdToken() function to validate ID tokens with full OIDC-compliant checks (signature, issuer, expiration, audience) before passing to IdP. If verification fails during logout, proceed without the id_token_hint parameter rather than sending an unverified token. --- src/lib/server/oidc.ts | 11 +++++++++++ src/routes/oidc/logout/+server.ts | 19 +++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index 97c08a9256..51fe0171b0 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -130,6 +130,17 @@ export async function verify( } } +/** + * Verify an ID token with full OIDC-compliant validation (signature, issuer, expiration, audience). + * + * @param idToken - The raw ID token string to verify + * @returns The decoded JWT payload if verification is successful + * @throws {Error} If the token is invalid, expired, or fails audience validation + */ +export async function verifyIdToken(idToken: string): Promise { + return verify(idToken, DEFAULT_JWKS_CLIENT, ID_TOKEN_VERIFY_OPTS); +} + /** * Verify that the nonce in an ID token matches the expected nonce. * This prevents replay attacks where an attacker reuses a previously issued ID token. diff --git a/src/routes/oidc/logout/+server.ts b/src/routes/oidc/logout/+server.ts index 1c7f2f5d42..5ba6cdd001 100644 --- a/src/routes/oidc/logout/+server.ts +++ b/src/routes/oidc/logout/+server.ts @@ -13,7 +13,20 @@ export const GET = async ({ cookies }) => { console.debug('/oidc/logout (GET)'); const client = await auth.Client.instance; - const idToken = cookies.get('idToken') ?? ''; + const idToken = cookies.get('idToken'); + + // Verify the ID token before using it as a hint to the IdP. + // If verification fails, we still proceed with logout but without the hint. + let verifiedIdToken: string | undefined; + if (idToken) { + try { + await auth.verifyIdToken(idToken); + verifiedIdToken = idToken; + } catch { + // Token invalid or expired - proceed without hint + console.debug('ID token verification failed during logout, proceeding without id_token_hint'); + } + } // delete cookies here cookies.delete('accessToken', { path: '/' }); @@ -26,7 +39,9 @@ export const GET = async ({ cookies }) => { const logoutUrl = new URL(client.getLogoutEndpoint()); logoutUrl.searchParams.set('post_logout_redirect_uri', `${ORIGIN}`); - logoutUrl.searchParams.set('id_token_hint', idToken); + if (verifiedIdToken) { + logoutUrl.searchParams.set('id_token_hint', verifiedIdToken); + } // redirect to the logout endpoint redirect(302, logoutUrl.toString()); From 96f8604e4e00234b5465984c8ee118e65db64aa0 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Mon, 8 Dec 2025 19:27:15 -0600 Subject: [PATCH 18/22] Add configurable JWT claim paths for OIDC Add environment variables to configure JWT claim namespace and paths: - OIDC_CLAIMS_NAMESPACE (server) / PUBLIC_OIDC_CLAIMS_NAMESPACE (client) - OIDC_CLAIMS_USER_ID / PUBLIC_OIDC_CLAIMS_USER_ID - OIDC_CLAIMS_ALLOWED_ROLES / PUBLIC_OIDC_CLAIMS_ALLOWED_ROLES - OIDC_CLAIMS_DEFAULT_ROLE / PUBLIC_OIDC_CLAIMS_DEFAULT_ROLE Defaults to Hasura's standard claim structure: https://hasura.io/jwt/claims -> x-hasura-user-id, x-hasura-allowed-roles, x-hasura-default-role Add extractClaims() helper functions in both oidc.ts (server) and auth.ts (client) to centralize claim extraction with proper validation. IMPORTANT: These settings must match: - Hasura's HASURA_GRAPHQL_JWT_SECRET claims_map - Aerie Gateway's JWT parsing logic - Your IdP's token mapper configuration --- src/lib/server/oidc.ts | 94 ++++++++++++++++++++++++++------ src/routes/auth/login/+server.ts | 9 +-- src/utilities/auth.ts | 61 ++++++++++++++++++--- 3 files changed, 134 insertions(+), 30 deletions(-) diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index 51fe0171b0..285d029993 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -1,6 +1,7 @@ import { browser, dev } from '$app/environment'; +import { env as dynamicEnv } from '$env/dynamic/private'; import * as env from '$env/static/private'; -import type { HasuraToken, MaybeToken, Rule } from '$lib/types/oidc'; +import type { MaybeToken, Rule } from '$lib/types/oidc'; import { type Cookies, type RequestEvent } from '@sveltejs/kit'; import * as arctic from 'arctic'; import crypto from 'crypto'; @@ -27,6 +28,68 @@ const DEFAULT_JWKS_CLIENT = (() => { // Can be overridden via OIDC_ALGORITHMS env var (space-separated, e.g., "RS256 RS384 RS512") const SUPPORTED_ALGORITHMS = (env.OIDC_ALGORITHMS?.split(' ') || ['RS256']) as jwt.Algorithm[]; +/** + * JWT claim path configuration. + * These paths specify where to find user identity and role information in the JWT. + * + * Default paths follow Hasura's JWT claims namespace convention: + * https://hasura.io/jwt/claims -> x-hasura-user-id, x-hasura-allowed-roles, x-hasura-default-role + * + * For custom IdP configurations, override with environment variables: + * OIDC_CLAIMS_NAMESPACE: The top-level claim key (default: "https://hasura.io/jwt/claims") + * OIDC_CLAIMS_USER_ID: The user ID claim within the namespace (default: "x-hasura-user-id") + * OIDC_CLAIMS_ALLOWED_ROLES: The allowed roles claim (default: "x-hasura-allowed-roles") + * OIDC_CLAIMS_DEFAULT_ROLE: The default role claim (default: "x-hasura-default-role") + * + * IMPORTANT: These must match the JWT configuration in: + * - Hasura's HASURA_GRAPHQL_JWT_SECRET claims_map + * - Aerie Gateway's JWT parsing logic + * - Your IdP's token mapper configuration + */ +export const CLAIMS_CONFIG = { + namespace: dynamicEnv.OIDC_CLAIMS_NAMESPACE || 'https://hasura.io/jwt/claims', + userId: dynamicEnv.OIDC_CLAIMS_USER_ID || 'x-hasura-user-id', + allowedRoles: dynamicEnv.OIDC_CLAIMS_ALLOWED_ROLES || 'x-hasura-allowed-roles', + defaultRole: dynamicEnv.OIDC_CLAIMS_DEFAULT_ROLE || 'x-hasura-default-role', +}; + +/** + * Extract claims from a decoded JWT token using the configured claim paths. + * Supports nested claims via the namespace configuration. + * + * @param token - The decoded JWT payload + * @returns Object with userId, allowedRoles, and defaultRole + * @throws Error if required claims are missing + */ +export function extractClaims(token: jwt.JwtPayload): { + userId: string; + allowedRoles: string[]; + defaultRole: string; +} { + const namespace = token[CLAIMS_CONFIG.namespace]; + if (!namespace || typeof namespace !== 'object') { + throw new Error(`JWT missing claims namespace: ${CLAIMS_CONFIG.namespace}`); + } + + const userId = namespace[CLAIMS_CONFIG.userId]; + const allowedRoles = namespace[CLAIMS_CONFIG.allowedRoles]; + const defaultRole = namespace[CLAIMS_CONFIG.defaultRole]; + + if (!userId || typeof userId !== 'string') { + throw new Error(`JWT missing or invalid user ID claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.userId}`); + } + if (!Array.isArray(allowedRoles)) { + throw new Error( + `JWT missing or invalid allowed roles claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.allowedRoles}`, + ); + } + if (!defaultRole || typeof defaultRole !== 'string') { + throw new Error(`JWT missing or invalid default role claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.defaultRole}`); + } + + return { userId, allowedRoles, defaultRole }; +} + /** * Base verification options for all tokens (signature, issuer, expiration). * Access tokens are treated as opaque by OIDC clients - audience validation @@ -320,30 +383,25 @@ const mutation = `mutation InsertUser($input: users_insert_input!) { } }`; // TODO: update other user tables in permissions schema? -async function upsertUser(decodedAccessToken: HasuraToken, accessToken: string): Promise { - const username = decodedAccessToken['https://hasura.io/jwt/claims']['x-hasura-user-id']; - // const defaultRole = decodedAccessToken['https://hasura.io/jwt/claims']['x-hasura-default-role']; - const allowedRoles = decodedAccessToken['https://hasura.io/jwt/claims']['x-hasura-allowed-roles']; +async function upsertUser(decodedAccessToken: jwt.JwtPayload, accessToken: string): Promise { + const claims = extractClaims(decodedAccessToken); + const username = claims.userId; + const allowedRoles = claims.allowedRoles; - // set the active and default role manually: + // Set the active and default role based on priority (aerie_admin > user > viewer) let defaultRole = 'viewer'; - switch (true) { - case allowedRoles.includes('aerie_admin'): - defaultRole = 'aerie_admin'; - break; - case allowedRoles.includes('user'): - defaultRole = 'user'; - break; - default: - defaultRole = 'viewer'; + if (allowedRoles.includes('aerie_admin')) { + defaultRole = 'aerie_admin'; + } else if (allowedRoles.includes('user')) { + defaultRole = 'user'; } const input = { default_role: defaultRole, username }; const user: User = { - activeRole: defaultRole, // TODO: check allowed roles and pick highest. forget about default role. + activeRole: defaultRole, allowedRoles, defaultRole, - id: username, // TODO: not exactly. I think this is supposed to be decodedAccessToken.sub. but we don't even use it. + id: username, permissibleQueries: null, rolePermissions: null, token: accessToken, @@ -375,7 +433,7 @@ export async function updateWithNewTokens(cookies: Cookies, tokens: arctic.OAuth // sort of an edge case, but if default role does change at the idp, it wouldn't hurt to update the local entry // TODO: should this be here? Where else could it go? - await upsertUser(accessJwt as HasuraToken, tokens.accessToken()); + await upsertUser(accessJwt as jwt.JwtPayload, tokens.accessToken()); return true; } diff --git a/src/routes/auth/login/+server.ts b/src/routes/auth/login/+server.ts index 3f7aebea73..92375d3473 100644 --- a/src/routes/auth/login/+server.ts +++ b/src/routes/auth/login/+server.ts @@ -1,8 +1,9 @@ import { dev } from '$app/environment'; +import { extractClaims } from '$lib/server/oidc'; import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; import { jwtDecode } from 'jwt-decode'; -import type { BaseUser, ParsedUserToken } from '../../../types/app'; +import type { BaseUser } from '../../../types/app'; import type { LoginRequestBody, ReqAuthResponse } from '../../../types/auth'; import effects from '../../../utilities/effects'; @@ -18,10 +19,10 @@ export const POST: RequestHandler = async event => { const user: BaseUser = { id: username, token }; const userStr = JSON.stringify(user); const userCookie = Buffer.from(userStr).toString('base64'); - const parsedUserToken: ParsedUserToken = jwtDecode(user.token); - const defaultRole = parsedUserToken['https://hasura.io/jwt/claims']['x-hasura-default-role']; + const decodedToken = jwtDecode(user.token) as Record; + const claims = extractClaims(decodedToken); - event.cookies.set('activeRole', defaultRole, { httpOnly: false, path: '/', sameSite: 'lax', secure: !dev }); + event.cookies.set('activeRole', claims.defaultRole, { httpOnly: false, path: '/', sameSite: 'lax', secure: !dev }); event.cookies.set('user', userCookie, { httpOnly: false, path: '/', sameSite: 'lax', secure: !dev }); return json({ success: true, user }); } else { diff --git a/src/utilities/auth.ts b/src/utilities/auth.ts index 8289d565f2..cd0f3a0efd 100644 --- a/src/utilities/auth.ts +++ b/src/utilities/auth.ts @@ -1,8 +1,54 @@ import { env } from '$env/dynamic/public'; import { jwtDecode } from 'jwt-decode'; -import type { BaseUser, ParsedUserToken, User } from '../types/app'; +import type { BaseUser, User } from '../types/app'; import effects from './effects'; +/** + * JWT claim path configuration (client-side). + * Must match the server-side CLAIMS_CONFIG in oidc.ts. + * + * Uses PUBLIC_ prefixed env vars for client accessibility. + * Falls back to Hasura's standard claim namespace. + */ +const CLAIMS_CONFIG = { + namespace: env.PUBLIC_OIDC_CLAIMS_NAMESPACE || 'https://hasura.io/jwt/claims', + userId: env.PUBLIC_OIDC_CLAIMS_USER_ID || 'x-hasura-user-id', + allowedRoles: env.PUBLIC_OIDC_CLAIMS_ALLOWED_ROLES || 'x-hasura-allowed-roles', + defaultRole: env.PUBLIC_OIDC_CLAIMS_DEFAULT_ROLE || 'x-hasura-default-role', +}; + +/** + * Extract claims from a decoded JWT token using the configured claim paths. + */ +function extractClaims(token: Record): { + userId: string; + allowedRoles: string[]; + defaultRole: string; +} { + const namespace = token[CLAIMS_CONFIG.namespace] as Record | undefined; + if (!namespace || typeof namespace !== 'object') { + throw new Error(`JWT missing claims namespace: ${CLAIMS_CONFIG.namespace}`); + } + + const userId = namespace[CLAIMS_CONFIG.userId] as string; + const allowedRoles = namespace[CLAIMS_CONFIG.allowedRoles] as string[]; + const defaultRole = namespace[CLAIMS_CONFIG.defaultRole] as string; + + if (!userId || typeof userId !== 'string') { + throw new Error(`JWT missing or invalid user ID claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.userId}`); + } + if (!Array.isArray(allowedRoles)) { + throw new Error( + `JWT missing or invalid allowed roles claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.allowedRoles}`, + ); + } + if (!defaultRole || typeof defaultRole !== 'string') { + throw new Error(`JWT missing or invalid default role claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.defaultRole}`); + } + + return { userId, allowedRoles, defaultRole }; +} + export async function computeRolesFromCookies( userCookie: string | null, activeRoleCookie: string | null, @@ -37,20 +83,19 @@ export async function computeRolesFromJWT(baseUser: BaseUser, activeRole: string return null; // expect to return in non-oidc case } - const decodedToken: ParsedUserToken = jwtDecode(baseUser.token); + const decodedToken = jwtDecode(baseUser.token) as Record; + const claims = extractClaims(decodedToken); if (baseUser.id === null && env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { - // since our scope is always one that includes email, and that's also a unique id, we can use that - // BUT sub is the one that matches hasura's expected x-hasura-user-id, which is important. - baseUser.id = decodedToken.sub; + // Use the configured user ID claim, which should match Hasura's expected x-hasura-user-id + baseUser.id = claims.userId; } - const allowedRoles = decodedToken['https://hasura.io/jwt/claims']['x-hasura-allowed-roles']; - const defaultRole = decodedToken['https://hasura.io/jwt/claims']['x-hasura-default-role']; + const { allowedRoles, defaultRole } = claims; const user: User = { ...baseUser, - activeRole: activeRole && allowedRoles.includes(activeRole) ? activeRole : defaultRole, // check to make sure whatever was passed in as activeRole if not null is still in allowedRoles + activeRole: activeRole && allowedRoles.includes(activeRole) ? activeRole : defaultRole, allowedRoles, defaultRole, permissibleQueries: null, From 23c109ce84ba5645566c4cdd6b8edf78a07d97b2 Mon Sep 17 00:00:00 2001 From: Jonathan Morton Date: Wed, 10 Dec 2025 10:28:58 -0600 Subject: [PATCH 19/22] Use dynamically loaded env vars for OIDC related values set at launch / runtime. --- src/lib/server/oidc.ts | 58 ++++++++++++++++++------------- src/routes/oidc/logout/+server.ts | 4 +-- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index 285d029993..423baf3efc 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -1,6 +1,5 @@ import { browser, dev } from '$app/environment'; -import { env as dynamicEnv } from '$env/dynamic/private'; -import * as env from '$env/static/private'; +import { env } from '$env/dynamic/private'; import type { MaybeToken, Rule } from '$lib/types/oidc'; import { type Cookies, type RequestEvent } from '@sveltejs/kit'; import * as arctic from 'arctic'; @@ -18,15 +17,20 @@ export function generateNonce(): string { return crypto.randomBytes(16).toString('base64url'); } -const DEFAULT_JWKS_CLIENT = (() => { - if (env.OIDC_JWKS_URL) { - return new JwksClient({ jwksUri: env.OIDC_JWKS_URL }); +// Lazily initialized JWKS client - created on first use to allow runtime env var configuration +let _jwksClient: JwksClient | undefined; +function getJwksClient(): JwksClient | undefined { + if (!_jwksClient && env.OIDC_JWKS_URL) { + _jwksClient = new JwksClient({ jwksUri: env.OIDC_JWKS_URL }); } -})(); + return _jwksClient; +} // Supported JWT signing algorithms. RS256 is the most common for OIDC. // Can be overridden via OIDC_ALGORITHMS env var (space-separated, e.g., "RS256 RS384 RS512") -const SUPPORTED_ALGORITHMS = (env.OIDC_ALGORITHMS?.split(' ') || ['RS256']) as jwt.Algorithm[]; +function getSupportedAlgorithms(): jwt.Algorithm[] { + return (env.OIDC_ALGORITHMS?.split(' ') || ['RS256']) as jwt.Algorithm[]; +} /** * JWT claim path configuration. @@ -47,10 +51,10 @@ const SUPPORTED_ALGORITHMS = (env.OIDC_ALGORITHMS?.split(' ') || ['RS256']) as j * - Your IdP's token mapper configuration */ export const CLAIMS_CONFIG = { - namespace: dynamicEnv.OIDC_CLAIMS_NAMESPACE || 'https://hasura.io/jwt/claims', - userId: dynamicEnv.OIDC_CLAIMS_USER_ID || 'x-hasura-user-id', - allowedRoles: dynamicEnv.OIDC_CLAIMS_ALLOWED_ROLES || 'x-hasura-allowed-roles', - defaultRole: dynamicEnv.OIDC_CLAIMS_DEFAULT_ROLE || 'x-hasura-default-role', + get namespace() { return env.OIDC_CLAIMS_NAMESPACE || 'https://hasura.io/jwt/claims'; }, + get userId() { return env.OIDC_CLAIMS_USER_ID || 'x-hasura-user-id'; }, + get allowedRoles() { return env.OIDC_CLAIMS_ALLOWED_ROLES || 'x-hasura-allowed-roles'; }, + get defaultRole() { return env.OIDC_CLAIMS_DEFAULT_ROLE || 'x-hasura-default-role'; }, }; /** @@ -95,20 +99,24 @@ export function extractClaims(token: jwt.JwtPayload): { * Access tokens are treated as opaque by OIDC clients - audience validation * is only required for ID tokens per the OIDC spec. */ -const BASE_VERIFY_OPTS: jwt.VerifyOptions = { - algorithms: SUPPORTED_ALGORITHMS, - ignoreExpiration: false, - issuer: env.OIDC_ISSUER, -}; +function getBaseVerifyOpts(): jwt.VerifyOptions { + return { + algorithms: getSupportedAlgorithms(), + ignoreExpiration: false, + issuer: env.OIDC_ISSUER, + }; +} /** * ID token verification includes audience validation per OIDC spec. * The audience must match the client ID that requested the token. */ -const ID_TOKEN_VERIFY_OPTS: jwt.VerifyOptions = { - ...BASE_VERIFY_OPTS, - audience: env.OIDC_AUDIENCE || undefined, -}; +function getIdTokenVerifyOpts(): jwt.VerifyOptions { + return { + ...getBaseVerifyOpts(), + audience: env.OIDC_AUDIENCE || undefined, + }; +} /** * Remove invalid tokens, refresh if appropriate, and set locals for tokens and roles. @@ -135,7 +143,7 @@ async function sanitize(evt: RequestEvent) { // Access tokens use base verification (no audience check - treated as opaque per OIDC spec) await verify(evt.cookies.get('accessToken')).catch(_ => evt.cookies.delete('accessToken', { path: '/' })); // ID tokens require audience validation per OIDC spec - await verify(evt.cookies.get('idToken'), DEFAULT_JWKS_CLIENT, ID_TOKEN_VERIFY_OPTS).catch(_ => + await verify(evt.cookies.get('idToken'), getJwksClient(), getIdTokenVerifyOpts()).catch(_ => evt.cookies.delete('idToken', { path: '/' }), ); return evt; @@ -174,8 +182,8 @@ async function refresh(evt: RequestEvent) { */ export async function verify( token: string | undefined, - client = DEFAULT_JWKS_CLIENT, - opts: jwt.VerifyOptions = BASE_VERIFY_OPTS, + client = getJwksClient(), + opts: jwt.VerifyOptions = getBaseVerifyOpts(), ): Promise { if (!token) { return undefined; @@ -201,7 +209,7 @@ export async function verify( * @throws {Error} If the token is invalid, expired, or fails audience validation */ export async function verifyIdToken(idToken: string): Promise { - return verify(idToken, DEFAULT_JWKS_CLIENT, ID_TOKEN_VERIFY_OPTS); + return verify(idToken, getJwksClient(), getIdTokenVerifyOpts()); } /** @@ -418,7 +426,7 @@ export async function updateWithNewTokens(cookies: Cookies, tokens: arctic.OAuth // Access tokens use base verification (no audience check - treated as opaque per OIDC spec) const accessJwt = await verify(tokens.accessToken()); // ID tokens require audience validation per OIDC spec - const idJwt = await verify(tokens.idToken(), DEFAULT_JWKS_CLIENT, ID_TOKEN_VERIFY_OPTS); + const idJwt = await verify(tokens.idToken(), getJwksClient(), getIdTokenVerifyOpts()); if (accessJwt && idJwt) { // SECURITY: Cookie settings explained: diff --git a/src/routes/oidc/logout/+server.ts b/src/routes/oidc/logout/+server.ts index 5ba6cdd001..ff144a2331 100644 --- a/src/routes/oidc/logout/+server.ts +++ b/src/routes/oidc/logout/+server.ts @@ -1,4 +1,4 @@ -import { ORIGIN } from '$env/static/private'; +import { env } from '$env/dynamic/private'; import * as auth from '$lib/server/oidc'; import { redirect } from '@sveltejs/kit'; @@ -38,7 +38,7 @@ export const GET = async ({ cookies }) => { // redirect browser to logout page (SSO session destroy) const logoutUrl = new URL(client.getLogoutEndpoint()); - logoutUrl.searchParams.set('post_logout_redirect_uri', `${ORIGIN}`); + logoutUrl.searchParams.set('post_logout_redirect_uri', `${env.ORIGIN}`); if (verifiedIdToken) { logoutUrl.searchParams.set('id_token_hint', verifiedIdToken); } From 6bf72602a1fa45125a848e53d50719d25783bff5 Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Tue, 17 Feb 2026 14:11:10 -0800 Subject: [PATCH 20/22] Fix WebSocket lifecycle and token refresh for OIDC Key changes: - Add proactive WebSocket restart on token refresh (Hasura monitors JWT expiration and kills connections) - Implement hybrid auto-recovery for subscription errors (connectionState listener + fallback timer) - Add token refresh retry on failure (offline resilience) - Fix HMR resilience for cookie store listeners - Prevent token refresh on login page - Fix logout to use window.location.href instead of goto() for server-only routes - Remove noisy auth error logging during normal logout flow - Add getCookieValue utility for reading cookies --- src/lib/stores/oidc.ts | 136 ++++++++++++++++++++++------------- src/routes/+layout.server.ts | 3 +- src/routes/+layout.svelte | 43 +++++++++-- src/stores/gqlClient.ts | 100 +++++++++++++++++++------- src/stores/subscribable.ts | 100 +++++++++++++++++++++++--- src/utilities/browser.ts | 11 +++ src/utilities/login.ts | 2 +- 7 files changed, 298 insertions(+), 97 deletions(-) diff --git a/src/lib/stores/oidc.ts b/src/lib/stores/oidc.ts index edc35ceca5..f571a96f95 100644 --- a/src/lib/stores/oidc.ts +++ b/src/lib/stores/oidc.ts @@ -1,10 +1,8 @@ import { jwtDecode } from 'jwt-decode'; -import { derived, get, type Readable } from 'svelte/store'; -import type { BaseUser, User } from '../../types/app'; -import { computeRolesFromJWT } from '../../utilities/auth'; -import { showFailureToast } from '../../utilities/toast'; +import { derived, get, writable, type Readable } from 'svelte/store'; +import { restartSharedClient } from '../../stores/gqlClient'; +import { getCookieValue } from '../../utilities/browser'; import type { MaybeToken } from '../types/oidc'; -import { userStore } from './auth'; type CookieChanged = { domain: string; @@ -31,10 +29,19 @@ type CookieStore = { declare global { interface Window { cookieStore: CookieStore; - addEventListener(type: string, listener: (this: Window, ev: CookieChangeEvent) => void, useCapture?: boolean): void; } } +// Store for the current access token (read from cookie) +// Used only for computing refresh timing, not for user state +const accessToken = writable(null); + +// Initialize from cookie on load +const initialToken = getCookieValue('accessToken'); +if (initialToken) { + accessToken.set(initialToken); +} + export function cookieStoreListener() { if (window && 'cookieStore' in window) { window.cookieStore.addEventListener('change', handleCookieStoreChange); @@ -43,12 +50,9 @@ export function cookieStoreListener() { console.error('Cookie store is not available in this environment. It is *required* for automatic refresh of JWT.'); } - // Delay is a `derived` value, ultimately from the user store... (see below). + // Delay is a `derived` value from the access token. // Whenever the delay changes, any prior timeout is cancelled and a new timeout // is created (using the new value of delay). - // - // We track an unsubscribe function to remove the cookie store change listener - // when the component is unmounted. const unsubscribe = delay.subscribe(value => { if (value) { console.debug(`Scheduling token refresh in ${value}ms`); @@ -58,18 +62,32 @@ export function cookieStoreListener() { // Return a cleanup function to remove the cookie store change listener // and unsubscribe from the delay store. - return () => { + const cleanup = () => { console.debug('Removing cookie store change listener.'); - window.cookieStore.removeEventListener('change', handleCookieStoreChange); + if ('cookieStore' in window) { + window.cookieStore.removeEventListener('change', handleCookieStoreChange); + } unsubscribe(); + if (prior) { + clearTimeout(prior); + prior = null; + } }; + + // Store on window so HMR module re-evaluation can find and clean up the old listener + (window as any).__oidcCookieCleanup = cleanup; + + return cleanup; } -// The decoded access token contains a timestamp that indicates when -// it will expire. -export const accessTokenDecoded: Readable = derived(userStore, $userStore => { - if ($userStore && $userStore.token) { - return jwtDecode($userStore.token) as MaybeToken; +// The decoded access token contains a timestamp that indicates when it will expire. +export const accessTokenDecoded: Readable = derived(accessToken, $accessToken => { + if ($accessToken) { + try { + return jwtDecode($accessToken) as MaybeToken; + } catch { + return null; + } } return null; }); @@ -111,10 +129,10 @@ export async function refresh(): Promise { } } -function reschedule(fn: () => Promise, delay: number, prior: number | null): any { - if (prior) { +function reschedule(fn: () => Promise, delay: number, previousTimeout: number | null): any { + if (previousTimeout) { console.debug(`Clearing previous timeout.`); - clearTimeout(prior); + clearTimeout(previousTimeout); } console.debug(`Scheduling ${fn.name} in ${delay}ms`); return setTimeout(async () => { @@ -123,7 +141,10 @@ function reschedule(fn: () => Promise, delay: number, prior: number | null } catch (err) { // Only log error message, not full object (may contain sensitive data) console.error('Error in scheduled refresh:', err instanceof Error ? err.message : 'Unknown error'); - showFailureToast('Failed to refresh your credentials, please login again.'); + // Retry after 5 seconds — network may have been temporarily unavailable. + // When it succeeds, the cookie update triggers the normal delay-based scheduling. + console.debug('Scheduling token refresh retry in 5000ms'); + prior = reschedule(fn, 5000, prior); } }, delay); } @@ -131,7 +152,12 @@ function reschedule(fn: () => Promise, delay: number, prior: number | null /** * Handles changes and deletions to the cookie store. * - * @param event: CookieChangeEvent - The event containing the changed or deleted cookies. + * Token refresh: Updates accessToken store, dispatches event to update user store, + * and restarts WebSocket. While Hasura validates JWT at connection_init, it also + * monitors expiration and kills connections when tokens expire. + * + * Role change: Handled by Nav.svelte → /auth/changeRole → user store update → + * +layout.svelte reactive block → WebSocket restart. */ const handleCookieStoreChange = async (ev: Event) => { const event = ev as CookieChangeEvent; @@ -144,34 +170,46 @@ const handleCookieStoreChange = async (ev: Event) => { 'deleted:', event.deleted.map(c => c.name), ); - event.changed.forEach(async ({ name, value }) => { + + let tokenRefreshed = false; + + event.changed.forEach(({ name, value }) => { if (name === 'accessToken') { - // set user store - const baseUser: BaseUser = { id: null, token: value }; // id can be null because any time this function is used, its in the context of oidc, and we specifically catch id being null for oidc in computeRolesFromJWT - const user: User | null = await computeRolesFromJWT(baseUser, null); // null role because if after a refresh a user has been demoted, wouldn't want to retain an invalid role - userStore.set(user); - } - if (name === 'idToken') { - const decoded = jwtDecode(value); - // update user store - userStore.update(user => { - if (user && decoded.sub) { - return { - ...user, - id: decoded.sub, - }; - } - return user; - }); - } - if (name === 'activeRole') { - // update the user store - userStore.update(user => { - if (user) { - user.activeRole = value; - } - return user; - }); + // Update internal store for refresh timing + accessToken.set(value); + tokenRefreshed = true; + + // Dispatch event so the layout can update the user store with the fresh token + window.dispatchEvent(new CustomEvent('oidc-token-refreshed', { detail: { token: value } })); } + // Note: activeRole changes are handled by Nav.svelte which updates the user store + // directly after receiving the updated user from the server. The +layout.svelte + // reactive statement then detects the role change and restarts the WebSocket. }); + + if (tokenRefreshed) { + // Restart WebSocket to pick up new credentials. While Hasura validates JWT only + // at connection_init, it ALSO monitors token expiration and closes connections + // when JWTs expire (observed in Hasura logs: "Could not verify JWT: JWTExpired"). + // Restarting proactively with the fresh token prevents this abrupt 1006 close. + console.debug('Token refreshed, restarting WebSocket with fresh credentials.'); + restartSharedClient(); + } }; + +// HMR resilience: when this module is re-evaluated during HMR, clean up the old listener +// (which references stale handleCookieStoreChange closure) and immediately re-establish +// with fresh module references. This keeps token refresh working during HMR. +// Only re-establish if there's a valid accessToken (user is authenticated). +if (typeof window !== 'undefined') { + const prevCleanup = (window as any).__oidcCookieCleanup as (() => void) | undefined; + if (prevCleanup) { + console.debug('HMR: cleaning up old OIDC listeners.'); + prevCleanup(); + // Only re-establish listener if we have a valid token (user is authenticated) + if (getCookieValue('accessToken')) { + console.debug('HMR: re-establishing OIDC listeners with fresh module references.'); + cookieStoreListener(); + } + } +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 314cc97588..d9a940ab5a 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -14,8 +14,7 @@ export const load: LayoutServerLoad = async ({ locals, url }) => { if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true' && !nonProtectedPage) { try { enforce(locals?.user, userIsDefined); - } catch (error) { - console.log(error); + } catch { const redirectTo = encodeURIComponent(url.pathname + url.search); redirect(302, `${base}/login?redirectTo=${redirectTo}`); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2d6df06862..7886079b37 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -16,6 +16,7 @@ import { disposeSharedClient, restartSharedClient } from '../stores/gqlClient'; import { plugins, pluginsError, pluginsLoaded } from '../stores/plugins'; import type { UserStore } from '../types/app'; + import { getCookieValue } from '../utilities/browser'; import { loadPluginCode } from '../utilities/plugins'; import type { LayoutData } from './$types'; @@ -29,23 +30,42 @@ $pluginsLoaded = pluginsEnabled ? false : true; $: { - user.set(data.user || null); + let userData = data.user ? { ...data.user } : null; + // In OIDC mode, data.user may be stale after HMR (token expired, role changed). + // Replace with current cookie values. + if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true' && userData) { + const freshToken = getCookieValue('accessToken'); + if (freshToken) { + userData = { ...userData, token: freshToken }; + } + const activeRole = getCookieValue('activeRole'); + if (activeRole) { + userData = { ...userData, activeRole }; + } + } + user.set(userData); } // Only restart WebSocket when role actually changes, not on every navigation // graphql-ws automatically re-subscribes all active subscriptions when reconnected $: { const newRole = $user?.activeRole ?? null; - if (newRole !== previousRole && previousRole !== null) { + // Only restart when role actually changes, not on initial load + if (previousRole !== null && newRole !== previousRole) { restartSharedClient(); } previousRole = newRole; } onMount(() => { - let unsubscribe = () => {}; - if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { - unsubscribe = cookieStoreListener(); + const onTokenRefreshed = (e: Event) => { + const { token } = (e as CustomEvent<{ token: string }>).detail; + user.update(u => (u ? { ...u, token } : u)); + }; + + if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true' && $user) { + cookieStoreListener(); + window.addEventListener('oidc-token-refreshed', onTokenRefreshed); } if (pluginsEnabled && !$pluginsLoaded) { @@ -53,10 +73,19 @@ } return () => { - unsubscribe(); + // Use the window-stored cleanup which always targets the current listener, + // even if HMR re-established it with fresh module references. + const oidcCleanup = (window as any).__oidcCookieCleanup as (() => void) | undefined; + oidcCleanup?.(); + window.removeEventListener('oidc-token-refreshed', onTokenRefreshed); console.log('Unsubscribed from cookie store changes.'); - disposeSharedClient(); + // Skip disposing the WebSocket client during HMR - the client should persist + // across layout re-mounts so restartSharedClient() can still manage it. + // On full page unload, the browser closes the WebSocket automatically. + if (!import.meta.hot) { + disposeSharedClient(); + } }; }); diff --git a/src/stores/gqlClient.ts b/src/stores/gqlClient.ts index 5e6decd11e..548ccf1e15 100644 --- a/src/stores/gqlClient.ts +++ b/src/stores/gqlClient.ts @@ -3,6 +3,7 @@ import { env } from '$env/dynamic/public'; import { createClient, type Client, type ClientOptions } from 'graphql-ws'; import { writable, type Readable } from 'svelte/store'; import type { BaseUser } from '../types/app'; +import { getCookieValue } from '../utilities/browser'; import { logout } from '../utilities/login'; import { EXPIRED_JWT } from '../utilities/permissions'; @@ -73,26 +74,30 @@ let subscriptionCounter = 0; let pendingQueryName: string | null = null; /** - * Helper that parses a user cookie to get a token. + * Helper that reads auth token from cookies. + * Supports both OIDC format (direct accessToken cookie) and + * standard JWT format (base64-encoded user cookie containing token). */ -function getTokenFromUserCookie(): string { - if (browser && document?.cookie) { - const cookies = document.cookie.split(/\s*;\s*/); - const userCookie = cookies.find(entry => entry.startsWith('user=')); - if (userCookie) { - try { - const splitCookie = userCookie.split('user=')[1]; - const decodedUserCookie = atob(decodeURIComponent(splitCookie)); - const parsedUserCookie: BaseUser = JSON.parse(decodedUserCookie); - return parsedUserCookie.token; - } catch (e) { - console.log(e); - return ''; - } - } else { - console.log(`No 'user' cookie found`); +function getToken(): string { + // OIDC format: direct accessToken cookie + const accessToken = getCookieValue('accessToken'); + if (accessToken) { + return accessToken; + } + + // Standard JWT/SSO format: base64-encoded user cookie containing token + const userCookie = getCookieValue('user'); + if (userCookie) { + try { + const decodedUserCookie = atob(decodeURIComponent(userCookie)); + const parsedUserCookie: BaseUser = JSON.parse(decodedUserCookie); + return parsedUserCookie.token; + } catch (e) { + console.log('Error parsing user cookie:', e); + return ''; } } + return ''; } @@ -100,15 +105,11 @@ function getTokenFromUserCookie(): string { * Helper that parses a role cookie. */ function getRoleFromCookie(): string { - if (browser && document?.cookie) { - const cookies = document.cookie.split(/\s*;\s*/); - const roleCookie = cookies.find(entry => entry.startsWith('activeRole=')); - if (roleCookie) { - return roleCookie.split('activeRole=')[1]; - } else { - console.log(`No 'role' cookie found`); - } + const role = getCookieValue('activeRole'); + if (role) { + return role; } + console.log(`No 'role' cookie found`); return ''; } @@ -116,12 +117,15 @@ function getRoleFromCookie(): string { * Creates the shared graphql-ws client with configured options. */ function createSharedClient(): Client { + // Capture reference so event handlers can detect if this client was replaced/disposed. + // When disposeSharedClient() sets client = null (or a new client is created), + // the old client's async close event won't corrupt shared state. const clientOptions: ClientOptions = { // connectionParams is a function so it gets fresh token/role on each reconnect connectionParams: () => { return { headers: { - Authorization: `Bearer ${getTokenFromUserCookie()}`, + Authorization: `Bearer ${getToken()}`, 'x-hasura-role': getRoleFromCookie(), }, }; @@ -134,6 +138,10 @@ function createSharedClient(): Client { }, on: { closed: (event: unknown) => { + // Ignore events from a disposed/replaced client + if (newClient !== client) { + return; + } activeSocket = null; // Update state to reconnecting (graphql-ws will auto-retry) connectionStateStore.set('reconnecting'); @@ -148,6 +156,9 @@ function createSharedClient(): Client { } }, connected: (socket: unknown) => { + if (newClient !== client) { + return; + } activeSocket = socket as WebSocket; connectionStateStore.set('connected'); // Handle pending restart request @@ -157,6 +168,9 @@ function createSharedClient(): Client { } }, connecting: () => { + if (newClient !== client) { + return; + } // Only set 'connecting' if we're not already reconnecting // (reconnecting state should persist until connected) if (currentConnectionState !== 'reconnecting') { @@ -164,6 +178,9 @@ function createSharedClient(): Client { } }, error: (err: unknown) => { + if (newClient !== client) { + return; + } console.error('WebSocket connection error', err); // Check for JWT expiration in error if (err && typeof err === 'object' && 'message' in err) { @@ -197,7 +214,11 @@ function createSharedClient(): Client { url: env.PUBLIC_HASURA_WEB_SOCKET_URL, }; - return createClient(clientOptions); + // newClient is referenced in the `on` handler closures above. + // Those closures only execute asynchronously (on WebSocket events), + // so newClient is guaranteed to be assigned by the time they run. + const newClient = createClient(clientOptions); + return newClient; } /** @@ -325,3 +346,28 @@ export function disposeSharedClient(): void { connectionStateStore.set('disconnected'); } } + +// HMR resilience: when this module is re-evaluated during HMR, the module-level +// `client` resets to null but the old WebSocket client is still connected with +// active subscriptions. Save state to window on connection changes (which follow +// activeSocket updates in event handlers) and restore on re-evaluation. +if (browser) { + const prev = (window as any).__gqlClientHmr as + | { activeSocket: WebSocket | null; client: Client; refCount: number } + | undefined; + if (prev?.client) { + console.debug('HMR: restoring shared GraphQL client reference.'); + client = prev.client; + activeSocket = prev.activeSocket; + refCount = prev.refCount; + connectionStateStore.set('connected'); + } + + connectionStateStore.subscribe(() => { + if (client) { + (window as any).__gqlClientHmr = { activeSocket, client, refCount }; + } else { + delete (window as any).__gqlClientHmr; + } + }); +} diff --git a/src/stores/subscribable.ts b/src/stores/subscribable.ts index da2b715572..8dd163c596 100644 --- a/src/stores/subscribable.ts +++ b/src/stores/subscribable.ts @@ -2,10 +2,10 @@ import { browser } from '$app/environment'; import { debounce, isEqual } from 'lodash-es'; import { type Readable, type Subscriber, type Unsubscriber, type Updater } from 'svelte/store'; import type { GqlSubscribable, NextValue, QueryVariables, Subscription } from '../types/subscribable'; -import { logout } from '../utilities/login'; import { EXPIRED_JWT } from '../utilities/permissions'; import { clearPendingQueryName, + connectionState, getSharedClient, registerSubscription, restartSharedClient, @@ -31,6 +31,8 @@ export function gqlSubscribable( let variables: QueryVariables | null = initialVariables; let loading: boolean = true; let error: string = ''; + let recoveryTimeout: ReturnType | null = null; + let recoveryStateUnsub: (() => void) | null = null; // Subscribers for the _loading and _error stores const loadingSubscribers: Set> = new Set(); @@ -86,6 +88,16 @@ export function gqlSubscribable( function clientSubscribe() { const client = getSharedClient(); if (browser && client && subscriptionActive) { + // Cancel any pending error recovery since we're resubscribing now + if (recoveryTimeout) { + clearTimeout(recoveryTimeout); + recoveryTimeout = null; + } + if (recoveryStateUnsub) { + recoveryStateUnsub(); + recoveryStateUnsub = null; + } + // Clean up any existing subscription before creating new one if (subscriptionCleanup) { subscriptionCleanup(); @@ -107,19 +119,75 @@ export function gqlSubscribable( // Subscription completed normally }, error: async (err: Error | CloseEvent) => { - console.error('Socket subscribe error', err); - + // Auth-related close events (expired JWT, 4401/4403) are handled by + // gqlClient.ts's on.closed handler, which has proper guards for HMR + // and connection lifecycle. Don't logout here — just report the error + // and let graphql-ws retry with fresh credentials from cookies. + let newError: string; + let isConnectionError = false; if ('reason' in err && err.reason.includes(EXPIRED_JWT)) { - await logout(EXPIRED_JWT); + newError = 'Session credentials expired'; + isConnectionError = true; + } else if (Array.isArray(err)) { + // GraphQL server errors (e.g., permission denied) — don't auto-recover + newError = err.map(e => e.message ?? 'Unknown socket error').join(', '); + } else if ('message' in err) { + newError = err.message; + isConnectionError = true; } else { - let newError: string; - if (Array.isArray(err)) { - newError = err.map(e => e.message ?? 'Unknown socket error').join(', '); - } else if ('message' in err) { - newError = err.message; - } else { - newError = 'Unknown socket error'; + newError = 'Unknown socket error'; + isConnectionError = true; + } + // Auto-recover from connection-level errors silently (keep stale data). + // Server errors (permission denied, etc.) are surfaced to the UI. + if (isConnectionError && subscriptionActive && subscribers.size > 0) { + // Clean up any prior recovery + if (recoveryStateUnsub) { + recoveryStateUnsub(); + recoveryStateUnsub = null; + } + if (recoveryTimeout) { + clearTimeout(recoveryTimeout); + recoveryTimeout = null; } + + // When graphql-ws fires the error callback, the subscription is terminated. + // Use two recovery strategies: + // 1. connectionState listener - fast recovery if graphql-ws reconnects + // 2. Fallback timer - kick graphql-ws out of lazy mode if needed + let skipFirst = true; + recoveryStateUnsub = connectionState.subscribe(state => { + if (skipFirst) { + skipFirst = false; + return; + } + if (state === 'connected') { + if (recoveryTimeout) { + clearTimeout(recoveryTimeout); + recoveryTimeout = null; + } + if (recoveryStateUnsub) { + recoveryStateUnsub(); + recoveryStateUnsub = null; + } + if (subscriptionActive && subscribers.size > 0) { + resubscribe(); + } + } + }); + + recoveryTimeout = setTimeout(() => { + recoveryTimeout = null; + if (recoveryStateUnsub) { + recoveryStateUnsub(); + recoveryStateUnsub = null; + } + if (subscriptionActive && subscribers.size > 0) { + resubscribe(); + } + }, 5000); + } else { + // Non-recoverable error (e.g., GraphQL server error) — surface to UI setError(newError); subscribers.forEach(({ next }) => { next(initialValue as T); @@ -243,6 +311,16 @@ export function gqlSubscribable( if (subscribers.size === 0 && subscriptionActive) { subscriptionActive = false; + // Cancel any pending error recovery + if (recoveryTimeout) { + clearTimeout(recoveryTimeout); + recoveryTimeout = null; + } + if (recoveryStateUnsub) { + recoveryStateUnsub(); + recoveryStateUnsub = null; + } + // Capture cleanup function before it might be reassigned const cleanup = subscriptionCleanup; const varUnsubs = [...variableUnsubscribers]; diff --git a/src/utilities/browser.ts b/src/utilities/browser.ts index 2604e0e96e..acdf02eb62 100644 --- a/src/utilities/browser.ts +++ b/src/utilities/browser.ts @@ -1,5 +1,16 @@ import { browser } from '$app/environment'; +/** + * Reads a cookie value by name. Returns null if not found or not in a browser. + */ +export function getCookieValue(name: string): string | null { + if (!browser || !document?.cookie) { + return null; + } + const cookie = document.cookie.split(/\s*;\s*/).find(entry => entry.startsWith(`${name}=`)); + return cookie ? cookie.split('=')[1] : null; +} + /** * Returns true if the current browser is running on MacOS */ diff --git a/src/utilities/login.ts b/src/utilities/login.ts index 87dec65a53..6f0be9fa57 100644 --- a/src/utilities/login.ts +++ b/src/utilities/login.ts @@ -12,7 +12,7 @@ export function shouldRedirectToLogin(user: User | null) { export async function logout(reason?: string) { if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { if (browser) { - await goto(`${base}/oidc/logout`); + window.location.href = `${base}/oidc/logout`; } else { console.error( `Logout triggered from server. NOTE - this is exceptional behavior and this logout handling exists to avoid a crash. Cited reason: ${reason}:`, From 7e56f90d623e2d1cd37fb6f829925486fe4b93ac Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Tue, 17 Feb 2026 14:15:46 -0800 Subject: [PATCH 21/22] Linting, fixes, and small cleanup --- src/lib/server/oidc.ts | 28 +++++++++++++------ src/lib/stores/auth.ts | 7 ----- src/routes/+layout.ts | 11 -------- src/routes/auth/changeRole/+server.ts | 4 +-- src/routes/login/+page.svelte | 10 +++---- .../[workspaceId]/actions/+page.svelte | 6 ++-- src/utilities/requests.ts | 14 ++++++++-- 7 files changed, 41 insertions(+), 39 deletions(-) delete mode 100644 src/lib/stores/auth.ts diff --git a/src/lib/server/oidc.ts b/src/lib/server/oidc.ts index 423baf3efc..b9f4ec078d 100644 --- a/src/lib/server/oidc.ts +++ b/src/lib/server/oidc.ts @@ -1,4 +1,4 @@ -import { browser, dev } from '$app/environment'; +import { dev } from '$app/environment'; import { env } from '$env/dynamic/private'; import type { MaybeToken, Rule } from '$lib/types/oidc'; import { type Cookies, type RequestEvent } from '@sveltejs/kit'; @@ -51,10 +51,18 @@ function getSupportedAlgorithms(): jwt.Algorithm[] { * - Your IdP's token mapper configuration */ export const CLAIMS_CONFIG = { - get namespace() { return env.OIDC_CLAIMS_NAMESPACE || 'https://hasura.io/jwt/claims'; }, - get userId() { return env.OIDC_CLAIMS_USER_ID || 'x-hasura-user-id'; }, - get allowedRoles() { return env.OIDC_CLAIMS_ALLOWED_ROLES || 'x-hasura-allowed-roles'; }, - get defaultRole() { return env.OIDC_CLAIMS_DEFAULT_ROLE || 'x-hasura-default-role'; }, + get allowedRoles() { + return env.OIDC_CLAIMS_ALLOWED_ROLES || 'x-hasura-allowed-roles'; + }, + get defaultRole() { + return env.OIDC_CLAIMS_DEFAULT_ROLE || 'x-hasura-default-role'; + }, + get namespace() { + return env.OIDC_CLAIMS_NAMESPACE || 'https://hasura.io/jwt/claims'; + }, + get userId() { + return env.OIDC_CLAIMS_USER_ID || 'x-hasura-user-id'; + }, }; /** @@ -66,9 +74,9 @@ export const CLAIMS_CONFIG = { * @throws Error if required claims are missing */ export function extractClaims(token: jwt.JwtPayload): { - userId: string; allowedRoles: string[]; defaultRole: string; + userId: string; } { const namespace = token[CLAIMS_CONFIG.namespace]; if (!namespace || typeof namespace !== 'object') { @@ -88,10 +96,12 @@ export function extractClaims(token: jwt.JwtPayload): { ); } if (!defaultRole || typeof defaultRole !== 'string') { - throw new Error(`JWT missing or invalid default role claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.defaultRole}`); + throw new Error( + `JWT missing or invalid default role claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.defaultRole}`, + ); } - return { userId, allowedRoles, defaultRole }; + return { allowedRoles, defaultRole, userId }; } /** @@ -240,8 +250,8 @@ export function verifyNonce(idToken: string, expectedNonce: string): void { * */ export class Client { - private static _instance: Client; private static _initPromise: Promise; + private static _instance: Client; private authorizationEndpoint!: string; private client!: arctic.OAuth2Client; diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts deleted file mode 100644 index 9c0e9796f0..0000000000 --- a/src/lib/stores/auth.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type Client } from 'graphql-ws'; -import { writable, type Writable } from 'svelte/store'; -import type { User } from '../../types/app'; - -// no need for id token because that's only used serverside. no need for activeRole cookie because that's part of the user...we just need to make sure that when it is updated, it is reflected in user store -export const userStore: Writable = writable(); -export const gqlWsClient: Writable = writable(); // TODO: add more robust handling for if this is null diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index c6f1b50852..35a9129fca 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,17 +1,6 @@ import '../css/app.css'; import type { LayoutLoad } from './$types'; -import { browser } from '$app/environment'; -import { createClient } from 'graphql-ws'; -import { gqlWsClient, userStore } from '../lib/stores/auth'; -import { getClientOptions } from '../stores/subscribable'; - export const load: LayoutLoad = async ({ data }) => { - if (browser) { - userStore.set(data.user); - gqlWsClient.set(createClient(getClientOptions())); - } - - // no PageData should be used client-side. but if it is accessed in other +page.ts or +layout.ts files, that should be okay, for SSR purposes. but anywhere on client, userStore should be used. return { ...data }; }; diff --git a/src/routes/auth/changeRole/+server.ts b/src/routes/auth/changeRole/+server.ts index 541bd51d7d..97f4cccdcc 100644 --- a/src/routes/auth/changeRole/+server.ts +++ b/src/routes/auth/changeRole/+server.ts @@ -1,9 +1,9 @@ -import { dev } from '$app/environment'; +import { base } from '$app/paths'; import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; import type { CookieSerializeOptions } from 'cookie'; -import { computeRolesFromJWT } from '../../../hooks.server'; import type { ChangeUserRoleRequestBody } from '../../../types/auth'; +import { computeRolesFromJWT } from '../../../utilities/auth'; export const POST: RequestHandler = async event => { const body: ChangeUserRoleRequestBody = await event.request.json(); diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 0865c8205f..d17c5c45c9 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -8,11 +8,13 @@ import { Button, Input, Label } from '@nasa-jpl/stellar-svelte'; import AlertError from '../../components/ui/AlertError.svelte'; import { SearchParameters } from '../../enums/searchParameters'; - import { userStore } from '../../lib/stores/auth'; + import { getUserStore } from '../../stores/user'; import type { LoginResponseBody } from '../../types/auth'; import { EXPIRED_JWT, hasNoAuthorization } from '../../utilities/permissions'; import { removeQueryParam } from '../../utilities/url'; + const user = getUserStore(); + let error: string | null = null; let fullError: string | null = null; let loginButtonText = 'Login'; @@ -20,7 +22,7 @@ let reason = $page.url.searchParams.get(SearchParameters.REASON); let username = ''; - $: if ($userStore?.permissibleQueries && hasNoAuthorization($userStore)) { + $: if ($user?.permissibleQueries && hasNoAuthorization($user)) { error = 'You are not authorized'; fullError = 'You are not authorized to access the page that you attempted to view. Please contact a tool administrator to request access.'; @@ -85,9 +87,7 @@ {#if isOidcEnabled()}
-
- -
+
{:else}
diff --git a/src/routes/workspaces/[workspaceId]/actions/+page.svelte b/src/routes/workspaces/[workspaceId]/actions/+page.svelte index 86fd178150..8627af1415 100644 --- a/src/routes/workspaces/[workspaceId]/actions/+page.svelte +++ b/src/routes/workspaces/[workspaceId]/actions/+page.svelte @@ -3,9 +3,11 @@