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 CAM token SSO support #51

Merged
merged 16 commits into from
Feb 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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;
duranb marked this conversation as resolved.
Show resolved Hide resolved
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/',
duranb marked this conversation as resolved.
Show resolved Hide resolved
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;
duranb marked this conversation as resolved.
Show resolved Hide resolved
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) {
Mythicaeda marked this conversation as resolved.
Show resolved Hide resolved
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);
});
Mythicaeda marked this conversation as resolved.
Show resolved Hide resolved

/**
* @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
Loading