From 757de48598339973af0953b3ce6bedbc08ae2155 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Thu, 7 Dec 2023 17:05:56 -0800 Subject: [PATCH 01/28] add new `reqGateway` function that passes cookies --- src/utilities/requests.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/utilities/requests.ts b/src/utilities/requests.ts index cecf14a2dc..76d7f703d6 100644 --- a/src/utilities/requests.ts +++ b/src/utilities/requests.ts @@ -82,6 +82,24 @@ 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): Promise { + const GATEWAY_URL = browser ? env.PUBLIC_GATEWAY_CLIENT_URL : env.PUBLIC_GATEWAY_SERVER_URL; + + const opts = { + headers: { + cookie: cookies, + }, + }; + + 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. */ From 9996addba6d33bec6fe531e080542ffa4b30e54d Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Thu, 7 Dec 2023 17:06:25 -0800 Subject: [PATCH 02/28] Rework auth hook logic to support SSO --- src/hooks.server.ts | 151 +++++++++++++++++++++++++++++++++----------- 1 file changed, 113 insertions(+), 38 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5c528a079a..df9e95a1f8 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,10 +1,13 @@ 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 { ReqAuthResponse, ReqSessionResponse } from './types/auth'; +import { reqGatewayForwardCookies } from './utilities/requests'; +import { base } from '$app/paths'; export const handle: Handle = async ({ event, resolve }) => { try { @@ -20,45 +23,77 @@ export const handle: Handle = async ({ event, resolve }) => { rolePermissions, token: '', }; - } 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 resolve(event); + } + + const cookieHeader = event.request.headers.get('cookie') ?? ''; + const cookies = parse(cookieHeader); + const { activeRole: activeRoleCookie = null, user: userCookie = null } = 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); } } + + console.log(`trying SSO, since JWT was invalid`); + + // pass all cookies to the gateway, who can determine if we have any valid SSO tokens + const validationData = await reqGatewayForwardCookies('/auth/validateSSO', cookieHeader); + + if (!validationData.success) { + console.log('Invalid SSO token, redirecting to login UI page'); + // if we're already on the login page, don't redirect + // otherwise we get stuck in a redirect loop + return event.url.pathname.startsWith('/login') + ? await resolve(event) + : new Response(null, { + headers: { + // message field from gateway response will contain our login UI URL + location: `${validationData.message}`, + }, + status: 307, + }); + } + + // otherwise, if we have a valid token, login with token to generate new JWT + const loginData = await reqGatewayForwardCookies('/auth/loginSSO', cookieHeader); + + if (loginData.success) { + const user: BaseUser = { + id: loginData.message, + token: loginData.token ?? '', + }; + + const roles = await computeRolesFromJWT(user, activeRoleCookie); + + if (roles) { + console.log(`successfully SSO'd for user ${user.id}`); + + event.locals.user = roles; + + // 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', + }; + event.cookies.set('user', userCookie, cookieOpts); + event.cookies.set('activeRole', roles.defaultRole, cookieOpts); + + return await resolve(event); + } + } + + // otherwise, we can't auth + console.log('unable to auth with JWT or SSO token'); + event.locals.user = null; } catch (e) { console.log(e); event.locals.user = null; @@ -66,3 +101,43 @@ export const handle: 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'); + const baseUser: BaseUser = JSON.parse(userStr); + + return computeRolesFromJWT(baseUser, activeRoleCookie); +} + +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']; + activeRole ??= defaultRole; + const user: User = { + ...baseUser, + activeRole, + allowedRoles, + defaultRole, + permissibleQueries: null, + rolePermissions: null, + }; + const permissibleQueries = await effects.getUserQueries(user); + + const rolePermissions = await effects.getRolePermissions(user); + return { + ...user, + permissibleQueries, + rolePermissions, + }; +} From 4b4a948310ad4a73da46698d18d37cf9d715b63e Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Tue, 12 Dec 2023 15:15:08 -0800 Subject: [PATCH 03/28] Add support for new gateway auth endpoints. This commit also removes the auth redirection logic from the UI, since this is handled by the gateway instead, which will return a redirect if deemed necessary. --- src/hooks.server.ts | 73 +++++++++++-------------------- src/routes/auth/logout/+server.ts | 6 ++- src/routes/login/+page.ts | 3 +- src/types/auth.ts | 8 ++++ src/utilities/login.ts | 10 +---- 5 files changed, 41 insertions(+), 59 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index df9e95a1f8..a7f039ce86 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -3,30 +3,12 @@ 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 { ReqAuthResponse, ReqSessionResponse } from './types/auth'; +import type { ReqValidateSSOResponse } from './types/auth'; import { reqGatewayForwardCookies } from './utilities/requests'; import { base } from '$app/paths'; 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: '', - }; - - return await resolve(event); - } - const cookieHeader = event.request.headers.get('cookie') ?? ''; const cookies = parse(cookieHeader); const { activeRole: activeRoleCookie = null, user: userCookie = null } = cookies; @@ -43,56 +25,53 @@ export const handle: Handle = async ({ event, resolve }) => { console.log(`trying SSO, since JWT was invalid`); // pass all cookies to the gateway, who can determine if we have any valid SSO tokens - const validationData = await reqGatewayForwardCookies('/auth/validateSSO', cookieHeader); + const validationData = await reqGatewayForwardCookies('/auth/validateSSO', cookieHeader); if (!validationData.success) { console.log('Invalid SSO token, redirecting to login UI page'); // if we're already on the login page, don't redirect // otherwise we get stuck in a redirect loop - return event.url.pathname.startsWith('/login') + return event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/auth') ? await resolve(event) : new Response(null, { headers: { - // message field from gateway response will contain our login UI URL - location: `${validationData.message}`, + // redirectURL field from gateway response will contain our login UI URL + location: `${validationData.redirectURL}`, }, status: 307, }); } - // otherwise, if we have a valid token, login with token to generate new JWT - const loginData = await reqGatewayForwardCookies('/auth/loginSSO', cookieHeader); + // otherwise, we had a valid SSO token, so compute roles from JWT + const user: BaseUser = { + id: validationData.userId ?? '', + token: validationData.token ?? '', + }; - if (loginData.success) { - const user: BaseUser = { - id: loginData.message, - token: loginData.token ?? '', - }; + const roles = await computeRolesFromJWT(user, activeRoleCookie); - const roles = await computeRolesFromJWT(user, activeRoleCookie); + if (roles) { + console.log(`successfully SSO'd for user ${user.id}`); - if (roles) { - console.log(`successfully SSO'd for user ${user.id}`); + event.locals.user = roles; - event.locals.user = roles; - - // 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', - }; - event.cookies.set('user', userCookie, cookieOpts); - event.cookies.set('activeRole', roles.defaultRole, cookieOpts); + // 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', + }; + event.cookies.set('user', userCookie, cookieOpts); + event.cookies.set('activeRole', roles.defaultRole, cookieOpts); - return await resolve(event); - } + return await resolve(event); } // otherwise, we can't auth console.log('unable to auth with JWT or SSO token'); + console.log(validationData.message); event.locals.user = null; } catch (e) { console.log(e); diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index 1b90fcf1f6..eb88e561e3 100644 --- a/src/routes/auth/logout/+server.ts +++ b/src/routes/auth/logout/+server.ts @@ -1,10 +1,12 @@ import { base } from '$app/paths'; import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; +import { reqGatewayForwardCookies } from '../../../utilities/requests'; -export const POST: RequestHandler = async () => { +export const POST: RequestHandler = async (event) => { + const invalidated = await reqGatewayForwardCookies("/auth/logoutSSO", event.request.headers.get("cookie") ?? ""); 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/types/auth.ts b/src/types/auth.ts index 3986d7a154..3927f90b52 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; + success: boolean; + token?: string; + userId?: string; + redirectURL?: string; +}; diff --git a/src/utilities/login.ts b/src/utilities/login.ts index c1e4010a03..686618fbba 100644 --- a/src/utilities/login.ts +++ b/src/utilities/login.ts @@ -1,22 +1,16 @@ import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { base } from '$app/paths'; -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 }); + await goto(`${base}/`); } } From 1ab416c2c12bc2517fc481598e6587a2a6229ade Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Tue, 12 Dec 2023 15:29:16 -0800 Subject: [PATCH 04/28] fix lint errors --- src/routes/auth/logout/+server.ts | 9 ++++++--- src/stores/subscribable.ts | 2 +- src/types/auth.ts | 2 +- src/utilities/login.test.ts | 15 +-------------- src/utilities/login.ts | 4 ++-- src/utilities/requests.ts | 2 +- 6 files changed, 12 insertions(+), 22 deletions(-) diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index eb88e561e3..3b57706245 100644 --- a/src/routes/auth/logout/+server.ts +++ b/src/routes/auth/logout/+server.ts @@ -3,10 +3,13 @@ import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; import { reqGatewayForwardCookies } from '../../../utilities/requests'; -export const POST: RequestHandler = async (event) => { - const invalidated = await reqGatewayForwardCookies("/auth/logoutSSO", event.request.headers.get("cookie") ?? ""); +export const POST: RequestHandler = async event => { + const invalidated = await reqGatewayForwardCookies( + '/auth/logoutSSO', + event.request.headers.get('cookie') ?? '', + ); return json( - { message: 'Logout successful', success: invalidated}, + { 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/stores/subscribable.ts b/src/stores/subscribable.ts index f30145619d..f4602a818e 100644 --- a/src/stores/subscribable.ts +++ b/src/stores/subscribable.ts @@ -41,7 +41,7 @@ export function gqlSubscribable( console.log(error); if ('reason' in error && error.reason.includes(EXPIRED_JWT)) { - await logout(EXPIRED_JWT); + await logout(); } else { subscribers.forEach(({ next }) => { if (initialValue !== null) { diff --git a/src/types/auth.ts b/src/types/auth.ts index 3927f90b52..3b923153da 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -36,8 +36,8 @@ export type ReqSessionResponse = { export type ReqValidateSSOResponse = { message: string; + redirectURL?: string; success: boolean; token?: string; userId?: string; - redirectURL?: string; }; diff --git a/src/utilities/login.test.ts b/src/utilities/login.test.ts index 705fe2aef0..41e636ad71 100644 --- a/src/utilities/login.test.ts +++ b/src/utilities/login.test.ts @@ -1,6 +1,6 @@ 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', () => ({ @@ -14,19 +14,6 @@ describe('login util functions', () => { 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'; diff --git a/src/utilities/login.ts b/src/utilities/login.ts index 686618fbba..a2065ffe96 100644 --- a/src/utilities/login.ts +++ b/src/utilities/login.ts @@ -5,10 +5,10 @@ import type { User } from '../types/app'; import { hasNoAuthorization } from './permissions'; export function shouldRedirectToLogin(user: User | null) { - return (!user || hasNoAuthorization(user)); + return !user || hasNoAuthorization(user); } -export async function logout(reason?: string) { +export async function logout() { if (browser) { await fetch(`${base}/auth/logout`, { method: 'POST' }); await goto(`${base}/`); diff --git a/src/utilities/requests.ts b/src/utilities/requests.ts index 76d7f703d6..7c43a6944a 100644 --- a/src/utilities/requests.ts +++ b/src/utilities/requests.ts @@ -152,7 +152,7 @@ export async function reqHasura( } } else if (code === INVALID_JWT) { // awaiting here only works if SSR is disabled - logout(error?.message); + logout(); } throw new Error(error?.message ?? defaultError); From 89279c48f1d5d6ff68ed7044f4c3a486b2fb6c36 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Tue, 12 Dec 2023 15:53:02 -0800 Subject: [PATCH 05/28] update tests for new login flow --- docker-compose-test.yml | 3 ++- src/utilities/login.test.ts | 34 ---------------------------------- 2 files changed, 2 insertions(+), 35 deletions(-) diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 1cb11835ca..7af3f43a61 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -8,7 +8,8 @@ services: container_name: aerie_gateway depends_on: ['postgres'] environment: - AUTH_TYPE: none + AUTH_TYPE: default + AUTH_UI_URL: 'http://localhost/login' GQL_API_URL: http://localhost:8080/v1/graphql HASURA_GRAPHQL_JWT_SECRET: '${HASURA_GRAPHQL_JWT_SECRET}' LOG_FILE: console diff --git a/src/utilities/login.test.ts b/src/utilities/login.test.ts index 41e636ad71..fe37195d17 100644 --- a/src/utilities/login.test.ts +++ b/src/utilities/login.test.ts @@ -1,4 +1,3 @@ -import { env } from '$env/dynamic/public'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { shouldRedirectToLogin } from './login'; import { ADMIN_ROLE } from './permissions'; @@ -16,8 +15,6 @@ describe('login util functions', () => { 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({ @@ -45,36 +42,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); - }); }); }); From 271508c862560716bfb04fbe6892318bae6d5318 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Wed, 13 Dec 2023 16:11:08 -0800 Subject: [PATCH 06/28] add referrer to validation requests --- src/hooks.server.ts | 5 ++++- src/routes/+layout.server.ts | 4 ++-- src/routes/auth/logout/+server.ts | 1 + src/utilities/requests.ts | 3 ++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index a7f039ce86..26c0fe1044 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -24,8 +24,11 @@ export const handle: Handle = async ({ event, resolve }) => { console.log(`trying SSO, since JWT was invalid`); + // trim any __data.json?x-sveltekit queries to just base url + const trimmedURL = event.request.url.substring(0, event.request.url.lastIndexOf("/")); + // pass all cookies to the gateway, who can determine if we have any valid SSO tokens - const validationData = await reqGatewayForwardCookies('/auth/validateSSO', cookieHeader); + const validationData = await reqGatewayForwardCookies('/auth/validateSSO', cookieHeader, trimmedURL); if (!validationData.success) { console.log('Invalid SSO token, redirecting to login UI page'); diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 55952d5fce..fb0a86a03a 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,11 +1,11 @@ import { base } from '$app/paths'; -import { redirect } from '@sveltejs/kit'; import { shouldRedirectToLogin } from '../utilities/login'; import type { LayoutServerLoad } from './$types'; +import { goto } from '$app/navigation'; export const load: LayoutServerLoad = async ({ locals, url }) => { if (!url.pathname.includes('login') && shouldRedirectToLogin(locals.user)) { - throw redirect(302, `${base}/login`); + return goto(`${base}/`); } return { ...locals }; }; diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index 3b57706245..07ab4af5d5 100644 --- a/src/routes/auth/logout/+server.ts +++ b/src/routes/auth/logout/+server.ts @@ -7,6 +7,7 @@ export const POST: RequestHandler = async event => { const invalidated = await reqGatewayForwardCookies( '/auth/logoutSSO', event.request.headers.get('cookie') ?? '', + event.url.origin, ); return json( { message: 'Logout successful', success: invalidated }, diff --git a/src/utilities/requests.ts b/src/utilities/requests.ts index 7c43a6944a..366a1c9535 100644 --- a/src/utilities/requests.ts +++ b/src/utilities/requests.ts @@ -85,12 +85,13 @@ export async function reqGateway( /** * Function to make HTTP requests to the Aerie Gateway, forwarding all cookies */ -export async function reqGatewayForwardCookies(path: string, cookies: string): Promise { +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 ?? "", }, }; From e4948b1a20e9061ca725c22d0d70cab566a965d0 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Thu, 14 Dec 2023 11:50:22 -0800 Subject: [PATCH 07/28] fix redirection logic --- src/hooks.server.ts | 13 +++++++++---- src/routes/+layout.server.ts | 4 ++-- src/routes/auth/logout/+server.ts | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 26c0fe1044..9b20a76aee 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -9,6 +9,10 @@ import { base } from '$app/paths'; export const handle: Handle = async ({ event, resolve }) => { try { + if (event.isDataRequest) { + return await resolve(event); + } + const cookieHeader = event.request.headers.get('cookie') ?? ''; const cookies = parse(cookieHeader); const { activeRole: activeRoleCookie = null, user: userCookie = null } = cookies; @@ -24,11 +28,12 @@ export const handle: Handle = async ({ event, resolve }) => { console.log(`trying SSO, since JWT was invalid`); - // trim any __data.json?x-sveltekit queries to just base url - const trimmedURL = event.request.url.substring(0, event.request.url.lastIndexOf("/")); - // pass all cookies to the gateway, who can determine if we have any valid SSO tokens - const validationData = await reqGatewayForwardCookies('/auth/validateSSO', cookieHeader, trimmedURL); + const validationData = await reqGatewayForwardCookies( + '/auth/validateSSO', + cookieHeader, + event.request.url + ); if (!validationData.success) { console.log('Invalid SSO token, redirecting to login UI page'); diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index fb0a86a03a..77102da2dc 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,11 +1,11 @@ import { base } from '$app/paths'; +import { redirect } from '@sveltejs/kit'; import { shouldRedirectToLogin } from '../utilities/login'; import type { LayoutServerLoad } from './$types'; -import { goto } from '$app/navigation'; export const load: LayoutServerLoad = async ({ locals, url }) => { if (!url.pathname.includes('login') && shouldRedirectToLogin(locals.user)) { - return goto(`${base}/`); + return redirect(302, base); } return { ...locals }; }; diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index 07ab4af5d5..444840ee38 100644 --- a/src/routes/auth/logout/+server.ts +++ b/src/routes/auth/logout/+server.ts @@ -7,7 +7,7 @@ export const POST: RequestHandler = async event => { const invalidated = await reqGatewayForwardCookies( '/auth/logoutSSO', event.request.headers.get('cookie') ?? '', - event.url.origin, + base ); return json( { message: 'Logout successful', success: invalidated }, From abf72c82f4c9b17ce9e1d9d11ab1436c2df31112 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Thu, 14 Dec 2023 14:00:03 -0800 Subject: [PATCH 08/28] decode URI encoded cookies --- src/stores/subscribable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/subscribable.ts b/src/stores/subscribable.ts index f4602a818e..2769f5dce6 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) { From 62095e21eae874e8858a93a6ce0d3269b482bb55 Mon Sep 17 00:00:00 2001 From: bduran Date: Wed, 13 Dec 2023 16:08:06 -0800 Subject: [PATCH 09/28] add ability to start local UI with https --- .gitignore | 1 + package-lock.json | 20 ++++++++++ package.json | 1 + vite.config.js | 93 +++++++++++++++++++++++++---------------------- 4 files changed, 71 insertions(+), 44 deletions(-) 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/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/vite.config.js b/vite.config.js index 9961abe12c..c8cd663f0f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,54 +1,59 @@ 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, + }, + ), + ], + 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; From 88f9d3e6a59decacffc921bc8aa439feb61cf3b1 Mon Sep 17 00:00:00 2001 From: bduran Date: Thu, 14 Dec 2023 08:38:26 -0800 Subject: [PATCH 10/28] add ability to specify local host domain --- vite.config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vite.config.js b/vite.config.js index c8cd663f0f..6dfe573ca0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -42,6 +42,9 @@ const config = ({ mode }) => { }, ), ], + server: { + host: viteEnvVars.VITE_HOST ?? 'localhost', + }, test: { alias: [{ find: /^svelte$/, replacement: 'svelte/internal' }], // https://github.com/vitest-dev/vitest/issues/2834 environment: 'jsdom', From 1c128394d4a7783d91bccfc682a84a9fcd44e8a6 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Thu, 14 Dec 2023 14:52:47 -0800 Subject: [PATCH 11/28] run prettier --- src/hooks.server.ts | 2 +- src/routes/auth/logout/+server.ts | 2 +- src/utilities/requests.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 9b20a76aee..1540d7be82 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -32,7 +32,7 @@ export const handle: Handle = async ({ event, resolve }) => { const validationData = await reqGatewayForwardCookies( '/auth/validateSSO', cookieHeader, - event.request.url + event.request.url, ); if (!validationData.success) { diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index 444840ee38..f94f6a7ffa 100644 --- a/src/routes/auth/logout/+server.ts +++ b/src/routes/auth/logout/+server.ts @@ -7,7 +7,7 @@ export const POST: RequestHandler = async event => { const invalidated = await reqGatewayForwardCookies( '/auth/logoutSSO', event.request.headers.get('cookie') ?? '', - base + base, ); return json( { message: 'Logout successful', success: invalidated }, diff --git a/src/utilities/requests.ts b/src/utilities/requests.ts index 366a1c9535..cd61266f1d 100644 --- a/src/utilities/requests.ts +++ b/src/utilities/requests.ts @@ -91,7 +91,7 @@ export async function reqGatewayForwardCookies(path: string, cookies: s const opts = { headers: { cookie: cookies, - referrer: referrer ?? "", + referrer: referrer ?? '', }, }; From 9ad13a53a028a75fbbde309fb6c40e2d612d33b4 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Thu, 14 Dec 2023 15:40:59 -0800 Subject: [PATCH 12/28] throw redirect instead of returning prevents `user` being typed as possibly undefined, which svelte-check didn't like --- src/routes/+layout.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 77102da2dc..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)) { - return redirect(302, base); + throw redirect(302, base); } return { ...locals }; }; From 3ad0cca5ccf3f9fbb339831b89034108ea1ba3c5 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Thu, 14 Dec 2023 16:22:48 -0800 Subject: [PATCH 13/28] remove `PUBLIC_LOGIN_PAGE` "NoAuthAdapter" now fulfills the use case where no auth is desired --- docs/ENVIRONMENT.md | 1 - src/utilities/login.test.ts | 6 ------ 2 files changed, 7 deletions(-) diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index af77daf4d7..9a22579dc0 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -11,4 +11,3 @@ This document provides detailed information about environment variables for Aeri | `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/src/utilities/login.test.ts b/src/utilities/login.test.ts index fe37195d17..c0a8e61eb3 100644 --- a/src/utilities/login.test.ts +++ b/src/utilities/login.test.ts @@ -2,12 +2,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { shouldRedirectToLogin } from './login'; import { ADMIN_ROLE } from './permissions'; -vi.mock('$env/dynamic/public', () => ({ - env: { - PUBLIC_LOGIN_PAGE: '', - }, -})); - describe('login util functions', () => { afterEach(() => { vi.resetAllMocks(); From 1c5fffd5345fb9148d3282155555fc476b7d4c3f Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Thu, 14 Dec 2023 16:32:43 -0800 Subject: [PATCH 14/28] add example env vars for local HTTPS + domain dev --- .env | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.env b/.env index c26d0da011..dd12454519 100644 --- a/.env +++ b/.env @@ -6,3 +6,5 @@ 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 +# VITE_HOST=localhost.jpl.nasa.gov +# VITE_HTTPS=true From b6ed6e996fff3c6e30c5da652a97539bc06bfc8b Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Tue, 2 Jan 2024 13:21:05 -0800 Subject: [PATCH 15/28] add new `PUBLIC_AUTH_TYPE` env var --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index dd12454519..6b448bf06c 100644 --- a/.env +++ b/.env @@ -5,6 +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_TYPE=JWT # VITE_HOST=localhost.jpl.nasa.gov # VITE_HTTPS=true From 3abf50a7b588ceb8a41aaf470e6cc256b8e44215 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Tue, 2 Jan 2024 14:39:49 -0800 Subject: [PATCH 16/28] switch auth flow based on new env var --- src/hooks.server.ts | 73 +++++++++++++++++++------------ src/routes/auth/logout/+server.ts | 13 +++--- 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 1540d7be82..b9c8313786 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -6,6 +6,7 @@ import effects from './utilities/effects'; 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 { @@ -13,9 +14,24 @@ export const handle: Handle = async ({ event, resolve }) => { return await resolve(event); } + if (env.PUBLIC_AUTH_TYPE === 'SSO') { + return await handleSSOAuth({ event, resolve }); + } else { + return await handleJWTAuth({ event, resolve }); + } + + } catch (e) { + console.log(e); + event.locals.user = null; + } + + 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 = null } = cookies; + const { activeRole: activeRoleCookie = null, user: userCookie } = cookies; // try to get role with current JWT if (userCookie) { @@ -26,7 +42,22 @@ export const handle: Handle = async ({ event, resolve }) => { } } - console.log(`trying SSO, since JWT was invalid`); + // if we're already on the login page, don't redirect + // otherwise we get stuck in a redirect loop + return event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/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( @@ -36,21 +67,17 @@ export const handle: Handle = async ({ event, resolve }) => { ); if (!validationData.success) { - console.log('Invalid SSO token, redirecting to login UI page'); - // if we're already on the login page, don't redirect - // otherwise we get stuck in a redirect loop - return event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/auth') - ? await resolve(event) - : new Response(null, { - headers: { - // redirectURL field from gateway response will contain our login UI URL - location: `${validationData.redirectURL}`, - }, - status: 307, - }); + 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 JWT + // otherwise, we had a valid SSO token, so compute roles from returned JWT const user: BaseUser = { id: validationData.userId ?? '', token: validationData.token ?? '', @@ -74,20 +101,12 @@ export const handle: Handle = async ({ event, resolve }) => { event.cookies.set('user', userCookie, cookieOpts); event.cookies.set('activeRole', roles.defaultRole, cookieOpts); - return await resolve(event); + } else { + event.locals.user = null; } - // otherwise, we can't auth - console.log('unable to auth with JWT or SSO token'); - console.log(validationData.message); - event.locals.user = null; - } catch (e) { - console.log(e); - event.locals.user = null; - } - - return await resolve(event); -}; + return await resolve(event); +} async function computeRolesFromCookies( userCookie: string | null, diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index f94f6a7ffa..fc7214b497 100644 --- a/src/routes/auth/logout/+server.ts +++ b/src/routes/auth/logout/+server.ts @@ -2,13 +2,16 @@ 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 = await reqGatewayForwardCookies( - '/auth/logoutSSO', - event.request.headers.get('cookie') ?? '', - base, - ); + const invalidated = (env.PUBLIC_AUTH_TYPE === "SSO") + ? await reqGatewayForwardCookies( + '/auth/logoutSSO', + event.request.headers.get('cookie') ?? '', + base) + : true; + return json( { message: 'Logout successful', success: invalidated }, { From e2b7e75b1c269b8b0fc62dab2f45853f8c5648c4 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Wed, 3 Jan 2024 13:26:13 -0800 Subject: [PATCH 17/28] run prettier --- src/hooks.server.ts | 130 +++++++++++++++--------------- src/routes/auth/logout/+server.ts | 10 +-- 2 files changed, 68 insertions(+), 72 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index b9c8313786..5b65d4f010 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -19,7 +19,6 @@ export const handle: Handle = async ({ event, resolve }) => { } else { return await handleJWTAuth({ event, resolve }); } - } catch (e) { console.log(e); event.locals.user = null; @@ -29,84 +28,83 @@ export const handle: Handle = async ({ event, resolve }) => { }; 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); - } + 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); } + } - // if we're already on the login page, don't redirect - // otherwise we get stuck in a redirect loop - return event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/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.request.url, - ); - - if (!validationData.success) { - console.log('Invalid SSO token, redirecting to SSO login UI page'); - return new Response(null, { + // if we're already on the login page, don't redirect + // otherwise we get stuck in a redirect loop + return event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/auth') + ? await resolve(event) + : new Response(null, { headers: { - // redirectURL field from gateway response will contain our login UI URL - location: `${validationData.redirectURL}`, + location: `${base}/login`, }, status: 307, }); - } +}; - // otherwise, we had a valid SSO token, so compute roles from returned JWT - const user: BaseUser = { - id: validationData.userId ?? '', - token: validationData.token ?? '', - }; +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.request.url, + ); + + 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, + }); + } - const roles = await computeRolesFromJWT(user, activeRoleCookie); + // otherwise, we had a valid SSO token, so compute roles from returned JWT + const user: BaseUser = { + id: validationData.userId ?? '', + token: validationData.token ?? '', + }; - if (roles) { - console.log(`successfully SSO'd for user ${user.id}`); + const roles = await computeRolesFromJWT(user, activeRoleCookie); - event.locals.user = roles; + 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', - }; - event.cookies.set('user', userCookie, cookieOpts); - event.cookies.set('activeRole', roles.defaultRole, cookieOpts); + event.locals.user = roles; - } else { - event.locals.user = null; - } + // 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', + }; + event.cookies.set('user', userCookie, cookieOpts); + event.cookies.set('activeRole', roles.defaultRole, cookieOpts); + } else { + event.locals.user = null; + } - return await resolve(event); -} + return await resolve(event); +}; async function computeRolesFromCookies( userCookie: string | null, diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index fc7214b497..cddbc54bec 100644 --- a/src/routes/auth/logout/+server.ts +++ b/src/routes/auth/logout/+server.ts @@ -5,12 +5,10 @@ import { reqGatewayForwardCookies } from '../../../utilities/requests'; import { env } from '$env/dynamic/public'; export const POST: RequestHandler = async event => { - const invalidated = (env.PUBLIC_AUTH_TYPE === "SSO") - ? await reqGatewayForwardCookies( - '/auth/logoutSSO', - event.request.headers.get('cookie') ?? '', - base) - : true; + const invalidated = + env.PUBLIC_AUTH_TYPE === 'SSO' + ? await reqGatewayForwardCookies('/auth/logoutSSO', event.request.headers.get('cookie') ?? '', base) + : true; return json( { message: 'Logout successful', success: invalidated }, From 714f3a69af76a3412716ac4000682a7ac67ca205 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Tue, 9 Jan 2024 11:28:20 -0800 Subject: [PATCH 18/28] change auth type env var to boolean --- .env | 2 +- src/hooks.server.ts | 2 +- src/routes/auth/logout/+server.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env b/.env index 6b448bf06c..61e06bf4dd 100644 --- a/.env +++ b/.env @@ -5,6 +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_AUTH_TYPE=JWT +PUBLIC_AUTH_SSO_ENABLED=true # VITE_HOST=localhost.jpl.nasa.gov # VITE_HTTPS=true diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5b65d4f010..71be6673a4 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -14,7 +14,7 @@ export const handle: Handle = async ({ event, resolve }) => { return await resolve(event); } - if (env.PUBLIC_AUTH_TYPE === 'SSO') { + if (env.PUBLIC_AUTH_SSO_ENABLED === 'true') { return await handleSSOAuth({ event, resolve }); } else { return await handleJWTAuth({ event, resolve }); diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index cddbc54bec..8689d7fd91 100644 --- a/src/routes/auth/logout/+server.ts +++ b/src/routes/auth/logout/+server.ts @@ -6,7 +6,7 @@ import { env } from '$env/dynamic/public'; export const POST: RequestHandler = async event => { const invalidated = - env.PUBLIC_AUTH_TYPE === 'SSO' + env.PUBLIC_AUTH_SSO_ENABLED === 'true' ? await reqGatewayForwardCookies('/auth/logoutSSO', event.request.headers.get('cookie') ?? '', base) : true; From 119a5860bd43ec4a4826f871d9c472c88c243ad2 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Tue, 9 Jan 2024 11:38:36 -0800 Subject: [PATCH 19/28] document new sso env var --- docs/ENVIRONMENT.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 9a22579dc0..e5a6aebfb0 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -6,6 +6,7 @@ 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 | From 23af49de8e00573105fa7ef6992e90ae12d2aa57 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Wed, 10 Jan 2024 08:56:09 -0800 Subject: [PATCH 20/28] refactor nullish assign --- src/hooks.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 71be6673a4..3b066b333b 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -127,10 +127,10 @@ async function computeRolesFromJWT(baseUser: BaseUser, activeRole: string | null const allowedRoles = decodedToken['https://hasura.io/jwt/claims']['x-hasura-allowed-roles']; const defaultRole = decodedToken['https://hasura.io/jwt/claims']['x-hasura-default-role']; - activeRole ??= defaultRole; + const user: User = { ...baseUser, - activeRole, + activeRole: activeRole ?? defaultRole, allowedRoles, defaultRole, permissibleQueries: null, From d9c8681f1a6b9a2407dd593959f379d58bf7a3ce Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Wed, 10 Jan 2024 10:00:10 -0800 Subject: [PATCH 21/28] fix test env vars --- .env | 2 +- docker-compose-test.yml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.env b/.env index 61e06bf4dd..eb70d13e36 100644 --- a/.env +++ b/.env @@ -5,6 +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_AUTH_SSO_ENABLED=true +PUBLIC_AUTH_SSO_ENABLED=false # VITE_HOST=localhost.jpl.nasa.gov # VITE_HTTPS=true diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 7af3f43a61..1cb11835ca 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -8,8 +8,7 @@ services: container_name: aerie_gateway depends_on: ['postgres'] environment: - AUTH_TYPE: default - AUTH_UI_URL: 'http://localhost/login' + AUTH_TYPE: none GQL_API_URL: http://localhost:8080/v1/graphql HASURA_GRAPHQL_JWT_SECRET: '${HASURA_GRAPHQL_JWT_SECRET}' LOG_FILE: console From 4bd06f4eae80e70157ae5af468f8bbb38843e978 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Wed, 10 Jan 2024 10:14:08 -0800 Subject: [PATCH 22/28] restore feature parity with logout reason --- src/stores/subscribable.ts | 2 +- src/utilities/login.ts | 10 ++++++++-- src/utilities/requests.ts | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/stores/subscribable.ts b/src/stores/subscribable.ts index 2769f5dce6..37c3242f91 100644 --- a/src/stores/subscribable.ts +++ b/src/stores/subscribable.ts @@ -41,7 +41,7 @@ export function gqlSubscribable( console.log(error); if ('reason' in error && error.reason.includes(EXPIRED_JWT)) { - await logout(); + await logout(EXPIRED_JWT); } else { subscribers.forEach(({ next }) => { if (initialValue !== null) { diff --git a/src/utilities/login.ts b/src/utilities/login.ts index a2065ffe96..9f226229ec 100644 --- a/src/utilities/login.ts +++ b/src/utilities/login.ts @@ -1,6 +1,7 @@ import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { base } from '$app/paths'; +import { env } from '$env/dynamic/public'; import type { User } from '../types/app'; import { hasNoAuthorization } from './permissions'; @@ -8,9 +9,14 @@ export function shouldRedirectToLogin(user: User | null) { return !user || hasNoAuthorization(user); } -export async function logout() { +export async function logout(reason?: string) { if (browser) { await fetch(`${base}/auth/logout`, { method: 'POST' }); - await goto(`${base}/`); + if (env.PUBLIC_AUTH_SSO_ENABLED === 'true') { + // hooks will handle SSO redirect + await goto(base); + } else { + await goto(`${base}/login${reason ? '?reason=' + reason : ''}`, { invalidateAll: true }); + } } } diff --git a/src/utilities/requests.ts b/src/utilities/requests.ts index cd61266f1d..9d355fec2b 100644 --- a/src/utilities/requests.ts +++ b/src/utilities/requests.ts @@ -153,7 +153,7 @@ export async function reqHasura( } } else if (code === INVALID_JWT) { // awaiting here only works if SSR is disabled - logout(); + logout(error?.message); } throw new Error(error?.message ?? defaultError); From 7b88769c7c068574de46efc39fdeb77cac6c1302 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Wed, 10 Jan 2024 10:49:03 -0800 Subject: [PATCH 23/28] add error handling to cookie parsing --- src/hooks.server.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 3b066b333b..5ca20551cb 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -112,9 +112,13 @@ async function computeRolesFromCookies( ): Promise { const userBuffer = Buffer.from(userCookie ?? '', 'base64'); const userStr = userBuffer.toString('utf-8'); - const baseUser: BaseUser = JSON.parse(userStr); - return computeRolesFromJWT(baseUser, activeRoleCookie); + try { + const baseUser: BaseUser = JSON.parse(userStr); + return computeRolesFromJWT(baseUser, activeRoleCookie); + } catch { + return null; + } } async function computeRolesFromJWT(baseUser: BaseUser, activeRole: string | null): Promise { From c4dd6b7bc02cf7ed69b0d7abe8c116c01fe5261b Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Wed, 10 Jan 2024 17:00:09 -0800 Subject: [PATCH 24/28] fix role switching with SSO flow --- src/hooks.server.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5ca20551cb..e2b5918977 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -77,6 +77,7 @@ const handleSSOAuth: Handle = async ({ event, resolve }) => { } // 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 ?? '', @@ -98,7 +99,10 @@ const handleSSOAuth: Handle = async ({ event, resolve }) => { sameSite: 'none', }; event.cookies.set('user', userCookie, cookieOpts); - event.cookies.set('activeRole', roles.defaultRole, cookieOpts); + // don't overwrite existing activeRole + if (!activeRoleCookie) { + event.cookies.set('activeRole', roles.defaultRole, cookieOpts); + } } else { event.locals.user = null; } From 865c09ed73fb9a0f1076aab4ea3611d6e0fb373c Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Thu, 11 Jan 2024 08:11:26 -0800 Subject: [PATCH 25/28] add env var mock to login tests --- src/utilities/login.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utilities/login.test.ts b/src/utilities/login.test.ts index c0a8e61eb3..aa713d0803 100644 --- a/src/utilities/login.test.ts +++ b/src/utilities/login.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { shouldRedirectToLogin } from './login'; import { ADMIN_ROLE } from './permissions'; +vi.mock('$env/dynamic/public', () => import.meta.env); // https://github.com/sveltejs/kit/issues/8180 + describe('login util functions', () => { afterEach(() => { vi.resetAllMocks(); From d97ea40fb4b081d0e960b164434ef897e9e73f73 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Thu, 18 Jan 2024 14:53:10 -0800 Subject: [PATCH 26/28] fix redirection loop by replacing `isDataRequest` conditional --- src/hooks.server.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index e2b5918977..c7c37214eb 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -10,7 +10,11 @@ import { env } from '$env/dynamic/public'; export const handle: Handle = async ({ event, resolve }) => { try { - if (event.isDataRequest) { + // sveltekit makes a request for json data from layout.server.ts as its first request for a page. + // we want to ignore that here, and instead run our hook logic for the actual SSR request + // this is mainly so that if we need to redirect, we have a human-readable URL (e.g. /plans/1) + // instead of some internal sveltekit URL (/plans/__data.json?x-sveltekit-invalidate=10) + if (event.request.url.includes('__data.json')) { return await resolve(event); } @@ -39,11 +43,13 @@ const handleJWTAuth: Handle = async ({ event, resolve }) => { 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.startsWith('/login') || event.url.pathname.startsWith('/auth') + return event.url.pathname.includes('/login') || event.url.pathname.includes('/auth') ? await resolve(event) : new Response(null, { headers: { From d8006daa804f7aaf32f25d66705ed300b401be0e Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Tue, 23 Jan 2024 16:52:38 -0800 Subject: [PATCH 27/28] fix redirect loop --- src/hooks.server.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c7c37214eb..7b9652bc04 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -10,14 +10,6 @@ import { env } from '$env/dynamic/public'; export const handle: Handle = async ({ event, resolve }) => { try { - // sveltekit makes a request for json data from layout.server.ts as its first request for a page. - // we want to ignore that here, and instead run our hook logic for the actual SSR request - // this is mainly so that if we need to redirect, we have a human-readable URL (e.g. /plans/1) - // instead of some internal sveltekit URL (/plans/__data.json?x-sveltekit-invalidate=10) - if (event.request.url.includes('__data.json')) { - return await resolve(event); - } - if (env.PUBLIC_AUTH_SSO_ENABLED === 'true') { return await handleSSOAuth({ event, resolve }); } else { @@ -68,7 +60,7 @@ const handleSSOAuth: Handle = async ({ event, resolve }) => { const validationData = await reqGatewayForwardCookies( '/auth/validateSSO', cookieHeader, - event.request.url, + event.url.toString(), ); if (!validationData.success) { From 76fb36d69a1822f64b857b480db410918e629315 Mon Sep 17 00:00:00 2001 From: "(skovati) Luke" Date: Wed, 24 Jan 2024 16:41:30 -0800 Subject: [PATCH 28/28] fix logout cookie setting race condition --- src/hooks.server.ts | 15 +++++++++------ src/utilities/login.ts | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 7b9652bc04..ff21e7a6de 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -86,8 +86,6 @@ const handleSSOAuth: Handle = async ({ event, resolve }) => { if (roles) { console.log(`successfully SSO'd for user ${user.id}`); - event.locals.user = roles; - // create and set cookies const userStr = JSON.stringify(user); const userCookie = Buffer.from(userStr).toString('base64'); @@ -96,15 +94,20 @@ const handleSSOAuth: Handle = async ({ event, resolve }) => { path: `${base}/`, sameSite: 'none', }; - event.cookies.set('user', userCookie, cookieOpts); + + // 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) { + if (!activeRoleCookie || activeRoleCookie === 'deleted') { event.cookies.set('activeRole', roles.defaultRole, cookieOpts); } - } else { - event.locals.user = null; } + event.locals.user = roles; + return await resolve(event); }; diff --git a/src/utilities/login.ts b/src/utilities/login.ts index 9f226229ec..467195cfa7 100644 --- a/src/utilities/login.ts +++ b/src/utilities/login.ts @@ -14,7 +14,7 @@ export async function logout(reason?: string) { await fetch(`${base}/auth/logout`, { method: 'POST' }); if (env.PUBLIC_AUTH_SSO_ENABLED === 'true') { // hooks will handle SSO redirect - await goto(base); + await goto(base, { invalidateAll: true }); } else { await goto(`${base}/login${reason ? '?reason=' + reason : ''}`, { invalidateAll: true }); }