From c75db01052a9cb0c627044336c103bdc3976135d Mon Sep 17 00:00:00 2001 From: Brian Fletcher Date: Fri, 24 Mar 2023 14:21:37 +0000 Subject: [PATCH] port k8s plugin from aws v2 to v3 client Signed-off-by: Brian Fletcher --- plugins/kubernetes-backend/package.json | 5 +- .../AwsIamKubernetesAuthTranslator.test.ts | 195 +++++++----------- .../AwsIamKubernetesAuthTranslator.ts | 135 +++++------- yarn.lock | 7 +- 4 files changed, 143 insertions(+), 199 deletions(-) diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 8f7b071c9a..141bf56ad5 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -34,6 +34,9 @@ "clean": "backstage-cli package clean" }, "dependencies": { + "@aws-crypto/sha256-js": "^3.0.0", + "@aws-sdk/credential-providers": "^3.208.0", + "@aws-sdk/signature-v4": "^3.208.0", "@azure/identity": "^2.0.4", "@backstage/backend-common": "workspace:^", "@backstage/catalog-client": "workspace:^", @@ -49,7 +52,6 @@ "@kubernetes/client-node": "0.18.1", "@types/express": "^4.17.6", "@types/luxon": "^3.0.0", - "aws-sdk": "^2.840.0", "aws4": "^1.11.0", "compression": "^1.7.4", "cors": "^2.8.5", @@ -71,7 +73,6 @@ "@backstage/cli": "workspace:^", "@types/aws4": "^1.5.1", "@types/http-proxy-middleware": "^0.19.3", - "aws-sdk-mock": "^5.2.1", "mock-fs": "^5.2.0", "msw": "^1.0.0", "supertest": "^6.1.3" diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts index 1f8d525ea8..97272f9faa 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts @@ -13,148 +13,111 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import AWS from 'aws-sdk'; -import AWSMock from 'aws-sdk-mock'; import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; -const awsError: AWS.AWSError = { - code: '123', - message: 'no way', - name: 'nope', - time: new Date(), -}; +let presign = jest.fn(async () => ({ + hostname: 'https://example.com', + query: {}, + path: '/asdf', +})); + +const fromEnv = jest.fn(() => { + return { + AccessId: 'asdf', + }; +}); +const fromTemporaryCredentials = jest.fn(); +jest.mock('@aws-sdk/signature-v4', () => ({ + SignatureV4: jest.fn().mockImplementation(() => ({ + presign, + })), +})); + +jest.mock('@aws-sdk/credential-providers', () => ({ + fromEnv: () => fromEnv(), + fromTemporaryCredentials: (opts: any) => fromTemporaryCredentials(opts), +})); describe('AwsIamKubernetesAuthTranslator tests', () => { - let role: any = undefined; - const credentials: any = { - accessKeyId: 'bloop', - secretAccessKey: 'omg-so-secret', - sessionToken: 'token', - }; - - let assumeResponse: any = { - Credentials: { - AccessKeyId: credentials.accessKeyId, - SecretAccessKey: credentials.secretAccessKey, - SessionToken: credentials.sessionToken, - }, - }; - - let mockedCredentials: any = undefined; - - AWS.config.credentials = new AWS.Credentials(credentials); - - AWSMock.setSDKInstance(AWS); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - afterAll(() => { - jest.resetAllMocks(); - }); - - function executeTranslation() { - AWSMock.mock('STS', 'assumeRole', (_params: any, callback: Function) => { - callback(null, assumeResponse); - }); - + beforeEach(() => {}); + it('returns a signed url for AWS credentials without assume role', async () => { const authTranslator = new AwsIamKubernetesAuthTranslator(); - if (mockedCredentials) { - jest - .spyOn(authTranslator, 'awsGetCredentials') - .mockImplementation(async () => mockedCredentials); - } - - const response = authTranslator.decorateClusterDetailsWithAuth({ - assumeRole: role, + const authPromise = authTranslator.decorateClusterDetailsWithAuth({ name: 'test-cluster', url: '', authProvider: 'aws', }); - - mockedCredentials = undefined; - - return response; - } - - it('returns a signed url for AWS credentials', async () => { - // These credentials are not real. - // Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html - AWS.config.credentials = new AWS.Credentials( - 'AKIAIOSFODNN7EXAMPLE', - 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + expect(fromEnv).toHaveBeenCalledWith(); + expect((await authPromise).serviceAccountToken).toEqual( + 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_', ); + }); - const response = await executeTranslation(); - expect(response.serviceAccountToken).toBeDefined(); + it('returns a signed url for AWS credentials with assume role', async () => { + const authTranslator = new AwsIamKubernetesAuthTranslator(); + + const authPromise = authTranslator.decorateClusterDetailsWithAuth({ + assumeRole: 'asdf', + name: 'test-cluster', + url: '', + authProvider: 'aws', + }); + expect(fromTemporaryCredentials).toHaveBeenCalledWith({ + clientConfig: { + region: 'us-east-1', + }, + masterCredentials: fromEnv(), + params: { + ExternalId: undefined, + RoleArn: 'asdf', + }, + }); + expect((await authPromise).serviceAccountToken).toEqual( + 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_', + ); + }); + + it('returns a signed url for AWS credentials and passes the external id', async () => { + const authTranslator = new AwsIamKubernetesAuthTranslator(); + + const authPromise = authTranslator.decorateClusterDetailsWithAuth({ + assumeRole: 'SomeRole', + externalId: 'external-id', + name: 'test-cluster', + url: '', + authProvider: 'aws', + }); + expect(fromTemporaryCredentials).toHaveBeenCalledWith({ + clientConfig: { + region: 'us-east-1', + }, + masterCredentials: fromEnv(), + params: { + ExternalId: 'external-id', + RoleArn: 'SomeRole', + }, + }); + expect((await authPromise).serviceAccountToken).toEqual( + 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_', + ); }); describe('When the credentials is failing', () => { beforeEach(() => { - jest.spyOn(AWS.config, 'getCredentials').mockImplementation(cb => { - cb(awsError, null); + presign = jest.fn(async () => { + throw new Error('no way'); }); }); it('throws the right error', async () => { const authTranslator = new AwsIamKubernetesAuthTranslator(); await expect( authTranslator.decorateClusterDetailsWithAuth({ - assumeRole: role, name: 'test-cluster', url: '', authProvider: 'aws', }), - ).rejects.toEqual(awsError); - }); - }); - - describe('When the role is assumed', () => { - // These credentials are not real. - // Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html - AWS.config.credentials = new AWS.Credentials( - 'AKIAIOSFODNN7EXAMPLE', - 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', - ); - role = 'SomeRole'; - - describe('When the role is valid', () => { - it('returns a signed url for AWS credentials', async () => { - const response = await executeTranslation(); - expect(response.serviceAccountToken).toBeDefined(); - }); - }); - - describe('When the role is invalid', () => { - it('returns the original AWS credentials', async () => { - assumeResponse = undefined; - await expect(executeTranslation()).rejects.toThrow( - /Unable to assume role:/, - ); - }); - }); - }); - - describe('When no AWS creds are available', () => { - it('throws unable to get AWS credentials', async () => { - mockedCredentials = new Error(); - await expect(executeTranslation()).rejects.toThrow( - 'No AWS credentials found.', - ); - }); - }); - - describe('When invalid AWS creds are available', () => { - it('throws credentials are invalid to get AWS credentials', async () => { - const undefinedSecret: any = undefined; - AWS.config.credentials = new AWS.Credentials( - 'AKIAIOSFODNN7EXAMPLE', - undefinedSecret, - ); - await expect(executeTranslation()).rejects.toThrow( - 'Invalid AWS credentials found.', - ); + ).rejects.toThrow('no way'); }); }); }); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts index 84e60c9c69..40f74136ea 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts @@ -13,10 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import AWS, { Credentials } from 'aws-sdk'; -import { sign } from 'aws4'; import { AWSClusterDetails } from '../types/types'; import { KubernetesAuthTranslator } from './types'; +import { + fromEnv, + fromTemporaryCredentials, +} from '@aws-sdk/credential-providers'; +import { SignatureV4 } from '@aws-sdk/signature-v4'; +import { Sha256 } from '@aws-crypto/sha256-js'; /** * @@ -28,6 +32,8 @@ export type SigningCreds = { sessionToken: string | undefined; }; +const defaultRegion = 'us-east-1'; + /** * * @public @@ -35,90 +41,63 @@ export type SigningCreds = { export class AwsIamKubernetesAuthTranslator implements KubernetesAuthTranslator { - validCredentials(creds: SigningCreds): boolean { - return (creds?.accessKeyId && creds?.secretAccessKey) as unknown as boolean; - } - - awsGetCredentials = async (): Promise => { - return new Promise((resolve, reject) => { - AWS.config.getCredentials(err => { - if (err) { - return reject(err); - } - - return resolve(AWS.config.credentials as Credentials); - }); - }); - }; - - async getCredentials( - assumeRole?: string, - externalId?: string, - ): Promise { - const awsCreds = await this.awsGetCredentials(); - - if (!(awsCreds instanceof Credentials)) - throw new Error('No AWS credentials found.'); - - let creds: SigningCreds = { - accessKeyId: awsCreds.accessKeyId, - secretAccessKey: awsCreds.secretAccessKey, - sessionToken: awsCreds.sessionToken, - }; - - if (!this.validCredentials(creds)) - throw new Error('Invalid AWS credentials found.'); - if (!assumeRole) return creds; - - try { - const params: AWS.STS.Types.AssumeRoleRequest = { - RoleArn: assumeRole, - RoleSessionName: 'backstage-login', - }; - if (externalId) params.ExternalId = externalId; - - const assumedRole = await new AWS.STS().assumeRole(params).promise(); - - if (!assumedRole.Credentials) { - throw new Error(`No credentials returned for role ${assumeRole}`); - } - - creds = { - accessKeyId: assumedRole.Credentials.AccessKeyId, - secretAccessKey: assumedRole.Credentials.SecretAccessKey, - sessionToken: assumedRole.Credentials.SessionToken, - }; - } catch (e) { - console.warn(`There was an error assuming the role: ${e}`); - throw new Error(`Unable to assume role: ${e}`); - } - return creds; - } - async getBearerToken( + private async getBearerToken( clusterName: string, assumeRole?: string, externalId?: string, ): Promise { - const credentials = await this.getCredentials(assumeRole, externalId); + const region = process.env.AWS_REGION ?? defaultRegion; + let credentials = fromEnv(); + if (assumeRole) { + credentials = fromTemporaryCredentials({ + masterCredentials: credentials, + clientConfig: { + region, + }, + params: { + RoleArn: assumeRole, + ExternalId: externalId, + }, + }); + } - const request = { - host: `sts.amazonaws.com`, - path: `/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Expires=60`, - headers: { - 'x-k8s-aws-id': clusterName, + const signer = new SignatureV4({ + credentials, + region, + service: 'sts', + sha256: Sha256, + }); + + const request = await signer.presign( + { + headers: { + host: `sts.${region}.amazonaws.com`, + 'x-k8s-aws-id': clusterName, + }, + hostname: `sts.${region}.amazonaws.com`, + method: 'GET', + path: '/', + protocol: 'https:', + query: { + Action: 'GetCallerIdentity', + Version: '2011-06-15', + }, }, - signQuery: true, - }; + { expiresIn: 0 }, + ); - const signed = sign(request, credentials); - const url = `https://${signed.host}${signed.path}`; - const base64Url = Buffer.from(url, 'binary').toString('base64'); - const urlSafeBase64Url = base64Url - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + const query = Object.keys(request?.query ?? {}) + .map( + q => + `${encodeURIComponent(q)}=${encodeURIComponent( + request.query?.[q] as string, + )}`, + ) + .join('&'); - return `k8s-aws-v1.${urlSafeBase64Url}`; + const url = `https://${request.hostname}${request.path}?${query}`; + + return `k8s-aws-v1.${Buffer.from(url).toString('base64url')}`; } async decorateClusterDetailsWithAuth( diff --git a/yarn.lock b/yarn.lock index 93a6661725..9d43cb0b84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1404,7 +1404,7 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/signature-v4@npm:3.296.0": +"@aws-sdk/signature-v4@npm:3.296.0, @aws-sdk/signature-v4@npm:^3.208.0": version: 3.296.0 resolution: "@aws-sdk/signature-v4@npm:3.296.0" dependencies: @@ -6881,6 +6881,9 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-kubernetes-backend@workspace:plugins/kubernetes-backend" dependencies: + "@aws-crypto/sha256-js": ^3.0.0 + "@aws-sdk/credential-providers": ^3.208.0 + "@aws-sdk/signature-v4": ^3.208.0 "@azure/identity": ^2.0.4 "@backstage/backend-common": "workspace:^" "@backstage/backend-test-utils": "workspace:^" @@ -6900,8 +6903,6 @@ __metadata: "@types/express": ^4.17.6 "@types/http-proxy-middleware": ^0.19.3 "@types/luxon": ^3.0.0 - aws-sdk: ^2.840.0 - aws-sdk-mock: ^5.2.1 aws4: ^1.11.0 compression: ^1.7.4 cors: ^2.8.5