From 35a30ed40089755c8b1b73a6e229556b1cb24bf5 Mon Sep 17 00:00:00 2001 From: luke Date: Fri, 2 Feb 2024 18:18:28 +0000 Subject: [PATCH] Add CAM token SSO support (#51) * Add CAM token SSO support * document new auth env vars * fix lint errors * add lint to CI * Refactor auth into adapters * fix lint errors * simplify CAM validation logic * add support for referrer based login UI redirection * rename DefaultAuthAdapter to FakeAuthAdapter, run prettier * convert sso token env var to string array * remove FakeAuthAdapter * update NoAuthAdapter to use new UI flow * add more secure AUTH_TYPE default case * update auth env var docs * remove auth type check in `session` handler * Throw unsupported configuration error in `NoAuthAdapter` --- .github/workflows/lint.yml | 27 +++++ docs/ENVIRONMENT.md | 8 +- package.json | 2 + src/env.ts | 8 ++ src/main.ts | 21 +++- src/packages/auth/adapters/CAMAuthAdapter.ts | 111 +++++++++++++++++++ src/packages/auth/adapters/NoAuthAdapter.ts | 11 ++ src/packages/auth/functions.ts | 26 +++-- src/packages/auth/routes.ts | 58 +++++++++- src/packages/auth/types.ts | 15 +++ src/packages/files/files.ts | 4 +- 11 files changed, 271 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 src/packages/auth/adapters/CAMAuthAdapter.ts create mode 100644 src/packages/auth/adapters/NoAuthAdapter.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..f80ec5a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,27 @@ +name: lint + +on: + push: + branches: + - develop + pull_request: + branches: + - develop + +jobs: + list: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '16.13.0' + - name: Install Dev Dependencies and Build + run: | + npm install + npm run build + - name: Lint + run: | + npm run lint diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index d7877cb..da7d1c9 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -5,11 +5,13 @@ This document provides detailed information about environment variables for the | Name | Description | Type | Default | | --------------------------- | ---------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------- | | `ALLOWED_ROLES` | Allowed roles when authentication is enabled. | `array` | ["user", "viewer"] | -| `ALLOWED_ROLES_NO_AUTH` | Allowed roles when authentication is disabled. | `array` | ["aerie_admin", "user", "viewer"] | +| `ALLOWED_ROLES_NO_AUTH` | Allowed roles when authentication is disabled. | `array` | ["aerie_admin", "user", "viewer"] | | `AUTH_TYPE` | Mode of authentication. Set to `cam` to enable CAM authentication. | `string` | none | -| `AUTH_URL` | URL of CAM REST API. Used if the given `AUTH_TYPE` is set to `cam`. | `string` | https://atb-ocio-12b.jpl.nasa.gov:8443/cam-api | +| `AUTH_URL` | URL of Auth provider's REST API. Used if the given `AUTH_TYPE` is not set to `none`. | `string` | https://atb-ocio-12b.jpl.nasa.gov:8443/cam-api | +| `AUTH_UI_URL` | URL of Auth provider's login UI. Returned to the UI if SSO token is invalid, so user is redirected | `string` | https://atb-ocio-12b.jpl.nasa.gov:8443/cam-ui | +| `AUTH_SSO_TOKEN_NAME` | The name of the SSO tokens the Gateway should parse cookies for. Likely found in auth provider docs. | `array` | ["iPlanetDirectoryPro"] | | `DEFAULT_ROLE` | Default role when authentication is enabled. | `array` | user | -| `DEFAULT_ROLE_NO_AUTH` | Default role when authentication is disabled. | `array` | aerie_admin | +| `DEFAULT_ROLE_NO_AUTH` | Default role when authentication is disabled. | `array` | aerie_admin | | `GQL_API_URL` | URL of GraphQL API for the GraphQL Playground. | `string` | http://localhost:8080/v1/graphql | | `GQL_API_WS_URL` | URL of GraphQL WebSocket API for the GraphQL Playground. | `string` | ws://localhost:8080/v1/graphql | | `HASURA_GRAPHQL_JWT_SECRET` | The JWT secret. Also in Hasura. **Required** even if auth off in Hasura. | `string` | | diff --git a/package.json b/package.json index 036266d..963ac69 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "altair-express-middleware": "^5.2.11", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "express": "^4.18.2", "express-rate-limit": "^6.7.0", @@ -32,6 +33,7 @@ "winston": "^3.9.0" }, "devDependencies": { + "@types/cookie-parser": "^1.4.6", "@types/cors": "^2.8.13", "@types/express": "^4.17.17", "@types/jsonwebtoken": "^9.0.2", diff --git a/src/env.ts b/src/env.ts index 71123e1..0d58870 100644 --- a/src/env.ts +++ b/src/env.ts @@ -3,7 +3,9 @@ import type { Algorithm } from 'jsonwebtoken'; export type Env = { ALLOWED_ROLES: string[]; ALLOWED_ROLES_NO_AUTH: string[]; + AUTH_SSO_TOKEN_NAME: string[]; AUTH_TYPE: string; + AUTH_UI_URL: string; AUTH_URL: string; DEFAULT_ROLE: string; DEFAULT_ROLE_NO_AUTH: string; @@ -28,7 +30,9 @@ export type Env = { export const defaultEnv: Env = { ALLOWED_ROLES: ['user', 'viewer'], ALLOWED_ROLES_NO_AUTH: ['aerie_admin', 'user', 'viewer'], + AUTH_SSO_TOKEN_NAME: ['iPlanetDirectoryPro'], // default CAM token name AUTH_TYPE: 'cam', + AUTH_UI_URL: 'https://atb-ocio-12b.jpl.nasa.gov:8443/cam-ui/', AUTH_URL: 'https://atb-ocio-12b.jpl.nasa.gov:8443/cam-api', DEFAULT_ROLE: 'user', DEFAULT_ROLE_NO_AUTH: 'aerie_admin', @@ -87,6 +91,8 @@ export function getEnv(): Env { const ALLOWED_ROLES_NO_AUTH = parseArray(env['ALLOWED_ROLES_NO_AUTH'], defaultEnv.ALLOWED_ROLES_NO_AUTH); const AUTH_TYPE = env['AUTH_TYPE'] ?? defaultEnv.AUTH_TYPE; const AUTH_URL = env['AUTH_URL'] ?? defaultEnv.AUTH_URL; + const AUTH_UI_URL = env['AUTH_UI_URL'] ?? defaultEnv.AUTH_UI_URL; + const AUTH_SSO_TOKEN_NAME = parseArray(env['AUTH_SSO_TOKEN_NAME'], defaultEnv.AUTH_SSO_TOKEN_NAME); const DEFAULT_ROLE = env['DEFAULT_ROLE'] ?? defaultEnv.DEFAULT_ROLE; const DEFAULT_ROLE_NO_AUTH = env['DEFAULT_ROLE_NO_AUTH'] ?? defaultEnv.DEFAULT_ROLE_NO_AUTH; const GQL_API_URL = env['GQL_API_URL'] ?? defaultEnv.GQL_API_URL; @@ -109,7 +115,9 @@ export function getEnv(): Env { return { ALLOWED_ROLES, ALLOWED_ROLES_NO_AUTH, + AUTH_SSO_TOKEN_NAME, AUTH_TYPE, + AUTH_UI_URL, AUTH_URL, DEFAULT_ROLE, DEFAULT_ROLE_NO_AUTH, diff --git a/src/main.ts b/src/main.ts index ce4fa0e..59f6dab 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,20 +9,37 @@ import { DbMerlin } from './packages/db/db.js'; import initFileRoutes from './packages/files/files.js'; import initHealthRoutes from './packages/health/health.js'; import initSwaggerRoutes from './packages/swagger/swagger.js'; +import cookieParser from 'cookie-parser'; +import { AuthAdapter } from './packages/auth/types.js'; +import { NoAuthAdapter } from './packages/auth/adapters/NoAuthAdapter.js'; +import { CAMAuthAdapter } from './packages/auth/adapters/CAMAuthAdapter.js'; async function main(): Promise { const logger = getLogger('main'); - const { PORT } = getEnv(); + const { PORT, AUTH_TYPE } = getEnv(); const app = express(); app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false })); app.use(cors()); app.use(express.json()); + app.use(cookieParser()); await DbMerlin.init(); + let authHandler: AuthAdapter; + switch (AUTH_TYPE) { + case 'none': + authHandler = NoAuthAdapter; + break; + case 'cam': + authHandler = CAMAuthAdapter; + break; + default: + throw new Error(`invalid auth type env var: ${AUTH_TYPE}`); + } + initApiPlaygroundRoutes(app); - initAuthRoutes(app); + initAuthRoutes(app, authHandler); initFileRoutes(app); initHealthRoutes(app); initSwaggerRoutes(app); diff --git a/src/packages/auth/adapters/CAMAuthAdapter.ts b/src/packages/auth/adapters/CAMAuthAdapter.ts new file mode 100644 index 0000000..bf33af1 --- /dev/null +++ b/src/packages/auth/adapters/CAMAuthAdapter.ts @@ -0,0 +1,111 @@ +import { getEnv } from '../../../env.js'; +import { generateJwt, getUserRoles } from '../functions.js'; +import fetch from 'node-fetch'; +import type { AuthAdapter, AuthResponse, ValidateResponse } from '../types.js'; + +import { Request } from 'express'; + +type CAMValidateResponse = { + validated?: boolean; + errorCode?: string; + errorMessage?: string; +}; + +type CAMInvalidateResponse = { + invalidated?: boolean; + errorCode?: string; + errorMessage?: string; +}; + +type CAMLoginResponse = { + userId?: string; + errorCode?: string; + errorMessage?: string; +}; + +export const CAMAuthAdapter: AuthAdapter = { + logout: async (req: Request): Promise => { + const { AUTH_SSO_TOKEN_NAME, AUTH_URL } = getEnv(); + + const cookies = req.cookies; + const ssoToken = cookies[AUTH_SSO_TOKEN_NAME[0]]; + + const body = JSON.stringify({ ssoToken }); + const url = `${AUTH_URL}/ssoToken?action=invalidate`; + const response = await fetch(url, { body, method: 'DELETE' }); + const { invalidated = false } = (await response.json()) as CAMInvalidateResponse; + + return invalidated; + }, + + validate: async (req: Request): Promise => { + const { AUTH_SSO_TOKEN_NAME, AUTH_URL, AUTH_UI_URL } = getEnv(); + + const cookies = req.cookies; + const ssoToken = cookies[AUTH_SSO_TOKEN_NAME[0]]; + + const body = JSON.stringify({ ssoToken }); + const url = `${AUTH_URL}/ssoToken?action=validate`; + const response = await fetch(url, { body, method: 'POST' }); + const json = (await response.json()) as CAMValidateResponse; + + const { validated = false, errorCode = false } = json; + + const redirectTo = req.headers.referrer; + + const redirectURL = `${AUTH_UI_URL}/?goto=${redirectTo}`; + + if (errorCode || !validated) { + return { + message: 'invalid token, redirecting to login UI', + redirectURL, + success: false, + }; + } + + const loginResp = await loginSSO(ssoToken); + + return { + message: 'valid SSO token', + redirectURL: '', + success: validated, + token: loginResp.token ?? undefined, + userId: loginResp.message, + }; + }, +}; + +async function loginSSO(ssoToken: any): Promise { + const { AUTH_URL, DEFAULT_ROLE, ALLOWED_ROLES } = getEnv(); + + try { + const body = JSON.stringify({ ssoToken }); + const url = `${AUTH_URL}/userProfile`; + const response = await fetch(url, { body, method: 'POST' }); + const json = (await response.json()) as CAMLoginResponse; + const { userId = '', errorCode = false } = json; + + if (errorCode) { + const { errorMessage } = json; + return { + message: errorMessage ?? 'error logging into CAM', + success: false, + token: null, + }; + } + + const { allowed_roles, default_role } = await getUserRoles(userId, DEFAULT_ROLE, ALLOWED_ROLES); + + return { + message: userId, + success: true, + token: generateJwt(userId, default_role, allowed_roles), + }; + } catch (error) { + return { + message: 'An unexpected error occurred', + success: false, + token: null, + }; + } +} diff --git a/src/packages/auth/adapters/NoAuthAdapter.ts b/src/packages/auth/adapters/NoAuthAdapter.ts new file mode 100644 index 0000000..2d91b4a --- /dev/null +++ b/src/packages/auth/adapters/NoAuthAdapter.ts @@ -0,0 +1,11 @@ +import type { AuthAdapter, ValidateResponse } from '../types.js'; + +export const NoAuthAdapter: AuthAdapter = { + logout: async (): Promise => true, + validate: async (): Promise => { + throw new Error(` + The UI is configured to use SSO auth, but the Gateway has AUTH_TYPE=none set, which is not a supported configuration. + Disable SSO auth on the UI if JWT-only auth is desired. + `); + }, +}; diff --git a/src/packages/auth/functions.ts b/src/packages/auth/functions.ts index fe036a4..207bbfb 100644 --- a/src/packages/auth/functions.ts +++ b/src/packages/auth/functions.ts @@ -41,7 +41,7 @@ export async function getUserRoles( [username], ); - if (rowCount > 0) { + if (rowCount && rowCount > 0) { const [row] = rows; const { hasura_allowed_roles, hasura_default_role } = row; return { allowed_roles: hasura_allowed_roles, default_role: hasura_default_role }; @@ -151,28 +151,30 @@ export async function login(username: string, password: string): Promise { - const { AUTH_TYPE } = getEnv(); + const { jwtErrorMessage, jwtPayload } = decodeJwt(authorizationHeader); - if (AUTH_TYPE === 'cam') { - const { jwtErrorMessage, jwtPayload } = decodeJwt(authorizationHeader); - - if (jwtPayload) { - return { message: 'Token is valid', success: true }; - } else { - return { message: jwtErrorMessage, success: false }; - } + if (jwtPayload) { + return { message: 'Token is valid', success: true }; } else { - return { message: `Authentication is disabled`, success: true }; + return { message: jwtErrorMessage, success: false }; } } diff --git a/src/packages/auth/routes.ts b/src/packages/auth/routes.ts index c3a3c94..7d2a7fd 100644 --- a/src/packages/auth/routes.ts +++ b/src/packages/auth/routes.ts @@ -2,8 +2,9 @@ import type { Express } from 'express'; import rateLimit from 'express-rate-limit'; import { getEnv } from '../../env.js'; import { login, session } from './functions.js'; +import { AuthAdapter } from './types.js'; -export default (app: Express) => { +export default (app: Express, auth: AuthAdapter) => { const { RATE_LIMITER_LOGIN_MAX } = getEnv(); const loginLimiter = rateLimit({ @@ -47,6 +48,61 @@ export default (app: Express) => { res.json(response); }); + /** + * @swagger + * /auth/validateSSO: + * get: + * parameters: + * - in: cookie + * name: AUTH_SSO_TOKEN_NAME + * schema: + * type: string + * description: SSO token cookie that is named according to the gateway environment variable + * produces: + * - application/json + * responses: + * 200: + * description: AuthResponse + * summary: Validates a user's SSO token against external auth providers + * tags: + * - Auth + */ + app.get('/auth/validateSSO', loginLimiter, async (req, res) => { + const { token, success, message, userId, redirectURL } = await auth.validate(req); + const resp = { + message, + redirectURL, + success, + token, + userId, + }; + res.json(resp); + }); + + /** + * @swagger + * /auth/logoutSSO: + * get: + * parameters: + * - in: cookie + * name: AUTH_SSO_TOKEN_NAME + * schema: + * type: string + * description: SSO token cookie that is named according to the gateway environment variable + * produces: + * - application/json + * responses: + * 200: + * description: boolean + * summary: Invalidates a user's SSO token against external auth providers + * tags: + * - Auth + */ + app.get('/auth/logoutSSO', async (req, res) => { + const success = await auth.logout(req); + res.json({ success }); + }); + /** * @swagger * /auth/session: diff --git a/src/packages/auth/types.ts b/src/packages/auth/types.ts index 47ed4db..ed1928d 100644 --- a/src/packages/auth/types.ts +++ b/src/packages/auth/types.ts @@ -1,3 +1,5 @@ +import { Request } from 'express'; + export type JsonWebToken = string; export type JwtDecode = { @@ -35,3 +37,16 @@ export type UserResponse = { export type User = { id: string; }; + +export type ValidateResponse = { + success: boolean; + message: string; + userId?: string; + token?: string; + redirectURL?: string; +}; + +export interface AuthAdapter { + validate(req: Request): Promise; + logout(req: Request): Promise; +} diff --git a/src/packages/files/files.ts b/src/packages/files/files.ts index f8e8987..85faa13 100644 --- a/src/packages/files/files.ts +++ b/src/packages/files/files.ts @@ -76,7 +76,7 @@ export default (app: Express) => { [deleted_date, id], ); - if (rowCount > 0) { + if (rowCount && rowCount > 0) { logger.info(`DELETE /file: Marked file as deleted in the database: ${id}`); } else { logger.info(`DELETE /file: No file was marked as deleted in the database`); @@ -133,7 +133,7 @@ export default (app: Express) => { const [row] = rows; const id = row ? row.id : null; - if (rowCount > 0) { + if (rowCount && rowCount > 0) { logger.info(`POST /file: Added file to the database: ${id}`); } else { logger.info(`POST /file: No file was added to the database`);