From c3a8cf867aa6358d7efdb9bb8d41886cffbf3da3 Mon Sep 17 00:00:00 2001 From: Ibrahim Najjar Date: Fri, 24 Nov 2023 20:08:36 +0100 Subject: [PATCH] add support for copying role credentials to clipboard * also fixes a bug with toasts not showing sometimes --- clients/awssso/aws-sso.go | 28 +++++++++ frontend/src/layout.tsx | 2 +- .../routes/aws-iam-idc/aws-iam-idc-card.tsx | 31 +++++++++- .../src/toast-provider/toast-provider.tsx | 2 +- frontend/src/wails-provider/wails-context.ts | 3 +- .../AwsIdentityCenterController.d.ts | 2 + .../awsiamidc/AwsIdentityCenterController.js | 4 ++ frontend/wailsjs/go/models.ts | 18 ++++++ providers/aws-iam-idc/aws-identity-center.go | 44 ++++++++++++++ .../aws-iam-idc/aws-identity-center_test.go | 57 +++++++++++++++++++ 10 files changed, 187 insertions(+), 4 deletions(-) diff --git a/clients/awssso/aws-sso.go b/clients/awssso/aws-sso.go index 5041702..1e9c631 100644 --- a/clients/awssso/aws-sso.go +++ b/clients/awssso/aws-sso.go @@ -75,6 +75,11 @@ type ListAccountsResponse struct { Accounts []AwsAccount } +type GetRoleCredentialsResponse struct { + AccessKeyId, SecretAccessKey, SessionToken string + Expiration int64 +} + type AwsSsoOidcClient interface { RegisterClient(ctx context.Context, friendlyClientName string) (*RegistrationResponse, error) @@ -83,6 +88,8 @@ type AwsSsoOidcClient interface { CreateToken(ctx context.Context, clientId, clientSecret, userCode, deviceCode string) (*GetTokenResponse, error) ListAccounts(ctx context.Context, accessToken string) (*ListAccountsResponse, error) + + GetRoleCredentials(ctx context.Context, accountId, roleName, accessToken string) (*GetRoleCredentialsResponse, error) } type awsSsoClientImpl struct { @@ -226,3 +233,24 @@ func (c *awsSsoClientImpl) ListAccounts(ctx context.Context, accessToken string) Accounts: accounts, }, nil } + +func (c *awsSsoClientImpl) GetRoleCredentials(ctx context.Context, accountId, roleName, accessToken string) (*GetRoleCredentialsResponse, error) { + output, err := c.ssoClient.GetRoleCredentials(ctx, &sso.GetRoleCredentialsInput{ + AccountId: aws.String(accountId), + RoleName: aws.String(roleName), + AccessToken: aws.String(accessToken), + }, func(options *sso.Options) { + options.Region = ctx.Value(AwsRegion("awsRegion")).(string) + }) + + if err != nil { + return nil, err + } + + return &GetRoleCredentialsResponse{ + AccessKeyId: *output.RoleCredentials.AccessKeyId, + SecretAccessKey: *output.RoleCredentials.SecretAccessKey, + SessionToken: *output.RoleCredentials.SessionToken, + Expiration: output.RoleCredentials.Expiration, + }, nil +} diff --git a/frontend/src/layout.tsx b/frontend/src/layout.tsx index 0b4ea28..3c2dfb2 100644 --- a/frontend/src/layout.tsx +++ b/frontend/src/layout.tsx @@ -12,7 +12,7 @@ export function Layout({ children }: { children: React.ReactNode }) { const [toasts, setToasts] = useState([]) function updateToasts(toasts: React.ReactElement[]) { - setToasts(toasts) + setToasts([...toasts]) } useEffect(() => { diff --git a/frontend/src/routes/aws-iam-idc/aws-iam-idc-card.tsx b/frontend/src/routes/aws-iam-idc/aws-iam-idc-card.tsx index 443fd56..9babb21 100644 --- a/frontend/src/routes/aws-iam-idc/aws-iam-idc-card.tsx +++ b/frontend/src/routes/aws-iam-idc/aws-iam-idc-card.tsx @@ -1,6 +1,7 @@ import React from "react" import { useFetcher, useNavigate, useRevalidator } from "react-router-dom" import { + GetRoleCredentials, MarkAsFavorite, RefreshAccessToken, UnmarkAsFavorite, @@ -10,8 +11,12 @@ import { AwsIamIdcCardDataError, AwsIamIdcCardDataResult, } from "./aws-iam-idc-card-data" +import { useWails } from "../../wails-provider/wails-context" +import { useToaster } from "../../toast-provider/toast-context" export function AwsIamIdcCard({ instanceId }: { instanceId: string }) { + const wails = useWails() + const toaster = useToaster() const navigate = useNavigate() const fetcher = useFetcher() const validator = useRevalidator() @@ -61,6 +66,22 @@ export function AwsIamIdcCard({ instanceId }: { instanceId: string }) { validator.revalidate() } + async function copyCredentials( + instanceId: string, + accountId: string, + roleName: string, + ) { + const output = await GetRoleCredentials(instanceId, accountId, roleName) + const credentialsProfile = ` + [default] + aws_access_key_id = ${output.accessKeyId} + aws_secret_access_key = ${output.secretAccessKey} + aws_session_token = ${output.sessionToken} + ` + await wails.runtime.ClipboardSetText(credentialsProfile) + toaster.showSuccess("Copied credentials to clipboard!") + } + const cardDataResult = fetcher.data as AwsIamIdcCardDataResult | undefined if (cardDataResult === undefined) { @@ -135,7 +156,15 @@ export function AwsIamIdcCard({ instanceId }: { instanceId: string }) { className="inline-flex items-center gap-2"> {role.roleName}
-
diff --git a/frontend/src/toast-provider/toast-provider.tsx b/frontend/src/toast-provider/toast-provider.tsx index fbc0877..5995076 100644 --- a/frontend/src/toast-provider/toast-provider.tsx +++ b/frontend/src/toast-provider/toast-provider.tsx @@ -9,7 +9,7 @@ import { Toast } from "../components/toast" export function ToastProvider({ children }: { children: React.ReactNode }) { const MaxToasts = 3 - const DefaultToastDuration = 3000 + const DefaultToastDuration = 2000 const toastId = useRef(1) const toastQueue = useRef([]) diff --git a/frontend/src/wails-provider/wails-context.ts b/frontend/src/wails-provider/wails-context.ts index 7849b41..dca477a 100644 --- a/frontend/src/wails-provider/wails-context.ts +++ b/frontend/src/wails-provider/wails-context.ts @@ -1,10 +1,11 @@ import React from "react" import { ShowErrorDialog, ShowWarningDialog, CatchUnhandledError } from '../../wailsjs/go/main/AppController' -import { BrowserOpenURL } from '../../wailsjs/runtime' +import { BrowserOpenURL, ClipboardSetText } from '../../wailsjs/runtime' export const ContextValue = { runtime: { BrowserOpenURL, + ClipboardSetText, ShowWarningDialog, ShowErrorDialog, CatchUnhandledError, diff --git a/frontend/wailsjs/go/awsiamidc/AwsIdentityCenterController.d.ts b/frontend/wailsjs/go/awsiamidc/AwsIdentityCenterController.d.ts index c620084..f8f4d46 100755 --- a/frontend/wailsjs/go/awsiamidc/AwsIdentityCenterController.d.ts +++ b/frontend/wailsjs/go/awsiamidc/AwsIdentityCenterController.d.ts @@ -10,6 +10,8 @@ export function FinalizeSetup(arg1:string,arg2:string,arg3:string,arg4:string,ar export function GetInstanceData(arg1:string,arg2:boolean):Promise; +export function GetRoleCredentials(arg1:string,arg2:string,arg3:string):Promise; + export function Init(arg1:context.Context,arg2:logging.ErrorHandler):Promise; export function ListInstances():Promise>; diff --git a/frontend/wailsjs/go/awsiamidc/AwsIdentityCenterController.js b/frontend/wailsjs/go/awsiamidc/AwsIdentityCenterController.js index 4ff645a..54d2fdf 100755 --- a/frontend/wailsjs/go/awsiamidc/AwsIdentityCenterController.js +++ b/frontend/wailsjs/go/awsiamidc/AwsIdentityCenterController.js @@ -14,6 +14,10 @@ export function GetInstanceData(arg1, arg2) { return window['go']['awsiamidc']['AwsIdentityCenterController']['GetInstanceData'](arg1, arg2); } +export function GetRoleCredentials(arg1, arg2, arg3) { + return window['go']['awsiamidc']['AwsIdentityCenterController']['GetRoleCredentials'](arg1, arg2, arg3); +} + export function Init(arg1, arg2) { return window['go']['awsiamidc']['AwsIdentityCenterController']['Init'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 9afbfc9..0ceec5d 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -75,6 +75,24 @@ export namespace awsiamidc { } } + export class AwsIdentityCenterAccountRoleCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + expiration: number; + + static createFrom(source: any = {}) { + return new AwsIdentityCenterAccountRoleCredentials(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.accessKeyId = source["accessKeyId"]; + this.secretAccessKey = source["secretAccessKey"]; + this.sessionToken = source["sessionToken"]; + this.expiration = source["expiration"]; + } + } export class AwsIdentityCenterCardData { instanceId: string; enabled: boolean; diff --git a/providers/aws-iam-idc/aws-identity-center.go b/providers/aws-iam-idc/aws-identity-center.go index 786c7c3..b41eeff 100644 --- a/providers/aws-iam-idc/aws-identity-center.go +++ b/providers/aws-iam-idc/aws-identity-center.go @@ -76,6 +76,13 @@ type AwsIdentityCenterAccount struct { Roles []AwsIdentityCenterAccountRole `json:"roles"` } +type AwsIdentityCenterAccountRoleCredentials struct { + AccessKeyId string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + Expiration int64 `json:"expiration"` +} + type AwsIdentityCenterCardData struct { InstanceId string `json:"instanceId"` Enabled bool `json:"enabled"` @@ -235,6 +242,43 @@ func (c *AwsIdentityCenterController) GetInstanceData(instanceId string, forceRe }, nil } +func (c *AwsIdentityCenterController) GetRoleCredentials(instanceId, accountId, roleName string) (*AwsIdentityCenterAccountRoleCredentials, error) { + row := c.db.QueryRowContext(c.ctx, "SELECT region, access_token_enc, access_token_created_at, access_token_expires_in, enc_key_id FROM aws_iam_idc_instances WHERE instance_id = ?", instanceId) + + var region string + var accessTokenEnc string + var accessTokenCreatedAt int64 + var accessTokenExpiresIn int64 + var encKeyId string + + if err := row.Scan(®ion, &accessTokenEnc, &accessTokenCreatedAt, &accessTokenExpiresIn, &encKeyId); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrInstanceWasNotFound + } + + c.errHandler.Catch(c.logger, err) + } + + accessToken, err := c.encryptionService.Decrypt(accessTokenEnc, encKeyId) + + c.errHandler.CatchWithMsg(c.logger, err, "failed to decrypt access token") + + ctx := context.WithValue(c.ctx, awssso.AwsRegion("awsRegion"), region) + res, err := c.awsSsoClient.GetRoleCredentials(ctx, accountId, roleName, accessToken) + + if err != nil { + c.logger.Error().Err(err).Msg("failed to get role credentials") + c.errHandler.Catch(c.logger, err) + } + + return &AwsIdentityCenterAccountRoleCredentials{ + AccessKeyId: res.AccessKeyId, + SecretAccessKey: res.SecretAccessKey, + SessionToken: res.SessionToken, + Expiration: res.Expiration, + }, nil +} + func (c *AwsIdentityCenterController) validateStartUrl(startUrl string) error { _, err := url.ParseRequestURI(startUrl) diff --git a/providers/aws-iam-idc/aws-identity-center_test.go b/providers/aws-iam-idc/aws-identity-center_test.go index 81fdb43..5069a70 100644 --- a/providers/aws-iam-idc/aws-identity-center_test.go +++ b/providers/aws-iam-idc/aws-identity-center_test.go @@ -42,6 +42,12 @@ func (m *mockAwsSsoOidcClient) ListAccounts(ctx context.Context, accessToken str return res, args.Error(1) } +func (m *mockAwsSsoOidcClient) GetRoleCredentials(ctx context.Context, accountId, roleName, accessToken string) (*awssso.GetRoleCredentialsResponse, error) { + args := m.Called(ctx, accountId, roleName, accessToken) + res, _ := args.Get(0).(*awssso.GetRoleCredentialsResponse) + return res, args.Error(1) +} + func initController(t *testing.T) (*AwsIdentityCenterController, *mockAwsSsoOidcClient, *testhelpers.MockClock) { db, err := migrations.NewInMemoryMigratedDatabase(t, "aws-iam-idc-controller-tests.db") require.NoError(t, err) @@ -450,6 +456,57 @@ func TestGetInstanceData(t *testing.T) { require.Equal(t, "test-account-name-2", instanceData.Accounts[1].AccountName) } +func TestGetRoleCredentials(t *testing.T) { + startUrl := "https://test-start-url.aws-apps.com/start" + region := "eu-west-1" + label := "test_label" + + roleName := "test-role-name" + accountId := "test-account-id" + + controller, mockAws, mockTimeProvider := initController(t) + + instanceId := simulateSuccessfulSetup(t, controller, mockAws, mockTimeProvider, startUrl, region, label) + + mockTimeProvider.On("NowUnix").Return(3) + + mockListAccountsRes := awssso.ListAccountsResponse{ + Accounts: []awssso.AwsAccount{ + { + AccountId: accountId, + AccountName: "test-account-name", + AccountEmail: "test-account-email", + Roles: []awssso.AwsAccountRole{ + { + RoleName: roleName, + }, + }, + }, + }, + } + + mockAws.On("ListAccounts", mock.Anything, mock.AnythingOfType("string")).Return(&mockListAccountsRes, nil) + + mockGetRoleCredentialsRes := awssso.GetRoleCredentialsResponse{ + AccessKeyId: "test-access-key-id", + SecretAccessKey: "test-secret-key", + SessionToken: "test-session-token", + Expiration: 100, + } + + mockAws.On("GetRoleCredentials", mock.Anything, accountId, roleName, mock.AnythingOfType("string")).Return(&mockGetRoleCredentialsRes, nil) + + roleCredentials, err := controller.GetRoleCredentials(instanceId, accountId, roleName) + + require.NoError(t, err) + require.Equal(t, roleCredentials, &AwsIdentityCenterAccountRoleCredentials{ + AccessKeyId: mockGetRoleCredentialsRes.AccessKeyId, + SecretAccessKey: mockGetRoleCredentialsRes.SecretAccessKey, + SessionToken: mockGetRoleCredentialsRes.SessionToken, + Expiration: mockGetRoleCredentialsRes.Expiration, + }) +} + func TestGetInstance_AccessTokenExpired(t *testing.T) { startUrl := "https://test-start-url.aws-apps.com/start" region := "eu-west-1"