Skip to content

Commit

Permalink
Add CAM token SSO support (#51)
Browse files Browse the repository at this point in the history
* 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`
  • Loading branch information
skovati authored Feb 2, 2024
1 parent 62a9bdc commit 35a30ed
Show file tree
Hide file tree
Showing 11 changed files with 271 additions and 20 deletions.
27 changes: 27 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 5 additions & 3 deletions docs/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | |
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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',
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
21 changes: 19 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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);
Expand Down
111 changes: 111 additions & 0 deletions src/packages/auth/adapters/CAMAuthAdapter.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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<ValidateResponse> => {
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<AuthResponse> {
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,
};
}
}
11 changes: 11 additions & 0 deletions src/packages/auth/adapters/NoAuthAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { AuthAdapter, ValidateResponse } from '../types.js';

export const NoAuthAdapter: AuthAdapter = {
logout: async (): Promise<boolean> => true,
validate: async (): Promise<ValidateResponse> => {
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.
`);
},
};
26 changes: 14 additions & 12 deletions src/packages/auth/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -151,28 +151,30 @@ export async function login(username: string, password: string): Promise<AuthRes
token: null,
};
}
} else {
} else if (AUTH_TYPE === 'none') {
const { allowed_roles, default_role } = await getUserRoles(username, DEFAULT_ROLE_NO_AUTH, ALLOWED_ROLES_NO_AUTH);
return {
message: 'Authentication is disabled',
success: true,
token: generateJwt(username, default_role, allowed_roles),
};
} else {
const message = 'user + pass login is not supported by current Gateway AUTH_TYPE';
logger.error(message);
return {
message,
success: false,
token: '',
}
}
}

export async function session(authorizationHeader: string | undefined): Promise<SessionResponse> {
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 };
}
}
58 changes: 57 additions & 1 deletion src/packages/auth/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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:
Expand Down
Loading

0 comments on commit 35a30ed

Please sign in to comment.