Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ export bamboo_RDF4J_PASSWORD=[your rdfdb password]
export bamboo_EDL_HOST=[edl host name]
export bamboo_EDL_UID=[edl user id]
export bamboo_EDL_PASSWORD=[edl password]
export bamboo_EDL_CLIENT_ID=[edl oauth client id]
export bamboo_CMR_BASE_URL=[cmr base url]
export bamboo_CMR_WRITER_TOKEN=[optional bearer token for CMR metadata writeback]
export bamboo_CMR_WRITEBACK_PROVIDERS=[optional provider id, comma-separated list, or ALL]
Expand Down
1 change: 1 addition & 0 deletions bin/deploy-bamboo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ dockerRun() {
--env "RDF4J_USER_NAME=$bamboo_RDF4J_USER_NAME" \
--env "RDF4J_PASSWORD=$bamboo_RDF4J_PASSWORD" \
--env "EDL_PASSWORD=$bamboo_EDL_PASSWORD" \
--env "EDL_CLIENT_ID=$bamboo_EDL_CLIENT_ID" \
--env "CMR_BASE_URL=$bamboo_CMR_BASE_URL" \
--env "CMR_WRITER_TOKEN=${bamboo_CMR_WRITER_TOKEN:-}" \
--env "CMR_WRITEBACK_PROVIDERS=${bamboo_CMR_WRITEBACK_PROVIDERS:-}" \
Expand Down
1 change: 1 addition & 0 deletions cdk/app/lib/KmsStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface KmsStackProps extends cdk.StackProps {
logDestinationArn: string
environment: {
CMR_BASE_URL: string
EDL_CLIENT_ID: string
EDL_PASSWORD: string
BLOCK_PUBLISH_ON_KEYWORD_DIFF_FAILURE?: string
LOG_LEVEL: string
Expand Down
1 change: 1 addition & 0 deletions cdk/app/lib/helper/KmsLambdaFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface LambdaFunctionsProps {
useLocalstack: boolean;
environment: {
CMR_BASE_URL: string;
EDL_CLIENT_ID: string;
EDL_PASSWORD: string;
BLOCK_PUBLISH_ON_KEYWORD_DIFF_FAILURE?: string;
REDIS_ENABLED?: string;
Expand Down
1 change: 1 addition & 0 deletions cdk/bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ async function main() {
RDF_BUCKET_NAME: process.env.RDF_BUCKET_NAME || 'kms-rdf-backup',
CMR_BASE_URL: cmrBaseUrl,
EDL_PASSWORD: process.env.EDL_PASSWORD || '',
EDL_CLIENT_ID: process.env.EDL_CLIENT_ID || '',
BLOCK_PUBLISH_ON_KEYWORD_DIFF_FAILURE: process.env.BLOCK_PUBLISH_ON_KEYWORD_DIFF_FAILURE,
LOG_LEVEL: process.env.LOG_LEVEL || 'INFO',
REDIS_ENABLED: redisEnabledValue,
Expand Down
3 changes: 3 additions & 0 deletions serverless/src/edlAuthorizer/__tests__/handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ vi.mock('@/shared/fetchEdlProfile')
describe('edlAuthorizer', () => {
const OLD_ENV = process.env
let loggerErrorSpy
let loggerInfoSpy

beforeEach(() => {
loggerErrorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {})
loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {})
process.env = { ...OLD_ENV }
fetchEdlProfile.mockReset()
fetchEdlProfile.mockResolvedValue({
Expand All @@ -28,6 +30,7 @@ describe('edlAuthorizer', () => {

afterAll(() => {
loggerErrorSpy?.mockRestore()
loggerInfoSpy?.mockRestore()
})

describe('when the token is for a valid user', () => {
Expand Down
134 changes: 111 additions & 23 deletions serverless/src/shared/__tests__/fetchEdlProfile.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,30 @@ import fetchEdlProfile from '../fetchEdlProfile'

vi.mock('../fetchEdlClientToken', () => ({ default: vi.fn() }))

const encodeBase64Url = (value) => Buffer
Comment thread
cgokey marked this conversation as resolved.
.from(JSON.stringify(value))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')

const createJwt = (payload) => [
encodeBase64Url({
alg: 'RS256',
typ: 'JWT'
}),
encodeBase64Url(payload),
'signature'
].join('.')

const originalConsoleLog = console.log
let loggerErrorSpy

beforeEach(() => {
vi.resetAllMocks()
console.log = vi.fn()
process.env.EDL_CLIENT_ID = 'kms-client-id'
process.env.EDL_PASSWORD = 'kms-client-password'
fetchEdlClientToken.mockImplementation(() => ('mock_token'))
loggerErrorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {})
})
Expand All @@ -20,73 +38,89 @@ afterEach(() => {
console.log = originalConsoleLog
loggerErrorSpy?.mockRestore()
loggerErrorSpy = undefined
delete process.env.EDL_CLIENT_ID
delete process.env.EDL_PASSWORD
delete process.env.IS_OFFLINE
})

describe('fetchEdlProfile', () => {
describe('when provided a Bearer access token', () => {
describe('when the token is valid', () => {
test('calls the oauth endpoint and returns the profile', async () => {
test('validates the token and returns the normalized profile', async () => {
const rawToken = createJwt({
uid: 'user.name',
assurance_level: 3
})

global.fetch = vi.fn(() => Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({
nams_auid: 'user.name',
uid: 'user.name',
first_name: 'User',
last_name: 'Name',
assurance_level: 3
uid: 'user.name'
})
}))

const profile = await fetchEdlProfile('Bearer bearer-token-value')
const profile = await fetchEdlProfile(`Bearer ${rawToken}`)

expect(profile).toEqual({
auid: 'user.name',
assuranceLevel: 3,
name: 'User Name',
name: 'user.name',
uid: 'user.name'
})

expect(fetchEdlClientToken).not.toHaveBeenCalled()
expect(fetch).toHaveBeenCalledTimes(1)
expect(fetch).toHaveBeenCalledWith('https://sit.urs.earthdata.nasa.gov/oauth/userInfo', {
headers: {
Authorization: 'Bearer bearer-token-value',
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
method: 'GET'
})
expect(fetch).toHaveBeenCalledWith(
'https://sit.urs.earthdata.nasa.gov/oauth/tokens/user',
Comment thread
cgokey marked this conversation as resolved.
Outdated
{
body: `client_id=${encodeURIComponent('kms-client-id')}&token=${encodeURIComponent(rawToken)}`,
headers: {
Accept: 'application/json',
Authorization: `Basic ${Buffer.from('kms-client-id:kms-client-password').toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
method: 'POST'
}
)
})
})

describe('when the oauth endpoint returns an unauthorized error', () => {
describe('when the token validation endpoint returns an unauthorized error', () => {
test('throws unauthorized', async () => {
const rawToken = createJwt({
uid: 'user.name',
assurance_level: 3
})
const mockResponse = {
ok: false,
status: 401,
json: () => Promise.resolve({ error: 'Unauthorized' })
}
global.fetch = vi.fn(() => Promise.resolve(mockResponse))

await expect(fetchEdlProfile('Bearer bearer-token-value')).rejects.toThrow('Unauthorized')
await expect(fetchEdlProfile(`Bearer ${rawToken}`)).rejects.toThrow('Unauthorized')
expect(fetchEdlClientToken).not.toHaveBeenCalled()
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledWith('EDL oauth error response:', mockResponse)
expect(logger.error).toHaveBeenCalledWith('EDL token validation error response:', mockResponse)
})
})

describe('when the oauth endpoint returns a non-auth error', () => {
describe('when the token validation endpoint returns a non-auth error', () => {
test('throws an error describing the failure', async () => {
const rawToken = createJwt({
uid: 'user.name',
assurance_level: 3
})
const mockResponse = {
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Server Error' })
}
global.fetch = vi.fn(() => Promise.resolve(mockResponse))

await expect(fetchEdlProfile('Bearer bearer-token-value')).rejects.toThrow('EDL oauth request failed with status 500')
await expect(fetchEdlProfile(`Bearer ${rawToken}`)).rejects.toThrow('EDL token validation request failed with status 500')
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledWith('EDL oauth error response:', mockResponse)
expect(logger.error).toHaveBeenCalledWith('EDL token validation error response:', mockResponse)
})
})

Expand All @@ -101,6 +135,61 @@ describe('fetchEdlProfile', () => {
expect(logger.error).toHaveBeenCalledWith('#fetchEdlProfile fetchEdlProfile Error:', expect.any(Error))
})
})

describe('when bearer validation configuration is missing', () => {
test('throws when EDL_CLIENT_ID is missing', async () => {
delete process.env.EDL_CLIENT_ID
global.fetch = vi.fn()

await expect(fetchEdlProfile(`Bearer ${createJwt({
uid: 'user.name',
assurance_level: 3
})}`)).rejects.toThrow('Missing EDL_CLIENT_ID configuration')

expect(fetch).not.toHaveBeenCalled()
})

test('throws when EDL_PASSWORD is missing', async () => {
delete process.env.EDL_PASSWORD
global.fetch = vi.fn()

await expect(fetchEdlProfile(`Bearer ${createJwt({
uid: 'user.name',
assurance_level: 3
})}`)).rejects.toThrow('Missing EDL_PASSWORD configuration')

expect(fetch).not.toHaveBeenCalled()
})
})

describe('when the token validation payload is malformed', () => {
test('throws an error when uid is missing', async () => {
const rawToken = createJwt({
uid: 'user.name',
assurance_level: 3
})

global.fetch = vi.fn(() => Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({})
}))

await expect(fetchEdlProfile(`Bearer ${rawToken}`)).rejects.toThrow('EDL token validation response missing uid')
})

test('throws an error when the jwt payload cannot be decoded', async () => {
global.fetch = vi.fn(() => Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({
uid: 'user.name'
})
}))

await expect(fetchEdlProfile('Bearer not-a-jwt')).rejects.toThrow('Invalid EDL token format')
})
})
})

describe('when provided a Launchpad access token', () => {
Expand Down Expand Up @@ -216,7 +305,6 @@ describe('fetchEdlProfile', () => {
})

expect(fetch).toHaveBeenCalledTimes(0)
process.env.IS_OFFLINE = false
})
})
})
77 changes: 66 additions & 11 deletions serverless/src/shared/fetchEdlProfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,36 @@ const buildEdlError = (response, source) => {
return new Error(`${source} failed with status ${status}`)
}

/**
* Builds the Basic authorization header value for app-authenticated EDL calls.
* @param {string} clientId Registered EDL application client id
* @param {string} password Registered EDL application password
* @returns {string} HTTP Basic authorization header value
*/
const buildBasicAuthorizationHeader = (clientId, password) => (
`Basic ${Buffer.from(`${clientId}:${password}`).toString('base64')}`
)

/**
* Decodes JWT claims from the token payload.
* @param {string} edlToken Direct EDL access token
* @returns {Object} Parsed JWT claims
*/
const decodeJwtClaims = (edlToken) => {
const payload = edlToken.split('.')[1]

if (!payload) {
throw new Error('Invalid EDL token format')
}

const normalizedPayload = payload
.replace(/-/g, '+')
.replace(/_/g, '/')
const paddedPayload = normalizedPayload.padEnd(Math.ceil(normalizedPayload.length / 4) * 4, '=')

return JSON.parse(Buffer.from(paddedPayload, 'base64').toString('utf8'))
}

/**
* Fetches the user profile using a Launchpad token via the Launchpad gateway
* @param {string} host EDL host base URL
Expand Down Expand Up @@ -81,31 +111,56 @@ const fetchProfileWithLaunchpadToken = async (host, launchpadToken) => {
}

/**
* Fetches the user profile directly from EDL using an access token
* Validates the bearer token with EDL and derives assurance level from the JWT.
* @param {string} host EDL host base URL
* @param {string} edlToken Direct EDL access token (Bearer token)
* @returns {Promise<Object>} normalized profile from the oauth endpoint
* @returns {Promise<Object>} normalized profile from validated bearer token data
*/
const fetchProfileWithEdlAccessToken = async (host, edlToken) => {
const response = await fetch(`${host}/oauth/userInfo`, {
method: 'GET',
const {
EDL_CLIENT_ID: clientId,
EDL_PASSWORD: password
} = process.env

if (!clientId) {
throw new Error('Missing EDL_CLIENT_ID configuration')
}

if (!password) {
throw new Error('Missing EDL_PASSWORD configuration')
}

const response = await fetch(`${host}/oauth/tokens/user`, {
method: 'POST',
headers: {
Authorization: `Bearer ${edlToken}`,
Accept: 'application/json',
Authorization: buildBasicAuthorizationHeader(clientId, password),
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}
},
body: `client_id=${encodeURIComponent(clientId)}&token=${encodeURIComponent(edlToken)}`
})

logger.debug('EDL oauth response status:', response.status)
logger.debug('EDL token validation response status:', response.status)

if (!response.ok) {
logger.error('EDL oauth error response:', response)
throw buildEdlError(response, 'EDL oauth request')
logger.error('EDL token validation error response:', response)
throw buildEdlError(response, 'EDL token validation request')
}

const profile = await response.json()
logger.debug('Received EDL oauth profile:', JSON.stringify(profile, null, 2))
logger.debug('Received EDL token validation payload:', JSON.stringify(profile, null, 2))

if (!profile.uid) {
throw new Error('EDL token validation response missing uid')
}

return buildProfile(profile)
const claims = decodeJwtClaims(edlToken)

return {
name: profile.uid,
uid: profile.uid,
assuranceLevel: claims.assurance_level
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/**
Expand Down
Loading