Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SSO support #1053

Merged
merged 28 commits into from
Feb 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
757de48
add new `reqGateway` function that passes cookies
skovati Dec 8, 2023
9996add
Rework auth hook logic to support SSO
skovati Dec 8, 2023
4b4a948
Add support for new gateway auth endpoints.
skovati Dec 12, 2023
1ab416c
fix lint errors
skovati Dec 12, 2023
89279c4
update tests for new login flow
skovati Dec 12, 2023
271508c
add referrer to validation requests
skovati Dec 14, 2023
e4948b1
fix redirection logic
skovati Dec 14, 2023
abf72c8
decode URI encoded cookies
skovati Dec 14, 2023
62095e2
add ability to start local UI with https
duranb Dec 14, 2023
88f9d3e
add ability to specify local host domain
duranb Dec 14, 2023
1c12839
run prettier
skovati Dec 14, 2023
9ad13a5
throw redirect instead of returning
skovati Dec 14, 2023
3ad0cca
remove `PUBLIC_LOGIN_PAGE`
skovati Dec 15, 2023
1c5fffd
add example env vars for local HTTPS + domain dev
skovati Dec 15, 2023
b6ed6e9
add new `PUBLIC_AUTH_TYPE` env var
skovati Jan 2, 2024
3abf50a
switch auth flow based on new env var
skovati Jan 2, 2024
e2b7e75
run prettier
skovati Jan 3, 2024
714f3a6
change auth type env var to boolean
skovati Jan 9, 2024
119a586
document new sso env var
skovati Jan 9, 2024
23af49d
refactor nullish assign
skovati Jan 10, 2024
d9c8681
fix test env vars
skovati Jan 10, 2024
4bd06f4
restore feature parity with logout reason
skovati Jan 10, 2024
7b88769
add error handling to cookie parsing
skovati Jan 10, 2024
c4dd6b7
fix role switching with SSO flow
skovati Jan 11, 2024
865c09e
add env var mock to login tests
skovati Jan 11, 2024
d97ea40
fix redirection loop by replacing `isDataRequest` conditional
skovati Jan 18, 2024
d8006da
fix redirect loop
skovati Jan 24, 2024
76fb36d
fix logout cookie setting race condition
skovati Jan 25, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ node_modules
/.svelte-kit
test-results
unit-test-results
*.local
2 changes: 1 addition & 1 deletion docs/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
skovati marked this conversation as resolved.
Show resolved Hide resolved
| `PUBLIC_LOGIN_PAGE` | Set to `enabled` to turn on login page. Otherwise set to `disabled` to turn off login page. | `string` | enabled |
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
192 changes: 140 additions & 52 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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<ReqValidateSSOResponse>(
'/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<User | null> {
const userBuffer = Buffer.from(userCookie ?? '', 'base64');
const userStr = userBuffer.toString('utf-8');

try {
duranb marked this conversation as resolved.
Show resolved Hide resolved
const baseUser: BaseUser = JSON.parse(userStr);
return computeRolesFromJWT(baseUser, activeRoleCookie);
} catch {
return null;
}
}

async function computeRolesFromJWT(baseUser: BaseUser, activeRole: string | null): Promise<User | null> {
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,
};
}
2 changes: 1 addition & 1 deletion src/routes/+layout.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
11 changes: 9 additions & 2 deletions src/routes/auth/logout/+server.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>('/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`,
Expand Down
3 changes: 1 addition & 2 deletions src/routes/login/+page.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
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';

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`);
}

Expand Down
2 changes: 1 addition & 1 deletion src/stores/subscribable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function gqlSubscribable<T>(
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) {
Expand Down
8 changes: 8 additions & 0 deletions src/types/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,11 @@ export type ReqSessionResponse = {
message: string;
success: boolean;
};

export type ReqValidateSSOResponse = {
message: string;
redirectURL?: string;
success: boolean;
token?: string;
userId?: string;
};
Loading
Loading