diff --git a/.env b/.env index 04a0310d75..73a48b431f 100644 --- a/.env +++ b/.env @@ -13,3 +13,17 @@ 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= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI= +OIDC_AUDIENCE= +OIDC_ISSUER= +OIDC_ALGORITHMS= 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/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/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', diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 6bd34c88a0..0014e876f7 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,28 +1,132 @@ +import { dev } from '$app/environment'; 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'; +/** + * 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 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 + const response = await resolve(event); + return addSecurityHeaders(response); + } + 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') { - return await handleSSOAuth({ event, resolve }); + if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { + return addSecurityHeaders(await handleOIDCAuth({ event, resolve })); + } else if (env.PUBLIC_AUTH_SSO_ENABLED === 'true') { + return addSecurityHeaders(await handleSSOAuth({ event, resolve })); } else { - return await handleJWTAuth({ event, resolve }); + return addSecurityHeaders(await handleJWTAuth({ event, resolve })); } } catch (e) { - console.log(e); + event.locals.user = null; + } + + return addSecurityHeaders(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}/`, + sameSite: 'lax', + secure: !dev, + }); + } + } else { event.locals.user = null; } @@ -95,21 +199,15 @@ 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', + secure: !dev, }; - // 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 +218,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..3b5a70a142 --- /dev/null +++ b/src/lib/server/oidc.ts @@ -0,0 +1,501 @@ +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'; +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'); +} + +// 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") +function getSupportedAlgorithms(): jwt.Algorithm[] { + return (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 = { + 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'; + }, +}; + +/** + * 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): { + allowedRoles: string[]; + defaultRole: string; + userId: 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 { allowedRoles, defaultRole, userId }; +} + +/** + * 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. + */ +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. + */ +function getIdTokenVerifyOpts(): jwt.VerifyOptions { + return { + ...getBaseVerifyOpts(), + 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) + * + * 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) { + // 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'), getJwksClient(), getIdTokenVerifyOpts()).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: '/' }); + try { + const client = await Client.instance; + const tokens = await client.refresh(refreshToken); + await updateWithNewTokens(evt.cookies, tokens); + } catch (err) { + // Refresh token is expired or invalid at the IdP. + // Clear remaining tokens and let the request proceed unauthenticated. + // The app's auth guards will redirect to login. + console.error( + 'Token refresh failed (refresh token likely expired):', + err instanceof Error ? err.message : err, + ); + evt.cookies.delete('accessToken', { path: '/' }); + evt.cookies.delete('idToken', { path: '/' }); + } + } + } + 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 = getJwksClient(), + opts: jwt.VerifyOptions = getBaseVerifyOpts(), +): 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; + } +} + +/** + * 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, getJwksClient(), getIdTokenVerifyOpts()); +} + +/** + * 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. + * + * It avoids re-fetching OIDC configuration by caching values on first use. + * + */ +export class Client { + private static _initPromise: Promise; + 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() { + // Use init() for async initialization + } + + private async init(): Promise { + // Fetch well-known configuration first if URL is provided + if (env.OIDC_WELL_KNOWN_URL) { + 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); + } + } + + // 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.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(): Promise { + if (!this._initPromise) { + const client = new Client(); + this._initPromise = client.init().then(() => { + this._instance = client; + return client; + }); + } + return this._initPromise; + } + + 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, + arctic.CodeChallengeMethod.S256, + verifier, + this.scopes, + ); + // Add nonce parameter for OIDC replay attack protection + authorizationUrl.searchParams.set('nonce', nonce); + return { authorizationUrl, nonce, 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: jwt.JwtPayload, accessToken: string): Promise { + const claims = extractClaims(decodedAccessToken); + const username = claims.userId; + const allowedRoles = claims.allowedRoles; + + // Set the active and default role based on priority (aerie_admin > user > viewer) + let 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, + allowedRoles, + defaultRole, + id: username, + permissibleQueries: null, + rolePermissions: null, + token: accessToken, + }; + console.debug('Registering user:', username); + const result = await reqHasura(mutation, { input }, user); + console.debug('Registered user:', username); +} + +export async function updateWithNewTokens(cookies: Cookies, tokens: arctic.OAuth2Tokens): Promise { + console.debug('Persisting tokens following a refresh...'); + + // Check token validity. + // 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(), getJwksClient(), getIdTokenVerifyOpts()); + + if (accessJwt && idJwt) { + // 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? + await upsertUser(accessJwt as jwt.JwtPayload, 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/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'; +}; diff --git a/src/lib/stores/oidc.ts b/src/lib/stores/oidc.ts new file mode 100644 index 0000000000..159a7421f9 --- /dev/null +++ b/src/lib/stores/oidc.ts @@ -0,0 +1,233 @@ +import { jwtDecode } from 'jwt-decode'; +import { derived, get, writable, type Readable } from 'svelte/store'; +import { restartSharedClient } from '../../stores/gqlClient'; +import { getCookieValue } from '../../utilities/browser'; +import { logout } from '../../utilities/login'; +import type { MaybeToken } from '../types/oidc'; + +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; + } +} + +// 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); + 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.'); + } + + // 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). + const unsubscribe = delay.subscribe(value => { + if (value !== null && value >= 0 && get(accessTokenDecoded)) { + console.debug(`Scheduling token refresh in ${value}ms`); + prior = reschedule(refresh, value, prior); + } + }); + + // Return a cleanup function to remove the cookie store change listener + // and unsubscribe from the delay store. + const cleanup = () => { + console.debug('Removing cookie store change listener.'); + 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(accessToken, $accessToken => { + if ($accessToken) { + try { + return jwtDecode($accessToken) as MaybeToken; + } catch { + return null; + } + } + 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; + +// Track consecutive refresh failures to detect expired refresh tokens +let consecutiveFailures = 0; +const MAX_REFRESH_FAILURES = 3; + +/// Private Helpers. + +export async function refresh(): Promise { + console.debug('Refreshing tokens...'); + const res = await fetch('/oidc/refresh', { credentials: 'include', method: 'POST' }); + if (res.ok) { + console.debug('Access token refresh succeeded.'); + consecutiveFailures = 0; // Reset on success + } else if (res.status === 401) { + // 401 means the refresh token is expired/invalid at the IdP. + // No point retrying — log out immediately. + console.error('Token refresh returned 401 — refresh token is expired. Logging out.'); + logout('Session expired - please log in again'); + return; + } else { + consecutiveFailures++; + console.error(`Token refresh failed (attempt ${consecutiveFailures}/${MAX_REFRESH_FAILURES}), status: ${res.status}`); + throw new Error('Token refresh failed'); + } +} + +function reschedule(fn: () => Promise, delay: number, previousTimeout: number | null): any { + if (previousTimeout) { + console.debug(`Clearing previous timeout.`); + clearTimeout(previousTimeout); + } + console.debug(`Scheduling ${fn.name} in ${delay}ms`); + return setTimeout(async () => { + try { + await fn(); + } catch (err) { + console.error('Error in scheduled refresh:', err instanceof Error ? err.message : 'Unknown error'); + + // After MAX_REFRESH_FAILURES consecutive failures, assume refresh token is expired + if (consecutiveFailures >= MAX_REFRESH_FAILURES) { + console.error(`Token refresh failed ${consecutiveFailures} times, refresh token likely expired. Logging out.`); + logout('Session expired - please log in again'); + return; + } + + // Retry after 5 seconds — network may have been temporarily unavailable + console.debug(`Scheduling token refresh retry in 5000ms`); + prior = reschedule(fn, 5000, prior); + } + }, delay); +} + +/** + * Handles changes and deletions to the cookie store. + * + * 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; + + // 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), + ); + + let tokenRefreshed = false; + + event.changed.forEach(({ name, value }) => { + if (name === 'accessToken') { + // 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/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/+layout.server.ts b/src/routes/+layout.server.ts index 6e04658178..d9a940ab5a 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,10 +1,24 @@ 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 { + 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..7886079b37 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,6 +3,7 @@
@@ -80,20 +85,34 @@ -
- - -
+ {#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/oidc/callback/+page.server.ts b/src/routes/oidc/callback/+page.server.ts new file mode 100644 index 0000000000..cd2d0bbf87 --- /dev/null +++ b/src/routes/oidc/callback/+page.server.ts @@ -0,0 +1,85 @@ +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. **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. + * + */ + +export const load: PageServerLoad = async ({ cookies, url }) => { + console.debug('/oidc/callback load'); + + const client = await auth.Client.instance; + 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') || '/'; + + // 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: '/' }); + cookies.delete('oidc_nonce', { 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, expectedNonce, 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.`); + } + + // 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 tokens.`); + } + } catch (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); + } + + redirect(302, back); +}; + +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 new file mode 100644 index 0000000000..23275f205b --- /dev/null +++ b/src/routes/oidc/login/+page.server.ts @@ -0,0 +1,42 @@ +import { dev } from '$app/environment'; +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: !dev, // Only require secure in production (HTTPS) +} 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. + // + // 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: '/', + sameSite: 'lax', + secure: !dev, + }); + + const client = await auth.Client.instance; + 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()); +}; diff --git a/src/routes/oidc/logout/+server.ts b/src/routes/oidc/logout/+server.ts new file mode 100644 index 0000000000..f95d2b6492 --- /dev/null +++ b/src/routes/oidc/logout/+server.ts @@ -0,0 +1,41 @@ +import { env } from '$env/dynamic/private'; +import { Client } 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 = await 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: '/' }); + + if (!idToken) { + // No id token available (e.g., already cleared by another tab's logout or refresh failure). + // We can't do an IdP logout without id_token_hint, so just redirect to origin. + console.debug('No id token available for logout hint, redirecting to origin.'); + redirect(302, `${env.ORIGIN}`); + } + + // Use the raw id token as the hint — the IdP needs it to identify the session, + // not to validate freshness. Expired tokens are accepted by most IdPs (including Keycloak) + // for logout hint purposes. + const logoutUrl = new URL(client.getLogoutEndpoint()); + logoutUrl.searchParams.set('post_logout_redirect_uri', `${env.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..defb27d159 --- /dev/null +++ b/src/routes/oidc/refresh/+server.ts @@ -0,0 +1,49 @@ +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 401 if refresh token is missing/expired. + */ +export const POST = async ({ cookies }) => { + console.debug('/oidc/refresh'); + + const refreshToken = cookies.get('refreshToken'); + + if (!refreshToken) { + return json({ error: 'missing_refresh_token', message: 'No refresh token available' }, { status: 401 }); + } + + try { + const client = await auth.Client.instance; + const tokens = await client.refresh(refreshToken); + + if (!tokens) { + console.error('Tokens came back null after refresh.'); + return json({ error: 'refresh_failed', message: 'Token refresh returned no tokens' }, { status: 401 }); + } + + if (await auth.updateWithNewTokens(cookies, tokens)) { + return json({ + accessToken: tokens.accessToken(), + idToken: tokens.idToken(), + }); + } else { + return json({ error: 'token_verification_failed', message: 'New tokens failed verification' }, { status: 401 }); + } + } catch (err) { + // This is the key case: the refresh token has expired at the IdP. + // The IdP rejects our refresh request, arctic throws an error. + // We must return a 401 so the client can detect this and log out. + console.error('Token refresh failed (refresh token likely expired):', err instanceof Error ? err.message : err); + + // Clean up the invalid refresh token + cookies.delete('refreshToken', { path: '/' }); + + return json({ error: 'refresh_token_expired', message: 'Refresh token is expired or invalid' }, { status: 401 }); + } +}; 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..8627af1415 100644 --- a/src/routes/workspaces/[workspaceId]/actions/+page.svelte +++ b/src/routes/workspaces/[workspaceId]/actions/+page.svelte @@ -3,13 +3,16 @@ - + 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/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/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..cd0f3a0efd --- /dev/null +++ b/src/utilities/auth.ts @@ -0,0 +1,119 @@ +import { env } from '$env/dynamic/public'; +import { jwtDecode } from 'jwt-decode'; +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, +): 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 = jwtDecode(baseUser.token) as Record; + const claims = extractClaims(decodedToken); + + if (baseUser.id === null && env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { + // Use the configured user ID claim, which should match Hasura's expected x-hasura-user-id + baseUser.id = claims.userId; + } + + const { allowedRoles, defaultRole } = claims; + + const user: User = { + ...baseUser, + activeRole: activeRole && allowedRoles.includes(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, + }; +} + +export function goToLogin() { + if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true') { + document.location.href = '/oidc/login'; + } else { + document.location.href = '/login'; + } +} 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/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..6f0be9fa57 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) { + window.location.href = `${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..8bc12e8152 100644 --- a/src/utilities/requests.ts +++ b/src/utilities/requests.ts @@ -4,9 +4,8 @@ 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'; +import { INVALID_JWT } from './permissions'; /** * Used to make calls to application external to Aerie. @@ -240,8 +239,16 @@ export async function reqHasura( } } } else if (code === INVALID_JWT) { - // awaiting here only works if SSR is disabled - logout(error?.message); + // This should never be triggered in the OIDC case, because we have refreshes. + // In any case, we do the following: + // * Display an error message. + // * Tell the user they need to log in again + // * Provide a way to do so. + // Don't automatically initiate logout. + console.error('Expired JWT in reqHasura for query:', query); + throw new Error( + `JWT Expired in reqHasura.\nCited Reason: ${json.errors[0]?.message ?? error?.message}\nFor query: ${query}.`, + ); } else { errors.push({ ...defaultError,