Skip to content
Draft
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
1 change: 0 additions & 1 deletion bin/api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ const resolveMmtHost = () => {
}

const localEnvDefaults = {
COOKIE_DOMAIN: '.localhost',
JWT_SECRET: 'local-secret',
JWT_VALID_TIME: '900',
MMT_HOST: resolveMmtHost()
Expand Down
2 changes: 0 additions & 2 deletions bin/deploy-bamboo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ config="`jq '.application.edscHost = $newValue' --arg newValue $bamboo_EDSC_HOST
config="`jq '.application.gkrHost = $newValue' --arg newValue $bamboo_GKR_HOST <<< $config`"
config="`jq '.application.kmsHost = $newValue' --arg newValue $bamboo_KMS_HOST <<< $config`"
config="`jq '.application.mmtKeywordManagerClientId = $newValue' --arg newValue "mmt-keyword-manager-$bamboo_STAGE_NAME" <<< $config`"
config="`jq '.application.cookieDomain = $newValue' --arg newValue $bamboo_COOKIE_DOMAIN <<< $config`"
config="`jq '.application.displayProdWarning = $newValue' --arg newValue $bamboo_DISPLAY_PROD_WARNING <<< $config`"
config="`jq '.application.tokenValidTime = $newValue' --arg newValue $bamboo_JWT_VALID_TIME <<< $config`"
config="`jq '.application.analytics.gtmPropertyId = $newValue' --arg newValue $bamboo_GTM_PROPERTY_ID <<< $config`"
Expand Down Expand Up @@ -76,7 +75,6 @@ dockerRun() {
-e "AWS_SECRET_ACCESS_KEY=$bamboo_AWS_SECRET_ACCESS_KEY" \
-e "AWS_SESSION_TOKEN=$bamboo_AWS_SESSION_TOKEN" \
-e "COLLECTION_TEMPLATES_BUCKET_NAME=${bamboo_COLLECTION_TEMPLATES_BUCKET_NAME}" \
-e "COOKIE_DOMAIN=$bamboo_COOKIE_DOMAIN" \
-e "DISPLAY_PROD_WARNING=$bamboo_DISPLAY_PROD_WARNING" \
-e "EDL_CLIENT_ID=$bamboo_EDL_CLIENT_ID" \
-e "EDL_PASSWORD=$bamboo_EDL_PASSWORD" \
Expand Down
2 changes: 0 additions & 2 deletions cdk/mmt/lib/mmt-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export interface MmtStackProps extends cdk.StackProps {}
const {
STAGE_NAME = 'dev',
COLLECTION_TEMPLATES_BUCKET_NAME = `mmt-${STAGE_NAME}-collection-templates`,
COOKIE_DOMAIN = '.localhost',
EDL_CLIENT_ID = '',
EDL_PASSWORD = '',
GTM_PROPERTY_ID = '',
Expand Down Expand Up @@ -80,7 +79,6 @@ export class MmtStack extends cdk.Stack {

const environment = {
COLLECTION_TEMPLATES_BUCKET_NAME,
COOKIE_DOMAIN,
EDL_CLIENT_ID,
EDL_PASSWORD,
GTM_PROPERTY_ID,
Expand Down
16 changes: 16 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@
<html lang="en" class="w-100 h-100">
<head>
<meta charset="UTF-8" />
<!--If the login redirect left a token in the URL, take it out before anything else on the page runs.

This has to be a classic inline script and it has to be first. Module scripts are deferred until
parsing finishes, so main.jsx cannot get ahead of the analytics and widget scripts below and
those read location.href. Clearing the fragment here keeps the token out of what they collect and
the app picks it up from window.mmtAuthToken instead. -->
<script>
(function () {
if (window.location.pathname !== '/auth-callback') return
if (window.location.hash.indexOf('token=') === -1) return

window.mmtAuthToken = new URLSearchParams(window.location.hash.slice(1)).get('token')

window.history.replaceState(null, '', window.location.pathname + window.location.search)
})()
</script>
<link rel="icon" type="image/svg+xml" href="/static/src/assets/images/logos/nasa-meatball-new.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Metadata Management Tool</title>
Expand Down
126 changes: 32 additions & 94 deletions serverless/src/edlCallback/__tests__/handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import edlCallback from '../handler'
import * as getConfig from '../../../../sharedUtils/getConfig'
import fetchEdlProfile from '../../utils/fetchEdlProfile'
import createJwt from '../../utils/createJwt'
import * as createCookieModule from '../../utils/createCookie'

beforeAll(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
Expand All @@ -20,7 +19,6 @@ afterAll(() => {
vi.restoreAllMocks()
})

const realCreateCookie = createCookieModule.default
vi.mock('../../utils/AuthorizationCode', () => ({
default: vi.fn()
}))
Expand All @@ -46,15 +44,27 @@ vi.mock('@sharedUtils/getConfig', () => {
vi.mock('../../utils/fetchEdlProfile')
vi.mock('../../utils/createJwt')

describe('edlCallback', () => {
let createCookieSpy
/**
* The complete set of headers the handler returns when redirecting back to MMT
*
* COMMENT TO BE REMVOED AFTER PR: Two things missing here: Set-Cookie because the token
* now travels in the URL fragment, and Access Control Allow Credentials because
* nothing makes a credentialed request against this endpoint and pairing that
* header with a wildcard origin is invalid per the CORS spec.
*/
const redirectHeaders = (location) => ({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Methods': 'GET, POST',
Location: location
})

describe('edlCallback', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.spyOn(Date, 'now').mockReturnValue(new Date('2022-12-31T23:45:00Z').getTime())
process.env.EDL_CLIENT_ID = 'test-client-id'
process.env.EDL_PASSWORD = 'test-client-secret'
process.env.COOKIE_DOMAIN = '.example.com'
delete process.env.IS_OFFLINE
delete process.env.JWT_VALID_TIME

Expand All @@ -70,9 +80,6 @@ describe('edlCallback', () => {
})

createJwt.mockReturnValue('test-jwt')
createCookieSpy = vi
.spyOn(createCookieModule, 'default')
.mockImplementation((...args) => realCreateCookie(...args))

AuthorizationCode.mockImplementation(() => ({
getToken: vi.fn().mockResolvedValue(undefined)
Expand All @@ -81,7 +88,7 @@ describe('edlCallback', () => {

describe('when handling EDL callback', () => {
describe('when the callback is successful', () => {
test('should return a redirect with a JWT cookie', async () => {
test('should return a redirect with a JWT in the URL fragment', async () => {
const mockEvent = {
queryStringParameters: {
code: 'test-code',
Expand All @@ -107,8 +114,7 @@ describe('edlCallback', () => {
const response = await edlCallback(mockEvent)

expect(response.statusCode).toBe(303)
expect(response.headers.Location).toBe('https://mmt.example.com/auth-callback?target=%2Fdashboard')
expect(response.headers['Set-Cookie']).toBe('_mmt_jwt_test=test-jwt; SameSite=Strict; Path=/; Domain=.example.com; Max-Age=900; Secure;')
expect(response.headers.Location).toBe('https://mmt.example.com/auth-callback?target=%2Fdashboard#token=test-jwt')
})
})

Expand Down Expand Up @@ -188,7 +194,6 @@ describe('edlCallback', () => {

const response = await edlCallback(mockEvent)
const offlineExpiration = '2023-01-01T00:15:00.000Z'
const expectedExpirationSeconds = Math.floor(new Date(offlineExpiration).getTime() / 1000)

expect(AuthorizationCode).not.toHaveBeenCalled()
expect(fetchEdlProfile).toHaveBeenCalledWith('ABC-1')
Expand All @@ -199,9 +204,8 @@ describe('edlCallback', () => {
{ uid: 'offline-user' }
)

expect(createCookieSpy).toHaveBeenCalledWith('test-jwt', expectedExpirationSeconds)
expect(response.statusCode).toBe(303)
expect(response.headers.Location).toBe('https://mmt.example.com/auth-callback?target=%2Fdashboard')
expect(response.headers).toEqual(redirectHeaders('https://mmt.example.com/auth-callback?target=%2Fdashboard#token=test-jwt'))

delete process.env.IS_OFFLINE
})
Expand Down Expand Up @@ -278,7 +282,7 @@ describe('edlCallback', () => {

const response = await edlCallback(mockEvent)

expect(response.headers.Location).toBe('https://mmt.example.com/auth-callback?target=%2F')
expect(response.headers).toEqual(redirectHeaders('https://mmt.example.com/auth-callback?target=%2F#token=test-jwt'))
})

test('should handle custom target in state', async () => {
Expand Down Expand Up @@ -306,12 +310,12 @@ describe('edlCallback', () => {

const response = await edlCallback(mockEvent)

expect(response.headers.Location).toBe('https://mmt.example.com/auth-callback?target=%2Fcustom-page')
expect(response.headers).toEqual(redirectHeaders('https://mmt.example.com/auth-callback?target=%2Fcustom-page#token=test-jwt'))
})
})

describe('when creating JWT and cookie', () => {
test('should call createJwt and createCookie with correct parameters', async () => {
describe('when creating JWT', () => {
test('should call createJwt with correct parameters', async () => {
const mockEvent = {
queryStringParameters: {
code: 'test-code',
Expand All @@ -335,10 +339,7 @@ describe('edlCallback', () => {
}
fetchEdlProfile.mockResolvedValue(mockEdlProfile)

await edlCallback(mockEvent)
const expectedExpirationSeconds = Math.floor(
new Date(mockToken.expires_at).getTime() / 1000
)
const response = await edlCallback(mockEvent)

expect(createJwt).toHaveBeenCalledWith(
mockToken.access_token,
Expand All @@ -347,7 +348,7 @@ describe('edlCallback', () => {
mockEdlProfile
)

expect(createCookieSpy).toHaveBeenCalledWith('test-jwt', expectedExpirationSeconds)
expect(response.headers).toEqual(redirectHeaders('https://mmt.example.com/auth-callback?target=%2F#token=test-jwt'))
})
})

Expand Down Expand Up @@ -376,8 +377,8 @@ describe('edlCallback', () => {
})
})

describe('when handling CORS headers', () => {
test('should include correct CORS headers in the response', async () => {
describe('when handing the token back to MMT', () => {
test('should url encode the token in the fragment', async () => {
const mockEvent = {
queryStringParameters: {
code: 'test-code',
Expand All @@ -400,42 +401,11 @@ describe('edlCallback', () => {
assuranceLevel: 5
})

const response = await edlCallback(mockEvent)

expect(response.headers['Access-Control-Allow-Origin']).toBe('*')
expect(response.headers['Access-Control-Allow-Headers']).toBe('*')
expect(response.headers['Access-Control-Allow-Methods']).toBe('GET, POST')
expect(response.headers['Access-Control-Allow-Credentials']).toBe(true)
})
})

describe('when handling cookie naming', () => {
test('should include the environment name in the cookie prefix', async () => {
const mockEvent = {
queryStringParameters: {
code: 'test-code',
state: encodeURIComponent(JSON.stringify({ target: '/' }))
}
}

AuthorizationCode.mockImplementation(() => ({
getToken: vi.fn().mockResolvedValue({
token: {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_at: '2023-01-01T00:00:00Z'
}
})
}))

fetchEdlProfile.mockResolvedValue({
uid: 'test-user',
assuranceLevel: 5
})
createJwt.mockReturnValue('jwt/with+reserved=characters')

const response = await edlCallback(mockEvent)

expect(response.headers['Set-Cookie']).toContain('_mmt_jwt_test=')
expect(response.headers).toEqual(redirectHeaders('https://mmt.example.com/auth-callback?target=%2F#token=jwt%2Fwith%2Breserved%3Dcharacters'))
})
})

Expand Down Expand Up @@ -538,8 +508,7 @@ describe('edlCallback', () => {
const response = await edlCallback(mockEvent)

expect(response.statusCode).toBe(303)
expect(response.headers.Location).toBe('https://mmt.example.com/auth-callback?target=%2F')
expect(response.headers['Set-Cookie']).toBe('_mmt_jwt_test=test-jwt; SameSite=Strict; Path=/; Domain=.example.com; Max-Age=900; Secure;')
expect(response.headers).toEqual(redirectHeaders('https://mmt.example.com/auth-callback?target=%2F#token=test-jwt'))
})
})

Expand Down Expand Up @@ -621,11 +590,11 @@ describe('edlCallback', () => {
})
}))

expect(response.headers.Location).toBe('https://custom-mmt.example.com/auth-callback?target=%2F')
expect(response.headers).toEqual(redirectHeaders('https://custom-mmt.example.com/auth-callback?target=%2F#token=test-jwt'))
})
})

describe('when handling errors in createJwt or createCookie', () => {
describe('when handling errors in createJwt', () => {
test('should throw an error if createJwt fails', async () => {
const mockEvent = {
queryStringParameters: {
Expand Down Expand Up @@ -655,37 +624,6 @@ describe('edlCallback', () => {

await expect(edlCallback(mockEvent)).rejects.toThrow('JWT creation failed')
})

test('should throw an error if createCookie fails', async () => {
const mockEvent = {
queryStringParameters: {
code: 'test-code',
state: encodeURIComponent(JSON.stringify({ target: '/' }))
}
}

AuthorizationCode.mockImplementation(() => ({
getToken: vi.fn().mockResolvedValue({
token: {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_at: '2023-01-01T00:00:00Z'
}
})
}))

fetchEdlProfile.mockResolvedValue({
uid: 'test-user',
assuranceLevel: 5
})

createJwt.mockReturnValue('test-jwt')
createCookieSpy.mockImplementation(() => {
throw new Error('Cookie creation failed')
})

await expect(edlCallback(mockEvent)).rejects.toThrow('Cookie creation failed')
})
})

describe('when handling unusual target URLs', () => {
Expand Down Expand Up @@ -714,7 +652,7 @@ describe('edlCallback', () => {

const response = await edlCallback(mockEvent)

expect(response.headers.Location).toBe('https://mmt.example.com/auth-callback?target=%2Funusual%20path%3Fparam%3Dvalue%26other%3D123')
expect(response.headers).toEqual(redirectHeaders('https://mmt.example.com/auth-callback?target=%2Funusual%20path%3Fparam%3Dvalue%26other%3D123#token=test-jwt'))
})
})

Expand Down
12 changes: 6 additions & 6 deletions serverless/src/edlCallback/handler.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { getApplicationConfig, getEdlConfig } from '../../../sharedUtils/getConfig'
import fetchEdlProfile from '../utils/fetchEdlProfile'
import createJwt from '../utils/createJwt'
import createCookie from '../utils/createCookie'
import AuthorizationCode from '../utils/AuthorizationCode'

/**
Expand Down Expand Up @@ -108,18 +107,19 @@ const edlCallback = async (event) => {
// Create JWT with EDL token and edl profile
const jwt = createJwt(accessToken, refreshToken, expiresAt, edlProfile)

const location = `${mmtHost}/auth-callback?target=${encodeURIComponent(target)}`

const expiresAtInSeconds = Math.floor(new Date(expiresAt).getTime() / 1000)
// The token is handed back in the URL fragment rather than a 'Set-Cookie'
// header. This handler runs on the API host, which sits on a different domain
// than MMT, so any cookie set would have to be scoped to a domain shared by
// every environment and would then be sent on requests to all of them. MMT
// stores the token itself, keeping the cookie scoped to its own host.
const location = `${mmtHost}/auth-callback?target=${encodeURIComponent(target)}#token=${encodeURIComponent(jwt)}`

const response = {
statusCode: 303,
headers: {
'Set-Cookie': createCookie(jwt, expiresAtInSeconds),
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Methods': 'GET, POST',
'Access-Control-Allow-Credentials': true,
Location: location
}
}
Expand Down
Loading
Loading