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
5 changes: 5 additions & 0 deletions packages/profile-sync-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `AuthenticationController.getCustomerServiceToken` method and messenger action to retrieve a Customer Service specific access token ([#9442](https://github.com/MetaMask/core/pull/9442))
- Exchanges the OIDC access token for a short-lived token scoped to the customer-service audience via `POST /api/v2/customer-service/token`, which Customer Service tooling consumes to identify and authenticate the user.

### Changed

- Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,22 @@ export type AuthenticationControllerGetUserProfileLineageAction = {
handler: AuthenticationController['getUserProfileLineage'];
};

/**
* Returns a Customer Service specific access token for the specified SRP,
* logging in if needed.
*
* Exchanges the OIDC access token for a short-lived token scoped to the
* customer-service audience. Customer Service tooling consumes this token to
* identify and authenticate the user.
*
* @param entropySourceId - The entropy source ID. Omit for the primary SRP.
* @returns The customer-service access token.
*/
export type AuthenticationControllerGetCustomerServiceTokenAction = {
type: `AuthenticationController:getCustomerServiceToken`;
handler: AuthenticationController['getCustomerServiceToken'];
};

export type AuthenticationControllerIsSignedInAction = {
type: `AuthenticationController:isSignedIn`;
handler: AuthenticationController['isSignedIn'];
Expand All @@ -101,4 +117,5 @@ export type AuthenticationControllerMethodActions =
| AuthenticationControllerGetSessionProfileAction
| AuthenticationControllerRefreshCanonicalProfileIdAction
| AuthenticationControllerGetUserProfileLineageAction
| AuthenticationControllerGetCustomerServiceTokenAction
| AuthenticationControllerIsSignedInAction;
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import type {
import type { LoginResponse } from '../../sdk';
import { Platform } from '../../sdk';
import { arrangeAuthAPIs } from '../../sdk/__fixtures__/auth';
import { MOCK_USER_PROFILE_LINEAGE_RESPONSE } from '../../sdk/mocks/auth';
import {
MOCK_ACCESS_JWT,
MOCK_USER_PROFILE_LINEAGE_RESPONSE,
} from '../../sdk/mocks/auth';
import { AuthenticationController } from './AuthenticationController';
import type {
AuthenticationControllerMessenger,
Expand Down Expand Up @@ -1069,6 +1072,75 @@ describe('AuthenticationController', () => {
});
});

describe('getCustomerServiceToken', () => {
it('should throw error if not logged in', async () => {
const metametrics = createMockAuthMetaMetrics();
const { messenger } = createMockAuthenticationMessenger();
const controller = new AuthenticationController({
messenger,
state: { isSignedIn: false, needsProfilePairing: true },
metametrics,
});

await expect(controller.getCustomerServiceToken()).rejects.toThrow(
expect.any(Error),
);
});

it('should return the customer service token', async () => {
const metametrics = createMockAuthMetaMetrics();
mockAuthenticationFlowEndpoints();

const { messenger } = createMockAuthenticationMessenger();
const originalState = mockSignedInState();
const controller = new AuthenticationController({
messenger,
state: originalState,
metametrics,
});

const result = await controller.getCustomerServiceToken();
expect(result).toBe(MOCK_ACCESS_JWT);
});

it('should throw if the customer service token request fails', async () => {
const metametrics = createMockAuthMetaMetrics();
mockAuthenticationFlowEndpoints({ endpointFail: 'customerService' });

const { messenger } = createMockAuthenticationMessenger();
const originalState = mockSignedInState();
const controller = new AuthenticationController({
messenger,
state: originalState,
metametrics,
});

await expect(controller.getCustomerServiceToken()).rejects.toThrow(
expect.any(Error),
);
});

it('should throw error if wallet is locked', async () => {
const metametrics = createMockAuthMetaMetrics();
const { messenger, mockKeyringControllerGetState } =
createMockAuthenticationMessenger();

const originalState = mockSignedInState();

mockKeyringControllerGetState.mockReturnValue({ isUnlocked: false });

const controller = new AuthenticationController({
messenger,
state: originalState,
metametrics,
});

await expect(controller.getCustomerServiceToken()).rejects.toThrow(
expect.any(Error),
);
});
});

describe('isSignedIn', () => {
it('should return false if not logged in', () => {
const metametrics = createMockAuthMetaMetrics();
Expand Down Expand Up @@ -1412,13 +1484,14 @@ function createMockAuthenticationMessenger() {
* @returns mock auth endpoints
*/
function mockAuthenticationFlowEndpoints(params?: {
endpointFail: 'nonce' | 'login' | 'token' | 'lineage';
endpointFail: 'nonce' | 'login' | 'token' | 'lineage' | 'customerService';
}) {
const {
mockNonceUrl,
mockOAuth2TokenUrl,
mockSrpLoginUrl,
mockUserProfileLineageUrl,
mockCustomerServiceTokenUrl,
} = arrangeAuthAPIs({
mockNonceUrl:
params?.endpointFail === 'nonce' ? { status: 500 } : undefined,
Expand All @@ -1428,13 +1501,16 @@ function mockAuthenticationFlowEndpoints(params?: {
params?.endpointFail === 'token' ? { status: 500 } : undefined,
mockUserProfileLineageUrl:
params?.endpointFail === 'lineage' ? { status: 500 } : undefined,
mockCustomerServiceTokenUrl:
params?.endpointFail === 'customerService' ? { status: 500 } : undefined,
});

return {
mockNonceUrl,
mockOAuth2TokenUrl,
mockSrpLoginUrl,
mockUserProfileLineageUrl,
mockCustomerServiceTokenUrl,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ const MESSENGER_EXPOSED_METHODS = [
'getSessionProfile',
'refreshCanonicalProfileId',
'getUserProfileLineage',
'getCustomerServiceToken',
'isSignedIn',
'requestProfilePairing',
] as const;
Expand Down Expand Up @@ -569,6 +570,26 @@ export class AuthenticationController extends BaseController<
return await this.#auth.getUserProfileLineage(resolvedId);
}

/**
* Returns a Customer Service specific access token for the specified SRP,
* logging in if needed.
*
* Exchanges the OIDC access token for a short-lived token scoped to the
* customer-service audience. Customer Service tooling consumes this token to
* identify and authenticate the user.
*
* @param entropySourceId - The entropy source ID. Omit for the primary SRP.
* @returns The customer-service access token.
*/
public async getCustomerServiceToken(
entropySourceId?: string,
): Promise<string> {
this.#assertIsUnlocked('getCustomerServiceToken');
const resolvedId =
entropySourceId ?? (await this.#getPrimaryEntropySourceId());
return await this.#auth.getCustomerServiceToken(resolvedId);
}

public isSignedIn(): boolean {
return this.state.isSignedIn;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
getMockAuthAccessTokenResponse,
getMockCustomerServiceTokenResponse,
getE2EIdentifierFromJwt,
MOCK_CUSTOMER_SERVICE_TOKEN_RESPONSE,
MOCK_OATH_TOKEN_RESPONSE,
} from './mockResponses';

Expand Down Expand Up @@ -75,3 +77,13 @@ describe('getMockAuthAccessTokenResponse()', () => {
expect(payload.exp).toBe(4102444800);
});
});

describe('getMockCustomerServiceTokenResponse()', () => {
it('returns a POST mock for the customer service token endpoint', () => {
const mock = getMockCustomerServiceTokenResponse();

expect(mock.requestMethod).toBe('POST');
expect(mock.url).toContain('/customer-service/token');
expect(mock.response).toStrictEqual(MOCK_CUSTOMER_SERVICE_TOKEN_RESPONSE);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import {
MOCK_SRP_LOGIN_RESPONSE as SDK_MOCK_SRP_LOGIN_RESPONSE,
MOCK_OIDC_TOKEN_RESPONSE as SDK_MOCK_OIDC_TOKEN_RESPONSE,
MOCK_PAIR_PROFILES_RESPONSE as SDK_MOCK_PAIR_PROFILES_RESPONSE,
MOCK_CUSTOMER_SERVICE_TOKEN_RESPONSE as SDK_MOCK_CUSTOMER_SERVICE_TOKEN_RESPONSE,
MOCK_NONCE_URL,
MOCK_SRP_LOGIN_URL,
MOCK_OIDC_TOKEN_URL,
MOCK_PAIR_PROFILES_URL,
MOCK_CUSTOMER_SERVICE_TOKEN_URL,
} from '../../../sdk/mocks/auth';

type MockResponse = {
Expand Down Expand Up @@ -144,3 +146,14 @@ export const getMockAuthAccessTokenResponse = (): MockResponse => {
},
} satisfies MockResponse;
};

export const MOCK_CUSTOMER_SERVICE_TOKEN_RESPONSE =
SDK_MOCK_CUSTOMER_SERVICE_TOKEN_RESPONSE;

export const getMockCustomerServiceTokenResponse = (): MockResponse => {
return {
url: MOCK_CUSTOMER_SERVICE_TOKEN_URL,
requestMethod: 'POST',
response: MOCK_CUSTOMER_SERVICE_TOKEN_RESPONSE,
} satisfies MockResponse;
};
23 changes: 23 additions & 0 deletions packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
MOCK_SRP_LOGIN_RESPONSE,
MOCK_SRP_LOGIN_URL,
MOCK_USER_PROFILE_LINEAGE_RESPONSE,
MOCK_CUSTOMER_SERVICE_TOKEN_URL,
MOCK_CUSTOMER_SERVICE_TOKEN_RESPONSE,
} from '../mocks/auth';

type MockReply = {
Expand Down Expand Up @@ -104,6 +106,21 @@ export const handleMockUserProfileLineage = (
return mockUserProfileLineageEndpoint;
};

export const handleMockCustomerServiceToken = (
mockReply?: MockReply,
): nock.Scope => {
const reply = mockReply ?? {
status: 200,
body: MOCK_CUSTOMER_SERVICE_TOKEN_RESPONSE,
};
const mockCustomerServiceTokenEndpoint = nock(MOCK_CUSTOMER_SERVICE_TOKEN_URL)
.persist()
.post('')
.reply(reply.status, reply.body);

return mockCustomerServiceTokenEndpoint;
};

export const arrangeAuthAPIs = (options?: {
mockNonceUrl?: MockReply;
mockOAuth2TokenUrl?: MockReply;
Expand All @@ -112,6 +129,7 @@ export const arrangeAuthAPIs = (options?: {
mockPairIdentifiers?: MockReply;
mockPairProfiles?: MockReply;
mockUserProfileLineageUrl?: MockReply;
mockCustomerServiceTokenUrl?: MockReply;
}): {
mockNonceUrl: nock.Scope;
mockOAuth2TokenUrl: nock.Scope;
Expand All @@ -120,6 +138,7 @@ export const arrangeAuthAPIs = (options?: {
mockPairIdentifiersUrl: nock.Scope;
mockPairProfilesUrl: nock.Scope;
mockUserProfileLineageUrl: nock.Scope;
mockCustomerServiceTokenUrl: nock.Scope;
} => {
const mockNonceUrl = handleMockNonce(options?.mockNonceUrl);
const mockOAuth2TokenUrl = handleMockOAuth2Token(options?.mockOAuth2TokenUrl);
Expand All @@ -132,6 +151,9 @@ export const arrangeAuthAPIs = (options?: {
const mockUserProfileLineageUrl = handleMockUserProfileLineage(
options?.mockUserProfileLineageUrl,
);
const mockCustomerServiceTokenUrl = handleMockCustomerServiceToken(
options?.mockCustomerServiceTokenUrl,
);

return {
mockNonceUrl,
Expand All @@ -141,5 +163,6 @@ export const arrangeAuthAPIs = (options?: {
mockPairIdentifiersUrl,
mockPairProfilesUrl,
mockUserProfileLineageUrl,
mockCustomerServiceTokenUrl,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
SIWE_LOGIN_URL,
authenticate,
authorizeOIDC,
getCustomerServiceToken,
getNonce,
getUserProfileLineage,
} from './services';
Expand Down Expand Up @@ -75,6 +76,11 @@ export class SIWEJwtBearerAuth implements IBaseAuth {
return await getUserProfileLineage(this.#config.env, accessToken);
}

async getCustomerServiceToken(): Promise<string> {
const accessToken = await this.getAccessToken();
return await getCustomerServiceToken(this.#config.env, accessToken);
}

async signMessage(message: string): Promise<string> {
this.#assertSigner(this.#signer);
return await this.#signer.signMessage(message);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ const mockGetNonce = jest.fn();
const mockAuthenticate = jest.fn();
const mockAuthorizeOIDC = jest.fn();
const mockPairProfiles = jest.fn();
const mockGetCustomerServiceToken = jest.fn();

jest.mock('./services', () => ({
authenticate: (...args: unknown[]): unknown => mockAuthenticate(...args),
authorizeOIDC: (...args: unknown[]): unknown => mockAuthorizeOIDC(...args),
getNonce: (...args: unknown[]): unknown => mockGetNonce(...args),
getUserProfileLineage: jest.fn(),
getCustomerServiceToken: (...args: unknown[]): unknown =>
mockGetCustomerServiceToken(...args),
pairProfiles: (...args: unknown[]): unknown => mockPairProfiles(...args),
}));

Expand Down Expand Up @@ -247,6 +250,21 @@ describe('SRPJwtBearerAuth rate limit handling', () => {
expect(token).toBe('access');
expect(mockGetNonce).toHaveBeenCalledTimes(1);
});

it('getCustomerServiceToken exchanges the access token via the service', async () => {
const { auth } = createAuth();
mockGetCustomerServiceToken.mockResolvedValue('cs-access-token');

const result = await auth.getCustomerServiceToken();

expect(result).toBe('cs-access-token');
// Logs in to obtain the OIDC access token, then exchanges it.
expect(mockAuthorizeOIDC).toHaveBeenCalledTimes(1);
expect(mockGetCustomerServiceToken).toHaveBeenCalledWith(
config.env,
'access',
);
});
});

describe('SRPJwtBearerAuth profileId resolution', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { validateLoginResponse } from '../utils/validate-login-response';
import {
authenticate,
authorizeOIDC,
getCustomerServiceToken,
getNonce,
getUserProfileLineage,
pairProfiles,
Expand Down Expand Up @@ -165,6 +166,11 @@ export class SRPJwtBearerAuth implements IBaseAuth {
return await getUserProfileLineage(this.#config.env, accessToken);
}

async getCustomerServiceToken(entropySourceId?: string): Promise<string> {
const accessToken = await this.getAccessToken(entropySourceId);
return await getCustomerServiceToken(this.#config.env, accessToken);
}

async pairSrpProfiles(
accessTokens: string[],
authAccessToken: string,
Expand Down
Loading