diff --git a/.env b/.env index a07261c762..97a5a174f1 100644 --- a/.env +++ b/.env @@ -14,3 +14,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/.env.test.oidc b/.env.test.oidc new file mode 100644 index 0000000000..8d02a38b89 --- /dev/null +++ b/.env.test.oidc @@ -0,0 +1,38 @@ +# All env vars needed to run OIDC e2e tests end-to-end (CI and local). +# Backend container creds match plandev's local-dev defaults; not real secrets. +# Loaded into the shell by `npm run test:e2e:oidc` and consumed by +# `docker compose --env-file .env.test.oidc` in the setup/teardown scripts. + +# --- Backend container credentials (used by docker-compose-test.yml) --- +AERIE_USERNAME=aerie_admin +AERIE_PASSWORD=aerie_admin +GATEWAY_USERNAME=gateway_user +GATEWAY_PASSWORD=gateway_user +MERLIN_USERNAME=merlin_user +MERLIN_PASSWORD=merlin_user +SCHEDULER_USERNAME=scheduler_user +SCHEDULER_PASSWORD=scheduler_user +SEQUENCING_USERNAME=sequencing_user +SEQUENCING_PASSWORD=sequencing_user +POSTGRES_USER=postgres_user +POSTGRES_PASSWORD=postgres_user +HASURA_GRAPHQL_ADMIN_SECRET=aerie +ACTION_COOKIE_NAMES= +ACTION_CORS_ALLOWED_ORIGIN= + +# --- Hasura JWT validation against Keycloak (RS256 via JWKS) --- +HASURA_GRAPHQL_JWT_SECRET='{"type":"RS256","jwk_url":"http://aerie_keycloak:8000/realms/aerie-dev/protocol/openid-connect/certs","claims_namespace":"https://hasura.io/jwt/claims"}' + +# --- UI-side OIDC config (read by SvelteKit's $env/dynamic/{public,private}) --- +PUBLIC_AUTH_OIDC_ENABLED=true +OIDC_WELL_KNOWN_URL=http://localhost:8000/realms/aerie-dev/.well-known/openid-configuration +OIDC_AUTHORIZATION_URL=http://localhost:8000/realms/aerie-dev/protocol/openid-connect/auth +OIDC_TOKEN_URL=http://localhost:8000/realms/aerie-dev/protocol/openid-connect/token +OIDC_LOGOUT_URL=http://localhost:8000/realms/aerie-dev/protocol/openid-connect/logout +OIDC_JWKS_URL=http://localhost:8000/realms/aerie-dev/protocol/openid-connect/certs +OIDC_SCOPES='openid profile email' +OIDC_CLIENT_ID=aerie +OIDC_REDIRECT_URI=http://localhost:3000/oidc/callback +OIDC_AUDIENCE=aerie +OIDC_ISSUER=http://localhost:8000/realms/aerie-dev +GATEWAY_IMAGE_TAG=oidc-local diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 697b08f3b3..ce0e0ddbec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -101,6 +101,8 @@ jobs: - name: Install Playwright Dependencies (Test - e2e) run: npx playwright install chromium --with-deps - name: Test (e2e) + # `npm run test:e2e` runs everything EXCEPT the `oidc tests` project — see package.json. + # OIDC needs Hasura on RS256+jwk_url and a running Keycloak; it runs in its own phase below. run: npm run test:e2e - name: Upload Results if: always() @@ -118,6 +120,42 @@ jobs: docker ps -a docker compose -f docker-compose-test.yml down docker ps -a + + # --- OIDC phase --- + # The HS256 stack is down. Bring everything back up with Hasura on RS256+jwk_url + # and Keycloak as the IdP, then run the OIDC-only Playwright project. All env + # config lives in .env.test.oidc (single source of truth for backend + UI). + - name: Start Services (OIDC phase) + run: | + npm run test:e2e:oidc:setup + docker ps -a --no-trunc + - name: Wait for Keycloak readiness + run: | + for i in {1..30}; do + if curl -sf http://localhost:8000/realms/aerie-dev/.well-known/openid-configuration > /dev/null; then + echo "Keycloak ready"; exit 0 + fi + sleep 2 + done + echo "Keycloak did not become ready within 60s"; exit 1 + - name: Test (e2e OIDC) + run: npm run test:e2e:oidc + - name: Upload OIDC Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: E2E OIDC Test Results + path: | + **/e2e-test-results + - name: Print Logs for OIDC Services + if: always() + run: docker compose --env-file .env.test.oidc -f docker-compose-test.yml --profile oidc logs -t + - name: Stop OIDC Services + if: always() + run: | + docker ps -a + npm run test:e2e:oidc:teardown + docker ps -a - name: Prune Volumes (PlanDev) if: always() run: docker volume prune --force diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 2dcf4d6c2a..f9cb07f29f 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -195,6 +195,22 @@ services: restart: always volumes: - postgres_data:/var/lib/postgresql/data + keycloak: + # Only started when running the OIDC test phase: `docker compose --profile oidc up`. + # Imports the canned realm from e2e-tests/oauth/realm-export.json, which defines the + # `aerie` OIDC client (PKCE) plus the three test users (AerieAdmin/AerieUser/AerieViewer). + profiles: ['oidc'] + container_name: aerie_keycloak + image: 'quay.io/keycloak/keycloak:latest' + ports: ['8000:8000'] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: kcadmin + KC_BOOTSTRAP_ADMIN_PASSWORD: kcadmin + KC_FEATURES: scripts + KC_HTTP_PORT: 8000 + command: ['start-dev', '--import-realm'] + volumes: + - ./e2e-tests/oauth/realm-export.json:/opt/keycloak/data/import/realm-export.json aerie_workspace: container_name: aerie_workspace depends_on: ['postgres'] 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..c78a782b82 --- /dev/null +++ b/e2e-tests/fixtures/OIDC.ts @@ -0,0 +1,259 @@ +import { expect, Locator, Page } from '@playwright/test'; +import { decode } from 'jsonwebtoken'; +import type { HasuraToken } from '../../src/lib/types/oidc'; +import { AppNav } from './AppNav'; + +const MAX_LOGIN_RETRIES = 5; + +// 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(); + for (let attempt = 0; attempt < MAX_LOGIN_RETRIES && !buttonClicked; attempt++) { + // 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(); + } + } + if (!buttonClicked) { + throw new Error(`OIDC login button did not trigger IdP redirect after ${MAX_LOGIN_RETRIES} attempts`); + } + } + + 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.getByRole('button', { name: 'Sign In' }); + } +} + +export class OIDC { + expectedDefaultRole: string; + expectedRoles: string[]; + + constructor( + public page: Page, + public username: string, + public password: string, + ) { + // Role names match the canonical Aerie Keycloak realm in e2e-tests/oauth/realm-export.json. + // expectedDefaultRole is the role the UI's priority logic picks + // (aerie_admin > user > viewer; see src/lib/server/oidc.ts:upsertUser). + switch (username) { + case 'AerieAdmin': + this.expectedRoles = ['aerie_admin', 'user', 'viewer']; + this.expectedDefaultRole = 'aerie_admin'; + break; + case 'AerieUser': + this.expectedRoles = ['user', 'viewer']; + this.expectedDefaultRole = 'user'; + break; + default: // AerieViewer + this.expectedRoles = ['viewer']; + this.expectedDefaultRole = 'viewer'; + } + } + + async checkCookieRoles() { + const { accessToken } = await this.extractTokens(); + + if (accessToken) { + // otherwise it is considered potentially 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).toContain(expectedRole); + } + } + } + + async checkCurrentRole() { + // Cookie-based check: the UI's role priority logic writes the activeRole cookie on login, + // and that cookie drives the dropdown text. Asserting the cookie value is more reliable + // than scraping the dropdown — the dropdown isn't even rendered for single-role users. + const cookies = await this.page.context().cookies(); + const activeRole = cookies.find(c => c.name === 'activeRole')?.value; + expect(activeRole).toBe(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(); + + // Match /login with or without a query string (the OIDC-mode redirect from + // the layout's enforce() guard tacks on ?redirectTo=...). + 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 the UI's pre-expiry timer to fire /oidc/refresh and the new + // accessToken cookie to land. Polling against the actual cookie change + // avoids depending on a specific Keycloak access_token_lifespan value. + await expect + .poll( + async () => { + const cookies = await this.page.context().cookies(); + return cookies.find(c => c.name === 'accessToken')?.value; + }, + { intervals: [500, 1000, 2000], timeout: 15000 }, + ) + .not.toBe(oldAccessToken); + + // get new cookies + const { + accessToken: newAccessToken, + idToken: newIdToken, + refreshToken: newRefreshToken, + } = await this.extractTokens(); + + expect(oldAccessToken).not.toEqual(newAccessToken); + expect(oldIdToken).not.toEqual(newIdToken); + expect(oldRefreshToken).not.toEqual(newRefreshToken); + + await this.checkCookieRoles(); // should still be right! + } + + /** + * Switch the active role via the Nav role dropdown and assert that the + * change propagated (success toast, updated dropdown text, activeRole + * cookie). Caller is responsible for being on a page that renders the + * dropdown (e.g., /plans) and being logged in as a user with multiple + * allowed roles. + */ + async switchRole(newRole: string) { + const combobox = this.page.getByRole('navigation').getByRole('combobox'); + await combobox.waitFor({ state: 'visible' }); + + // Right after the OIDC login redirect lands on /plans, a click on the + // role switcher can register before melt-ui has fully wired up its + // pointer handler (hydration race). Retry the click until the listbox + // shows up. + const listbox = this.page.getByRole('listbox'); + for (let attempt = 1; attempt <= 3; attempt++) { + await combobox.click(); + try { + await listbox.waitFor({ state: 'visible', timeout: 1500 }); + break; + } catch { + if (attempt === 3) { + throw new Error('Role-switcher dropdown did not open after 3 click attempts'); + } + } + } + + await listbox.getByRole('option', { name: newRole }).click(); + await expect(this.page.getByText('Changed Role Successfully')).toBeVisible(); + await expect(combobox).toHaveText(newRole); + + await expect + .poll( + async () => { + const cookies = await this.page.context().cookies(); + return cookies.find(c => c.name === 'activeRole')?.value; + }, + { intervals: [200, 500, 1000], timeout: 5000 }, + ) + .toBe(newRole); + } +} diff --git a/e2e-tests/oauth/realm-export.json b/e2e-tests/oauth/realm-export.json new file mode 100644 index 0000000000..896b5247d0 --- /dev/null +++ b/e2e-tests/oauth/realm-export.json @@ -0,0 +1,148 @@ +{ + "id": "aerie-dev", + "realm": "aerie-dev", + "enabled": "true", + "sslRequired": "none", + "defaultSignatureAlgorithm": "RS256", + "roles": { + "client": { + "aerie": [ + { "name": "viewer", "clientRole": true }, + { "name": "user", "clientRole": true }, + { "name": "aerie_admin", "clientRole": true } + ] + } + }, + "clients": [ + { + "id": "aerie", + "clientId": "aerie", + "enabled": "true", + "redirectUris": ["*"], + "publicClient": true, + "protocol": "openid-connect", + "standardFlowEnabled": true, + "attributes": { + "access.token.lifespan": "20", + "refresh.token.lifespan": "1800", + "client.session.idle.timeout": "1800", + "client.session.max.lifespan": "3600", + "pkce.code.challenge.method": "S256", + "token.endpoint.auth.signing.max.exp": "60" + }, + "protocolMappers": [ + { + "name": "x-hasura-allowed-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "https://hasura\\.io/jwt/claims.x-hasura-allowed-roles", + "jsonType.label": "String", + "usermodel.clientRoleMapping.clientId": "aerie" + } + }, + { + "name": "x-hasura-user-id", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "https://hasura\\.io/jwt/claims.x-hasura-user-id", + "jsonType.label": "String" + } + }, + { + "name": "x-hasura-default-role", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "default_role", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "https://hasura\\.io/jwt/claims.x-hasura-default-role", + "jsonType.label": "String" + } + } + ] + } + ], + "users": [ + { + "username": "AerieAdmin", + "enabled": "true", + "email": "AerieAdmin@aerie-dev.gov", + "firstName": "Admin", + "lastName": "Aerie", + "emailVerified": "true", + "attributes": { + "default_role": ["aerie_admin"] + }, + "credentials": [ + { + "type": "password", + "value": "password" + } + ], + "clientRoles": { + "aerie": ["aerie_admin", "user", "viewer"] + } + }, + { + "username": "AerieUser", + "enabled": "true", + "email": "AerieUser@aerie-dev.gov", + "firstName": "User", + "lastName": "Aerie", + "emailVerified": "true", + "attributes": { + "default_role": ["user"] + }, + "credentials": [ + { + "type": "password", + "value": "password" + } + ], + "clientRoles": { + "aerie": ["user", "viewer"] + } + }, + { + "username": "AerieViewer", + "enabled": "true", + "email": "AerieViewer@aerie-dev.gov", + "firstName": "Viewer", + "lastName": "Aerie", + "emailVerified": "true", + "attributes": { + "default_role": ["viewer"] + }, + "credentials": [ + { + "type": "password", + "value": "password" + } + ], + "clientRoles": { + "aerie": ["viewer"] + } + } + ] +} diff --git a/e2e-tests/tests/oidc.test.ts b/e2e-tests/tests/oidc.test.ts new file mode 100644 index 0000000000..f37cd33592 --- /dev/null +++ b/e2e-tests/tests/oidc.test.ts @@ -0,0 +1,275 @@ +import test, { expect, 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.describe('Different Logins', () => { + // Fresh browser context per test — login tests need isolated session/cookie + // state, otherwise the previous user's tokens or Keycloak SSO cookie bleed + // through and the next test never sees the "Login Using OIDC" landing page. + test.beforeEach(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + }); + + test.afterEach(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(); + + // Viewer has only one allowed role, so the role-switch combobox is suppressed + // (Nav.svelte gates it on userRoles.length > 1). Lock that in via the + // role-switcher's aria-label, since /plans has other comboboxes (filters etc.) + // that we don't want to assert against. + await expect(page.getByLabel('Select Role')).not.toBeVisible(); + }); +}); + +test.describe('Refresh Functionality', () => { + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + }); + + test.afterAll(async () => { + await page.close(); + await context.close(); + }); + + // AerieAdmin exercises the most surface (most allowed roles) — pin it for reproducibility. + test('Refresh as admin', async () => { + const { username, password } = users[0]; + + 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.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + }); + + test.afterAll(async () => { + await page.close(); + await context.close(); + }); + + test('Logout as admin', async () => { + const { username, password } = users[0]; + + const oidc = new OIDC(page, username, password); + await oidc.login(); + await page.waitForTimeout(2000); // wait for a sec + await oidc.logout(); + }); +}); + +test.describe('Tab Backgrounding', () => { + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + }); + + test.afterAll(async () => { + await page.close(); + await context.close(); + }); + + // Chrome throttles background-tab setTimeout to 1s minimum after the tab + // has been hidden for a few minutes. Playwright can't actually drive the + // OS-level visibility throttling, but we can simulate the + // visibilitychange event the way Chrome's throttling first manifests, + // wait past the refresh window, and verify the next API call still works + // (whether refresh actually fired in time or the fallback recovered). + test('Refresh survives a visibilitychange to hidden', async () => { + const { username, password } = users[0]; + const oidc = new OIDC(page, username, password); + + await oidc.login(); + await page.waitForURL('**/plans'); + + const cookiesBefore = await context.cookies(); + const oldAccessToken = cookiesBefore.find(c => c.name === 'accessToken')?.value; + expect(oldAccessToken).toBeTruthy(); + + // Make the page report itself as hidden. + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + // Wait past the refresh window — accessToken should rotate even while + // the tab is "hidden" (Playwright doesn't actually throttle timers, + // but the test still proves the refresh path doesn't depend on + // visibility state). + await expect + .poll( + async () => { + const cookies = await context.cookies(); + return cookies.find(c => c.name === 'accessToken')?.value; + }, + { intervals: [500, 1000, 2000], timeout: 15000 }, + ) + .not.toBe(oldAccessToken); + + // Restore visibility and reload — page should remain authenticated + // (still on /plans, no redirect to /login). + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.reload(); + expect(page.url()).toContain('/plans'); + }); +}); + +test.describe('Multi-tab Refresh', () => { + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + }); + + test.afterAll(async () => { + await page.close(); + await context.close(); + }); + + // Two tabs sharing the same context (= same cookies) both run their own + // setTimeout-based refresh. If the IdP rotates refresh tokens, the second + // tab's refresh attempt could fail. This test verifies that at least one + // tab successfully rotated tokens and neither was kicked back to /login. + test('Two tabs survive a refresh cycle', async () => { + const { username, password } = users[0]; + const oidc = new OIDC(page, username, password); + + await oidc.login(); + await page.waitForURL('**/plans'); + + const cookiesBefore = await context.cookies(); + const oldAccessToken = cookiesBefore.find(c => c.name === 'accessToken')?.value; + expect(oldAccessToken).toBeTruthy(); + + // Open a second tab in the same context. Cookies are shared, so it picks + // up the existing session without going through OIDC login again. + const secondPage = await context.newPage(); + try { + await secondPage.goto('/plans'); + await secondPage.waitForURL('**/plans'); + + // Wait for the refresh timer to fire. Test realm sets access.token.lifespan=20s + // and the UI's refresh fires at exp-10s, so the new cookie should land ~10s + // after login. Polling rather than sleeping keeps the test resilient if the + // realm TTL is tuned later. + await expect + .poll( + async () => { + const cookies = await context.cookies(); + return cookies.find(c => c.name === 'accessToken')?.value; + }, + { intervals: [500, 1000, 2000], timeout: 15000 }, + ) + .not.toBe(oldAccessToken); + + // Neither tab should have been redirected to /login. + expect(page.url()).toContain('/plans'); + expect(secondPage.url()).toContain('/plans'); + } finally { + await secondPage.close(); + } + }); +}); + +test.describe('Role Switching', () => { + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + }); + + test.afterAll(async () => { + await page.close(); + await context.close(); + }); + + // Exercises the WS-restart path triggered by /auth/changeRole. The shared + // graphql-ws client should close the active socket with the intentional + // restart code 4999 (see INTENTIONAL_RESTART_CODE in src/stores/gqlClient.ts), + // reconnect with the new x-hasura-role in connectionParams, and resume + // subscriptions transparently. We can't read the close code from + // Playwright's WebSocket API directly, so we verify the observable signals + // instead: activeRole cookie flip, a new WS connection opening, and no + // page-level JS errors during the transition. + test('Switch role from admin to user without errors', async () => { + const { username, password } = users[0]; + const oidc = new OIDC(page, username, password); + + await oidc.login(); + await page.waitForURL('**/plans'); + + const pageErrors: Error[] = []; + page.on('pageerror', err => pageErrors.push(err)); + + const newWebSockets: string[] = []; + page.on('websocket', ws => newWebSockets.push(ws.url())); + const wsCountBefore = newWebSockets.length; + + await oidc.switchRole('user'); + + // graphql-ws reconnects within a few hundred ms after the role change; + // poll rather than rely on a fixed sleep. + await expect.poll(() => newWebSockets.length, { timeout: 5000 }).toBeGreaterThan(wsCountBefore); + + expect(pageErrors).toEqual([]); + }); +}); diff --git a/e2e-tests/utilities/helpers.ts b/e2e-tests/utilities/helpers.ts index 62ec3ea817..4681116671 100644 --- a/e2e-tests/utilities/helpers.ts +++ b/e2e-tests/utilities/helpers.ts @@ -2,6 +2,9 @@ import { expect, type Cookie, type Locator, type 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 5e4b8f2d1c..b75b202d39 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", @@ -1663,6 +1667,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", @@ -2389,6 +2439,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", @@ -2404,6 +2464,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", @@ -2458,6 +2524,16 @@ "license": "MIT", "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", @@ -3083,6 +3159,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", @@ -3378,6 +3465,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", @@ -4398,6 +4491,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", @@ -6152,6 +6254,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", @@ -6252,6 +6363,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", @@ -6269,6 +6402,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", @@ -6345,6 +6515,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", @@ -6392,12 +6567,60 @@ "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "license": "MIT" }, + "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", @@ -6418,6 +6641,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", @@ -8112,7 +8357,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" }, @@ -10171,6 +10415,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 9939667369..64a62125bb 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ "test:e2e:clear-cache": "rm -rf .playwright", "test:e2e:codegen": "playwright codegen http://localhost:3000", "test:e2e:debug": "playwright test --debug", + "test:e2e:oidc": "set -a && . ./.env.test.oidc && set +a && OIDC_TESTS=true playwright test", + "test:e2e:oidc:setup": "docker compose --env-file .env.test.oidc -f docker-compose-test.yml --profile oidc up -d", + "test:e2e:oidc:teardown": "docker compose --env-file .env.test.oidc -f docker-compose-test.yml --profile oidc down", "test:e2e:with-ui": "playwright test --ui", "test:unit": "vitest run", "test:unit:coverage": "vitest run --coverage", @@ -62,6 +65,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 +83,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 +120,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..c3acd8d3a8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -22,47 +22,67 @@ export const USER_STORAGE_STATES: Record = { const MAIN_TEST_SUITE_BASE_URL = 'http://localhost:3000'; const SEQUENCE_TEMPLATE_TEST_SUITE_BASE_URL = 'http://localhost:3001'; -const config: PlaywrightTestConfig = { - forbidOnly: !!process.env.CI, - projects: [ - { - name: 'setup-auth', - testMatch: /global\.setup\.auth\.ts/, - use: { - baseURL: MAIN_TEST_SUITE_BASE_URL, - }, - }, - { - name: 'setup-jar', - testMatch: /global\.setup\.jar\.ts/, +// OIDC tests are opt-in via the OIDC_TESTS env var. They require Keycloak running +// and Hasura configured for RS256+jwk_url, so we exclude them from the default +// `playwright test` run and only enable them under `npm run test:e2e:oidc` (which +// sets OIDC_TESTS=true). When opted in, the regular projects are excluded entirely +// since the stack is in OIDC mode and HS256-signed test logins won't work. +const isOidcRun = process.env.OIDC_TESTS === 'true'; + +const regularProjects: PlaywrightTestConfig['projects'] = [ + { + name: 'setup-auth', + testMatch: /global\.setup\.auth\.ts/, + use: { + baseURL: MAIN_TEST_SUITE_BASE_URL, }, - { - dependencies: ['setup-auth', 'setup-jar'], - name: 'e2e tests', - teardown: 'teardown', - testDir: './e2e-tests', - testIgnore: /.*\/sequence-templates\.test\.ts/, - use: { - baseURL: MAIN_TEST_SUITE_BASE_URL, - storageState: STORAGE_STATE, - }, + }, + { + name: 'setup-jar', + testMatch: /global\.setup\.jar\.ts/, + }, + { + dependencies: ['setup-auth', 'setup-jar'], + name: 'e2e tests', + teardown: 'teardown', + testDir: './e2e-tests', + testIgnore: /.*\/(sequence-templates|oidc)\.test\.ts/, + use: { + baseURL: MAIN_TEST_SUITE_BASE_URL, + storageState: STORAGE_STATE, }, - { - dependencies: ['setup-auth', 'setup-jar'], - name: 'e2e sequence template tests', - teardown: 'teardown', - testDir: './e2e-tests', - testMatch: /.*\/sequence-templates\.test\.ts/, - use: { - baseURL: SEQUENCE_TEMPLATE_TEST_SUITE_BASE_URL, - storageState: STORAGE_STATE, - }, + }, + { + dependencies: ['setup-auth', 'setup-jar'], + name: 'e2e sequence template tests', + teardown: 'teardown', + testDir: './e2e-tests', + testMatch: /.*\/sequence-templates\.test\.ts/, + use: { + baseURL: SEQUENCE_TEMPLATE_TEST_SUITE_BASE_URL, + storageState: STORAGE_STATE, }, - { - name: 'teardown', - testMatch: /global\.teardown\.ts/, + }, + { + name: 'teardown', + testMatch: /global\.teardown\.ts/, + }, +]; + +const oidcProjects: PlaywrightTestConfig['projects'] = [ + { + name: 'oidc tests', + testDir: './e2e-tests', + testMatch: /.*\/oidc\.test\.ts/, + use: { + baseURL: MAIN_TEST_SUITE_BASE_URL, }, - ], + }, +]; + +const config: PlaywrightTestConfig = { + forbidOnly: !!process.env.CI, + projects: isOidcRun ? oidcProjects : regularProjects, reportSlowTests: { max: 0, threshold: 60000, @@ -83,9 +103,15 @@ const config: PlaywrightTestConfig = { }, webServer: [ { - command: 'npm run preview', + // Preview ("production") mode hardcodes `dev=false` in $app/environment, so + // src/lib/server/oidc.ts:updateWithNewTokens sets cookies with secure=true, + // which the browser silently drops over http://localhost. Use the dev server + // for OIDC tests so cookies actually land; preview is fine everywhere else. + command: isOidcRun ? 'npm run dev' : 'npm run preview', port: 3000, - reuseExistingServer: !process.env.CI, + // OIDC tests need a fresh dev server (see command comment above); reusing + // a stale preview server would silently reintroduce the secure-cookie bug. + reuseExistingServer: isOidcRun ? false : !process.env.CI, }, { command: 'PUBLIC_COMMAND_EXPANSION_MODE=templating npm run preview', diff --git a/src/components/app/Nav.svelte b/src/components/app/Nav.svelte index f9a0362a67..32833e91ab 100644 --- a/src/components/app/Nav.svelte +++ b/src/components/app/Nav.svelte @@ -11,7 +11,9 @@ let userRoles: UserRole[] = []; - $: userRoles = $user?.allowedRoles ?? []; + // Sort alphabetically so the dropdown order is stable across logins/refreshes; + // Keycloak's x-hasura-allowed-roles emits roles in non-deterministic order. + $: userRoles = ($user?.allowedRoles ?? []).slice().sort(); async function changeRole(value: string) { const updatedUser = await changeUserRole(value as string); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 6bd34c88a0..934bf31bdc 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.startsWith(`${base}/error`) || event.url.pathname.startsWith(`${base}/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.startsWith(`${base}/auth`) && + !event.url.pathname.startsWith(`${base}/auth/changeRole`) + ) { + 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..10096f366b --- /dev/null +++ b/src/lib/server/oidc.ts @@ -0,0 +1,407 @@ +import { dev } from '$app/environment'; +import { env } from '$env/dynamic/private'; +import { type ClaimsConfig, type MaybeToken, type 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'; + +/** + * 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 (server side). + * 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 + * + * Override via OIDC_CLAIMS_NAMESPACE / OIDC_CLAIMS_USER_ID / OIDC_CLAIMS_ALLOWED_ROLES / OIDC_CLAIMS_DEFAULT_ROLE. + * Must stay in lockstep with the client-side config in src/utilities/auth.ts, Hasura's HASURA_GRAPHQL_JWT_SECRET + * claims_map, the gateway's JWT parsing, and your IdP's token mapper. + */ +export function getClaimsConfig(): ClaimsConfig { + return { + allowedRoles: env.OIDC_CLAIMS_ALLOWED_ROLES || 'x-hasura-allowed-roles', + defaultRole: env.OIDC_CLAIMS_DEFAULT_ROLE || 'x-hasura-default-role', + namespace: env.OIDC_CLAIMS_NAMESPACE || 'https://hasura.io/jwt/claims', + userId: env.OIDC_CLAIMS_USER_ID || 'x-hasura-user-id', + }; +} + +/** + * 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 + } + + 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; + } + + 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); + } + } + + /** + * 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; + } +} + +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 }); + + // User row provisioning is handled gateway-side via session()'s lazy upsert + // (see aerie-gateway/src/packages/auth/functions.ts:session). Doing it here + // through Hasura with the user's own JWT would require widening permissions.users + // insert/update rights to user/viewer roles, which we deliberately avoid. + 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..0ff22318eb --- /dev/null +++ b/src/lib/stores/oidc.ts @@ -0,0 +1,238 @@ +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.'); + } + + // Subscribe to accessTokenDecoded (object) rather than delay (number). + // Svelte's safe_not_equal treats every object emission as a change, so the + // subscriber fires on every accessToken update — including consecutive + // refreshes that happen to produce the same numeric delay (which a `delay` + // subscribe would silently dedupe, leaving the schedule un-armed). + const unsubscribe = accessTokenDecoded.subscribe($decoded => { + if (!$decoded?.exp) { + return; + } + const refreshTime = $decoded.exp * 1000 - 10 * 1000; + const delayMs = Math.max(0, refreshTime - Date.now()); + console.debug(`Scheduling token refresh in ${delayMs}ms`); + prior = reschedule(refresh, delayMs, 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..14a5dfff6b --- /dev/null +++ b/src/lib/types/oidc.ts @@ -0,0 +1,55 @@ +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; + +export type ClaimsConfig = { + allowedRoles: string; + defaultRole: string; + namespace: string; + userId: string; +}; + +export type ExtractedClaims = { + allowedRoles: string[]; + defaultRole: string; + userId: string; +}; + +/** + * Extract the three Hasura claims from a decoded JWT payload, using the + * configured claim paths. Pure function — no env imports, no jsonwebtoken + * runtime dependency. Server and client both supply their own ClaimsConfig. + */ +export function extractClaims(token: Record, config: ClaimsConfig): ExtractedClaims { + const namespace = token[config.namespace]; + if (!namespace || typeof namespace !== 'object') { + throw new Error(`JWT missing claims namespace: ${config.namespace}`); + } + const ns = namespace as Record; + const userId = ns[config.userId]; + const allowedRoles = ns[config.allowedRoles]; + const defaultRole = ns[config.defaultRole]; + + if (!userId || typeof userId !== 'string') { + throw new Error(`JWT missing or invalid user ID claim: ${config.namespace}.${config.userId}`); + } + if (!Array.isArray(allowedRoles)) { + throw new Error(`JWT missing or invalid allowed roles claim: ${config.namespace}.${config.allowedRoles}`); + } + if (!defaultRole || typeof defaultRole !== 'string') { + throw new Error(`JWT missing or invalid default role claim: ${config.namespace}.${config.defaultRole}`); + } + + return { allowedRoles, defaultRole, userId }; +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 6e04658178..a7c3761073 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,12 +1,36 @@ 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)) { +export const load: LayoutServerLoad = async ({ cookies, locals, url }) => { + const nonProtectedPage: boolean = + url.pathname.startsWith(`${base}/error`) || + url.pathname.startsWith(`${base}/oidc`) || + url.pathname.startsWith(`${base}/login`) || + url.pathname.startsWith(`${base}/auth`); + + const buildLoginRedirect = (): string => { const redirectTo = encodeURIComponent(url.pathname + url.search); - redirect(302, `${base}/login?redirectTo=${redirectTo}`); + // Consume the one-shot logoutReason cookie set by /oidc/logout to surface why the user was bounced. + const reason = cookies.get('logoutReason'); + if (reason) { + cookies.delete('logoutReason', { path: '/' }); + } + const reasonParam = reason ? `&reason=${encodeURIComponent(reason)}` : ''; + return `${base}/login?redirectTo=${redirectTo}${reasonParam}`; + }; + + if (env.PUBLIC_AUTH_OIDC_ENABLED === 'true' && !nonProtectedPage) { + try { + enforce(locals?.user, userIsDefined); + } catch { + redirect(302, buildLoginRedirect()); + } + } else if (!nonProtectedPage && !locals.user) { + redirect(302, buildLoginRedirect()); } return { user: locals.user }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 36b7badae3..9f74a4e8b5 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/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..ddc87fb168 --- /dev/null +++ b/src/routes/oidc/logout/+server.ts @@ -0,0 +1,49 @@ +import { dev } from '$app/environment'; +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, url }) => { + 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: '/' }); + + // Stash the logout reason so +layout.server.ts can surface it on /login after the IdP roundtrip + // (the IdP strips query params from post_logout_redirect_uri, so we can't pass it through the URL). + const reason = url.searchParams.get('reason'); + if (reason) { + cookies.set('logoutReason', reason, { httpOnly: true, maxAge: 60, path: '/', sameSite: 'lax', secure: !dev }); + } + + 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/stores/gqlClient.ts b/src/stores/gqlClient.ts index 5e6decd11e..6915eb32bd 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'; @@ -55,6 +56,15 @@ if (browser) { * - Connection-level error handling separate from subscription-level errors */ +// Custom close code + reason used when WE intentionally restart the WebSocket +// (e.g., after a token refresh or role switch). The closed handler matches BOTH +// to suppress the 'reconnecting' state — keeps the banner from flashing on the +// fast path. If our reconnect happens to hang on a network/server issue, the +// natural close that follows will use a different code and surface the banner +// via the normal path (with graphql-ws's connectionAckWaitTimeout latency). +const INTENTIONAL_RESTART_CODE = 4999; +const INTENTIONAL_RESTART_REASON = 'Client Restart'; + // Singleton client instance let client: Client | null = null; @@ -73,26 +83,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 +114,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,38 +126,52 @@ 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(), }, }; }, // Generate debuggable subscription IDs: "QUERY_NAME-N" - // Counter is managed by registerSubscription/setPendingQueryName, not here + // Counter is incremented by setPendingQueryName before each subscribe call generateID: () => { const queryName = pendingQueryName ?? 'unknown'; return `${queryName}-${subscriptionCounter}`; }, 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'); - // Check for auth-related close codes - if (event && typeof event === 'object' && 'code' in event) { - const closeEvent = event as CloseEvent; - // 4401 = Unauthorized - // 4403 = Forbidden - if (closeEvent.code === 4401 || closeEvent.code === 4403) { - logout('Session expired'); - } + const closeEvent = + event && typeof event === 'object' && 'code' in event ? (event as CloseEvent) : undefined; + const code = closeEvent?.code; + // Our own intentional restart (token refresh, role switch). Reconnect is immediate + // and intentional — skip the reconnecting state so the banner doesn't flash. + // Gate on BOTH code and reason so a foreign close can't silently swallow the banner. + const isIntentionalRestart = + code === INTENTIONAL_RESTART_CODE && closeEvent?.reason === INTENTIONAL_RESTART_REASON; + if (!isIntentionalRestart) { + connectionStateStore.set('reconnecting'); + } + // 4401 = Unauthorized, 4403 = Forbidden + if (code === 4401 || code === 4403) { + logout('Session expired'); } }, connected: (socket: unknown) => { + if (newClient !== client) { + return; + } activeSocket = socket as WebSocket; connectionStateStore.set('connected'); // Handle pending restart request @@ -157,6 +181,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,11 +191,18 @@ function createSharedClient(): Client { } }, error: (err: unknown) => { + if (newClient !== client) { + return; + } console.error('WebSocket connection error', err); - // Check for JWT expiration in error + // Hasura's WS-side JWT rejection surfaces here. Two variants worth catching: + // - 'JWTExpired' — token decoded fine but exp claim passed + // - 'JWSError' — signature failed verification (key rotation, tampered cookie) + // Both leave the WS unable to recover on its own (graphql-ws would retry forever + // with the same bad token). Auto-logout so the user lands at /login. if (err && typeof err === 'object' && 'message' in err) { const errorMessage = (err as { message: string }).message; - if (errorMessage.includes(EXPIRED_JWT)) { + if (errorMessage.includes(EXPIRED_JWT) || errorMessage.includes('JWSError')) { logout(EXPIRED_JWT); } } @@ -197,7 +231,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; } /** @@ -205,8 +243,9 @@ function createSharedClient(): Client { */ function doRestart(): void { if (activeSocket && activeSocket.readyState === WebSocket.OPEN) { - // Custom close code (4205) triggers graphql-ws to reconnect - activeSocket.close(4205, 'Client Restart'); + // Custom close code triggers graphql-ws to reconnect. The closed handler + // recognizes this code+reason pair and suppresses the 'reconnecting' state. + activeSocket.close(INTENTIONAL_RESTART_CODE, INTENTIONAL_RESTART_REASON); } else { // Socket not ready, flag for restart when it opens restartRequested = true; @@ -237,16 +276,13 @@ export function getSharedClient(): Client | null { } /** - * Registers a subscription and returns a debuggable ID. - * Call this before client.subscribe() to set the pending query name. + * Registers a subscription. Sets the pending query name so the + * generateID callback can build a debuggable subscription ID + * ("QUERY_NAME-N") for graphql-ws. */ -export function registerSubscription(queryName: string): string { +export function registerSubscription(queryName: string): void { refCount++; - // Set pending query name for generateID callback pendingQueryName = queryName; - // Pre-increment to match what generateID will produce - const id = `${queryName}-${++subscriptionCounter}`; - return id; } /** @@ -325,3 +361,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 12a1a74f73..523477c097 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); setLoading(false); subscribers.forEach(({ next }) => { @@ -244,6 +312,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..8adfb61dd0 --- /dev/null +++ b/src/utilities/auth.ts @@ -0,0 +1,88 @@ +import { env } from '$env/dynamic/public'; +import { extractClaims, type ClaimsConfig } from '$lib/types/oidc'; +import { jwtDecode } from 'jwt-decode'; +import type { BaseUser, User } from '../types/app'; +import effects from './effects'; + +/** + * JWT claim path configuration (client-side). + * Uses PUBLIC_ prefixed env vars so the values are available in the browser. + * Must stay in lockstep with the server-side getClaimsConfig() in src/lib/server/oidc.ts. + */ +function getClaimsConfig(): ClaimsConfig { + return { + allowedRoles: env.PUBLIC_OIDC_CLAIMS_ALLOWED_ROLES || 'x-hasura-allowed-roles', + defaultRole: env.PUBLIC_OIDC_CLAIMS_DEFAULT_ROLE || 'x-hasura-default-role', + namespace: env.PUBLIC_OIDC_CLAIMS_NAMESPACE || 'https://hasura.io/jwt/claims', + userId: env.PUBLIC_OIDC_CLAIMS_USER_ID || 'x-hasura-user-id', + }; +} + +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, getClaimsConfig()); + + 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 879bb63059..be79f0539c 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -5228,6 +5228,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); @@ -5582,6 +5583,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..49921480a8 100644 --- a/src/utilities/login.ts +++ b/src/utilities/login.ts @@ -10,13 +10,30 @@ 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) { + // Pass reason as a query param so the server can persist it across the IdP roundtrip + // (the IdP strips post_logout_redirect_uri query params, so a cookie is set in /oidc/logout + // and consumed by +layout.server.ts when redirecting to /login). + const query = reason ? `?reason=${encodeURIComponent(reason)}` : ''; + window.location.href = `${base}/oidc/logout${query}`; } 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 bd658811b5..56016e2680 100644 --- a/src/utilities/requests.ts +++ b/src/utilities/requests.ts @@ -4,9 +4,9 @@ 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 { logout } from './login'; +import { INVALID_JWT } from './permissions'; /** * Used to make calls to application external to Aerie.