diff --git a/.env b/.env index c26d0da011..eb70d13e36 100644 --- a/.env +++ b/.env @@ -5,4 +5,6 @@ PUBLIC_GATEWAY_SERVER_URL=http://localhost:9000 PUBLIC_HASURA_CLIENT_URL=http://localhost:8080/v1/graphql PUBLIC_HASURA_SERVER_URL=http://localhost:8080/v1/graphql PUBLIC_HASURA_WEB_SOCKET_URL=ws://localhost:8080/v1/graphql -PUBLIC_LOGIN_PAGE=enabled +PUBLIC_AUTH_SSO_ENABLED=false +# VITE_HOST=localhost.jpl.nasa.gov +# VITE_HTTPS=true diff --git a/.gitignore b/.gitignore index c22afea9b1..63621b0fd5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ node_modules /.svelte-kit test-results unit-test-results +*.local diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index af77daf4d7..e5a6aebfb0 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -6,9 +6,9 @@ This document provides detailed information about environment variables for Aeri | -------------------------------- | --------------------------------------------------------------------------------------------------------- | -------- | -------------------------------- | | `ORIGIN` | Url of where the UI is served from. See the [Svelte Kit Adapter Node docs][svelte-kit-adapter-node-docs]. | `string` | http://localhost | | `PUBLIC_AERIE_FILE_STORE_PREFIX` | Prefix to prepend to files uploaded through simulation configuration. | `string` | /usr/src/app/merlin_file_store/ | +| `PUBLIC_AUTH_SSO_ENABLED` | Whether to use the SSO-based auth flow, or the /login page auth flow | `string` | false | | `PUBLIC_GATEWAY_CLIENT_URL` | Url of the Gateway as called from the client (i.e. web browser) | `string` | http://localhost:9000 | | `PUBLIC_GATEWAY_SERVER_URL` | Url of the Gateway as called from the server (i.e. Node.js container) | `string` | http://localhost:9000 | | `PUBLIC_HASURA_CLIENT_URL` | Url of Hasura as called from the client (i.e. web browser) | `string` | http://localhost:8080/v1/graphql | | `PUBLIC_HASURA_SERVER_URL` | Url of Hasura as called from the server (i.e. Node.js container) | `string` | http://localhost:8080/v1/graphql | | `PUBLIC_HASURA_WEB_SOCKET_URL` | Url of Hasura called to establish a web-socket connection from the client | `string` | ws://localhost:8080/v1/graphql | -| `PUBLIC_LOGIN_PAGE` | Set to `enabled` to turn on login page. Otherwise set to `disabled` to turn off login page. | `string` | enabled | diff --git a/package-lock.json b/package-lock.json index b1e6c5e7c9..0f886777e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "@types/toastify-js": "^1.11.1", "@typescript-eslint/eslint-plugin": "^5.60.1", "@typescript-eslint/parser": "^5.60.1", + "@vitejs/plugin-basic-ssl": "^1.0.2", "@vitest/ui": "^0.32.2", "cloc": "^2.11.0", "d3-format": "^3.1.0", @@ -1223,6 +1224,18 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.0.2.tgz", + "integrity": "sha512-DKHKVtpI+eA5fvObVgQ3QtTGU70CcCnedalzqmGSR050AzKZMdUzgC8KmlOneHWH8dF2hJ3wkC9+8FDVAaDRCw==", + "dev": true, + "engines": { + "node": ">=14.6.0" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/@vitest/expect": { "version": "0.32.2", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.32.2.tgz", @@ -7699,6 +7712,13 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@vitejs/plugin-basic-ssl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.0.2.tgz", + "integrity": "sha512-DKHKVtpI+eA5fvObVgQ3QtTGU70CcCnedalzqmGSR050AzKZMdUzgC8KmlOneHWH8dF2hJ3wkC9+8FDVAaDRCw==", + "dev": true, + "requires": {} + }, "@vitest/expect": { "version": "0.32.2", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.32.2.tgz", diff --git a/package.json b/package.json index 9eb958657a..a794d0ae6a 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "@types/toastify-js": "^1.11.1", "@typescript-eslint/eslint-plugin": "^5.60.1", "@typescript-eslint/parser": "^5.60.1", + "@vitejs/plugin-basic-ssl": "^1.0.2", "@vitest/ui": "^0.32.2", "cloc": "^2.11.0", "d3-format": "^3.1.0", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5c528a079a..ff21e7a6de 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,63 +1,19 @@ import type { Handle } from '@sveltejs/kit'; -import { parse } from 'cookie'; +import { parse, type CookieSerializeOptions } from 'cookie'; import jwtDecode from 'jwt-decode'; import type { BaseUser, ParsedUserToken, User } from './types/app'; import effects from './utilities/effects'; -import { isLoginEnabled } from './utilities/login'; -import { ADMIN_ROLE } from './utilities/permissions'; +import type { ReqValidateSSOResponse } from './types/auth'; +import { reqGatewayForwardCookies } from './utilities/requests'; +import { base } from '$app/paths'; +import { env } from '$env/dynamic/public'; export const handle: Handle = async ({ event, resolve }) => { try { - if (!isLoginEnabled()) { - const permissibleQueries = await effects.getUserQueries(null); - const rolePermissions = await effects.getRolePermissions(null); - event.locals.user = { - activeRole: ADMIN_ROLE, - allowedRoles: [ADMIN_ROLE], - defaultRole: ADMIN_ROLE, - id: 'unknown', - permissibleQueries, - rolePermissions, - token: '', - }; + if (env.PUBLIC_AUTH_SSO_ENABLED === 'true') { + return await handleSSOAuth({ event, resolve }); } else { - const cookieHeader = event.request.headers.get('cookie') ?? ''; - const cookies = parse(cookieHeader); - const { activeRole: activeRoleCookie = null, user: userCookie = null } = cookies; - - if (userCookie) { - const userBuffer = Buffer.from(userCookie, 'base64'); - const userStr = userBuffer.toString('utf-8'); - const baseUser: BaseUser = JSON.parse(userStr); - const { success } = await effects.session(baseUser); - const decodedToken: ParsedUserToken = jwtDecode(baseUser.token); - - if (success) { - 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 activeRole = activeRoleCookie ?? defaultRole; - const user: User = { - ...baseUser, - activeRole, - allowedRoles, - defaultRole, - permissibleQueries: null, - rolePermissions: null, - }; - const permissibleQueries = await effects.getUserQueries(user); - - const rolePermissions = await effects.getRolePermissions(user); - event.locals.user = { - ...user, - permissibleQueries, - rolePermissions, - }; - } else { - event.locals.user = null; - } - } else { - event.locals.user = null; - } + return await handleJWTAuth({ event, resolve }); } } catch (e) { console.log(e); @@ -66,3 +22,135 @@ export const handle: Handle = async ({ event, resolve }) => { return await resolve(event); }; + +const handleJWTAuth: Handle = async ({ event, resolve }) => { + const cookieHeader = event.request.headers.get('cookie') ?? ''; + const cookies = parse(cookieHeader); + const { activeRole: activeRoleCookie = null, user: userCookie } = cookies; + + // try to get role with current JWT + if (userCookie) { + const user = await computeRolesFromCookies(userCookie, activeRoleCookie); + if (user) { + event.locals.user = user; + return await resolve(event); + } + } else { + event.locals.user = null; + } + + // if we're already on the login page, don't redirect + // otherwise we get stuck in a redirect loop + return event.url.pathname.includes('/login') || event.url.pathname.includes('/auth') + ? await resolve(event) + : new Response(null, { + headers: { + location: `${base}/login`, + }, + status: 307, + }); +}; + +const handleSSOAuth: Handle = async ({ event, resolve }) => { + const cookieHeader = event.request.headers.get('cookie') ?? ''; + const cookies = parse(cookieHeader); + const { activeRole: activeRoleCookie = null } = cookies; + + // pass all cookies to the gateway, who can determine if we have any valid SSO tokens + const validationData = await reqGatewayForwardCookies( + '/auth/validateSSO', + cookieHeader, + event.url.toString(), + ); + + if (!validationData.success) { + console.log('Invalid SSO token, redirecting to SSO login UI page'); + return new Response(null, { + headers: { + // redirectURL field from gateway response will contain our login UI URL + location: `${validationData.redirectURL}`, + }, + status: 307, + }); + } + + // otherwise, we had a valid SSO token, so compute roles from returned JWT + // note, this sets a new JWT cookie each time + const user: BaseUser = { + id: validationData.userId ?? '', + token: validationData.token ?? '', + }; + + const roles = await computeRolesFromJWT(user, activeRoleCookie); + + if (roles) { + console.log(`successfully SSO'd for user ${user.id}`); + + // create and set cookies + const userStr = JSON.stringify(user); + const userCookie = Buffer.from(userStr).toString('base64'); + const cookieOpts: CookieSerializeOptions = { + httpOnly: false, + path: `${base}/`, + sameSite: 'none', + }; + + // if logout just cleared user cookie, don't re-set it + if (!event.url.pathname.includes('/auth/logout')) { + event.cookies.set('user', userCookie, cookieOpts); + } + + // don't overwrite existing activeRole + if (!activeRoleCookie || activeRoleCookie === 'deleted') { + event.cookies.set('activeRole', roles.defaultRole, cookieOpts); + } + } + + event.locals.user = roles; + + 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; + } +} + +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/routes/+layout.server.ts b/src/routes/+layout.server.ts index 55952d5fce..eeaa6b7438 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -5,7 +5,7 @@ import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = async ({ locals, url }) => { if (!url.pathname.includes('login') && shouldRedirectToLogin(locals.user)) { - throw redirect(302, `${base}/login`); + throw redirect(302, base); } return { ...locals }; }; diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index 1b90fcf1f6..8689d7fd91 100644 --- a/src/routes/auth/logout/+server.ts +++ b/src/routes/auth/logout/+server.ts @@ -1,10 +1,17 @@ import { base } from '$app/paths'; import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; +import { reqGatewayForwardCookies } from '../../../utilities/requests'; +import { env } from '$env/dynamic/public'; + +export const POST: RequestHandler = async event => { + const invalidated = + env.PUBLIC_AUTH_SSO_ENABLED === 'true' + ? await reqGatewayForwardCookies('/auth/logoutSSO', event.request.headers.get('cookie') ?? '', base) + : true; -export const POST: RequestHandler = async () => { return json( - { message: 'Logout successful', success: true }, + { message: 'Logout successful', success: invalidated }, { headers: { 'set-cookie': `activeRole=deleted; path=${base}/,user=deleted; path=${base}/; expires=Thu, 01 Jan 1970 00:00:00 GMT`, diff --git a/src/routes/login/+page.ts b/src/routes/login/+page.ts index c609d3060f..13c35a7294 100644 --- a/src/routes/login/+page.ts +++ b/src/routes/login/+page.ts @@ -1,5 +1,4 @@ import { base } from '$app/paths'; -import { env } from '$env/dynamic/public'; import { redirect } from '@sveltejs/kit'; import { hasNoAuthorization } from '../../utilities/permissions'; import type { PageLoad } from './$types'; @@ -7,7 +6,7 @@ import type { PageLoad } from './$types'; export const load: PageLoad = async ({ parent }) => { const { user } = await parent(); - if (env.PUBLIC_LOGIN_PAGE === 'disabled' || (user && !hasNoAuthorization(user))) { + if (user && !hasNoAuthorization(user)) { throw redirect(302, `${base}/plans`); } diff --git a/src/stores/subscribable.ts b/src/stores/subscribable.ts index f30145619d..37c3242f91 100644 --- a/src/stores/subscribable.ts +++ b/src/stores/subscribable.ts @@ -92,7 +92,7 @@ export function gqlSubscribable( if (userCookie) { try { const splitCookie = userCookie.split('user=')[1]; - const decodedUserCookie = atob(splitCookie); + const decodedUserCookie = atob(decodeURIComponent(splitCookie)); const parsedUserCookie: BaseUser = JSON.parse(decodedUserCookie); return parsedUserCookie.token; } catch (e) { diff --git a/src/types/auth.ts b/src/types/auth.ts index 3986d7a154..3b923153da 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -33,3 +33,11 @@ export type ReqSessionResponse = { message: string; success: boolean; }; + +export type ReqValidateSSOResponse = { + message: string; + redirectURL?: string; + success: boolean; + token?: string; + userId?: string; +}; diff --git a/src/utilities/login.test.ts b/src/utilities/login.test.ts index 705fe2aef0..aa713d0803 100644 --- a/src/utilities/login.test.ts +++ b/src/utilities/login.test.ts @@ -1,36 +1,16 @@ -import { env } from '$env/dynamic/public'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import { isLoginEnabled, shouldRedirectToLogin } from './login'; +import { shouldRedirectToLogin } from './login'; import { ADMIN_ROLE } from './permissions'; -vi.mock('$env/dynamic/public', () => ({ - env: { - PUBLIC_LOGIN_PAGE: '', - }, -})); +vi.mock('$env/dynamic/public', () => import.meta.env); // https://github.com/sveltejs/kit/issues/8180 describe('login util functions', () => { afterEach(() => { vi.resetAllMocks(); }); - describe('isLoginEnabled', () => { - test('Should return whether or not the login page is enabled', () => { - vi.mocked(env).PUBLIC_LOGIN_PAGE = ''; - expect(isLoginEnabled()).toEqual(true); - - vi.mocked(env).PUBLIC_LOGIN_PAGE = 'enabled'; - expect(isLoginEnabled()).toEqual(true); - - vi.mocked(env).PUBLIC_LOGIN_PAGE = 'disabled'; - expect(isLoginEnabled()).toEqual(false); - }); - }); - describe('shouldRedirectToLogin', () => { test('Should determine if the route should redirect to the login page when login is enabled', () => { - vi.mocked(env).PUBLIC_LOGIN_PAGE = 'enabled'; - expect(shouldRedirectToLogin(null)).toEqual(true); expect( shouldRedirectToLogin({ @@ -58,36 +38,5 @@ describe('login util functions', () => { }), ).toEqual(false); }); - - test('Should not redirect if login is disabled', () => { - vi.mocked(env).PUBLIC_LOGIN_PAGE = 'disabled'; - - expect(shouldRedirectToLogin(null)).toEqual(false); - expect( - shouldRedirectToLogin({ - activeRole: 'user', - allowedRoles: [ADMIN_ROLE, 'user'], - defaultRole: 'user', - id: 'foo', - permissibleQueries: {}, - rolePermissions: {}, - token: 'foo', - }), - ).toEqual(false); - - expect( - shouldRedirectToLogin({ - activeRole: 'user', - allowedRoles: [ADMIN_ROLE, 'user'], - defaultRole: 'user', - id: 'foo', - permissibleQueries: { - constraints: true, - }, - rolePermissions: {}, - token: 'foo', - }), - ).toEqual(false); - }); }); }); diff --git a/src/utilities/login.ts b/src/utilities/login.ts index c1e4010a03..467195cfa7 100644 --- a/src/utilities/login.ts +++ b/src/utilities/login.ts @@ -5,18 +5,18 @@ import { env } from '$env/dynamic/public'; import type { User } from '../types/app'; import { hasNoAuthorization } from './permissions'; -export function isLoginEnabled() { - // if PUBLIC_LOGIN_PAGE is not explicitly set to "disabled", then assume it is enabled - return env.PUBLIC_LOGIN_PAGE !== 'disabled'; -} - export function shouldRedirectToLogin(user: User | null) { - return isLoginEnabled() && (!user || hasNoAuthorization(user)); + return !user || hasNoAuthorization(user); } export async function logout(reason?: string) { if (browser) { await fetch(`${base}/auth/logout`, { method: 'POST' }); - await goto(`${base}/login${reason ? '?reason=' + reason : ''}`, { invalidateAll: true }); + 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 cecf14a2dc..9d355fec2b 100644 --- a/src/utilities/requests.ts +++ b/src/utilities/requests.ts @@ -82,6 +82,25 @@ export async function reqGateway( return data; } +/** + * Function to make HTTP requests to the Aerie Gateway, forwarding all cookies + */ +export async function reqGatewayForwardCookies(path: string, cookies: string, referrer?: string): Promise { + const GATEWAY_URL = browser ? env.PUBLIC_GATEWAY_CLIENT_URL : env.PUBLIC_GATEWAY_SERVER_URL; + + const opts = { + headers: { + cookie: cookies, + referrer: referrer ?? '', + }, + }; + + const validationResponse = await fetch(`${GATEWAY_URL}${path}`, opts); + const validationData: T = await validationResponse.json(); + + return validationData; +} + /** * Function to make HTTP POST requests to the Hasura GraphQL API. */ diff --git a/vite.config.js b/vite.config.js index 9961abe12c..6dfe573ca0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,54 +1,62 @@ import { sveltekit } from '@sveltejs/kit/vite'; import svg from '@poppanator/sveltekit-svg'; +import basicSsl from '@vitejs/plugin-basic-ssl'; +import { defineConfig, loadEnv } from 'vite'; import { WorkerBuildPlugin } from './vite.worker-build-plugin'; -/** @type {import('vite').UserConfig} */ -const config = { - build: { - minify: true, - }, - css: { - devSourcemap: true, - }, - plugins: [ - sveltekit(), - svg({ - svgoOptions: { - multipass: true, - plugins: [ - { - name: 'preset-default', - // by default svgo removes the viewBox which prevents svg icons from scaling - // not a good idea! https://github.com/svg/svgo/pull/1461 - params: { overrides: { removeViewBox: false } }, - }, - { - name: 'addClassesToSVGElement', - params: { - classNames: ['st-icon'], +const config = ({ mode }) => { + const viteEnvVars = loadEnv(mode, process.cwd()); + return defineConfig({ + build: { + minify: true, + }, + css: { + devSourcemap: true, + }, + plugins: [ + ...(viteEnvVars.VITE_HTTPS === 'true' ? [basicSsl()] : []), + sveltekit(), + svg({ + svgoOptions: { + multipass: true, + plugins: [ + { + name: 'preset-default', + // by default svgo removes the viewBox which prevents svg icons from scaling + // not a good idea! https://github.com/svg/svgo/pull/1461 + params: { overrides: { removeViewBox: false } }, }, - }, - ], - }, - }), - WorkerBuildPlugin( - ['./src/workers/customTS.worker.ts', './node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js'], - { - log: true, + { + name: 'addClassesToSVGElement', + params: { + classNames: ['st-icon'], + }, + }, + ], + }, + }), + WorkerBuildPlugin( + ['./src/workers/customTS.worker.ts', './node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js'], + { + log: true, + }, + ), + ], + server: { + host: viteEnvVars.VITE_HOST ?? 'localhost', + }, + test: { + alias: [{ find: /^svelte$/, replacement: 'svelte/internal' }], // https://github.com/vitest-dev/vitest/issues/2834 + environment: 'jsdom', + include: ['./src/**/*.test.ts'], + outputFile: { + html: 'unit-test-results/html-results/index.html', + json: 'unit-test-results/json-results.json', + junit: 'unit-test-results/junit-results.xml', }, - ), - ], - test: { - alias: [{ find: /^svelte$/, replacement: 'svelte/internal' }], // https://github.com/vitest-dev/vitest/issues/2834 - environment: 'jsdom', - include: ['./src/**/*.test.ts'], - outputFile: { - html: 'unit-test-results/html-results/index.html', - json: 'unit-test-results/json-results.json', - junit: 'unit-test-results/junit-results.xml', + reporters: ['verbose', 'json', 'junit', 'html'], }, - reporters: ['verbose', 'json', 'junit', 'html'], - }, + }); }; export default config;