Skip to content

Commit

Permalink
Add SSO support (#1053)
Browse files Browse the repository at this point in the history
* add new `reqGateway` function that passes cookies

* Rework auth hook logic to support SSO

* 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.

* fix lint errors

* update tests for new login flow

* add referrer to validation requests

* fix redirection logic

* decode URI encoded cookies

* add ability to start local UI with https

* add ability to specify local host domain

* run prettier

* throw redirect instead of returning

prevents `user` being typed as possibly undefined, which svelte-check didn't like

* remove `PUBLIC_LOGIN_PAGE`

"NoAuthAdapter" now fulfills the use case where no auth is desired

* add example env vars for local HTTPS + domain dev

* add new `PUBLIC_AUTH_TYPE` env var

* switch auth flow based on new env var

* run prettier

* change auth type env var to boolean

* document new sso env var

* refactor nullish assign

* fix test env vars

* restore feature parity with logout reason

* add error handling to cookie parsing

* fix role switching with SSO flow

* add env var mock to login tests

* fix redirection loop by replacing `isDataRequest` conditional

* fix redirect loop

* fix logout cookie setting race condition

---------

Co-authored-by: bduran <[email protected]>
  • Loading branch information
skovati and duranb authored Feb 2, 2024
1 parent eba1502 commit 69e41e8
Show file tree
Hide file tree
Showing 15 changed files with 266 additions and 164 deletions.
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 |
| `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 {
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

0 comments on commit 69e41e8

Please sign in to comment.