Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions .changeset/bright-worms-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@aws-amplify/ui-react-core": patch
"@aws-amplify/ui-react-native": patch
"@aws-amplify/ui-react": patch
"@aws-amplify/ui": patch
"@aws-amplify/ui-vue": patch
"@aws-amplify/ui-angular": patch
---

fix(authenticator): migrate totpSecretCode generation to state machine
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ <h3 class="amplify-heading amplify-heading--3">{{ this.headerText }}</h3>
height="228"
/>
<div class="amplify-flex" data-amplify-copy>
<div>{{ secretKey }}</div>
<div>{{ totpSecretCode }}</div>
<div data-amplify-copy-svg (click)="copyText()">
<div data-amplify-copy-tooltip>{{ copyTextLabel }}</div>
<svg
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ import { BaseFormFieldsComponent } from '../base-form-fields/base-form-fields.co
import { FormFieldComponent } from '../form-field/form-field.component';
import { ButtonComponent } from '../../../../primitives/button/button.component';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import QRCode from 'qrcode';
import { Auth } from 'aws-amplify';

import { MockComponent } from 'ng-mocks';
import { getTotpCodeURL } from '@aws-amplify/ui';

const mockUser = { username: 'username' };
const mockContext = {
Expand All @@ -18,16 +16,8 @@ const mockContext = {
user: mockUser,
};

const DEFAULT_TOTP_ISSUER = 'AWSCognito';
const SECRET_KEY = 'secretKey';

const setupTOTPSpy = jest.spyOn(Auth, 'setupTOTP');

const toDataURLSpy = jest.spyOn(QRCode, 'toDataURL');

describe('SetupTotpComponent', () => {
let fixture: ComponentFixture<SetupTotpComponent>;
let component: SetupTotpComponent;

beforeEach(async () => {
jest.resetAllMocks();
Expand All @@ -44,6 +34,7 @@ describe('SetupTotpComponent', () => {
submitForm: jest.fn(),
context: jest.fn().mockReturnValue({}),
slotContext: jest.fn().mockReturnValue({}),
totpSecretCode: 'Keep it quiet!',
};

await TestBed.configureTestingModule({
Expand All @@ -61,27 +52,9 @@ describe('SetupTotpComponent', () => {
}).compileComponents();

fixture = TestBed.createComponent(SetupTotpComponent);
component = fixture.componentInstance;
});

it('successfully mounts', () => {
expect(fixture).toBeTruthy();
});

it('validate generateQR Code generates correct code', async () => {
setupTOTPSpy.mockResolvedValue(SECRET_KEY);
const defaultTotpCode = getTotpCodeURL(
DEFAULT_TOTP_ISSUER,
mockUser.username,
SECRET_KEY
);
await fixture.detectChanges();

expect(setupTOTPSpy).toHaveBeenCalledTimes(1);
expect(setupTOTPSpy).toHaveBeenCalledWith(mockUser);

await fixture.detectChanges();

expect(toDataURLSpy).toHaveBeenCalledTimes(1);
expect(toDataURLSpy).toHaveBeenCalledWith(defaultTotpCode);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Component, HostBinding, OnInit } from '@angular/core';
import QRCode from 'qrcode';
import { Auth, Logger } from 'aws-amplify';
import { Logger } from 'aws-amplify';
import {
FormFieldsArray,
getActorContext,
Expand Down Expand Up @@ -29,7 +29,7 @@ export class SetupTotpComponent implements OnInit {
@HostBinding('attr.data-amplify-authenticator-setup-totp') dataAttr = '';
public headerText = getSetupTOTPText();
public qrCodeSource = '';
public secretKey = '';
public totpSecretCode = '';
public copyTextLabel = getCopyText();

// translated texts
Expand All @@ -48,15 +48,19 @@ export class SetupTotpComponent implements OnInit {
}

async generateQRCode() {
// TODO: This should be handled in core.
const state = this.authenticator.authState;
const actorContext = getActorContext(state) as SignInContext;
const { user, formFields } = actorContext;
const { authState: state, totpSecretCode, user } = this.authenticator;
const { formFields } = getActorContext(state) as SignInContext;
const { totpIssuer = 'AWSCognito', totpUsername = user?.username } =
formFields?.setupTOTP?.QR ?? {};

this.totpSecretCode = totpSecretCode;

try {
this.secretKey = await Auth.setupTOTP(user);
const totpCode = getTotpCodeURL(totpIssuer, totpUsername, this.secretKey);
const totpCode = getTotpCodeURL(
totpIssuer,
totpUsername,
this.totpSecretCode
);

logger.info('totp code was generated:', totpCode);
this.qrCodeSource = await QRCode.toDataURL(totpCode);
Expand All @@ -77,7 +81,7 @@ export class SetupTotpComponent implements OnInit {
}

copyText(): void {
navigator.clipboard.writeText(this.secretKey);
navigator.clipboard.writeText(this.totpSecretCode);
this.copyTextLabel = getCopiedText();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ export class AuthenticatorService implements OnDestroy {
return this._facade?.codeDeliveryDetails;
}

public get totpSecretCode() {
return this._facade?.totpSecretCode;
}

/**
* Service facades
*/
Expand Down
54 changes: 27 additions & 27 deletions packages/react-core/src/Authenticator/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
LegacyFormFieldOptions,
} from '@aws-amplify/ui';

import { UseAuthenticator } from './useAuthenticator';

export type AuthenticatorRouteComponentKey =
| 'confirmResetPassword'
| 'confirmSignIn'
Expand All @@ -31,8 +33,6 @@ export type AuthenticatorMachineContextKey = keyof AuthenticatorMachineContext;
export type AuthenticatorRouteComponentName =
Capitalize<AuthenticatorRouteComponentKey>;

export type GetTotpSecretCode = () => Promise<string>;

interface HeaderProps {
children?: React.ReactNode;
}
Expand All @@ -42,8 +42,8 @@ interface FooterProps {
}

type FormFieldsProps = {
isPending: AuthenticatorMachineContext['isPending'];
validationErrors?: AuthenticatorMachineContext['validationErrors'];
isPending: UseAuthenticator['isPending'];
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced usage of AuthenticatorMachineContext with UseAuthenticator for these downstream types since they are ultimately returned by useAuthenticator

validationErrors?: UseAuthenticator['validationErrors'];
};

export type FooterComponent<Props = {}> = React.ComponentType<
Expand All @@ -70,74 +70,74 @@ export interface ComponentSlots<FieldType = {}> {
* Common component prop types used for both RWA and RNA implementations
*/
export type CommonRouteProps = {
error?: AuthenticatorMachineContext['error'];
isPending: AuthenticatorMachineContext['isPending'];
handleBlur: AuthenticatorMachineContext['updateBlur'];
handleChange: AuthenticatorMachineContext['updateForm'];
handleSubmit: AuthenticatorMachineContext['submitForm'];
error?: UseAuthenticator['error'];
isPending: UseAuthenticator['isPending'];
handleBlur: UseAuthenticator['updateBlur'];
handleChange: UseAuthenticator['updateForm'];
handleSubmit: UseAuthenticator['submitForm'];
};

/**
* Base Route component props
*/
export type ConfirmResetPasswordBaseProps<FieldType = {}> = {
resendCode: AuthenticatorMachineContext['resendCode'];
validationErrors?: AuthenticatorMachineContext['validationErrors'];
resendCode: UseAuthenticator['resendCode'];
validationErrors?: UseAuthenticator['validationErrors'];
} & CommonRouteProps &
ComponentSlots<FieldType>;

export type ConfirmSignInBaseProps<FieldType = {}> = {
challengeName: AuthChallengeName;
toSignIn: AuthenticatorMachineContext['toSignIn'];
toSignIn: UseAuthenticator['toSignIn'];
} & CommonRouteProps &
ComponentSlots<FieldType>;

export type ConfirmSignUpBaseProps<FieldType = {}> = {
codeDeliveryDetails: AuthenticatorMachineContext['codeDeliveryDetails'];
resendCode: AuthenticatorMachineContext['resendCode'];
codeDeliveryDetails: UseAuthenticator['codeDeliveryDetails'];
resendCode: UseAuthenticator['resendCode'];
} & CommonRouteProps &
ComponentSlots<FieldType>;

export type ConfirmVerifyUserProps<FieldType = {}> = {
skipVerification: AuthenticatorMachineContext['skipVerification'];
skipVerification: UseAuthenticator['skipVerification'];
} & CommonRouteProps &
ComponentSlots<FieldType>;

export type ForceResetPasswordBaseProps<FieldType = {}> = {
toSignIn: AuthenticatorMachineContext['toSignIn'];
validationErrors?: AuthenticatorMachineContext['validationErrors'];
toSignIn: UseAuthenticator['toSignIn'];
validationErrors?: UseAuthenticator['validationErrors'];
} & CommonRouteProps &
ComponentSlots<FieldType>;

export type ResetPasswordBaseProps<FieldType = {}> = {
toSignIn: AuthenticatorMachineContext['toSignIn'];
toSignIn: UseAuthenticator['toSignIn'];
} & CommonRouteProps &
ComponentSlots<FieldType>;

export type SetupTOTPBaseProps<FieldType = {}> = {
getTotpSecretCode: GetTotpSecretCode;
toSignIn: AuthenticatorMachineContext['toSignIn'];
toSignIn: UseAuthenticator['toSignIn'];
totpSecretCode: UseAuthenticator['totpSecretCode'];
} & CommonRouteProps &
ComponentSlots<FieldType>;

export type SignInBaseProps<FieldType = {}> = {
hideSignUp?: boolean;
toFederatedSignIn: AuthenticatorMachineContext['toFederatedSignIn'];
toResetPassword: AuthenticatorMachineContext['toResetPassword'];
toSignUp: AuthenticatorMachineContext['toSignUp'];
toFederatedSignIn: UseAuthenticator['toFederatedSignIn'];
toResetPassword: UseAuthenticator['toResetPassword'];
toSignUp: UseAuthenticator['toSignUp'];
} & CommonRouteProps &
ComponentSlots<FieldType>;

export type SignUpBaseProps<FieldType = {}> = {
hideSignIn?: boolean;
toFederatedSignIn: AuthenticatorMachineContext['toFederatedSignIn'];
toSignIn: AuthenticatorMachineContext['toSignIn'];
validationErrors?: AuthenticatorMachineContext['validationErrors'];
toFederatedSignIn: UseAuthenticator['toFederatedSignIn'];
toSignIn: UseAuthenticator['toSignIn'];
validationErrors?: UseAuthenticator['validationErrors'];
} & CommonRouteProps &
ComponentSlots<FieldType>;

export type VerifyUserProps<FieldType = {}> = {
skipVerification: AuthenticatorMachineContext['skipVerification'];
skipVerification: UseAuthenticator['skipVerification'];
} & CommonRouteProps &
ComponentSlots<FieldType>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const getTotpSecretCode = jest.fn();
const hasValidationErrors = false;
const initializeMachine = jest.fn();
const isPending = false;
const QRFields = null;
const resendCode = jest.fn();
const route = 'idle';
const skipVerification = jest.fn();
Expand All @@ -24,6 +25,7 @@ const toFederatedSignIn = jest.fn();
const toResetPassword = jest.fn();
const toSignIn = jest.fn();
const toSignUp = jest.fn();
const totpSecretCode = null;
const unverifiedContactMethods = {};
const updateBlur = jest.fn();
const updateForm = jest.fn();
Expand Down Expand Up @@ -53,6 +55,7 @@ export const mockMachineContext: AuthenticatorMachineContext = {
socialProviders,
toFederatedSignIn,
toResetPassword,
totpSecretCode,
unverifiedContactMethods,
validationErrors,
};
Expand All @@ -61,4 +64,5 @@ export const mockUseAuthenticatorOutput: UseAuthenticator = {
...mockMachineContext,
fields,
getTotpSecretCode,
} as unknown as UseAuthenticator;
QRFields,
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,31 @@

exports[`useAuthenticator returns the expected values 1`] = `
Object {
"QRFields": undefined,
"QRFields": null,
"authStatus": "authenticated",
"codeDeliveryDetails": Object {},
"error": undefined,
"fields": undefined,
"getTotpSecretCode": undefined,
"hasValidationErrors": false,
"initializeMachine": [Function],
"initializeMachine": [MockFunction],
"isPending": false,
"resendCode": [Function],
"resendCode": [MockFunction],
"route": "idle",
"signOut": [Function],
"skipVerification": [Function],
"signOut": [MockFunction],
"skipVerification": [MockFunction],
"socialProviders": Array [],
"submitForm": [Function],
"toFederatedSignIn": [Function],
"toResetPassword": [Function],
"toSignIn": [Function],
"toSignUp": [Function],
"submitForm": [MockFunction],
"toFederatedSignIn": [MockFunction],
"toResetPassword": [MockFunction],
"toSignIn": [MockFunction],
"toSignUp": [MockFunction],
"totpSecretCode": null,
"unverifiedContactMethods": Object {
"email": "test#example.com",
},
"updateBlur": [Function],
"updateForm": [Function],
"updateBlur": [MockFunction],
"updateForm": [MockFunction],
"user": Object {},
"validationErrors": undefined,
}
Expand Down
Loading