diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/mock-auth.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/mock-auth.ts index f26779434c..557b1aba07 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/mock-auth.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/mock-auth.ts @@ -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'; @@ -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); @@ -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) @@ -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, }; }; diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts index bea7b6abc6..b46acc2499 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/services.ts @@ -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`; @@ -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 { + 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 * diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/types.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/types.ts index 205b27f1fe..66aef881ba 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/types.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/types.ts @@ -70,3 +70,10 @@ export type ErrorMessage = { message: string; error: string; }; + +export type Pair = { + identifier: string; + encryptedStorageKey: string; + identifierType: 'SIWE' | 'SRP'; + signMessage: (message: string) => Promise; +}; diff --git a/packages/profile-sync-controller/src/sdk/authentication.test.ts b/packages/profile-sync-controller/src/sdk/authentication.test.ts index e1a71c2a12..e09e5043e2 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.test.ts @@ -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, @@ -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', + 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', + 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', + identifier: + '0xc89a614e873c2c1f08fc8d72590e13c961ea856cc7a9cd08af4bf3d3fca11111', + identifierType: 'SRP', + signMessage: async (message: string): Promise => { + 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', + 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(() => { diff --git a/packages/profile-sync-controller/src/sdk/authentication.ts b/packages/profile-sync-controller/src/sdk/authentication.ts index 9ff671768a..d3cf10ace8 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.ts @@ -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 extends infer U ? { [K in keyof U]: U[K] } : never; @@ -16,10 +21,13 @@ type JwtBearerAuthParams = SiweParams | SRPParams; export class JwtBearerAuth implements SIWEInterface, SRPInterface { #type: AuthType; + #env: Env; + #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]); @@ -50,6 +58,36 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface { return await this.#sdk.signMessage(message); } + async pairIdentifiers(pairing: Pair[]): Promise { + const profile = await this.getUserProfile(); + const n = await getNonce(profile.profileId, this.#env); + + const logins = await Promise.all( + pairing.map(async (p) => { + 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(); + await pairIdentifiers(n.nonce, logins, accessToken, this.#env); + } + prepare(signer: { address: string; chainId: number; diff --git a/packages/profile-sync-controller/src/sdk/errors.ts b/packages/profile-sync-controller/src/sdk/errors.ts index 10531a3e11..40ce5bc778 100644 --- a/packages/profile-sync-controller/src/sdk/errors.ts +++ b/packages/profile-sync-controller/src/sdk/errors.ts @@ -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);