diff --git a/README.md b/README.md index 8ac26715..0d48a9ee 100644 --- a/README.md +++ b/README.md @@ -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] diff --git a/bin/deploy-bamboo.sh b/bin/deploy-bamboo.sh index 556ad322..d4454d5f 100755 --- a/bin/deploy-bamboo.sh +++ b/bin/deploy-bamboo.sh @@ -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:-}" \ diff --git a/cdk/app/lib/KmsStack.ts b/cdk/app/lib/KmsStack.ts index 60609043..fdc6e6d1 100644 --- a/cdk/app/lib/KmsStack.ts +++ b/cdk/app/lib/KmsStack.ts @@ -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 diff --git a/cdk/app/lib/helper/KmsLambdaFunctions.ts b/cdk/app/lib/helper/KmsLambdaFunctions.ts index 05477e64..ce2484f7 100644 --- a/cdk/app/lib/helper/KmsLambdaFunctions.ts +++ b/cdk/app/lib/helper/KmsLambdaFunctions.ts @@ -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; diff --git a/cdk/bin/main.ts b/cdk/bin/main.ts index c62ff5ea..da797a10 100644 --- a/cdk/bin/main.ts +++ b/cdk/bin/main.ts @@ -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, diff --git a/serverless/src/edlAuthorizer/__tests__/handler.test.js b/serverless/src/edlAuthorizer/__tests__/handler.test.js index 14743511..8b7507d3 100644 --- a/serverless/src/edlAuthorizer/__tests__/handler.test.js +++ b/serverless/src/edlAuthorizer/__tests__/handler.test.js @@ -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({ @@ -28,6 +30,7 @@ describe('edlAuthorizer', () => { afterAll(() => { loggerErrorSpy?.mockRestore() + loggerInfoSpy?.mockRestore() }) describe('when the token is for a valid user', () => { diff --git a/serverless/src/shared/__tests__/fetchEdlProfile.test.js b/serverless/src/shared/__tests__/fetchEdlProfile.test.js index 5ec11cdf..a18dc498 100644 --- a/serverless/src/shared/__tests__/fetchEdlProfile.test.js +++ b/serverless/src/shared/__tests__/fetchEdlProfile.test.js @@ -1,3 +1,4 @@ +import * as getConfig from '@/shared/getConfig' import { logger } from '@/shared/logger' import fetchEdlClientToken from '../fetchEdlClientToken' @@ -5,62 +6,103 @@ import fetchEdlProfile from '../fetchEdlProfile' vi.mock('../fetchEdlClientToken', () => ({ default: vi.fn() })) +const TEST_EDL_HOST = 'https://edl.example.test' + +const encodeBase64Url = (value) => Buffer + .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 getEdlConfigSpy 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')) + getEdlConfigSpy = vi.spyOn(getConfig, 'getEdlConfig').mockImplementation(() => ({ + host: TEST_EDL_HOST + })) + loggerErrorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {}) }) afterEach(() => { vi.clearAllMocks() console.log = originalConsoleLog + + getEdlConfigSpy?.mockRestore() + getEdlConfigSpy = undefined + 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( + `${TEST_EDL_HOST}/oauth/tokens/user`, + { + 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, @@ -68,15 +110,19 @@ describe('fetchEdlProfile', () => { } 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, @@ -84,9 +130,9 @@ describe('fetchEdlProfile', () => { } 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) }) }) @@ -101,6 +147,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', () => { @@ -127,7 +228,7 @@ describe('fetchEdlProfile', () => { }) expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith('https://sit.urs.earthdata.nasa.gov/api/nams/edl_user', { + expect(fetch).toHaveBeenCalledWith(`${TEST_EDL_HOST}/api/nams/edl_user`, { body: 'token=mock-token', headers: { Authorization: 'Bearer mock_token', @@ -161,7 +262,7 @@ describe('fetchEdlProfile', () => { }) expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith('https://sit.urs.earthdata.nasa.gov/api/nams/edl_user', { + expect(fetch).toHaveBeenCalledWith(`${TEST_EDL_HOST}/api/nams/edl_user`, { body: 'token=mock-token', headers: { Authorization: 'Bearer mock_token', @@ -216,7 +317,6 @@ describe('fetchEdlProfile', () => { }) expect(fetch).toHaveBeenCalledTimes(0) - process.env.IS_OFFLINE = false }) }) }) diff --git a/serverless/src/shared/fetchEdlProfile.js b/serverless/src/shared/fetchEdlProfile.js index 0fe9aa02..7f56b48b 100644 --- a/serverless/src/shared/fetchEdlProfile.js +++ b/serverless/src/shared/fetchEdlProfile.js @@ -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 @@ -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} normalized profile from the oauth endpoint + * @returns {Promise} 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 + } } /**