Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): associateWebAuthnCredential API #13875

Open
wants to merge 12 commits into
base: feat/passwordless
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { Amplify, fetchAuthSession } from '@aws-amplify/core';
import { decodeJWT } from '@aws-amplify/core/internals/utils';

import {
createGetWebAuthnRegistrationOptionsClient,
createVerifyWebAuthnRegistrationResultClient,
} from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider';
import {
PasskeyError,
PasskeyErrorCode,
} from '../../../src/client/utils/passkey/errors';
import { associateWebAuthnCredential } from '../../../src/client/apis/associateWebAuthnCredential';
import {
passkeyCredentialCreateOptions,
passkeyRegistrationResult,
} from '../../mockData';
import { serializePkcToJson } from '../../../src/client/utils/passkey/serde';
import * as utils from '../../../src/client/utils';
import { getIsPasskeySupported } from '../../../src/client/utils/passkey/getIsPasskeySupported';
import { setUpGetConfig } from '../../providers/cognito/testUtils/setUpGetConfig';
import { mockAccessToken } from '../../providers/cognito/testUtils/data';

jest.mock('@aws-amplify/core', () => ({
...(jest.createMockFromModule('@aws-amplify/core') as object),
Amplify: { getConfig: jest.fn(() => ({})) },
}));
jest.mock('@aws-amplify/core/internals/utils', () => ({
...jest.requireActual('@aws-amplify/core/internals/utils'),
isBrowser: jest.fn(() => false),
}));
jest.mock(
'../../../src/foundation/factories/serviceClients/cognitoIdentityProvider',
);
jest.mock('../../../src/providers/cognito/factories');

jest.mock('../../../src/client/utils/passkey/getIsPasskeySupported');

Object.assign(navigator, {
credentials: {
create: jest.fn(),
},
});

describe('associateWebAuthnCredential', () => {
const navigatorCredentialsCreateSpy = jest.spyOn(
navigator.credentials,
'create',
);
const registerPasskeySpy = jest.spyOn(utils, 'registerPasskey');

const mockFetchAuthSession = jest.mocked(fetchAuthSession);

const mockGetIsPasskeySupported = jest.mocked(getIsPasskeySupported);

const mockGetWebAuthnRegistrationOptions = jest.fn();
const mockCreateGetWebAuthnRegistrationOptionsClient = jest.mocked(
createGetWebAuthnRegistrationOptionsClient,
);

const mockVerifyWebAuthnRegistrationResult = jest.fn();
const mockCreateVerifyWebAuthnRegistrationResultClient = jest.mocked(
createVerifyWebAuthnRegistrationResultClient,
);

beforeAll(() => {
setUpGetConfig(Amplify);
mockFetchAuthSession.mockResolvedValue({
tokens: { accessToken: decodeJWT(mockAccessToken) },
});
mockCreateGetWebAuthnRegistrationOptionsClient.mockReturnValue(
mockGetWebAuthnRegistrationOptions,
);
mockCreateVerifyWebAuthnRegistrationResultClient.mockReturnValue(
mockVerifyWebAuthnRegistrationResult,
);
mockVerifyWebAuthnRegistrationResult.mockImplementation(() => ({
CredentialId: '12345',
}));

navigatorCredentialsCreateSpy.mockResolvedValue(passkeyRegistrationResult);

mockGetIsPasskeySupported.mockReturnValue(true);
});

afterEach(() => {
mockFetchAuthSession.mockClear();
mockGetWebAuthnRegistrationOptions.mockReset();
navigatorCredentialsCreateSpy.mockClear();
});

it('should pass the correct service options when retrieving credential creation options', async () => {
mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({
CredentialCreationOptions: passkeyCredentialCreateOptions,
}));

await associateWebAuthnCredential();

expect(mockGetWebAuthnRegistrationOptions).toHaveBeenCalledWith(
{
region: 'us-west-2',
userAgentValue: expect.any(String),
},
{
AccessToken: mockAccessToken,
},
);
});

it('should pass the correct service options when verifying a credential', async () => {
mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({
CredentialCreationOptions: passkeyCredentialCreateOptions,
}));

await associateWebAuthnCredential();

expect(mockVerifyWebAuthnRegistrationResult).toHaveBeenCalledWith(
{
region: 'us-west-2',
userAgentValue: expect.any(String),
},
{
AccessToken: mockAccessToken,
Credential: JSON.stringify(
serializePkcToJson(passkeyRegistrationResult),
),
},
);
});

it('should call the registerPasskey function with correct input', async () => {
mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({
CredentialCreationOptions: passkeyCredentialCreateOptions,
}));

await associateWebAuthnCredential();

expect(registerPasskeySpy).toHaveBeenCalledWith(
JSON.parse(passkeyCredentialCreateOptions),
);

expect(navigatorCredentialsCreateSpy).toHaveBeenCalled();
});

it('should throw an error when service returns empty credential creation options', async () => {
expect.assertions(2);

mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({
CredentialCreationOptions: undefined,
}));

try {
await associateWebAuthnCredential();
} catch (error: any) {
expect(error).toBeInstanceOf(PasskeyError);
expect(error.name).toBe(
PasskeyErrorCode.InvalidCredentialCreationOptions,
);
}
});

it('should throw an error when passkeys are not supported', async () => {
expect.assertions(2);

mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({
CredentialCreationOptions: passkeyCredentialCreateOptions,
}));

mockGetIsPasskeySupported.mockReturnValue(false);

try {
await associateWebAuthnCredential();
} catch (error: any) {
expect(error).toBeInstanceOf(PasskeyError);
expect(error.name).toBe(PasskeyErrorCode.PasskeyNotSupported);
}
});
});
47 changes: 47 additions & 0 deletions packages/auth/__tests__/client/utils/passkey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
deserializeJsonToPkcCreationOptions,
serializePkcToJson,
} from '../../../src/client/utils/passkey/serde';
import {
passkeyRegistrationRequest,
passkeyRegistrationRequestJson,
passkeyRegistrationResult,
passkeyRegistrationResultJson,
} from '../../mockData';

describe('passkey', () => {
it('serializes pkc into correct json format', () => {
expect(JSON.stringify(serializePkcToJson(passkeyRegistrationResult))).toBe(
JSON.stringify(passkeyRegistrationResultJson),
);
});

it('deserializes json into correct pkc format', () => {
const deserialized = deserializeJsonToPkcCreationOptions(
passkeyRegistrationRequestJson,
);

expect(deserialized.challenge.byteLength).toEqual(
passkeyRegistrationRequest.challenge.byteLength,
);
expect(deserialized.user.id.byteLength).toEqual(
passkeyRegistrationRequest.user.id.byteLength,
);

expect(deserialized).toEqual(
expect.objectContaining({
rp: expect.any(Object),
user: {
id: expect.any(ArrayBuffer),
name: expect.any(String),
displayName: expect.any(String),
},
challenge: expect.any(ArrayBuffer),
pubKeyCredParams: expect.any(Array),
timeout: expect.any(Number),
excludeCredentials: expect.any(Array),
authenticatorSelection: expect.any(Object),
}),
);
});
});
32 changes: 32 additions & 0 deletions packages/auth/__tests__/foundation/convert/base64url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
convertArrayBufferToBase64Url,
convertBase64UrlToArrayBuffer,
} from '../../../src/foundation/convert';

describe('base64url', () => {
it('converts ArrayBuffer values to base64url', () => {
expect(convertArrayBufferToBase64Url(new Uint8Array([]))).toBe('');
expect(convertArrayBufferToBase64Url(new Uint8Array([0]))).toBe('AA');
expect(convertArrayBufferToBase64Url(new Uint8Array([1, 2, 3]))).toBe(
'AQID',
);
});
it('converts base64url values to ArrayBuffer', () => {
expect(
convertArrayBufferToBase64Url(convertBase64UrlToArrayBuffer('')),
).toBe(convertArrayBufferToBase64Url(new Uint8Array([])));
expect(
convertArrayBufferToBase64Url(convertBase64UrlToArrayBuffer('AA')),
).toBe(convertArrayBufferToBase64Url(new Uint8Array([0])));
expect(
convertArrayBufferToBase64Url(convertBase64UrlToArrayBuffer('AQID')),
).toBe(convertArrayBufferToBase64Url(new Uint8Array([1, 2, 3])));
});

it('converts base64url to ArrayBuffer and back without data loss', () => {
const input = '_h7NMedx8qUAz_yHKhgHt74P2UrTU_qcB4_ToULz12M';
expect(
convertArrayBufferToBase64Url(convertBase64UrlToArrayBuffer(input)),
).toBe(input);
});
});
94 changes: 94 additions & 0 deletions packages/auth/__tests__/mockData.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import {
PasskeyCreateOptions,
PasskeyCreateOptionsJson,
PasskeyCreateResult,
PasskeyCreateResultJson,
} from '../src/client/utils/passkey/types';

// device tracking mock device data
export const mockDeviceArray = [
{
Expand Down Expand Up @@ -180,3 +187,90 @@ export const mockAuthConfigWithOAuth = {
},
},
};

export const passkeyCredentialCreateOptions =
'{"rp":{"id":"localhost","name":"localhost"},"user":{"id":"M2M0NjMyMGItYzYwZS00YTIxLTlkNjQtNTgyOWJmZWRlMWM0","name":"james","displayName":""},"challenge":"zsBch6DlNLUb6SgRdzHysw","pubKeyCredParams":[{"type":"public-key","alg":-7},{"type":"public-key","alg":-257}],"timeout":60000,"excludeCredentials":[{"type":"public-key","id":"VWxodmRFMUtjbEJZVWs1NE9IaHhOblZUTTBsUVJWSXRTbWhhUkdwZldHaDBSbVpmUmxKamFWRm5XUQ"},{"type":"public-key","id":"WDJnM1RrMWxaSGc0Y1ZWQmVsOTVTRXRvWjBoME56UlFNbFZ5VkZWZmNXTkNORjlVYjFWTWVqRXlUUQ"}],"authenticatorSelection":{"requireResidentKey":true,"residentKey":"required","userVerification":"required"}}';

export const passkeyRegistrationResultJson: PasskeyCreateResultJson = {
type: 'public-key',
id: 'vJCit9S2cglAvvW3txQ-OWRBb-NyhxaLOvRRisnr1aE',
rawId: 'vJCit9S2cglAvvW3txQ-OQ',
response: {
clientDataJSON: 'vJCit9S2cglAvvW3txQ-OQ',
attestationObject: 'vJCit9S2cglAvvW3txQ-OQ',
},
};
export const passkeyRegistrationResult: PasskeyCreateResult = {
type: 'public-key',
id: 'vJCit9S2cglAvvW3txQ-OWRBb-NyhxaLOvRRisnr1aE',
rawId: new Uint8Array([
188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57,
]),
response: {
clientDataJSON: new Uint8Array([
188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57,
]),
attestationObject: new Uint8Array([
188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57,
]),
},
};

export const passkeyRegistrationRequest: PasskeyCreateOptions = {
rp: { id: 'localhost', name: 'localhost' },
user: {
id: new Uint8Array([
188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57,
]),
name: 'james',
displayName: '',
},
challenge: new Uint8Array([
188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57,
]),
pubKeyCredParams: [
{ type: 'public-key' as any, alg: -7 },
{ type: 'public-key' as any, alg: -257 },
],
timeout: 60000,
excludeCredentials: [
{
type: 'public-key' as any,
id: new Uint8Array([
188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62,
57,
]),
},
],
authenticatorSelection: {
requireResidentKey: true,
residentKey: 'required' as any,
userVerification: 'required' as any,
},
};

export const passkeyRegistrationRequestJson: PasskeyCreateOptionsJson = {
rp: { id: 'localhost', name: 'localhost' },
user: {
id: 'vJCit9S2cglAvvW3txQ-OQ',
name: 'james',
displayName: '',
},
challenge: 'vJCit9S2cglAvvW3txQ-OQ',
pubKeyCredParams: [
{ type: 'public-key', alg: -7 },
{ type: 'public-key', alg: -257 },
],
timeout: 60000,
excludeCredentials: [
{
type: 'public-key',
id: 'vJCit9S2cglAvvW3txQ-OQ',
},
],
authenticatorSelection: {
requireResidentKey: true,
residentKey: 'required',
userVerification: 'required',
},
};
Loading
Loading