Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/multi-session-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@aws-amplify/auth': minor
'@aws-amplify/core': minor
'aws-amplify': minor
---

feat(auth): add multi-session multi-profile support. Introduces `setCurrentUser` and `listCurrentUsers` (client and server-side), an `AuthUserList` session roster alongside `LastAuthUser`, and boundary Hub events (`userSignedIn`, `switchActiveUser`, `userSignedOut`). Multiple Cognito users can be signed in simultaneously with one active session at a time.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { RespondToAuthChallengeCommandOutput } from '../../../src/foundation/fac
import { authAPITestParams } from './testUtils/authApiTestParams';

jest.mock('../../../src/providers/cognito/apis/getCurrentUser');
jest.mock('../../../src/providers/cognito/utils/dispatchSignedInHubEvent');
jest.mock(
'../../../src/foundation/factories/serviceClients/cognitoIdentityProvider',
);
Expand Down Expand Up @@ -106,12 +107,6 @@ describe('confirmSignIn API happy path cases', () => {

const smsCode = '123456';

mockedGetCurrentUser.mockImplementationOnce(async () => {
return {
username: 'username',
userId: 'userId',
};
});
const confirmSignInResult = await confirmSignIn({
challengeResponse: smsCode,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { createAuthSessionSwitcher } from '../../../src/providers/cognito/tokenProvider/createAuthSessionSwitcher';
import { USER_NOT_SIGNED_IN_EXCEPTION } from '../../../src/errors/constants';

const mockGetAuthUserList = jest.fn();
const mockGetStoredIdToken = jest.fn();
const mockAddActiveSession = jest.fn();
const mockStore = {
getAuthUserList: mockGetAuthUserList,
getStoredIdToken: mockGetStoredIdToken,
addActiveSession: mockAddActiveSession,
};

describe('createAuthSessionSwitcher', () => {
beforeEach(() => {
mockAddActiveSession.mockResolvedValue(undefined);
});

afterEach(() => {
mockGetAuthUserList.mockReset();
mockGetStoredIdToken.mockReset();
mockAddActiveSession.mockReset();
});

it('listSessionUsernames delegates to the store roster read', async () => {
mockGetAuthUserList.mockResolvedValue(['alice', 'bob']);
const switcher = createAuthSessionSwitcher(mockStore);

await expect(switcher.listSessionUsernames()).resolves.toEqual([
'alice',
'bob',
]);
});

it('getStoredIdToken delegates to the store', async () => {
const idToken = { payload: {}, toString: () => 'idToken' };
mockGetStoredIdToken.mockResolvedValue(idToken);
const switcher = createAuthSessionSwitcher(mockStore);

await expect(switcher.getStoredIdToken('alice')).resolves.toBe(idToken);
expect(mockGetStoredIdToken).toHaveBeenCalledWith('alice');
});

it('setActiveSession throws when the username is absent and does not reorder', async () => {
mockGetAuthUserList.mockResolvedValue(['alice']);
const switcher = createAuthSessionSwitcher(mockStore);

await expect(switcher.setActiveSession('bob')).rejects.toMatchObject({
name: USER_NOT_SIGNED_IN_EXCEPTION,
});
// Non-destructive: never adds a non-signed-in user.
expect(mockAddActiveSession).not.toHaveBeenCalled();
});

it('setActiveSession reorders (non-destructively) when the username is present', async () => {
mockGetAuthUserList.mockResolvedValue(['alice', 'bob']);
const switcher = createAuthSessionSwitcher(mockStore);

await switcher.setActiveSession('bob');

expect(mockAddActiveSession).toHaveBeenCalledWith('bob');
expect(mockAddActiveSession).toHaveBeenCalledTimes(1);
});
});
170 changes: 170 additions & 0 deletions packages/auth/__tests__/providers/cognito/listCurrentUsers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { Amplify } from '@aws-amplify/core';

import { listCurrentUsers } from '../../../src/providers/cognito/apis/listCurrentUsers';
import { cognitoUserPoolsTokenProvider } from '../../../src/providers/cognito/tokenProvider';

const userPoolClientId = '111111-aaaaa-42d8-891d-ee81a1549398';
const authConfig = {
Cognito: {
userPoolClientId,
userPoolId: 'us-west-2_zzzzz',
},
};

cognitoUserPoolsTokenProvider.setAuthConfig(authConfig);
Amplify.configure({
Auth: authConfig,
});

const { authTokenStore } = cognitoUserPoolsTokenProvider;

const mockKeyValueStorage = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};

describe('listCurrentUsers', () => {
let getAuthUserListSpy: jest.SpyInstance;
let getStoredIdTokenSpy: jest.SpyInstance;
let loadTokensSpy: jest.SpyInstance;

beforeEach(() => {
jest
.spyOn(authTokenStore, 'getKeyValueStorage')
.mockReturnValue(mockKeyValueStorage);
getAuthUserListSpy = jest.spyOn(authTokenStore, 'getAuthUserList');
getStoredIdTokenSpy = jest.spyOn(authTokenStore, 'getStoredIdToken');
loadTokensSpy = jest.spyOn(authTokenStore, 'loadTokens');
});

afterEach(() => {
jest.restoreAllMocks();
mockKeyValueStorage.getItem.mockReset();
});

it('returns AuthUser[] resolved from stored id tokens in roster order', async () => {
getAuthUserListSpy.mockResolvedValue(['alice', 'bob']);
getStoredIdTokenSpy.mockImplementation((username: string) =>
Promise.resolve({
payload: { 'cognito:username': username, sub: `${username}-sub` },
toString: () => `${username}-idToken`,
}),
);

const result = await listCurrentUsers();

expect(result).toEqual([
{ username: 'alice', userId: 'alice-sub' },
{ username: 'bob', userId: 'bob-sub' },
]);
});

it('drops roster entries whose stored tokens cannot be resolved', async () => {
getAuthUserListSpy.mockResolvedValue(['alice', 'ghost', 'bob']);
getStoredIdTokenSpy.mockImplementation((username: string) => {
if (username === 'ghost') return Promise.resolve(undefined);

return Promise.resolve({
payload: { 'cognito:username': username, sub: `${username}-sub` },
toString: () => `${username}-idToken`,
});
});

const result = await listCurrentUsers();

expect(result).toEqual([
{ username: 'alice', userId: 'alice-sub' },
{ username: 'bob', userId: 'bob-sub' },
]);
});

it('drops roster entries whose stored id token lacks a `sub` claim', async () => {
getAuthUserListSpy.mockResolvedValue(['alice', 'nosub', 'bob']);
getStoredIdTokenSpy.mockImplementation((username: string) => {
if (username === 'nosub') {
return Promise.resolve({
payload: { 'cognito:username': username },
toString: () => `${username}-idToken`,
});
}

return Promise.resolve({
payload: { 'cognito:username': username, sub: `${username}-sub` },
toString: () => `${username}-idToken`,
});
});

const result = await listCurrentUsers();

expect(result).toEqual([
{ username: 'alice', userId: 'alice-sub' },
{ username: 'bob', userId: 'bob-sub' },
]);
});

it('does not trigger a token refresh', async () => {
getAuthUserListSpy.mockResolvedValue(['alice']);
getStoredIdTokenSpy.mockResolvedValue({
payload: { 'cognito:username': 'alice', sub: 'alice-sub' },
toString: () => 'alice-idToken',
});

await listCurrentUsers();

// identities are read directly from stored tokens; loadTokens (which can
// drive a refresh) must not be invoked.
expect(loadTokensSpy).not.toHaveBeenCalled();
});

it('includes signInDetails when stored signInDetails key is present', async () => {
getAuthUserListSpy.mockResolvedValue(['alice']);
getStoredIdTokenSpy.mockResolvedValue({
payload: { 'cognito:username': 'alice', sub: 'alice-sub' },
toString: () => 'alice-idToken',
});

const signInDetails = {
loginId: 'alice@example.com',
authFlowType: 'USER_SRP_AUTH',
};
mockKeyValueStorage.getItem.mockImplementation((key: string) => {
if (key.endsWith('.signInDetails')) {
return Promise.resolve(JSON.stringify(signInDetails));
}

return Promise.resolve(null);
});

const result = await listCurrentUsers();

expect(result).toEqual([
{
username: 'alice',
userId: 'alice-sub',
signInDetails,
},
]);
});

it('still returns the user when signInDetails read fails', async () => {
getAuthUserListSpy.mockResolvedValue(['alice']);
getStoredIdTokenSpy.mockResolvedValue({
payload: { 'cognito:username': 'alice', sub: 'alice-sub' },
toString: () => 'alice-idToken',
});

// signInDetails key returns invalid JSON — parse will throw, but the
// implementation currently only reads it conditionally so it may return
// null. Either way the user should still be resolvable.
mockKeyValueStorage.getItem.mockResolvedValue(null);

const result = await listCurrentUsers();

expect(result).toEqual([{ username: 'alice', userId: 'alice-sub' }]);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[nit] No test covers the signInDetails branch (the stored signInDetails key being present and populated on the returned AuthUser). Worth adding one case — the path is a distinct storage read that can fail independently of the idToken read.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added — one test with stored signInDetails populated on the returned AuthUser, one where the read fails and the user is still returned without it. Fixed in 2c52b05.

Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { getAmplifyServerContext } from '@aws-amplify/core/internals/adapter-core';

import { listCurrentUsers } from '../../../../src/providers/cognito/apis/server/listCurrentUsers';

jest.mock('@aws-amplify/core/internals/adapter-core');

const mockGetAmplifyServerContext = jest.mocked(getAmplifyServerContext);
const mockListSessionUsernames = jest.fn();
const mockGetStoredIdToken = jest.fn();
const mockSwitcher = {
listSessionUsernames: mockListSessionUsernames,
getStoredIdToken: mockGetStoredIdToken,
setActiveSession: jest.fn(),
};
const mockGetTokenProvider = jest.fn();
const mockContextSpec = { token: { value: Symbol('test') } } as any;

const idToken = (username: string) => ({
payload: { 'cognito:username': username, sub: `${username}-sub` },
toString: () => `${username}-idToken`,
});

describe('server-side listCurrentUsers', () => {
beforeEach(() => {
mockGetTokenProvider.mockReturnValue({
getTokens: jest.fn(),
getSessionSwitcher: () => mockSwitcher,
});
mockGetAmplifyServerContext.mockReturnValue({
amplify: {
Auth: { getTokenProvider: mockGetTokenProvider },
},
} as any);
});

afterEach(() => {
jest.clearAllMocks();
});

it('returns AuthUser[] resolved from stored id tokens in roster order', async () => {
mockListSessionUsernames.mockResolvedValue(['alice', 'bob']);
mockGetStoredIdToken.mockImplementation((username: string) =>
Promise.resolve(idToken(username)),
);

await expect(listCurrentUsers(mockContextSpec)).resolves.toEqual([
{ username: 'alice', userId: 'alice-sub' },
{ username: 'bob', userId: 'bob-sub' },
]);
});

it('drops roster entries whose stored id token cannot be resolved', async () => {
mockListSessionUsernames.mockResolvedValue(['alice', 'ghost', 'bob']);
mockGetStoredIdToken.mockImplementation((username: string) =>
Promise.resolve(username === 'ghost' ? undefined : idToken(username)),
);

await expect(listCurrentUsers(mockContextSpec)).resolves.toEqual([
{ username: 'alice', userId: 'alice-sub' },
{ username: 'bob', userId: 'bob-sub' },
]);
});

it('drops roster entries whose stored id token lacks a `sub` claim', async () => {
mockListSessionUsernames.mockResolvedValue(['alice', 'nosub', 'bob']);
mockGetStoredIdToken.mockImplementation((username: string) =>
Promise.resolve(
username === 'nosub'
? { payload: { 'cognito:username': 'nosub' }, toString: () => 'x' }
: idToken(username),
),
);

await expect(listCurrentUsers(mockContextSpec)).resolves.toEqual([
{ username: 'alice', userId: 'alice-sub' },
{ username: 'bob', userId: 'bob-sub' },
]);
});

it('throws when the context has no session-switching token provider', async () => {
mockGetTokenProvider.mockReturnValue(undefined);

await expect(listCurrentUsers(mockContextSpec)).rejects.toMatchObject({
name: 'TokenProviderNotFoundException',
});
});
});
Loading
Loading