From 2f3bce7af46270d4396e48c55420cfce51385d87 Mon Sep 17 00:00:00 2001 From: Md Mozammil Khan Date: Wed, 29 Oct 2025 15:28:21 +0530 Subject: [PATCH 1/4] authentication settings and implementation --- .../aws-kinesis/generated-types.ts | 11 +++++- .../src/destinations/aws-kinesis/index.ts | 38 +++++++++++++------ .../src/destinations/aws-kinesis/utils.ts | 4 ++ .../destination-actions/src/lib/AWS/sts.ts | 36 ++++++++++++++++++ .../destination-actions/src/lib/AWS/utils.ts | 1 + 5 files changed, 78 insertions(+), 12 deletions(-) create mode 100644 packages/destination-actions/src/destinations/aws-kinesis/utils.ts create mode 100644 packages/destination-actions/src/lib/AWS/utils.ts diff --git a/packages/destination-actions/src/destinations/aws-kinesis/generated-types.ts b/packages/destination-actions/src/destinations/aws-kinesis/generated-types.ts index 4ab2786ec60..21b6b7e5bf0 100644 --- a/packages/destination-actions/src/destinations/aws-kinesis/generated-types.ts +++ b/packages/destination-actions/src/destinations/aws-kinesis/generated-types.ts @@ -1,3 +1,12 @@ // Generated file. DO NOT MODIFY IT BY HAND. -export interface Settings {} +export interface Settings { + /** + * The ARN of the IAM Role to assume for sending data to Kinesis. + */ + iamRoleArn: string + /** + * The external ID to use when assuming the IAM Role. Generate a secure string and treat it like a password. This is often used as an additional security measure. + */ + iamExternalId: string +} diff --git a/packages/destination-actions/src/destinations/aws-kinesis/index.ts b/packages/destination-actions/src/destinations/aws-kinesis/index.ts index f6ef61f86aa..d148b5e1ef4 100644 --- a/packages/destination-actions/src/destinations/aws-kinesis/index.ts +++ b/packages/destination-actions/src/destinations/aws-kinesis/index.ts @@ -1,5 +1,9 @@ import type { DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' +import { IntegrationError } from '@segment/actions-core' +import { assumeRole } from '../../lib/AWS/sts' +import { validateIamRoleArnFormat } from './utils' +import { APP_AWS_REGION } from '../../lib/AWS/utils' import send from './send' @@ -10,20 +14,32 @@ const destination: DestinationDefinition = { authentication: { scheme: 'custom', - fields: {}, - testAuthentication: (_) => { - // Return a request that tests/validates the user's credentials. - // If you do not have a way to validate the authentication fields safely, - // you can remove the `testAuthentication` function, though discouraged. + fields: { + iamRoleArn: { + label: 'IAM Role ARN', + description: 'The ARN of the IAM Role to assume for sending data to Kinesis.', + type: 'string', + required: true + }, + iamExternalId: { + label: 'IAM External ID', + description: + 'The external ID to use when assuming the IAM Role. Generate a secure string and treat it like a password. This is often used as an additional security measure.', + type: 'password', + required: true + } + }, + testAuthentication: async (_, { settings }) => { + const { iamRoleArn, iamExternalId } = settings + console.log(123) + if (!validateIamRoleArnFormat(iamRoleArn)) { + throw new IntegrationError('The provided IAM Role ARN format is not valid', 'INVALID_IAM_ROLE_ARN_FORMAT', 400) + } + console.log(234) + await assumeRole(iamRoleArn, iamExternalId, APP_AWS_REGION) } }, - onDelete: async (_, _1) => { - // Return a request that performs a GDPR delete for the provided Segment userId or anonymousId - // provided in the payload. If your destination does not support GDPR deletion you should not - // implement this function and should remove it completely. - }, - actions: { send } diff --git a/packages/destination-actions/src/destinations/aws-kinesis/utils.ts b/packages/destination-actions/src/destinations/aws-kinesis/utils.ts new file mode 100644 index 00000000000..0a5d8006874 --- /dev/null +++ b/packages/destination-actions/src/destinations/aws-kinesis/utils.ts @@ -0,0 +1,4 @@ +export const validateIamRoleArnFormat = (arn: string): boolean => { + const iamRoleArnRegex = /^arn:aws:iam::\d{12}:role\/[A-Za-z0-9+=,.@_\-/]+$/ + return iamRoleArnRegex.test(arn) +} diff --git a/packages/destination-actions/src/lib/AWS/sts.ts b/packages/destination-actions/src/lib/AWS/sts.ts index 74dd0df60a0..e4315d3de83 100644 --- a/packages/destination-actions/src/lib/AWS/sts.ts +++ b/packages/destination-actions/src/lib/AWS/sts.ts @@ -1,5 +1,8 @@ import { readFileSync } from 'fs' import { RequestClient } from '@segment/actions-core' +import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts' +import { IntegrationError, ErrorCodes } from '@segment/actions-core' +import { v4 as uuidv4 } from '@lukeed/uuid' export type AWSCredentials = { accessKeyId: string @@ -128,3 +131,36 @@ export async function getAWSCredentialsFromEKS(request: RequestClient): Promise< return credentials } + +export const assumeRole = async (roleArn: string, externalId: string, region: string): Promise => { + const intermediaryARN = process.env.AMAZON_S3_ACTIONS_ROLE_ADDRESS as string + const intermediaryExternalId = process.env.AMAZON_S3_ACTIONS_EXTERNAL_ID as string + const intermediaryCreds = await getSTSCredentials(intermediaryARN, intermediaryExternalId, region) + return getSTSCredentials(roleArn, externalId, region, intermediaryCreds) +} + +const getSTSCredentials = async (roleId: string, externalId: string, region: string, credentials?: AWSCredentials) => { + const options = { credentials, region: region } + const stsClient = new STSClient(options) + const roleSessionName: string = uuidv4() + const command = new AssumeRoleCommand({ + RoleArn: roleId, + RoleSessionName: roleSessionName, + ExternalId: externalId + }) + const result = await stsClient.send(command) + if ( + !result.Credentials || + !result.Credentials.AccessKeyId || + !result.Credentials.SecretAccessKey || + !result.Credentials.SessionToken + ) { + throw new IntegrationError('Failed to assume role', ErrorCodes.INVALID_AUTHENTICATION, 403) + } + + return { + accessKeyId: result.Credentials.AccessKeyId, + secretAccessKey: result.Credentials.SecretAccessKey, + sessionToken: result.Credentials.SessionToken + } +} diff --git a/packages/destination-actions/src/lib/AWS/utils.ts b/packages/destination-actions/src/lib/AWS/utils.ts new file mode 100644 index 00000000000..5492b40f1c8 --- /dev/null +++ b/packages/destination-actions/src/lib/AWS/utils.ts @@ -0,0 +1 @@ +export const APP_AWS_REGION = process.env['AWS_REGION'] || `us-west-2` From f0b8e05b33c7f80eb88f7842430a30192e42af98 Mon Sep 17 00:00:00 2001 From: Md Mozammil Khan Date: Wed, 29 Oct 2025 22:15:22 +0530 Subject: [PATCH 2/4] adding tests --- .../aws-kinesis/__test__/index.test.ts | 75 ++++++++++++ .../aws-kinesis/__test__/utils.test.ts | 56 +++++++++ .../src/destinations/aws-kinesis/index.ts | 3 +- .../src/lib/AWS/__test__/index.test.ts | 107 ++++++++++++++++++ 4 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/aws-kinesis/__test__/utils.test.ts create mode 100644 packages/destination-actions/src/lib/AWS/__test__/index.test.ts diff --git a/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts b/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts new file mode 100644 index 00000000000..57aac7d316d --- /dev/null +++ b/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts @@ -0,0 +1,75 @@ +import destination from '../index' +import { assumeRole } from '../../../lib/AWS/sts' +import { validateIamRoleArnFormat } from '../utils' +import { APP_AWS_REGION } from '../../../lib/AWS/utils' + +// --- Mock all dependencies --- +jest.mock('../../../lib/AWS/sts', () => ({ + assumeRole: jest.fn() +})) + +jest.mock('../utils', () => ({ + validateIamRoleArnFormat: jest.fn() +})) + +jest.mock('@segment/actions-core', () => ({ + IntegrationError: jest.fn().mockImplementation((message, code, status) => ({ + name: 'IntegrationError', + message, + code, + status + })) +})) + +jest.mock('../../../lib/AWS/utils', () => ({ + APP_AWS_REGION: 'us-east-1' +})) + +describe('AWS Kinesis Destination - testAuthentication', () => { + const testAuth = destination.authentication!.testAuthentication! + + const validSettings = { + iamRoleArn: 'arn:aws:iam::123456789012:role/MyRole', + iamExternalId: 'external-id' + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should call assumeRole when IAM Role ARN format is valid', async () => { + ;(validateIamRoleArnFormat as jest.Mock).mockReturnValue(true) + ;(assumeRole as jest.Mock).mockResolvedValue({ + accessKeyId: 'AKIA...', + secretAccessKey: 'SECRET...', + sessionToken: 'TOKEN...' + }) + + await expect(testAuth({}, { settings: validSettings })).resolves.not.toThrow() + + expect(validateIamRoleArnFormat).toHaveBeenCalledWith(validSettings.iamRoleArn) + expect(assumeRole).toHaveBeenCalledWith(validSettings.iamRoleArn, validSettings.iamExternalId, APP_AWS_REGION) + }) + + it('should throw IntegrationError if IAM Role ARN format is invalid', async () => { + ;(validateIamRoleArnFormat as jest.Mock).mockReturnValue(false) + + const result = testAuth({}, { settings: validSettings }) + + await expect(result).rejects.toMatchObject({ + name: 'IntegrationError', + message: 'The provided IAM Role ARN format is not valid', + code: 'INVALID_IAM_ROLE_ARN_FORMAT', + status: 400 + }) + + expect(assumeRole).not.toHaveBeenCalled() + }) + + it('should propagate errors from assumeRole', async () => { + ;(validateIamRoleArnFormat as jest.Mock).mockReturnValue(true) + ;(assumeRole as jest.Mock).mockRejectedValue(new Error('AssumeRole failed')) + + await expect(testAuth({}, { settings: validSettings })).rejects.toThrow('AssumeRole failed') + }) +}) diff --git a/packages/destination-actions/src/destinations/aws-kinesis/__test__/utils.test.ts b/packages/destination-actions/src/destinations/aws-kinesis/__test__/utils.test.ts new file mode 100644 index 00000000000..f1d85f6df6a --- /dev/null +++ b/packages/destination-actions/src/destinations/aws-kinesis/__test__/utils.test.ts @@ -0,0 +1,56 @@ +import { validateIamRoleArnFormat } from '../utils' + +describe('validateIamRoleArnFormat', () => { + it('should return true for a valid IAM Role ARN', () => { + const validArns = [ + 'arn:aws:iam::123456789012:role/MyRole', + 'arn:aws:iam::000000000000:role/service-role/My-Service_Role', + 'arn:aws:iam::987654321098:role/path/to/MyRole', + 'arn:aws:iam::111122223333:role/MyRole-With.Special@Chars_+=,.' + ] + + for (const arn of validArns) { + expect(validateIamRoleArnFormat(arn)).toBe(true) + } + }) + + it('should return false for an ARN with invalid prefix', () => { + const invalidArn = 'arn:aws:s3::123456789012:role/MyRole' + expect(validateIamRoleArnFormat(invalidArn)).toBe(false) + }) + + it('should return false if missing account ID', () => { + const invalidArn = 'arn:aws:iam:::role/MyRole' + expect(validateIamRoleArnFormat(invalidArn)).toBe(false) + }) + + it('should return false if account ID is not 12 digits', () => { + const invalidArns = ['arn:aws:iam::12345:role/MyRole', 'arn:aws:iam::1234567890123:role/MyRole'] + for (const arn of invalidArns) { + expect(validateIamRoleArnFormat(arn)).toBe(false) + } + }) + + it('should return false if missing "role/" segment', () => { + const invalidArn = 'arn:aws:iam::123456789012:MyRole' + expect(validateIamRoleArnFormat(invalidArn)).toBe(false) + }) + + it('should return false if role name contains invalid characters', () => { + const invalidArns = [ + 'arn:aws:iam::123456789012:role/My Role', // space + 'arn:aws:iam::123456789012:role/MyRole#InvalidChar' + ] + for (const arn of invalidArns) { + expect(validateIamRoleArnFormat(arn)).toBe(false) + } + }) + + it('should return false for empty or null values', () => { + expect(validateIamRoleArnFormat('')).toBe(false) + // @ts-expect-error testing invalid input type + expect(validateIamRoleArnFormat(null)).toBe(false) + // @ts-expect-error testing invalid input type + expect(validateIamRoleArnFormat(undefined)).toBe(false) + }) +}) diff --git a/packages/destination-actions/src/destinations/aws-kinesis/index.ts b/packages/destination-actions/src/destinations/aws-kinesis/index.ts index d148b5e1ef4..1641ea1979f 100644 --- a/packages/destination-actions/src/destinations/aws-kinesis/index.ts +++ b/packages/destination-actions/src/destinations/aws-kinesis/index.ts @@ -31,11 +31,10 @@ const destination: DestinationDefinition = { }, testAuthentication: async (_, { settings }) => { const { iamRoleArn, iamExternalId } = settings - console.log(123) if (!validateIamRoleArnFormat(iamRoleArn)) { throw new IntegrationError('The provided IAM Role ARN format is not valid', 'INVALID_IAM_ROLE_ARN_FORMAT', 400) } - console.log(234) + await assumeRole(iamRoleArn, iamExternalId, APP_AWS_REGION) } }, diff --git a/packages/destination-actions/src/lib/AWS/__test__/index.test.ts b/packages/destination-actions/src/lib/AWS/__test__/index.test.ts new file mode 100644 index 00000000000..0b1cb7009ff --- /dev/null +++ b/packages/destination-actions/src/lib/AWS/__test__/index.test.ts @@ -0,0 +1,107 @@ +import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts' +import { assumeRole } from '../sts' +import { ErrorCodes } from '@segment/actions-core' + +// Mock dependencies +jest.mock('@aws-sdk/client-sts') +jest.mock('uuid', () => ({ v4: jest.fn(() => 'mocked-session-id') })) +jest.mock('@segment/actions-core', () => ({ + IntegrationError: jest.fn().mockImplementation((message, code, status) => ({ + name: 'IntegrationError', + message, + code, + status + })), + ErrorCodes: { INVALID_AUTHENTICATION: 'INVALID_AUTHENTICATION' } +})) + +describe('assumeRole', () => { + const mockSend = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + ;(STSClient as jest.Mock).mockImplementation(() => ({ + send: mockSend + })) + process.env.AMAZON_S3_ACTIONS_ROLE_ADDRESS = 'arn:aws:iam::111111111111:role/IntermediaryRole' + process.env.AMAZON_S3_ACTIONS_EXTERNAL_ID = 'intermediary-external-id' + }) + + it('should successfully assume both intermediary and target roles', async () => { + // Mock intermediary STS credentials + mockSend + .mockResolvedValueOnce({ + Credentials: { + AccessKeyId: 'AKIA_INTERMEDIARY', + SecretAccessKey: 'SECRET_INTERMEDIARY', + SessionToken: 'TOKEN_INTERMEDIARY' + } + }) + .mockResolvedValueOnce({ + Credentials: { + AccessKeyId: 'AKIA_FINAL', + SecretAccessKey: 'SECRET_FINAL', + SessionToken: 'TOKEN_FINAL' + } + }) + + const creds = await assumeRole('arn:aws:iam::222222222222:role/TargetRole', 'external-id-final', 'us-east-1') + + expect(STSClient).toHaveBeenCalledTimes(2) + expect(AssumeRoleCommand).toHaveBeenCalledTimes(2) + + expect(creds).toEqual({ + accessKeyId: 'AKIA_FINAL', + secretAccessKey: 'SECRET_FINAL', + sessionToken: 'TOKEN_FINAL' + }) + }) + + it('should throw IntegrationError if STS returns missing credentials', async () => { + mockSend.mockResolvedValueOnce({ + Credentials: { AccessKeyId: 'X' } // missing fields + }) + + const promise = assumeRole('arn:aws:iam::222222222222:role/TargetRole', 'external-id-final', 'us-east-1') + + await expect(promise).rejects.toMatchObject({ + name: 'IntegrationError', + message: 'Failed to assume role', + code: ErrorCodes.INVALID_AUTHENTICATION, + status: 403 + }) + }) + + it('should pass intermediary credentials to second STS call', async () => { + mockSend + .mockResolvedValueOnce({ + Credentials: { + AccessKeyId: 'AKIA_INTER', + SecretAccessKey: 'SECRET_INTER', + SessionToken: 'TOKEN_INTER' + } + }) + .mockResolvedValueOnce({ + Credentials: { + AccessKeyId: 'AKIA_FINAL', + SecretAccessKey: 'SECRET_FINAL', + SessionToken: 'TOKEN_FINAL' + } + }) + + await assumeRole('arn:aws:iam::333333333333:role/TargetRole', 'external-id-final', 'us-west-2') + + // Check that STSClient was initialized with the intermediary creds for the second call + expect(STSClient).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + credentials: { + accessKeyId: 'AKIA_INTER', + secretAccessKey: 'SECRET_INTER', + sessionToken: 'TOKEN_INTER' + }, + region: 'us-west-2' + }) + ) + }) +}) From 2cc67e9b6fe284fd8a613a2e0cd09a80444605e3 Mon Sep 17 00:00:00 2001 From: Md Mozammil Khan Date: Thu, 30 Oct 2025 15:29:02 +0530 Subject: [PATCH 3/4] fixing tests --- .../aws-kinesis/__test__/index.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts b/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts index 57aac7d316d..64448ddca3b 100644 --- a/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts +++ b/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts @@ -2,6 +2,8 @@ import destination from '../index' import { assumeRole } from '../../../lib/AWS/sts' import { validateIamRoleArnFormat } from '../utils' import { APP_AWS_REGION } from '../../../lib/AWS/utils' +import type { Settings } from '../generated-types' +import { createTestIntegration } from '@segment/actions-core' // --- Mock all dependencies --- jest.mock('../../../lib/AWS/sts', () => ({ @@ -25,10 +27,10 @@ jest.mock('../../../lib/AWS/utils', () => ({ APP_AWS_REGION: 'us-east-1' })) -describe('AWS Kinesis Destination - testAuthentication', () => { - const testAuth = destination.authentication!.testAuthentication! +const testDestination = createTestIntegration(destination) - const validSettings = { +describe('AWS Kinesis Destination - testAuthentication', () => { + const validSettings: Settings = { iamRoleArn: 'arn:aws:iam::123456789012:role/MyRole', iamExternalId: 'external-id' } @@ -45,7 +47,7 @@ describe('AWS Kinesis Destination - testAuthentication', () => { sessionToken: 'TOKEN...' }) - await expect(testAuth({}, { settings: validSettings })).resolves.not.toThrow() + await expect(testDestination.testAuthentication(validSettings)).resolves.not.toThrow() expect(validateIamRoleArnFormat).toHaveBeenCalledWith(validSettings.iamRoleArn) expect(assumeRole).toHaveBeenCalledWith(validSettings.iamRoleArn, validSettings.iamExternalId, APP_AWS_REGION) @@ -54,7 +56,7 @@ describe('AWS Kinesis Destination - testAuthentication', () => { it('should throw IntegrationError if IAM Role ARN format is invalid', async () => { ;(validateIamRoleArnFormat as jest.Mock).mockReturnValue(false) - const result = testAuth({}, { settings: validSettings }) + const result = testDestination.testAuthentication(validSettings) await expect(result).rejects.toMatchObject({ name: 'IntegrationError', @@ -70,6 +72,6 @@ describe('AWS Kinesis Destination - testAuthentication', () => { ;(validateIamRoleArnFormat as jest.Mock).mockReturnValue(true) ;(assumeRole as jest.Mock).mockRejectedValue(new Error('AssumeRole failed')) - await expect(testAuth({}, { settings: validSettings })).rejects.toThrow('AssumeRole failed') + await expect(testDestination.testAuthentication(validSettings)).rejects.toThrow('AssumeRole failed') }) }) From 551510b02d485573873c523f6aedd67f775236fd Mon Sep 17 00:00:00 2001 From: Md Mozammil Khan Date: Thu, 30 Oct 2025 15:46:32 +0530 Subject: [PATCH 4/4] code --- .../src/destinations/aws-kinesis/__test__/index.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts b/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts index 64448ddca3b..f8bcbf57a26 100644 --- a/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts +++ b/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts @@ -3,7 +3,7 @@ import { assumeRole } from '../../../lib/AWS/sts' import { validateIamRoleArnFormat } from '../utils' import { APP_AWS_REGION } from '../../../lib/AWS/utils' import type { Settings } from '../generated-types' -import { createTestIntegration } from '@segment/actions-core' +import { createTestIntegration } from '../../../../../core/src/create-test-integration' // --- Mock all dependencies --- jest.mock('../../../lib/AWS/sts', () => ({ @@ -58,12 +58,7 @@ describe('AWS Kinesis Destination - testAuthentication', () => { const result = testDestination.testAuthentication(validSettings) - await expect(result).rejects.toMatchObject({ - name: 'IntegrationError', - message: 'The provided IAM Role ARN format is not valid', - code: 'INVALID_IAM_ROLE_ARN_FORMAT', - status: 400 - }) + await expect(result).rejects.toThrow('Credentials are invalid: The provided IAM Role ARN format is not valid') expect(assumeRole).not.toHaveBeenCalled() })