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

MMT-3568: Setup a lambda for launchpad login/callbacks #1109

Merged
merged 4 commits into from
Jan 26, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions bin/deploy-bamboo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@ config="`cat static.config.json`"
# update keys for deployment
config="`jq '.application.version = $newValue' --arg newValue ${RELEASE_VERSION} <<< $config`"
config="`jq '.application.graphQlHost = $newValue' --arg newValue $bamboo_GRAPHQL_HOST <<< $config`"
config="`jq '.application.mmtHost = $newValue' --arg newValue $bamboo_MMT_HOST <<< $config`"
config="`jq '.application.apiHost = $newValue' --arg newValue $bamboo_API_HOST <<< $config`"
config="`jq '.application.cmrHost = $newValue' --arg newValue $bamboo_CMR_HOST <<< $config`"
config="`jq '.application.jwtSecret = $newValue' --arg newValue $bamboo_JWT_SECRET <<< $config`"
config="`jq '.saml.host = $newValue' --arg newValue $bamboo_SAML_HOST <<< $config`"
config="`jq '.saml.callbackUrl = $newValue' --arg newValue $bamboo_SAML_CALLBACK_URL <<< $config`"
config="`jq '.saml.issuer = $newValue' --arg newValue $bamboo_SAML_ISSUER <<< $config`"
config="`jq '.saml.cookieName = $newValue' --arg newValue $bamboo_SAML_COOKIE_NAME <<< $config`"

# overwrite static.config.json with new values
echo $config > tmp.$$.json && mv tmp.$$.json static.config.json
Expand Down
609 changes: 595 additions & 14 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@apollo/client": "^3.8.5",
"@edsc/metadata-preview": "^1.2.0",
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@node-saml/node-saml": "^4.0.5",
"@rjsf/core": "^5.15.0",
"@rjsf/utils": "^5.15.0",
"@rjsf/validator-ajv8": "^5.15.0",
Expand All @@ -32,9 +33,13 @@
"classnames": "^2.3.2",
"commafy": "^0.0.6",
"compact-object-deep": "^1.0.0",
"cookie": "^0.6.0",
"esbuild": "^0.19.5",
"fs": "^0.0.1-security",
"graphql": "^16.8.1",
"json-schema": "^0.4.0",
"jwt-decode": "^4.0.0",
"jwt-encode": "^1.0.1",
"lodash-es": "^4.17.21",
"moment": "^2.29.4",
"pluralize": "^8.0.0",
Expand All @@ -52,11 +57,14 @@
"react-router-dom": "^6.18.0",
"react-select": "^5.8.0",
"react-select-country-list": "^2.2.3",
"request": "^2.88.2",
"rollup-plugin-polyfill-node": "^0.12.0",
"saml2js": "^0.1.2",
"serverless": "^3.35.2",
"serverless-esbuild": "^1.48.5",
"serverless-finch": "^4.0.3",
"serverless-offline": "^13.2.0",
"tiny-cookie": "^2.5.1",
"vite": "^4.5.0"
},
"devDependencies": {
Expand Down
16 changes: 16 additions & 0 deletions serverless-configs/aws-functions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,19 @@ errorLogger:
method: post
cors: ${file(./serverless-configs/${self:provider.name}-cors-configuration.yml)}
path: error-logger
samlLogin:
handler: serverless/src/samlLogin/handler.default
timeout: ${env:LAMBDA_TIMEOUT, '30'}
events:
- http:
method: get
cors: ${file(./serverless-configs/${self:provider.name}-cors-configuration.yml)}
path: saml-login
samlCallback:
handler: serverless/src/samlCallback/handler.default
timeout: ${env:LAMBDA_TIMEOUT, '30'}
events:
- http:
method: post
cors: ${file(./serverless-configs/${self:provider.name}-cors-configuration.yml)}
path: saml-acs
43 changes: 43 additions & 0 deletions serverless/src/samlCallback/__tests__/handler.test.js

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

56 changes: 56 additions & 0 deletions serverless/src/samlCallback/handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { stringify } from 'querystring'
import { createJwtToken } from '../../../static/src/js/utils/createJwtToken'
import { getApplicationConfig, getSamlConfig } from '../../../static/src/js/utils/getConfig'

const Saml2js = require('saml2js')
const { URLSearchParams } = require('url')
const cookie = require('cookie')

/**
* Pulls the launchpad token out of the cookie header
* @param {*} cookieString
* @returns the launchpad token
*/
const getLaunchpadToken = (cookieString) => {
const cookies = cookie.parse(cookieString)

return cookies[getSamlConfig().cookieName]
}

/**
* Handles saml callback during authentication
* @param {Object} event Details about the HTTP request that it received
*/
const samlCallback = async (event) => {
const { body, headers } = event
const { Cookie } = headers
const { mmtHost } = getApplicationConfig()

const params = new URLSearchParams(body)

const launchpadToken = getLaunchpadToken(Cookie)

const parser = new Saml2js(params.get('SAMLResponse'))
const path = params.get('RelayState')
const samlResponse = parser.toObject()

const data = {
path,
token: launchpadToken,
...samlResponse
}
const jwtToken = createJwtToken(data)
const queryParams = { jwt: jwtToken }
queryParams.jwt = jwtToken

const location = `${mmtHost}/auth_callback?${stringify(queryParams)}`

return {
statusCode: 303,
headers: {
Location: location
}
}
}

export default samlCallback
47 changes: 47 additions & 0 deletions serverless/src/samlLogin/__tests__/handler.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { escapeRegExp } from 'lodash-es'
import samlLogin from '../handler'
import * as getConfig from '../../../../static/src/js/utils/getConfig'

beforeEach(() => {
jest.clearAllMocks()

jest.spyOn(getConfig, 'getSamlConfig').mockImplementation(() => ({
host: 'https://mmt.localtest.earthdata.nasa.gov',
callbackUrl: 'https://mmt.localtest.earthdata.nasa.gov/saml/acs',
path: '/saml/acs',
entryPoint: 'https://auth.launchpad-sbx.nasa.gov/affwebservices/public/saml2sso',
issuer: 'https://mmt.localtest.earthdata.nasa.gov/saml/acs',
cert: 'fake cert',
protocol: 'https://',
signatureAlgorithm: 'sha256',
digestAlgorithm: 'sha256',
authnContext: ['urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'],
identifierFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
}))
})

describe('samlLogin', () => {
test('returns a redirect to saml auth url where user passes a target', async () => {
const event = {
queryStringParameters: {
target: 'mock_target'
}
}

const response = await samlLogin(event)

expect(response.statusCode).toBe(307)
const regexp = `^${escapeRegExp('https://auth.launchpad-sbx.nasa.gov/affwebservices/public/saml2sso?SAMLRequest=')}.*RelayState=mock_target$`
expect(response.headers.Location).toMatch(RegExp(regexp))
})

test('returns a redirect to saml auth url even if we do not pass a target', async () => {
const event = {}

const response = await samlLogin(event)

expect(response.statusCode).toBe(307)
const regexp = `^${escapeRegExp('https://auth.launchpad-sbx.nasa.gov/affwebservices/public/saml2sso?SAMLRequest=')}.*`
expect(response.headers.Location).toMatch(RegExp(regexp))
})
})
26 changes: 26 additions & 0 deletions serverless/src/samlLogin/handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getSamlConfig } from '../../../static/src/js/utils/getConfig'

const { SAML } = require('@node-saml/node-saml')

/**
* Handles login authentication
* @param {Object} event Details about the HTTP request that it received
*/

const samlLogin = async (event) => {
const options = getSamlConfig()
const saml = new SAML(options)
const { queryStringParameters } = event
const { target: relayState } = queryStringParameters || {}

const authorizeUrl = await saml.getAuthorizeUrlAsync(relayState, options)

return {
statusCode: 307,
headers: {
Location: authorizeUrl
}
}
}

export default samlLogin
21 changes: 19 additions & 2 deletions static.config.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
{
"application": {
"mmtHost": "http://localhost:5173",
"apiHost": "http://localhost:4001/dev",
"graphQlHost": "http://localhost:3013/dev/api",
"cmrHost": "http://localhost:4000",
"version": "development"
"version": "development",
"jwtSecret": "jwt_secret"
},
"ummVersions": {
"ummC": "1.17.3",
"ummS": "1.4",
"ummT": "1.1",
"ummV": "1.9.0"
},
"saml": {
"host": "http://localhost:4001",
"callbackUrl": "http://localhost:4001/saml-acs",
"entryPoint": "https://auth.launchpad-sbx.nasa.gov/affwebservices/public/saml2sso",
"issuer": "http://localhost:4001/saml-acs",
"cert": "fake cert",
"protocol": "https://",
"signatureAlgorithm": "sha256",
"digestAlgorithm": "sha256",
"authnContext": [
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
],
"identifierFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
"cookieName": "SBXSESSION"
}
}
}
23 changes: 23 additions & 0 deletions static/src/js/utils/__tests__/createJwtToken.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { jwtDecode } from 'jwt-decode'
import createJwtToken from '../createJwtToken'

beforeEach(() => {
jest.clearAllMocks()
})

describe('createJwtToken', () => {
describe('creates a jwt token with a secret key', () => {
test('returns a jwt token, decodes it to verify its data', async () => {
const data = {
foo: 'bar',
alpha: 'beta'
}

const token = createJwtToken(data)
expect(jwtDecode(token)).toEqual({
foo: 'bar',
alpha: 'beta'
})
})
})
})
15 changes: 15 additions & 0 deletions static/src/js/utils/createJwtToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { getApplicationConfig } from './getConfig'

const sign = require('jwt-encode')

/**
* Create a signed JWT Token with user information
* @param {Object} user User object from database
*/
export const createJwtToken = (object) => {
const { jwtSecret } = getApplicationConfig()

return sign(object, jwtSecret)
}

export default createJwtToken
1 change: 1 addition & 0 deletions static/src/js/utils/getConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ const getConfig = () => staticConfig

export const getApplicationConfig = () => getConfig().application
export const getUmmVersionsConfig = () => getConfig().ummVersions
export const getSamlConfig = () => getConfig().saml
Loading