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: added pairIdentifiers service #4269

Merged
merged 7 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SIWE_LOGIN_URL,
SRP_LOGIN_URL,
OIDC_TOKEN_URL,
PAIR_IDENTIFIERS,
} from '../authentication-jwt-bearer/services';
import { Env } from '../env';

Expand All @@ -15,6 +16,7 @@ type MockReply = {

const MOCK_NONCE_URL = NONCE_URL(Env.DEV);
const MOCK_SIWE_LOGIN_URL = SIWE_LOGIN_URL(Env.DEV);
const MOCK_PAIR_IDENTIFIERS_URL = PAIR_IDENTIFIERS(Env.DEV);
const MOCK_SRP_LOGIN_URL = SRP_LOGIN_URL(Env.DEV);
const MOCK_OIDC_TOKEN_URL = OIDC_TOKEN_URL(Env.DEV);

Expand Down Expand Up @@ -83,6 +85,16 @@ export const handleMockSiweLogin = (mockReply?: MockReply) => {
return mockLoginEndpoint;
};

export const handleMockPairIdentifiers = (mockReply?: MockReply) => {
const reply = mockReply ?? { status: 204 };
const mockPairIdentifiersEndpoint = nock(MOCK_PAIR_IDENTIFIERS_URL)
.persist()
.post('')
.reply(reply.status, reply.body);

return mockPairIdentifiersEndpoint;
};

export const handleMockSrpLogin = (mockReply?: MockReply) => {
const reply = mockReply ?? { status: 200, body: MOCK_SRP_LOGIN_RESPONSE };
const mockLoginEndpoint = nock(MOCK_SRP_LOGIN_URL)
Expand All @@ -108,16 +120,21 @@ export const arrangeAuthAPIs = (options?: {
mockOAuth2TokenUrl?: MockReply;
mockSrpLoginUrl?: MockReply;
mockSiweLoginUrl?: MockReply;
mockPairIdentifiers?: MockReply;
}) => {
const mockNonceUrl = handleMockNonce(options?.mockNonceUrl);
const mockOAuth2TokenUrl = handleMockOAuth2Token(options?.mockOAuth2TokenUrl);
const mockSrpLoginUrl = handleMockSrpLogin(options?.mockSrpLoginUrl);
const mockSiweLoginUrl = handleMockSiweLogin(options?.mockSiweLoginUrl);
const mockPairIdentifiersUrl = handleMockPairIdentifiers(
options?.mockPairIdentifiers,
);

return {
mockNonceUrl,
mockOAuth2TokenUrl,
mockSrpLoginUrl,
mockSiweLoginUrl,
mockPairIdentifiersUrl,
};
};
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import type { Env } from '../env';
import { getEnvUrls, getOidcClientId } from '../env';
import { NonceRetrievalError, SignInError, ValidationError } from '../errors';
import {
NonceRetrievalError,
PairError,
SignInError,
ValidationError,
} from '../errors';
import type { AccessToken, ErrorMessage, UserProfile } from './types';
import { AuthType } from './types';

export const NONCE_URL = (env: Env) =>
`${getEnvUrls(env).authApiUrl}/api/v2/nonce`;

export const PAIR_IDENTIFIERS = (env: Env) =>
`${getEnvUrls(env).authApiUrl}/api/v2/identifiers/pair`;

export const OIDC_TOKEN_URL = (env: Env) =>
`${getEnvUrls(env).oidcApiUrl}/oauth2/token`;

Expand Down Expand Up @@ -36,6 +44,57 @@ type NonceResponse = {
expiresIn: number;
};

type PairRequest = {
signature: string;
raw_message: string;
encrypted_storage_key: string;
identifier_type: 'SIWE' | 'SRP';
};

/**
* Pair multiple identifiers under a single profile
*
* @param nonce - session nonce
* @param logins - pairing request payload
* @param accessToken - JWT access token used to access protected resources
* @param env - server environment
* @returns void.
*/
export async function pairIdentifiers(
nonce: string,
logins: PairRequest[],
accessToken: string,
env: Env,
): Promise<void> {
const pairUrl = new URL(PAIR_IDENTIFIERS(env));

try {
const response = await fetch(pairUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
nonce,
logins,
}),
});

if (!response.ok) {
const responseBody = (await response.json()) as ErrorMessage;
throw new Error(
`HTTP error message: ${responseBody.message}, error: ${responseBody.error}`,
);
}
} catch (e) {
/* istanbul ignore next */
const errorMessage =
e instanceof Error ? e.message : JSON.stringify(e ?? '');
throw new PairError(`unable to pair identifiers: ${errorMessage}`);
}
}

/**
* Service to Get Nonce for JWT Bearer Flow
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,10 @@ export type ErrorMessage = {
message: string;
error: string;
};

export type Pair = {
identifier: string;
encryptedStorageKey: string;
Prithpal-Sooriya marked this conversation as resolved.
Show resolved Hide resolved
identifierType: 'SIWE' | 'SRP';
signMessage: (message: string) => Promise<string>;
};
113 changes: 112 additions & 1 deletion packages/profile-sync-controller/src/sdk/authentication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import {
import type { MockVariable } from './__fixtures__/test-utils';
import { arrangeAuth } from './__fixtures__/test-utils';
import { JwtBearerAuth } from './authentication';
import type { LoginResponse } from './authentication-jwt-bearer/types';
import type { LoginResponse, Pair } from './authentication-jwt-bearer/types';
import { Env } from './env';
import {
NonceRetrievalError,
PairError,
SignInError,
UnsupportedAuthTypeError,
ValidationError,
Expand All @@ -19,6 +20,116 @@ import * as Eip6963MetamaskProvider from './utils/eip-6963-metamask-provider';
const MOCK_SRP = '0x6265617665726275696c642e6f7267';
const MOCK_ADDRESS = '0x68757d15a4d8d1421c17003512AFce15D3f3FaDa';

describe('Identifier Pairing', () => {
it('should pair identifiers', async () => {
const { auth, mockSignMessage } = arrangeAuth('SRP', MOCK_SRP);
const { mockNonceUrl, mockPairIdentifiersUrl, mockSrpLoginUrl } =
arrangeAuthAPIs();

const pairing: Pair[] = [
{
encryptedStorageKey: 'encrypted<original-storage-key>',
identifier:
'0xc89a614e873c2c1f08fc8d72590e13c961ea856cc7a9cd08af4bf3d3fca53046',
identifierType: 'SRP',
signMessage: mockSignMessage,
},
];
await auth.pairIdentifiers(pairing);

// API
expect(mockSrpLoginUrl.isDone()).toBe(true);
expect(mockNonceUrl.isDone()).toBe(true);
expect(mockPairIdentifiersUrl.isDone()).toBe(true);
});

it('should handle pair identifiers API errors', async () => {
const { auth, mockSignMessage } = arrangeAuth('SRP', MOCK_SRP);
const { mockNonceUrl, mockPairIdentifiersUrl, mockSrpLoginUrl } =
arrangeAuthAPIs({
mockPairIdentifiers: {
status: 401,
body: {
message: 'invalid pair signature',
error: 'invalid-pair-request',
},
},
});

const pairing: Pair[] = [
{
encryptedStorageKey: 'encrypted<original-storage-key>',
identifier:
'0xc89a614e873c2c1f08fc8d72590e13c961ea856cc7a9cd08af4bf3d3fca11111',
identifierType: 'SRP',
signMessage: mockSignMessage,
},
];

await expect(auth.pairIdentifiers(pairing)).rejects.toThrow(PairError);

// API
expect(mockSrpLoginUrl.isDone()).toBe(true);
expect(mockNonceUrl.isDone()).toBe(true);
expect(mockPairIdentifiersUrl.isDone()).toBe(true);
});

it('should handle sign message errors', async () => {
const { auth } = arrangeAuth('SRP', MOCK_SRP);
const { mockNonceUrl, mockPairIdentifiersUrl, mockSrpLoginUrl } =
arrangeAuthAPIs();

const pairing: Pair[] = [
{
encryptedStorageKey: 'encrypted<original-storage-key>',
identifier:
'0xc89a614e873c2c1f08fc8d72590e13c961ea856cc7a9cd08af4bf3d3fca11111',
identifierType: 'SRP',
signMessage: async (message: string): Promise<string> => {
return new Promise((_, reject) => {
reject(new Error(`unable to sign message: ${message}`));
});
},
},
];

await expect(auth.pairIdentifiers(pairing)).rejects.toThrow(PairError);

// API
expect(mockSrpLoginUrl.isDone()).toBe(true);
expect(mockNonceUrl.isDone()).toBe(true);
expect(mockPairIdentifiersUrl.isDone()).toBe(false);
});

it('should handle nonce errors', async () => {
const { auth, mockSignMessage } = arrangeAuth('SRP', MOCK_SRP);

const { mockNonceUrl, mockPairIdentifiersUrl } = arrangeAuthAPIs({
mockNonceUrl: {
status: 400,
body: { message: 'invalid identifier', error: 'validation-error' },
},
});

const pairing: Pair[] = [
{
encryptedStorageKey: 'encrypted<original-storage-key>',
identifier: '0x12345',
identifierType: 'SRP',
signMessage: mockSignMessage,
},
];

await expect(auth.pairIdentifiers(pairing)).rejects.toThrow(
NonceRetrievalError,
);

// API
expect(mockNonceUrl.isDone()).toBe(true);
expect(mockPairIdentifiersUrl.isDone()).toBe(false);
});
});

describe('Authentication - constructor()', () => {
it('errors on invalid auth type', async () => {
expect(() => {
Expand Down
42 changes: 40 additions & 2 deletions packages/profile-sync-controller/src/sdk/authentication.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { SIWEJwtBearerAuth } from './authentication-jwt-bearer/flow-siwe';
import { SRPJwtBearerAuth } from './authentication-jwt-bearer/flow-srp';
import type { UserProfile } from './authentication-jwt-bearer/types';
import {
getNonce,
pairIdentifiers,
} from './authentication-jwt-bearer/services';
import type { UserProfile, Pair } from './authentication-jwt-bearer/types';
import { AuthType } from './authentication-jwt-bearer/types';
import { UnsupportedAuthTypeError } from './errors';
import type { Env } from './env';
import { PairError, UnsupportedAuthTypeError } from './errors';

// Computing the Classes, so we only get back the public methods for the interface.
type Compute<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
Expand All @@ -16,10 +21,13 @@ type JwtBearerAuthParams = SiweParams | SRPParams;
export class JwtBearerAuth implements SIWEInterface, SRPInterface {
#type: AuthType;

#env: Env;
dovydas55 marked this conversation as resolved.
Show resolved Hide resolved

#sdk: SIWEJwtBearerAuth | SRPJwtBearerAuth;

constructor(...args: JwtBearerAuthParams) {
this.#type = args[0].type;
this.#env = args[0].env;

if (args[0].type === AuthType.SRP) {
this.#sdk = new SRPJwtBearerAuth(args[0], args[1]);
Expand Down Expand Up @@ -50,6 +58,36 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface {
return await this.#sdk.signMessage(message);
}

async pairIdentifiers(pairing: Pair[]): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmmm...

I'm hesitant if implementations should be done in this combined class vs in the individual implementations.

Context:

  • Combined Class: 1 class; but poor type interface
  • Individual Class: the developer can choose a specific implementation & has great type interface.

Copy link
Contributor

@Prithpal-Sooriya Prithpal-Sooriya May 15, 2024

Choose a reason for hiding this comment

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

Yes lets move the implementation to the individual classes, that way the better typed implementations can also utilise this method.

Either do this now, or in a separate implementation.

Copy link
Contributor

Choose a reason for hiding this comment

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

Only annoying thing is that the implementation is identical on both implementations, so a bit redundant. (Sadly JS does not support the trait system lol).

Even though there would be duplication, I think it is nicer to have that abstraction.

const profile = await this.getUserProfile();
const n = await getNonce(profile.profileId, this.#env);

const logins = await Promise.all(
dovydas55 marked this conversation as resolved.
Show resolved Hide resolved
pairing.map(async (p) => {
Prithpal-Sooriya marked this conversation as resolved.
Show resolved Hide resolved
try {
const raw = `metamask:${n.nonce}:${p.identifier}`;
const sig = await p.signMessage(raw);
return {
signature: sig,
raw_message: raw,
encrypted_storage_key: p.encryptedStorageKey,
identifier_type: p.identifierType,
};
} catch (e) {
/* istanbul ignore next */
const errorMessage =
e instanceof Error ? e.message : JSON.stringify(e ?? '');
throw new PairError(
`failed to sign pairing message: ${errorMessage}`,
);
}
}),
);

const accessToken = await this.getAccessToken();
return pairIdentifiers(n.nonce, logins, accessToken, this.#env);
}

prepare(signer: {
address: string;
chainId: number;
Expand Down
7 changes: 7 additions & 0 deletions packages/profile-sync-controller/src/sdk/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ export class SignInError extends Error {
}
}

export class PairError extends Error {
constructor(message: string) {
super(message);
this.name = 'PairError';
}
}

export class UserStorageError extends Error {
constructor(message: string) {
super(message);
Expand Down
Loading