From 9914a0698ad2bc9163652549ab9aa5269c432bfa Mon Sep 17 00:00:00 2001 From: Martin Lingstuyl Date: Thu, 28 Sep 2023 23:59:16 +0200 Subject: [PATCH] Adds multiple account functionality. Closes #3587 --- .eslintrc.cjs | 1 + docs/docs/cmd/identity/identity-list.mdx | 105 +++++ docs/docs/cmd/identity/identity-set.mdx | 107 +++++ docs/docs/cmd/login.mdx | 13 +- docs/docs/cmd/logout.mdx | 28 +- docs/docs/cmd/status.mdx | 13 +- docs/src/config/sidebars.js | 14 + src/Auth.spec.ts | 313 ++++++++++----- src/Auth.ts | 241 +++++++++-- src/Command.ts | 4 + src/auth/FileTokenStorage.spec.ts | 32 +- src/m365/commands/IdentityDetails.ts | 9 + src/m365/commands/login.ts | 39 +- src/m365/commands/logout.spec.ts | 272 ++++++++++++- src/m365/commands/logout.ts | 111 +++++- src/m365/commands/status.spec.ts | 239 +++++++---- src/m365/commands/status.ts | 34 +- src/m365/identity/commands.ts | 6 + .../identity/commands/identity-list.spec.ts | 151 +++++++ src/m365/identity/commands/identity-list.ts | 37 ++ .../identity/commands/identity-set.spec.ts | 374 ++++++++++++++++++ src/m365/identity/commands/identity-set.ts | 136 +++++++ src/m365/spo/commands/file/file-get.spec.ts | 19 + src/m365/spo/commands/page/page-add.spec.ts | 52 ++- src/m365/spo/commands/page/page-set.spec.ts | 13 +- .../spo/commands/site/site-ensure.spec.ts | 6 - src/m365/spo/commands/spo-set.ts | 1 + 27 files changed, 2053 insertions(+), 317 deletions(-) create mode 100644 docs/docs/cmd/identity/identity-list.mdx create mode 100644 docs/docs/cmd/identity/identity-set.mdx create mode 100644 src/m365/commands/IdentityDetails.ts create mode 100644 src/m365/identity/commands.ts create mode 100644 src/m365/identity/commands/identity-list.spec.ts create mode 100644 src/m365/identity/commands/identity-list.ts create mode 100644 src/m365/identity/commands/identity-set.spec.ts create mode 100644 src/m365/identity/commands/identity-set.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 3137e992727..3719309cabc 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -39,6 +39,7 @@ const dictionary = [ 'historical', 'home', 'hub', + 'identity', 'in', 'info', 'inheritance', diff --git a/docs/docs/cmd/identity/identity-list.mdx b/docs/docs/cmd/identity/identity-list.mdx new file mode 100644 index 00000000000..073095d43e8 --- /dev/null +++ b/docs/docs/cmd/identity/identity-list.mdx @@ -0,0 +1,105 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# identity list + +Shows a list of currently signed in identities + +## Usage + +```sh +m365 identity list [options] +``` + +## Options + + + +## Remarks + +If you are logged in to Microsoft 365, the `identity list` command will show you a list of users and/or applications used to sign in and the details about the stored refresh and access tokens and their expiration date and time when run in debug mode. + +## Examples + +Show the list of available identities used to sign in to Microsoft 365 + +```sh +m365 identity list +``` + +## Response + + + + + ```json + [ + { + "connectedAs": "alexw@contoso.com", + "identityId": "028de82d-7fd9-476e-a9fd-be9714280ff3", + "authType": "DeviceCode", + "appId": "31359c7f-bd7e-475c-86db-fdb8c937548e", + "appTenant": "common", + "cloudType": "Public" + }, + { + "connectedAs": "Contoso Application", + "identityId": "acd6df42-10a9-4315-8928-53334f1c9d01", + "authType": "Secret", + "appId": "39446e2e-5081-4887-980c-f285919fccca", + "appTenant": "db308122-52f3-4241-af92-1734aa6e2e50", + "cloudType": "Public" + } + ] + ``` + + + + + ```text + connectedAs authType + ------------------------------------ ----------------------------------------------- + alexw@contoso.com DeviceCode + Contoso Application Secret + ``` + + + + + ```csv + connectedAs,identityId,authType,appId,appTenant,cloudType + alexw@contoso.com,028de82d-7fd9-476e-a9fd-be9714280ff3,DeviceCode,31359c7f-bd7e-475c-86db-fdb8c937548e,common,Public + Contoso Application,acd6df42-10a9-4315-8928-53334f1c9d01,Secret,39446e2e-5081-4887-980c-f285919fccca,db308122-52f3-4241-af92-1734aa6e2e50,Public + ``` + + + + + ```md + # identity list + + Date: 7/2/2023 + + Property | Value + ---------|------- + connectedAs | alexw@contoso.com + identityId | 028de82d-7fd9-476e-a9fd-be9714280ff3 + authType | DeviceCode + appId | 31359c7f-bd7e-475c-86db-fdb8c937548e + appTenant | common + cloudType | Public + + Property | Value + ---------|------- + connectedAs | Contoso Application + identityId | acd6df42-10a9-4315-8928-53334f1c9d01 + authType | Secret + appId | 39446e2e-5081-4887-980c-f285919fccca + appTenant | db308122-52f3-4241-af92-1734aa6e2e50 + cloudType | Public + ``` + + + + diff --git a/docs/docs/cmd/identity/identity-set.mdx b/docs/docs/cmd/identity/identity-set.mdx new file mode 100644 index 00000000000..2f3f8004d68 --- /dev/null +++ b/docs/docs/cmd/identity/identity-set.mdx @@ -0,0 +1,107 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# identity set + +Switches to another identity, when signed into multiple identities + +## Usage + +```sh +m365 identity set [options] +``` + +## Options + +```md definition-list +`-i, --id [id]` +: The Id (GUID) of the identity to switch to. Specify either `id` or `name` but not both. + +`-n, --name [name]` +: The name of the identity to switch to. Specify either `id` or `name` but not both. +``` + + + +## Remarks + +The values for `--id` or `--name` can be found by running [m365 identity list](identity-list.mdx). + +## Examples + +Switch to a user identity by name + +```sh +m365 identity set --name 'alexw@contoso.com' +``` + +Switch to a given identity by id + +```sh +m365 identity set --id '6e70c8ea-571d-4daf-bc48-9e1b49ac3390' +``` + +Switch to an application identity by name + +```sh +m365 identity set --name 'My contoso application' +``` + +## Response + + + + + ```json + { + "connectedAs": "alexw@contoso.com", + "identityId": "028de82d-7fd9-476e-a9fd-be9714280ff3", + "authType": "DeviceCode", + "appId": "31359c7f-bd7e-475c-86db-fdb8c937548e", + "appTenant": "common", + "cloudType": "Public" + } + ``` + + + + + ```text + appId : 31359c7f-bd7e-475c-86db-fdb8c937548e + identityId : 028de82d-7fd9-476e-a9fd-be9714280ff3 + appTenant : common + authType : DeviceCode + connectedAs: alexw@contoso.com + cloudType : Public + ``` + + + + + ```csv + connectedAs,authType,appId,appTenant,cloudType + alexw@contoso.com,028de82d-7fd9-476e-a9fd-be9714280ff3,DeviceCode,31359c7f-bd7e-475c-86db-fdb8c937548e,common,Public + ``` + + + + + ```md + # identity set + + Date: 7/2/2023 + + Property | Value + ---------|------- + connectedAs | alexw@contoso.com + identityId | 028de82d-7fd9-476e-a9fd-be9714280ff3 + authType | DeviceCode + appId | 31359c7f-bd7e-475c-86db-fdb8c937548e + appTenant | common + cloudType | Public + ``` + + + + diff --git a/docs/docs/cmd/login.mdx b/docs/docs/cmd/login.mdx index 911c1446e17..235a9679b26 100644 --- a/docs/docs/cmd/login.mdx +++ b/docs/docs/cmd/login.mdx @@ -186,7 +186,8 @@ m365 login --authType secret --secret topSeCr3t@007 Upon successful login: ```json { - "connectedAs": "john.doe@contoso.onmicrosoft.com", + "connectedAs": "alexw@contoso.com", + "identityId": "028de82d-7fd9-476e-a9fd-be9714280ff3", "authType": "DeviceCode", "appId": "31359c7f-bd7e-475c-86db-fdb8c937548e", "appTenant": "common", @@ -203,9 +204,10 @@ m365 login --authType secret --secret topSeCr3t@007 Upon successful login: ```text appId : 31359c7f-bd7e-475c-86db-fdb8c937548e + identityId : 028de82d-7fd9-476e-a9fd-be9714280ff3 appTenant : common authType : DeviceCode - connectedAs: john.doe@contoso.onmicrosoft.com, + connectedAs: alexw@contoso.com cloudType : Public ``` @@ -218,7 +220,7 @@ m365 login --authType secret --secret topSeCr3t@007 Upon successful login: ```csv connectedAs,authType,appId,appTenant,cloudType - john.doe@contoso.onmicrosoft.com,DeviceCode,31359c7f-bd7e-475c-86db-fdb8c937548e,common,Public + alexw@contoso.com,028de82d-7fd9-476e-a9fd-be9714280ff3,DeviceCode,31359c7f-bd7e-475c-86db-fdb8c937548e,common,Public ``` @@ -233,11 +235,10 @@ m365 login --authType secret --secret topSeCr3t@007 Date: 7/2/2023 - - Property | Value ---------|------- - connectedAs | john.doe@contoso.onmicrosoft.com + connectedAs | alexw@contoso.com + identityId | 028de82d-7fd9-476e-a9fd-be9714280ff3 authType | DeviceCode appId | 31359c7f-bd7e-475c-86db-fdb8c937548e appTenant | common diff --git a/docs/docs/cmd/logout.mdx b/docs/docs/cmd/logout.mdx index 4946f01dd17..5d9285751b8 100644 --- a/docs/docs/cmd/logout.mdx +++ b/docs/docs/cmd/logout.mdx @@ -12,11 +12,21 @@ m365 logout [options] ## Options +```md definition-list +`-i, --identityId [identityId]` +: The optional Id (GUID) of the identity to logout from. Specify either `identityId` or `identityName` but not both. If not specified, all identities will be logged out from. + +`-n, --identityName [identityName]` +: The optional name of the identity to switch to. Specify either `identityId` or `identityName` but not both. If not specified, all identities will be logged out from. +``` + ## Remarks -The `logout` command logs out from Microsoft 365 and removes any access and refresh tokens from memory +The `logout` command logs out from Microsoft 365 and removes any access and refresh tokens from memory. + +The values for `--identityId` or `--identityName` can be found by running [m365 identity list](identity/identity-list.mdx). ## Examples @@ -26,10 +36,22 @@ Log out from Microsoft 365 m365 logout ``` -Log out from Microsoft 365 in debug mode including detailed debug information in the console output +Log out from a given user identity by name + +```sh +m365 logout --identityName 'alexw@contoso.com' +``` + +Log out from a given application identity by name + +```sh +m365 logout --identityName 'My contoso application' +``` + +Log out from a given identity by id ```sh -m365 logout --debug +m365 logout --identityId '23269cc7-c005-4a36-b020-b6e99f997710' ``` ## Response diff --git a/docs/docs/cmd/status.mdx b/docs/docs/cmd/status.mdx index 68488921f08..2a563302792 100644 --- a/docs/docs/cmd/status.mdx +++ b/docs/docs/cmd/status.mdx @@ -35,7 +35,8 @@ m365 status ```json { - "connectedAs": "john.doe@contoso.onmicrosoft.com", + "connectedAs": "alexw@contoso.com", + "identityId": "028de82d-7fd9-476e-a9fd-be9714280ff3", "authType": "DeviceCode", "appId": "31359c7f-bd7e-475c-86db-fdb8c937548e", "appTenant": "common", @@ -48,9 +49,10 @@ m365 status ```text appId : 31359c7f-bd7e-475c-86db-fdb8c937548e + identityId : 028de82d-7fd9-476e-a9fd-be9714280ff3 appTenant : common authType : DeviceCode - connectedAs: john.doe@contoso.onmicrosoft.com + connectedAs: alexw@contoso.com cloudType : Public ``` @@ -59,7 +61,7 @@ m365 status ```csv connectedAs,authType,appId,appTenant,cloudType - john.doe@contoso.onmicrosoft.com,DeviceCode,31359c7f-bd7e-475c-86db-fdb8c937548e,common,Public + alexw@contoso.com,028de82d-7fd9-476e-a9fd-be9714280ff3,DeviceCode,31359c7f-bd7e-475c-86db-fdb8c937548e,common,Public ``` @@ -70,11 +72,10 @@ m365 status Date: 7/2/2023 - - Property | Value ---------|------- - connectedAs | john.doe@contoso.onmicrosoft.com + connectedAs | alexw@contoso.com + identityId | 028de82d-7fd9-476e-a9fd-be9714280ff3 authType | DeviceCode appId | 31359c7f-bd7e-475c-86db-fdb8c937548e appTenant | common diff --git a/docs/src/config/sidebars.js b/docs/src/config/sidebars.js index 28f8a409d37..af13859c2fa 100644 --- a/docs/src/config/sidebars.js +++ b/docs/src/config/sidebars.js @@ -596,6 +596,20 @@ const sidebars = { } ] }, + { + 'Identity (identity)': [ + { + type: 'doc', + label: 'list', + id: 'cmd/identity/identity-list' + }, + { + type: 'doc', + label: 'set', + id: 'cmd/identity/identity-set' + } + ] + }, { 'Microsoft 365 apps (app)': [ { diff --git a/src/Auth.spec.ts b/src/Auth.spec.ts index b92be03dc97..bd0667c0daa 100644 --- a/src/Auth.spec.ts +++ b/src/Auth.spec.ts @@ -95,6 +95,8 @@ describe('Auth', () => { userCode: "", verificationUri: "" }; + auth.service.connected = true; + auth.service.identityId = '34b70d68-17b0-4b54-b2dd-8f85ebc9d624'; auth.service.appId = '9bc3ab49-b65d-410a-85ad-de819febfddc'; auth.service.tenant = '9bc3ab49-b65d-410a-85ad-de819febfddd'; (auth as any)._authServer = authServer; @@ -107,6 +109,7 @@ describe('Auth', () => { openStub = sinon.stub(browserUtil, 'open').callsFake(async () => { return; }); clipboardStub = sinon.stub((auth as any)._clipboardy, 'writeSync').callsFake(() => 'clippy'); getSettingWithDefaultValueStub = sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((() => 'key')); + sinon.stub(auth, 'storeConnectionInfo').resolves(); }); afterEach(() => { @@ -118,6 +121,7 @@ describe('Auth', () => { request.get, (auth as any).getClientApplication, (auth as any).getDeviceCodeResponse, + auth.storeConnectionInfo, publicApplication.acquireTokenSilent, publicApplication.acquireTokenByDeviceCode, publicApplication.acquireTokenByUsernamePassword, @@ -130,7 +134,7 @@ describe('Auth', () => { }); after(() => { - auth.service.cloudType = CloudType.Public; + auth.service.logout(); }); it('returns existing access token if still valid', (done) => { @@ -196,33 +200,28 @@ describe('Auth', () => { }); }); - it('retrieves new access token silently if already signed in', (done) => { + it('retrieves new access token silently if already signed in', async () => { sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const acquireTokenSilentStub = sinon.stub(publicApplication, 'acquireTokenSilent').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'abc' + accessToken: 'abc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); - auth.ensureAccessToken(resource, logger).then(() => { - try { - assert(acquireTokenSilentStub.called); - done(); - } - catch (e) { - done(e); - } - }, (err) => { - done(err); - }); + await assert.doesNotReject(auth.ensureAccessToken(resource, logger)); + assert(acquireTokenSilentStub.called); }); it('retrieves new access token silently if already signed in (debug)', (done) => { sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const acquireTokenSilentStub = sinon.stub(publicApplication, 'acquireTokenSilent').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'abc' + accessToken: 'abc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); auth.ensureAccessToken(resource, logger, true).then(() => { @@ -364,10 +363,12 @@ describe('Auth', () => { accessToken: 'abc' }; sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); sinon.stub(publicApplication, 'acquireTokenSilent').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'acc' + accessToken: 'acc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); auth.ensureAccessToken(resource, logger, true).then((accessToken) => { @@ -393,10 +394,12 @@ describe('Auth', () => { accessToken: 'abc' }; sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); sinon.stub(publicApplication, 'acquireTokenSilent').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'acc' + accessToken: 'acc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); auth.ensureAccessToken(resource, logger, true, true).then((accessToken) => { @@ -417,10 +420,12 @@ describe('Auth', () => { sinon.stub(config, 'get').callsFake((() => 'value')); sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); sinon.stub(publicApplication, 'acquireTokenByDeviceCode').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'acc' + accessToken: 'acc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); auth.ensureAccessToken(resource, logger).then((accessToken) => { @@ -467,10 +472,12 @@ describe('Auth', () => { sinon.stub(config, 'get').callsFake((() => 'value')); sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const acquireTokenByDeviceCodeStub = sinon.stub(publicApplication, 'acquireTokenByDeviceCode').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'acc' + accessToken: 'acc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); auth.service.authType = AuthType.DeviceCode; @@ -490,10 +497,12 @@ describe('Auth', () => { it('retrieves token using password flow when authType password specified', (done) => { sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const acquireTokenByUsernamePasswordStub = sinon.stub(publicApplication, 'acquireTokenByUsernamePassword').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'acc' + accessToken: 'acc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); auth.service.authType = AuthType.Password; @@ -513,10 +522,12 @@ describe('Auth', () => { it('retrieves token using password flow when authType password specified (debug)', (done) => { sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const acquireTokenByUsernamePasswordStub = sinon.stub(publicApplication, 'acquireTokenByUsernamePassword').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'acc' + accessToken: 'acc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); auth.service.authType = AuthType.Password; @@ -536,7 +547,6 @@ describe('Auth', () => { it('handles error when retrieving token using password flow failed', (done) => { sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); sinon.stub(publicApplication, 'acquireTokenByUsernamePassword').callsFake(_ => Promise.reject({ errorCode: 'error', errorMessage: `An error has occurred` @@ -556,13 +566,48 @@ describe('Auth', () => { }); }); + it('handles null response when retrieving token using device code authentication flow', async () => { + const config = cli.config; + sinon.stub(config, 'get').callsFake((() => 'value')); + sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); + sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); + const acquireTokenByDeviceCodeStub = sinon.stub(publicApplication, 'acquireTokenByDeviceCode').resolves(null); + auth.service.authType = AuthType.DeviceCode; + + try { + await assert.rejects(auth.ensureAccessToken(resource, logger)); + } + catch (error: any) { + assert(acquireTokenByDeviceCodeStub.called); + assert.strictEqual(error.errorMessage, 'Failed to retrieve an access token. Please try again'); + } + }); + + + it('handles null response when retrieving token using password flow', async () => { + sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); + sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); + const acquireTokenByUsernamePasswordStub = sinon.stub(publicApplication, 'acquireTokenByUsernamePassword').resolves(null); + auth.service.authType = AuthType.Password; + + try { + await assert.rejects(auth.ensureAccessToken(resource, logger)); + assert(acquireTokenByUsernamePasswordStub.called); + } + catch (error: any) { + assert.strictEqual(error.errorMessage, 'Failed to retrieve an access token. Please try again'); + } + }); + it('uses browser auth and retrieves a successful response', (done) => { sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const acquireTokenByCodeStub = sinon.stub(publicApplication, 'acquireTokenByCode').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'acc' + accessToken: 'acc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); auth.service.authType = AuthType.Browser; @@ -586,10 +631,12 @@ describe('Auth', () => { it('uses browser auth and retrieves a successful response (debug)', (done) => { sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const acquireTokenByCodeStub = sinon.stub(publicApplication, 'acquireTokenByCode').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'acc' + accessToken: 'acc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); auth.service.authType = AuthType.Browser; @@ -613,10 +660,12 @@ describe('Auth', () => { it('uses browser auth and retrieves an unsuccessful response', (done) => { sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const acquireTokenByCodeStub = sinon.stub(publicApplication, 'acquireTokenByCode').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'acc' + accessToken: 'acc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); const error = { @@ -647,10 +696,12 @@ describe('Auth', () => { it('uses browser auth and retrieves an unsuccessful response (debug)', (done) => { sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const acquireTokenByCodeStub = sinon.stub(publicApplication, 'acquireTokenByCode').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'acc' + accessToken: 'acc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); const error = { @@ -685,7 +736,6 @@ describe('Auth', () => { }; sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); sinon.stub(publicApplication, 'acquireTokenByCode').callsFake(_ => Promise.reject(error)); auth.service.authType = AuthType.Browser; @@ -709,7 +759,6 @@ describe('Auth', () => { }; sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); sinon.stub(publicApplication, 'acquireTokenByCode').callsFake(_ => Promise.reject(error)); auth.service.authType = AuthType.Browser; @@ -744,7 +793,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, false).then(_ => { try { @@ -778,7 +826,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, true).then(_ => { try { @@ -813,7 +860,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, false).then(_ => { try { @@ -848,7 +894,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, true).then(_ => { try { @@ -885,7 +930,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, false).then(_ => { try { @@ -922,7 +966,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, true).then(_ => { try { @@ -955,7 +998,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, false).then(() => { try { @@ -987,7 +1029,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, true).then(() => { try { @@ -1023,7 +1064,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, false).then(_ => { try { @@ -1060,7 +1100,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, true).then(() => { try { @@ -1097,7 +1136,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, true).then(() => { try { @@ -1129,7 +1167,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, true).then(() => { done('Expected error'); @@ -1156,7 +1193,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger).then(() => { done('Got access token'); @@ -1171,6 +1207,32 @@ describe('Auth', () => { }); }); + it('handles null response when retrieving token using certificate flow ', async () => { + auth.service.certificate = base64EncodedPemCert; + auth.service.authType = AuthType.Certificate; + + let actualThumbprint: string = ''; + let acquireTokenByClientCredentialStub: any; + let originalGetConfidentialClient = (auth as any).getConfidentialClient; + originalGetConfidentialClient = originalGetConfidentialClient.bind(auth); + sinon.stub(auth as any, 'getConfidentialClient').callsFake(async (logger, debug, thumbprint, cert) => { + actualThumbprint = thumbprint as string; + const confidentialApplication = await originalGetConfidentialClient(logger, debug, thumbprint, cert); + acquireTokenByClientCredentialStub = sinon.stub(confidentialApplication, 'acquireTokenByClientCredential').resolves(null); + return confidentialApplication; + }); + sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); + + try { + await assert.rejects(auth.ensureAccessToken(resource, logger, false)); + } + catch (error: any) { + assert(acquireTokenByClientCredentialStub.called); + assert.strictEqual(actualThumbprint, "ccf4f2a3c3d209c512b3724bb883a5474c0921dc"); + assert.strictEqual(error.errorMessage, 'Failed to retrieve an access token. Please try again'); + } + }); + it('logs error when retrieving token using certificate flow failed in debug mode', (done) => { auth.service.authType = AuthType.Certificate; auth.service.certificate = base64EncodedPemCert; @@ -1183,7 +1245,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger, true).then(() => { done('Got access token'); @@ -1199,7 +1260,6 @@ describe('Auth', () => { }); it('calls api with correct params using system managed identity flow when authType identity and Azure VM api', (done) => { - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake(() => { return Promise.resolve({ "access_token": "eyJ0eXAiOiJKV1QiLCJ...", @@ -1231,7 +1291,6 @@ describe('Auth', () => { }); it('gets token using system managed identity flow when authType identity and Azure VM api', (done) => { - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); sinon.stub(request, 'get').callsFake(() => { return Promise.resolve({ "access_token": "eyJ0eXAiOiJKV1QiLCJ...", @@ -1261,7 +1320,6 @@ describe('Auth', () => { }); it('calls api with correct params user-assigned managed identity flow when authType identity and client_id and Azure VM api', (done) => { - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake(() => { return Promise.resolve({ "access_token": "eyJ0eXAiOiJKV1QiLCJ...", @@ -1293,7 +1351,6 @@ describe('Auth', () => { }); it('calls api with correct params user-assigned managed identity flow when authType identity and principal_id and Azure VM api', (done) => { - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake((opts) => { if ((opts.url as string).indexOf('&client_id=') !== -1) { @@ -1331,7 +1388,6 @@ describe('Auth', () => { }); it('retrieves token using user-assigned managed identity flow when authType identity and principal_id and Azure VM api', (done) => { - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); sinon.stub(request, 'get').callsFake((opts) => { if ((opts.url as string).indexOf('&client_id=') !== -1) { @@ -1367,7 +1423,6 @@ describe('Auth', () => { }); it('handles error when using user-assigned managed identity flow when authType identity and principal_id and Azure VM api', (done) => { - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake((opts) => { if ((opts.url as string).indexOf('&client_id=') !== -1) { @@ -1397,7 +1452,6 @@ describe('Auth', () => { }); it('handles EACCES error when using user-assigned managed identity flow when authType identity and principal_id and Azure VM api', (done) => { - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake((opts) => { if ((opts.url as string).indexOf('&client_id=') !== -1) { @@ -1429,7 +1483,6 @@ describe('Auth', () => { it('calls api with correct params using system managed identity flow when authType identity and Azure Function api', (done) => { process.env.IDENTITY_ENDPOINT = 'http://127.0.0.1:41932/MSI/token/'; process.env.IDENTITY_HEADER = 'AFBA957766234A0CA9F3B6FA3D9582C7'; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake(() => { return Promise.resolve({ "access_token": "eyJ0eXAiOiJKV1QiLCJ...", @@ -1464,7 +1517,6 @@ describe('Auth', () => { process.env = { IDENTITY_ENDPOINT: 'http://localhost:50342/oauth2/token' }; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake(() => { return Promise.resolve({ "access_token": "eyJ0eXAiOiJKV1QiLCJ...", @@ -1500,7 +1552,6 @@ describe('Auth', () => { IDENTITY_ENDPOINT: 'http://localhost:50342/oauth2/token', ACC_CLOUD: 'abc' }; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); sinon.stub(request, 'get').callsFake(() => { return Promise.resolve(); }); @@ -1524,7 +1575,6 @@ describe('Auth', () => { process.env = { MSI_ENDPOINT: 'http://localhost:50342/oauth2/token' }; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake(() => { return Promise.resolve({ "access_token": "eyJ0eXAiOiJKV1QiLCJ...", @@ -1560,7 +1610,6 @@ describe('Auth', () => { MSI_ENDPOINT: 'http://localhost:50342/oauth2/token', ACC_CLOUD: 'abc' }; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); sinon.stub(request, 'get').callsFake(() => { return Promise.resolve(); }); @@ -1583,7 +1632,6 @@ describe('Auth', () => { it('handles error when using system managed identity flow when authType identity and Azure Function api', (done) => { process.env.IDENTITY_ENDPOINT = 'http://127.0.0.1:41932/MSI/token/'; process.env.IDENTITY_HEADER = 'AFBA957766234A0CA9F3B6FA3D9582C7'; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake(() => { return Promise.reject({ error: { "StatusCode": 400, "Message": "No Managed Identity found for specified ClientId/ResourceId/PrincipalId.", "CorrelationId": "0507ee4d-c15f-421a-b96b-e71e351bc69a" } }); }); @@ -1609,7 +1657,6 @@ describe('Auth', () => { it('calls api with correct params using user-assigned managed identity flow when authType identity and client_id and Azure Functions api', (done) => { process.env.IDENTITY_ENDPOINT = 'http://127.0.0.1:41932/MSI/token/'; process.env.IDENTITY_HEADER = 'AFBA957766234A0CA9F3B6FA3D9582C7'; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake(() => { return Promise.resolve({ "access_token": "eyJ0eXAiOiJKV1QiLCJ...", @@ -1643,7 +1690,6 @@ describe('Auth', () => { it('calls api with correct params using user-assigned managed identity flow when authType identity and principal_id and Azure Functions api', (done) => { process.env.IDENTITY_ENDPOINT = 'http://127.0.0.1:41932/MSI/token/'; process.env.IDENTITY_HEADER = 'AFBA957766234A0CA9F3B6FA3D9582C7'; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake((opts) => { if ((opts.url as string).indexOf('&client_id=') !== -1) { @@ -1674,7 +1720,6 @@ describe('Auth', () => { it('handles error when using user-assigned managed identity flow when authType identity and principal_id and Azure Functions api', (done) => { process.env.IDENTITY_ENDPOINT = 'http://127.0.0.1:41932/MSI/token/'; process.env.IDENTITY_HEADER = 'AFBA957766234A0CA9F3B6FA3D9582C7'; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake((opts) => { if ((opts.url as string).indexOf('&client_id=') !== -1) { @@ -1706,7 +1751,6 @@ describe('Auth', () => { it('handles EACCES error when using user-assigned managed identity flow when authType identity and principal_id and Azure Functions api', (done) => { process.env.IDENTITY_ENDPOINT = 'http://127.0.0.1:41932/MSI/token/'; process.env.IDENTITY_HEADER = 'AFBA957766234A0CA9F3B6FA3D9582C7'; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake((opts) => { if ((opts.url as string).indexOf('&client_id=') !== -1) { @@ -1738,7 +1782,6 @@ describe('Auth', () => { it('handles undefined error when using user-assigned managed identity flow when authType identity and client_id and Azure Functions api', (done) => { process.env.IDENTITY_ENDPOINT = 'http://127.0.0.1:41932/MSI/token/'; process.env.IDENTITY_HEADER = 'AFBA957766234A0CA9F3B6FA3D9582C7'; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake(() => { return Promise.reject({ error: { "error": "invalid_request", "error_description": "Undefined" } }); }); @@ -1764,7 +1807,6 @@ describe('Auth', () => { it('handles undefined error when using user-assigned managed identity flow when authType identity and principal_id and Azure Functions api', (done) => { process.env.IDENTITY_ENDPOINT = 'http://127.0.0.1:41932/MSI/token/'; process.env.IDENTITY_HEADER = 'AFBA957766234A0CA9F3B6FA3D9582C7'; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake((opts) => { if ((opts.url as string).indexOf('&client_id=') !== -1) { @@ -1796,7 +1838,6 @@ describe('Auth', () => { MSI_ENDPOINT: 'http://127.0.0.1:41932/MSI/token/', MSI_SECRET: 'AFBA957766234A0CA9F3B6FA3D9582C7' }; - sinon.stub(auth as any, 'storeConnectionInfo').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(request, 'get').callsFake(() => { return Promise.resolve(JSON.stringify({ "access_token": "eyJ0eXAiOiJKV1QiLCJ...", @@ -1828,12 +1869,16 @@ describe('Auth', () => { }); it('returns access token if persisting connection fails', (done) => { + sinonUtil.restore(auth.storeConnectionInfo); sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.reject('An error has occurred')); sinon.stub(publicApplication, 'acquireTokenByDeviceCode').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'acc' + accessToken: 'acc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); auth.ensureAccessToken(resource, logger).then((accessToken) => { @@ -1845,12 +1890,16 @@ describe('Auth', () => { }); it('logs error message if persisting connection fails in debug mode', (done) => { + sinonUtil.restore(auth.storeConnectionInfo); sinon.stub(auth as any, 'getClientApplication').callsFake(_ => publicApplication); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.reject('An error has occurred')); sinon.stub(publicApplication, 'acquireTokenByDeviceCode').callsFake(_ => Promise.resolve({ expiresOn: new Date(), - accessToken: 'acc' + accessToken: 'acc', + account: { + localAccountId: '684af3c0-e57a-410f-961c-d4c45d407ce5' + } } as any)); auth.ensureAccessToken(resource, logger, true).then((accessToken) => { @@ -1928,6 +1977,7 @@ describe('Auth', () => { }); it('handles error when restoring authentication', (done) => { + auth.service.connected = false; sinon.stub(auth as any, 'getServiceConnectionInfo').callsFake(() => Promise.reject('An error has occurred')); auth @@ -1946,6 +1996,7 @@ describe('Auth', () => { }); it('doesn\'t fail when restoring authentication from an incorrect JSON string', (done) => { + auth.service.connected = false; const mockStorage = { get: () => Promise.resolve('abc') }; @@ -1962,6 +2013,7 @@ describe('Auth', () => { }); it('doesn\'t fail when restoring authentication failed', (done) => { + auth.service.connected = false; const mockStorage = { get: () => Promise.reject('abc') }; @@ -1978,6 +2030,7 @@ describe('Auth', () => { }); it('stores connection information in the configured token storage', (done) => { + sinonUtil.restore(auth.storeConnectionInfo); const mockStorage = new MockTokenStorage(); const mockStorageSetStub = sinon.stub(mockStorage, 'set').callsFake(() => Promise.resolve()); sinon.stub(auth, 'getTokenStorage').callsFake(() => mockStorage); @@ -1995,23 +2048,31 @@ describe('Auth', () => { }); }); - it('clears connection information in the configured token storage', (done) => { + it('clears connection information in the configured token storage', async () => { const mockStorage = new MockTokenStorage(); const mockStorageRemoveStub = sinon.stub(mockStorage, 'remove').callsFake(() => Promise.resolve()); sinon.stub(auth, 'getTokenStorage').callsFake(() => mockStorage); sinon.stub(auth as any, 'getMsalCacheStorage').callsFake(() => mockStorage); - auth - .clearConnectionInfo() - .then(() => { - try { - assert(mockStorageRemoveStub.calledTwice, 'token storage or MSAL cache not cleared'); - done(); - } - catch (e) { - done(e); - } - }); + try { + await auth.clearConnectionInfo(logger, false); + } + finally { + assert(mockStorageRemoveStub.calledTwice, 'token storage or MSAL cache not cleared'); + } + }); + + it('clears connection information for a specified identity in the configured token storage', async () => { + const mockClient = { + getTokenCache: () => { + return { + getAccountByLocalId: (localAccountId: string): Promise => { return Promise.resolve({ localAccountId }); }, + removeAccount: (account: any): Promise => { return Promise.resolve(account); } + }; + } + }; + sinon.stub(auth as any, 'getPublicClient').resolves(mockClient); + await assert.doesNotReject(auth.clearConnectionInfo(logger, false, 'alexw@contoso.com')); }); it('resets connection information on logout', () => { @@ -2138,7 +2199,6 @@ describe('Auth', () => { return confidentialApplication; }); sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); - sinon.stub(auth, 'storeConnectionInfo').callsFake(() => Promise.resolve()); auth.ensureAccessToken(resource, logger).then(() => { try { @@ -2153,6 +2213,81 @@ describe('Auth', () => { }); }); + + it('handles null response when retrieving token using client secret flow', async () => { + auth.service.authType = AuthType.Secret; + auth.service.secret = "SomeSecretValue"; + + let acquireTokenByClientCredentialStub: any; + let originalGetConfidentialClient = (auth as any).getConfidentialClient; + originalGetConfidentialClient = originalGetConfidentialClient.bind(auth); + sinon.stub(auth as any, 'getConfidentialClient').callsFake(async (logger, debug, thumbprint, cert, clientSecret) => { + const confidentialApplication = await originalGetConfidentialClient(logger, debug, undefined, undefined, clientSecret); + acquireTokenByClientCredentialStub = sinon.stub(confidentialApplication, 'acquireTokenByClientCredential').resolves(null); + return confidentialApplication; + }); + sinon.stub(tokenCache, 'getAllAccounts').callsFake(() => []); + + try { + await assert.rejects(auth.ensureAccessToken(resource, logger)); + } + catch (error: any) { + assert(acquireTokenByClientCredentialStub.called); + assert.strictEqual(error.errorMessage, 'Failed to retrieve an access token. Please try again'); + } + }); + + it('retrieves an MSAL public client with authType DeviceCode', async () => { + const mockClient = {}; + sinon.stub(auth as any, 'getPublicClient').resolves(mockClient); + + auth.service.authType = AuthType.DeviceCode; + const clientApplication = await (auth as any).getClientApplication(null, null); + assert.strictEqual(mockClient, clientApplication); + }); + + it('retrieves an MSAL public client with authType Browser', async () => { + const mockClient = {}; + sinon.stub(auth as any, 'getPublicClient').resolves(mockClient); + + auth.service.authType = AuthType.Browser; + const clientApplication = await (auth as any).getClientApplication(null, null); + assert.strictEqual(mockClient, clientApplication); + }); + + it('retrieves an MSAL public client with authType Password', async () => { + const mockClient = {}; + sinon.stub(auth as any, 'getPublicClient').resolves(mockClient); + + auth.service.authType = AuthType.Password; + const clientApplication = await (auth as any).getClientApplication(null, null); + assert.strictEqual(mockClient, clientApplication); + }); + + it('retrieves an MSAL confidential client with authType Certificate', async () => { + const mockClient = {}; + sinon.stub(auth as any, 'getConfidentialClient').resolves(mockClient); + + auth.service.authType = AuthType.Certificate; + const clientApplication = await (auth as any).getClientApplication(null, null); + assert.strictEqual(mockClient, clientApplication); + }); + + it('retrieves an MSAL confidential client with authType Secret', async () => { + const mockClient = {}; + sinon.stub(auth as any, 'getConfidentialClient').resolves(mockClient); + + auth.service.authType = AuthType.Secret; + const clientApplication = await (auth as any).getClientApplication(null, null); + assert.strictEqual(mockClient, clientApplication); + }); + + it('retrieves no MSAL client with authType Identity', async () => { + auth.service.authType = AuthType.Identity; + const clientApplication = await (auth as any).getClientApplication(null, null); + assert.strictEqual(clientApplication, undefined); + }); + it('configures cloud for auth to AzureChina for China cloud', async () => { auth.service.cloudType = CloudType.China; const actual: msal.Configuration = await (auth as any).getAuthClientConfiguration(logger, false); diff --git a/src/Auth.ts b/src/Auth.ts index 18e66b9045d..257ef9a7e31 100644 --- a/src/Auth.ts +++ b/src/Auth.ts @@ -13,6 +13,8 @@ import config from './config.js'; import request from './request.js'; import { settingsNames } from './settingsNames.js'; import { browserUtil } from './utils/browserUtil.js'; +import * as accessTokenUtil from './utils/accessToken.js'; +import { IdentityDetails } from './m365/commands/IdentityDetails.js'; interface Hash { [key: string]: TValue; @@ -23,6 +25,10 @@ interface AccessToken { accessToken: string; } +interface AuthenticationResult extends AccessToken { + identityId: string; +} + export interface InteractiveAuthorizationCodeResponse { code: string; redirectUri: string; @@ -41,8 +47,34 @@ export enum CloudType { China } -export class Service { +export interface Identity { + connected: boolean; + connectedAs?: string; + identityId?: string; + authType: AuthType; + userName?: string; + password?: string; + secret?: string; + certificateType: CertificateType; + certificate?: string; + thumbprint?: string; + accessTokens: Hash; + spoUrl?: string; + tenantId?: string; + // ID of the Azure AD app used to authenticate + appId: string; + // ID of the tenant where the Azure AD app is registered; common if multitenant + tenant: string; + cloudType: CloudType; +} + +export class Service implements Identity { connected: boolean = false; + connectedAs?: string; + // identityid: The localAccountId of the account in the MSAL Token Store + // or application identities, no account is available. + // In those scenario's the 'oid' claim from the access token is used. + identityId?: string; authType: AuthType = AuthType.DeviceCode; userName?: string; password?: string; @@ -58,21 +90,45 @@ export class Service { // ID of the tenant where the Azure AD app is registered; common if multitenant tenant: string; cloudType: CloudType = CloudType.Public; + // A list of available identities, including the active one + availableIdentities?: Identity[]; constructor() { this.accessTokens = {}; this.appId = config.cliAadAppId; this.tenant = config.tenant; this.cloudType = CloudType.Public; + this.availableIdentities = []; } - public logout(): void { + public logout(identityId?: string): void { + if (identityId === undefined) { + this.resetProperties(); + this.availableIdentities = []; + } + else { + this.availableIdentities = this.availableIdentities?.filter(a => a.identityId !== identityId); + if (this.identityId === identityId) { + this.resetProperties(); + } + } + } + + // Make room for a new account to be activated. + public deactivateIdentity(): void { + this.resetProperties(); + } + + private resetProperties(): void { this.connected = false; + this.connectedAs = undefined; + this.identityId = undefined; this.accessTokens = {}; this.authType = AuthType.DeviceCode; this.userName = undefined; this.password = undefined; this.certificateType = CertificateType.Unknown; + this.cloudType = CloudType.Public; this.certificate = undefined; this.thumbprint = undefined; this.spoUrl = undefined; @@ -156,6 +212,13 @@ export class Auth { try { const service: Service = await this.getServiceConnectionInfo(); + + // 20231022: Graceful fallback for pre-multiaccount sign-ins + if (service.availableIdentities === undefined) { + service.connectedAs = accessTokenUtil.accessToken.getUserNameFromAccessToken(service.accessTokens[this.defaultResource].accessToken); + service.identityId = accessTokenUtil.accessToken.getUserIdFromAccessToken(service.accessTokens[this.defaultResource].accessToken); + service.availableIdentities = [{ ...service }]; + } this._service = Object.assign(this._service, service); } catch { @@ -188,17 +251,19 @@ export class Auth { } } - let getTokenPromise: ((resource: string, logger: Logger, debug: boolean, fetchNew: boolean) => Promise) | undefined; + let getTokenPromise: ((resource: string, logger: Logger, debug: boolean, fetchNew: boolean) => Promise) | undefined; - // when using cert, you can't retrieve token silently, because there is - // no account. Also cert auth instantiates clientApplication itself + // When using an application identity, you can't retrieve the access token silently, because there is + // no account. Also (for cert auth) clientApplication is instantiated later // after inspecting the specified cert and calculating thumbprint if one // wasn't specified - if (this.service.authType !== AuthType.Certificate) { + if (this.service.authType !== AuthType.Certificate + && this.service.authType !== AuthType.Secret + && this.service.authType !== AuthType.Identity) { this.clientApplication = await this.getClientApplication(logger, debug); if (this.clientApplication) { const accounts = await this.clientApplication.getTokenCache().getAllAccounts(); - if (accounts.length > 0) { + if (accounts.length > 0 && this.service.connected === true) { getTokenPromise = this.ensureAccessTokenSilent.bind(this); } } @@ -242,11 +307,17 @@ export class Auth { } } + const connectedAs = accessTokenUtil.accessToken.getUserNameFromAccessToken(response.accessToken); this.service.accessTokens[resource] = { expiresOn: response.expiresOn, accessToken: response.accessToken }; this.service.connected = true; + this.service.connectedAs = connectedAs; + this.service.identityId = response.identityId; + + this.updateAvailableIdentitiesList(); + try { await this.storeConnectionInfo(); } @@ -368,7 +439,7 @@ export class Auth { }); } - private async ensureAccessTokenWithBrowser(resource: string, logger: Logger, debug: boolean): Promise { + private async ensureAccessTokenWithBrowser(resource: string, logger: Logger, debug: boolean): Promise { if (debug) { await logger.logToStderr(`Retrieving new access token using interactive browser session...`); } @@ -378,28 +449,45 @@ export class Auth { await logger.logToStderr(`The service returned the code '${response.code}'`); } - return (this.clientApplication as Msal.PublicClientApplication).acquireTokenByCode({ + const result = await (this.clientApplication as Msal.PublicClientApplication).acquireTokenByCode({ code: response.code, redirectUri: response.redirectUri, scopes: [`${resource}/.default`] }); + + return { + accessToken: result.accessToken, + expiresOn: result.expiresOn, + identityId: result.account!.localAccountId + }; } - private async ensureAccessTokenSilent(resource: string, logger: Logger, debug: boolean, fetchNew: boolean): Promise { + private async ensureAccessTokenSilent(resource: string, logger: Logger, debug: boolean, fetchNew: boolean): Promise { if (debug) { await logger.logToStderr(`Retrieving new access token silently`); } const accounts = await (this.clientApplication as Msal.ClientApplication) .getTokenCache().getAllAccounts(); - return (this.clientApplication as Msal.ClientApplication).acquireTokenSilent({ - account: accounts[0], + const account = accounts.filter(a => a.localAccountId === this.service.identityId)[0]; + const result = await (this.clientApplication as Msal.ClientApplication).acquireTokenSilent({ + account: account, scopes: [`${resource}/.default`], forceRefresh: fetchNew }); + + if (result === null) { + return null; + } + + return { + accessToken: result.accessToken, + expiresOn: result.expiresOn, + identityId: this.service.identityId! + }; } - private async ensureAccessTokenWithDeviceCode(resource: string, logger: Logger, debug: boolean): Promise { + private async ensureAccessTokenWithDeviceCode(resource: string, logger: Logger, debug: boolean): Promise { if (debug) { await logger.logToStderr(`Starting Auth.ensureAccessTokenWithDeviceCode. resource: ${resource}, debug: ${debug}`); } @@ -410,7 +498,18 @@ export class Auth { deviceCodeCallback: response => this.processDeviceCodeCallback(response, logger, debug), scopes: [`${resource}/.default`] }; - return (this.clientApplication as Msal.PublicClientApplication).acquireTokenByDeviceCode(this.deviceCodeRequest) as Promise; + + const result = await (this.clientApplication as Msal.PublicClientApplication).acquireTokenByDeviceCode(this.deviceCodeRequest); + + if (result === null) { + return null; + } + + return { + accessToken: result.accessToken, + expiresOn: result.expiresOn, + identityId: result.account!.localAccountId + }; } private async processDeviceCodeCallback(response: DeviceCodeResponse, logger: Logger, debug: boolean): Promise { @@ -449,19 +548,29 @@ export class Auth { } } - private async ensureAccessTokenWithPassword(resource: string, logger: Logger, debug: boolean): Promise { + private async ensureAccessTokenWithPassword(resource: string, logger: Logger, debug: boolean): Promise { if (debug) { await logger.logToStderr(`Retrieving new access token using credentials...`); } - return (this.clientApplication as Msal.PublicClientApplication).acquireTokenByUsernamePassword({ + const result = await (this.clientApplication as Msal.PublicClientApplication).acquireTokenByUsernamePassword({ username: this.service.userName as string, password: this.service.password as string, scopes: [`${resource}/.default`] }); + + if (result === null) { + return null; + } + + return { + accessToken: result.accessToken, + expiresOn: result.expiresOn, + identityId: result.account!.localAccountId + }; } - private async ensureAccessTokenWithCertificate(resource: string, logger: Logger, debug: boolean): Promise { + private async ensureAccessTokenWithCertificate(resource: string, logger: Logger, debug: boolean): Promise { const nodeForge = (await import('node-forge')).default; const { pem, pki, asn1, pkcs12 } = nodeForge; @@ -531,12 +640,22 @@ export class Auth { } this.clientApplication = await this.getConfidentialClient(logger, debug, this.service.thumbprint as string, cert); - return (this.clientApplication as Msal.ConfidentialClientApplication).acquireTokenByClientCredential({ + const result = await (this.clientApplication as Msal.ConfidentialClientApplication).acquireTokenByClientCredential({ scopes: [`${resource}/.default`] }); + + if (result === null) { + return null; + } + + return { + accessToken: result.accessToken, + expiresOn: result.expiresOn, + identityId: accessTokenUtil.accessToken.getUserIdFromAccessToken(result.accessToken) + }; } - private async ensureAccessTokenWithIdentity(resource: string, logger: Logger, debug: boolean): Promise { + private async ensureAccessTokenWithIdentity(resource: string, logger: Logger, debug: boolean): Promise { const userName = this.service.userName; if (debug) { await logger.logToStderr('Will try to retrieve access token using identity...'); @@ -615,8 +734,9 @@ export class Auth { const accessTokenResponse = await request.get<{ access_token: string; expires_on: string }>(requestOptions); return { accessToken: accessTokenResponse.access_token, - expiresOn: new Date(parseInt(accessTokenResponse.expires_on) * 1000) - }; + expiresOn: new Date(parseInt(accessTokenResponse.expires_on) * 1000), + identityId: accessTokenUtil.accessToken.getUserIdFromAccessToken(accessTokenResponse.access_token) + } as any; } catch (e: any) { if (!userName) { @@ -653,8 +773,9 @@ export class Auth { const accessTokenResponse = await request.get<{ access_token: string; expires_on: string }>(requestOptions); return { accessToken: accessTokenResponse.access_token, - expiresOn: new Date(parseInt(accessTokenResponse.expires_on) * 1000) - }; + expiresOn: new Date(parseInt(accessTokenResponse.expires_on) * 1000), + identityId: accessTokenUtil.accessToken.getUserIdFromAccessToken(accessTokenResponse.access_token) + } as any; } catch (err: any) { // will give up and not try any further with the 'msi_res_id' (resource id) query string param @@ -671,11 +792,21 @@ export class Auth { } } - private async ensureAccessTokenWithSecret(resource: string, logger: Logger, debug: boolean): Promise { + private async ensureAccessTokenWithSecret(resource: string, logger: Logger, debug: boolean): Promise { this.clientApplication = await this.getConfidentialClient(logger, debug, undefined, undefined, this.service.secret); - return (this.clientApplication as Msal.ConfidentialClientApplication).acquireTokenByClientCredential({ + const result = await (this.clientApplication as Msal.ConfidentialClientApplication).acquireTokenByClientCredential({ scopes: [`${resource}/.default`] }); + + if (result === null) { + return null; + } + + return { + accessToken: result.accessToken, + expiresOn: result.expiresOn, + identityId: accessTokenUtil.accessToken.getUserIdFromAccessToken(result.accessToken) + }; } private async calculateThumbprint(certificate: NodeForge.pki.Certificate): Promise { @@ -720,13 +851,28 @@ export class Auth { return tokenStorage.set(JSON.stringify(this.service)); } - public async clearConnectionInfo(): Promise { - const tokenStorage = this.getTokenStorage(); - await tokenStorage.remove(); - // we need to manually clear MSAL cache, because MSAL doesn't have support - // for logging out when using cert-based auth - const msalCache = this.getMsalCacheStorage(); - await msalCache.remove(); + public async clearConnectionInfo(logger: Logger, debug: boolean, identityId?: string): Promise { + if (identityId === undefined) { + const tokenStorage = this.getTokenStorage(); + await tokenStorage.remove(); + // we need to manually clear MSAL cache, because MSAL doesn't have support + // for logging out when using cert-based auth + const msalCache = this.getMsalCacheStorage(); + await msalCache.remove(); + } + else { + this.clientApplication = await this.getClientApplication(logger, debug); + + if (this.clientApplication) { + const tokenCache = this.clientApplication.getTokenCache(); + const account = await tokenCache.getAccountByLocalId(identityId); + if (account !== null) { + await tokenCache.removeAccount(account); + } + } + + await this.storeConnectionInfo(); + } } public getTokenStorage(): TokenStorage { @@ -746,6 +892,37 @@ export class Auth { return resource; } } + + public updateAvailableIdentitiesList(): void { + this.service.availableIdentities = this.service.availableIdentities!.filter(i => i.identityId !== this.service.identityId); + const identity = { ...this.service }; + delete identity.availableIdentities; + this.service.availableIdentities!.push(identity); + } + + public getIdentityDetails(identity: Identity, debug: boolean): IdentityDetails { + const authInfo: IdentityDetails = { + connectedAs: identity.connectedAs!, + identityId: identity.identityId!, + authType: AuthType[identity.authType], + appId: identity.appId, + appTenant: identity.tenant, + cloudType: CloudType[identity.cloudType] + }; + + if (debug) { + authInfo.accessTokens = JSON.stringify(identity.accessTokens, null, 2); + } + + return authInfo; + } + + public async switchToIdentity(identity: Identity): Promise { + this.service.deactivateIdentity(); + this._service = Object.assign(this._service, identity); + + await this.storeConnectionInfo(); + } } Auth.initialize(); diff --git a/src/Command.ts b/src/Command.ts index 5ec64687383..8116de46f09 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -465,6 +465,10 @@ export default abstract class Command { request.debug = this.debug; request.logger = logger; + if (this.debug && auth.service.identityId !== undefined) { + logger.logToStderr(`Executing command as '${auth.service.connectedAs}', appId: ${auth.service.appId}, tenantId: ${auth.service.tenant}`); + } + telemetry.trackEvent(this.getUsedCommandName(), this.getTelemetryProperties(args)); } diff --git a/src/auth/FileTokenStorage.spec.ts b/src/auth/FileTokenStorage.spec.ts index 9d9c91138eb..12f6815d1e7 100644 --- a/src/auth/FileTokenStorage.spec.ts +++ b/src/auth/FileTokenStorage.spec.ts @@ -3,7 +3,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import sinon from 'sinon'; -import { AuthType, CertificateType, CloudType, Service } from '../Auth.js'; +import { AuthType, CertificateType, CloudType, Identity } from '../Auth.js'; import { sinonUtil } from '../utils/sinonUtil.js'; import { FileTokenStorage } from './FileTokenStorage.js'; @@ -40,15 +40,14 @@ describe('FileTokenStorage', () => { }); it('returns connection info from file', (done) => { - const tokensFile: Service = { + const tokensFile: Identity = { accessTokens: {}, appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', tenant: 'common', cloudType: CloudType.Public, authType: AuthType.DeviceCode, certificateType: CertificateType.Unknown, - connected: false, - logout: () => { } + connected: false }; sinon.stub(fs, 'existsSync').callsFake(() => true); sinon.stub(fs, 'readFileSync').callsFake(() => JSON.stringify(tokensFile)); @@ -66,15 +65,14 @@ describe('FileTokenStorage', () => { }); it('saves the connection info in the file when the file doesn\'t exist', (done) => { - const expected: Service = { + const expected: Identity = { accessTokens: {}, appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', tenant: 'common', cloudType: CloudType.Public, authType: AuthType.DeviceCode, certificateType: CertificateType.Unknown, - connected: false, - logout: () => { } + connected: false }; let actual: string = ''; sinon.stub(fs, 'existsSync').callsFake(() => false); @@ -93,15 +91,14 @@ describe('FileTokenStorage', () => { }); it('saves the connection info in the file when the file is empty', (done) => { - const expected: Service = { + const expected: Identity = { accessTokens: {}, appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', tenant: 'common', cloudType: CloudType.Public, authType: AuthType.DeviceCode, certificateType: CertificateType.Unknown, - connected: false, - logout: () => { } + connected: false }; let actual: string = ''; sinon.stub(fs, 'existsSync').callsFake(() => true); @@ -121,15 +118,14 @@ describe('FileTokenStorage', () => { }); it('saves the connection info in the file when the file contains an empty JSON object', (done) => { - const expected: Service = { + const expected: Identity = { accessTokens: {}, appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', tenant: 'common', cloudType: CloudType.Public, authType: AuthType.DeviceCode, certificateType: CertificateType.Unknown, - connected: false, - logout: () => { } + connected: false }; let actual: string = ''; sinon.stub(fs, 'existsSync').callsFake(() => true); @@ -149,15 +145,14 @@ describe('FileTokenStorage', () => { }); it('saves the connection info in the file when the file contains no access tokens', (done) => { - const expected: Service = { + const expected: Identity = { accessTokens: {}, appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', tenant: 'common', cloudType: CloudType.Public, authType: AuthType.DeviceCode, certificateType: CertificateType.Unknown, - connected: false, - logout: () => { } + connected: false }; let actual: string = ''; sinon.stub(fs, 'existsSync').callsFake(() => true); @@ -177,15 +172,14 @@ describe('FileTokenStorage', () => { }); it('adds the connection info to the file when the file contains access tokens', (done) => { - const expected: Service = { + const expected: Identity = { accessTokens: {}, appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', tenant: 'common', cloudType: CloudType.Public, authType: AuthType.DeviceCode, certificateType: CertificateType.Unknown, - connected: false, - logout: () => { } + connected: false }; let actual: string = ''; sinon.stub(fs, 'existsSync').callsFake(() => true); diff --git a/src/m365/commands/IdentityDetails.ts b/src/m365/commands/IdentityDetails.ts new file mode 100644 index 00000000000..971a46bf3c3 --- /dev/null +++ b/src/m365/commands/IdentityDetails.ts @@ -0,0 +1,9 @@ +export interface IdentityDetails { + connectedAs: string; + identityId: string; + authType: string; + appId: string; + appTenant: string; + accessTokens?: string; + cloudType: string; +} \ No newline at end of file diff --git a/src/m365/commands/login.ts b/src/m365/commands/login.ts index 63329a58392..92f6b5c8705 100644 --- a/src/m365/commands/login.ts +++ b/src/m365/commands/login.ts @@ -6,7 +6,6 @@ import Command, { } from '../../Command.js'; import config from '../../config.js'; import GlobalOptions from '../../GlobalOptions.js'; -import { accessToken } from '../../utils/accessToken.js'; import { misc } from '../../utils/misc.js'; import commands from './commands.js'; @@ -145,11 +144,9 @@ class LoginCommand extends Command { public async commandAction(logger: Logger, args: CommandArgs): Promise { // disconnect before re-connecting if (this.debug) { - await logger.logToStderr(`Logging out from Microsoft 365...`); + await logger.logToStderr(`Deactivating active signed in account...`); } - const logout: () => void = (): void => auth.service.logout(); - const login: () => Promise = async (): Promise => { if (this.verbose) { await logger.logToStderr(`Signing in to Microsoft 365...`); @@ -204,39 +201,11 @@ class LoginCommand extends Command { throw new CommandError(error.message); } - if (this.debug) { - await logger.log({ - connectedAs: accessToken.getUserNameFromAccessToken(auth.service.accessTokens[auth.defaultResource].accessToken), - authType: AuthType[auth.service.authType], - appId: auth.service.appId, - appTenant: auth.service.tenant, - accessToken: JSON.stringify(auth.service.accessTokens, null, 2), - cloudType: CloudType[auth.service.cloudType] - }); - } - else { - await logger.log({ - connectedAs: accessToken.getUserNameFromAccessToken(auth.service.accessTokens[auth.defaultResource].accessToken), - authType: AuthType[auth.service.authType], - appId: auth.service.appId, - appTenant: auth.service.tenant, - cloudType: CloudType[auth.service.cloudType] - }); - } + await logger.log(auth.getIdentityDetails(auth.service, this.debug)); }; - try { - await auth.clearConnectionInfo(); - } - catch (error: any) { - if (this.debug) { - await logger.logToStderr(new CommandError(error)); - } - } - finally { - logout(); - await login(); - } + auth.service.deactivateIdentity(); + await login(); } public async action(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/commands/logout.spec.ts b/src/m365/commands/logout.spec.ts index d717f30fbd0..24afe13e2d1 100644 --- a/src/m365/commands/logout.spec.ts +++ b/src/m365/commands/logout.spec.ts @@ -1,6 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; -import auth from '../../Auth.js'; +import auth, { AuthType, CertificateType, CloudType } from '../../Auth.js'; import { Logger } from '../../cli/Logger.js'; import { CommandError } from '../../Command.js'; import { telemetry } from '../../telemetry.js'; @@ -9,18 +9,25 @@ import { session } from '../../utils/session.js'; import { sinonUtil } from '../../utils/sinonUtil.js'; import commands from './commands.js'; import command from './logout.js'; +import { Cli } from '../../cli/Cli.js'; +import { CommandInfo } from '../../cli/CommandInfo.js'; +import { settingsNames } from '../../settingsNames.js'; describe(commands.LOGOUT, () => { + let cli: Cli; let log: string[]; let logger: Logger; let authClearConnectionInfoStub: sinon.SinonStub; + let commandInfo: CommandInfo; before(() => { - sinon.stub(auth, 'restoreAuth').callsFake(() => Promise.resolve()); + cli = Cli.getInstance(); authClearConnectionInfoStub = sinon.stub(auth, 'clearConnectionInfo').callsFake(() => Promise.resolve()); + sinon.stub(auth, 'storeConnectionInfo').resolves(); sinon.stub(telemetry, 'trackEvent').callsFake(() => { }); sinon.stub(pid, 'getProcessName').callsFake(() => ''); sinon.stub(session, 'getId').callsFake(() => ''); + commandInfo = Cli.getCommandInfo(command); }); beforeEach(() => { @@ -36,10 +43,71 @@ describe(commands.LOGOUT, () => { log.push(msg); } }; + + sinon.stub(auth as any, 'getServiceConnectionInfo').resolves({ + authType: AuthType.DeviceCode, + connectedAs: 'alexw@contoso.com', + connected: true, + identityId: '028de82d-7fd9-476e-a9fd-be9714280ff3', + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + }, + availableIdentities: [ + { + authType: AuthType.DeviceCode, + connectedAs: 'alexw@contoso.com', + connected: true, + identityId: '028de82d-7fd9-476e-a9fd-be9714280ff3', + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + }, + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + } + ] + }); + }); + + afterEach(() => { + auth.service.logout(); + sinonUtil.restore([ + (auth as any).getServiceConnectionInfo, + cli.getSettingWithDefaultValue, + Cli.handleMultipleResultsFound + ]); }); after(() => { sinon.restore(); + auth.service.logout(); }); it('has correct name', () => { @@ -50,21 +118,56 @@ describe(commands.LOGOUT, () => { assert.notStrictEqual(command.description, null); }); + it('fails validation if the identityId is not a valid guid', async () => { + const actual = await command.validate({ options: { identityId: 'abc' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if the identityId is a valid guid', async () => { + const actual = await command.validate({ options: { identityId: '0dbe7872-62f1-4b7c-b3c3-2bb71f2c63c4' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if neither identityId or identityName is specified', async () => { + const actual = await command.validate({ options: {} }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation if identityId and identityName are specified', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + const actual = await command.validate({ options: { identityId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', identityName: 'My app' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + it('logs out from Microsoft 365 when logged in', async () => { - auth.service.connected = true; await command.action(logger, { options: { debug: true } }); assert(!auth.service.connected); }); it('logs out from Microsoft 365 when not logged in', async () => { - auth.service.connected = false; + sinonUtil.restore((auth as any).getServiceConnectionInfo); await command.action(logger, { options: { debug: true } }); assert(!auth.service.connected); }); + it('logs out from specific user account by name when logged in', async () => { + await assert.doesNotReject(command.action(logger, { options: { debug: true, identityName: 'alexw@contoso.com' } })); + assert(!auth.service.connected); + }); + + it('logs out from specific user account by id when logged in', async () => { + await assert.doesNotReject(command.action(logger, { options: { debug: true, identityId: '028de82d-7fd9-476e-a9fd-be9714280ff3' } })); + assert(!auth.service.connected); + }); + it('clears persisted connection info when logging out', async () => { - auth.service.connected = true; - await command.action(logger, { options: { debug: true } }); + await assert.doesNotReject(command.action(logger, { options: { debug: true } })); assert(authClearConnectionInfoStub.called); }); @@ -72,7 +175,6 @@ describe(commands.LOGOUT, () => { sinonUtil.restore(auth.clearConnectionInfo); sinon.stub(auth, 'clearConnectionInfo').callsFake(() => Promise.reject('An error has occurred')); const logoutSpy = sinon.spy(auth.service, 'logout'); - auth.service.connected = true; try { await command.action(logger, { options: {} }); @@ -89,7 +191,6 @@ describe(commands.LOGOUT, () => { it('correctly handles error while clearing persisted connection info (debug)', async () => { sinon.stub(auth, 'clearConnectionInfo').callsFake(() => Promise.reject('An error has occurred')); const logoutSpy = sinon.spy(auth.service, 'logout'); - auth.service.connected = true; try { await command.action(logger, { options: { debug: true } }); @@ -112,8 +213,163 @@ describe(commands.LOGOUT, () => { } finally { sinonUtil.restore([ + auth.restoreAuth, auth.clearConnectionInfo ]); } }); + + it('correctly handles error when not finding an identity by name in the list of available identities', async () => { + await assert.rejects(command.action(logger, { options: { identityName: 'non-existent identity' } } as any), new CommandError(`The identity 'non-existent identity' cannot be found.`)); + }); + + it('correctly handles error when not finding an identity by id in the list of available identities', async () => { + await assert.rejects(command.action(logger, { options: { identityId: 'ecd7e376-31cb-4b7e-b59c-0272195fda80' } } as any), new CommandError(`The identity 'ecd7e376-31cb-4b7e-b59c-0272195fda80' cannot be found.`)); + }); + + it('handles selecting single result when multiple identities with the specified name found', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + sinonUtil.restore((auth as any).getServiceConnectionInfo); + sinon.stub(auth as any, 'getServiceConnectionInfo').resolves({ + authType: AuthType.DeviceCode, + connectedAs: 'alexw@contoso.com', + connected: true, + identityId: '028de82d-7fd9-476e-a9fd-be9714280ff3', + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + }, + availableIdentities: [ + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + }, + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: '46657f7d-a133-43f1-8721-6e4f53b43c97', + appId: '0445b0a6-88ff-499b-b91b-c181d0c24772', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + } + ] + }); + + await assert.rejects(command.action(logger, { options: { identityName: 'Contoso Application' } }), new CommandError(`Multiple identities with 'Contoso Application' found. Found: acd6df42-10a9-4315-8928-53334f1c9d01, 46657f7d-a133-43f1-8721-6e4f53b43c97.`)); + }); + + it('handles selecting single result when multiple identities with the specified name found and cli is set to prompt', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return true; + } + + return defaultValue; + }); + + sinonUtil.restore((auth as any).getServiceConnectionInfo); + sinon.stub(auth as any, 'getServiceConnectionInfo').resolves({ + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + }, + availableIdentities: [ + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + }, + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: '46657f7d-a133-43f1-8721-6e4f53b43c97', + appId: '0445b0a6-88ff-499b-b91b-c181d0c24772', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + } + ] + }); + + sinon.stub(Cli, 'handleMultipleResultsFound').resolves({ + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + }); + + await assert.doesNotReject(command.action(logger, { options: { identityName: 'Contoso Application' } })); + assert(!auth.service.connected); + }); }); diff --git a/src/m365/commands/logout.ts b/src/m365/commands/logout.ts index 381075874be..c98edd47821 100644 --- a/src/m365/commands/logout.ts +++ b/src/m365/commands/logout.ts @@ -1,8 +1,21 @@ -import auth from '../../Auth.js'; +import auth, { Identity } from '../../Auth.js'; +import { Cli } from '../../cli/Cli.js'; import { Logger } from '../../cli/Logger.js'; -import Command, { CommandArgs, CommandError } from '../../Command.js'; +import Command, { CommandError } from '../../Command.js'; +import GlobalOptions from '../../GlobalOptions.js'; +import { formatting } from '../../utils/formatting.js'; +import { validation } from '../../utils/validation.js'; import commands from './commands.js'; +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + identityId?: string; + identityName?: string; +} + class LogoutCommand extends Command { public get name(): string { return commands.LOGOUT; @@ -12,24 +25,79 @@ class LogoutCommand extends Command { return 'Log out from Microsoft 365'; } - public async commandAction(logger: Logger): Promise { + constructor() { + super(); + + this.#initTelemetry(); + this.#initValidators(); + this.#initOptions(); + this.#initOptionSets(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + identityId: typeof args.options.identityId !== 'undefined', + identityName: typeof args.options.identityName !== 'undefined' + }); + }); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.identityId && !validation.isValidGuid(args.options.identityId as string)) { + return `${args.options.identityId} is not a valid GUID`; + } + + return true; + } + ); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-i, --identityId [identityId]' + }, + { + option: '-n, --identityName [identityName]' + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ options: ['identityId', 'identityName'], runsWhen: (args) => args.options.identityId || args.options.identityName }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { if (this.verbose) { await logger.logToStderr('Logging out from Microsoft 365...'); } - const logout: () => void = (): void => auth.service.logout(); + const identity = await this.getIdentityToLogout(args.options); try { - await auth.clearConnectionInfo(); + if (identity) { + if (this.verbose) { + await logger.logToStderr(`Logging out from identity ${identity.identityId}...`); + } + + auth.service.logout(identity.identityId); + + await auth.clearConnectionInfo(logger, this.debug, identity.identityId); + } + else { + auth.service.logout(); + + await auth.clearConnectionInfo(logger, this.debug); + } } catch (error: any) { if (this.debug) { await logger.logToStderr(new CommandError(error)); } } - finally { - logout(); - } } public async action(logger: Logger, args: CommandArgs): Promise { @@ -41,7 +109,32 @@ class LogoutCommand extends Command { } this.initAction(args, logger); - await this.commandAction(logger); + await this.commandAction(logger, args); + } + + private async getIdentityToLogout(options: Options): Promise { + try { + if (!options.identityId && !options.identityName) { + return; + } + + const identities = auth.service.availableIdentities!.filter(i => i.connectedAs === options.identityName || i.identityId === options.identityId); + + if (identities.length === 0) { + throw new Error(`The identity '${options.identityId || options.identityName}' cannot be found.`); + } + + if (identities.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('identityId', identities); + const result = await Cli.handleMultipleResultsFound(`Multiple identities with '${options.identityName}' found.`, resultAsKeyValuePair); + return result; + } + + return identities[0]; + } + catch (error: any) { + throw new CommandError(error.message); + } } } diff --git a/src/m365/commands/status.spec.ts b/src/m365/commands/status.spec.ts index 120c8eab819..5740e9b6ae1 100644 --- a/src/m365/commands/status.spec.ts +++ b/src/m365/commands/status.spec.ts @@ -1,10 +1,9 @@ import assert from 'assert'; import sinon from 'sinon'; -import auth, { AuthType, CloudType } from '../../Auth.js'; +import auth, { AuthType, CertificateType, CloudType } from '../../Auth.js'; import { CommandError } from '../../Command.js'; import { Logger } from '../../cli/Logger.js'; import { telemetry } from '../../telemetry.js'; -import { accessToken } from '../../utils/accessToken.js'; import { pid } from '../../utils/pid.js'; import { session } from '../../utils/session.js'; import { sinonUtil } from '../../utils/sinonUtil.js'; @@ -17,11 +16,21 @@ describe(commands.STATUS, () => { let loggerLogSpy: sinon.SinonSpy; let loggerLogToStderrSpy: sinon.SinonSpy; + const mockUserIdentityResponse = { + "connectedAs": "alexw@contoso.com", + "identityId": "028de82d-7fd9-476e-a9fd-be9714280ff3", + "authType": "DeviceCode", + "appId": "31359c7f-bd7e-475c-86db-fdb8c937548e", + "appTenant": "common", + "cloudType": "Public" + }; + before(() => { - sinon.stub(auth, 'restoreAuth').callsFake(() => Promise.resolve()); - sinon.stub(telemetry, 'trackEvent').callsFake(() => { }); - sinon.stub(pid, 'getProcessName').callsFake(() => ''); - sinon.stub(session, 'getId').callsFake(() => ''); + sinon.stub(auth, 'clearConnectionInfo').resolves(); + sinon.stub(auth, 'storeConnectionInfo').resolves(); + sinon.stub(telemetry, 'trackEvent').resolves(); + sinon.stub(pid, 'getProcessName').resolves(''); + sinon.stub(session, 'getId').resolves(''); }); beforeEach(() => { @@ -39,12 +48,64 @@ describe(commands.STATUS, () => { }; loggerLogSpy = sinon.spy(logger, 'log'); loggerLogToStderrSpy = sinon.spy(logger, 'logToStderr'); + + sinon.stub(auth as any, 'getServiceConnectionInfo').resolves({ + authType: AuthType.DeviceCode, + connectedAs: 'alexw@contoso.com', + connected: true, + identityId: '028de82d-7fd9-476e-a9fd-be9714280ff3', + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: '123', + accessToken: 'abc' + } + }, + availableIdentities: [ + { + authType: AuthType.DeviceCode, + connectedAs: 'alexw@contoso.com', + connected: true, + identityId: '028de82d-7fd9-476e-a9fd-be9714280ff3', + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: '123', + accessToken: 'abc' + } + } + }, + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: '123', + accessToken: 'abc' + } + } + } + ] + }); }); afterEach(() => { + auth.service.logout(); sinonUtil.restore([ auth.ensureAccessToken, - accessToken.getUserNameFromAccessToken + (auth as any).getServiceConnectionInfo ]); }); @@ -61,15 +122,94 @@ describe(commands.STATUS, () => { }); it('shows logged out status when not logged in', async () => { - auth.service.connected = false; + auth.service.logout(); + sinonUtil.restore(auth.restoreAuth); + sinon.stub(auth, 'restoreAuth').resolves(); await command.action(logger, { options: {} }); assert(loggerLogSpy.calledWith('Logged out')); + sinonUtil.restore(auth.restoreAuth); }); it('shows logged out status when not logged in (verbose)', async () => { - auth.service.connected = false; + auth.service.logout(); + sinonUtil.restore(auth.restoreAuth); + sinon.stub(auth, 'restoreAuth').resolves(); await command.action(logger, { options: { verbose: true } }); assert(loggerLogToStderrSpy.calledWith('Logged out from Microsoft 365')); + sinonUtil.restore(auth.restoreAuth); + }); + + it('shows logged out status when not logged in, but identities available', async () => { + sinon.stub(auth, 'ensureAccessToken').resolves(); + sinonUtil.restore((auth as any).getServiceConnectionInfo); + sinon.stub(auth as any, 'getServiceConnectionInfo').resolves({ + authType: AuthType.DeviceCode, + connectedAs: undefined, + connected: false, + identityId: undefined, + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: {}, + availableIdentities: [ + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: '123', + accessToken: 'abc' + } + } + } + ] + }); + await command.action(logger, { options: {} }); + assert(loggerLogSpy.calledWith('Logged out, signed in identities available')); + }); + + it('shows logged out status when not logged in, but identities available (verbose)', async () => { + sinon.stub(auth, 'ensureAccessToken').resolves(); + sinonUtil.restore((auth as any).getServiceConnectionInfo); + sinon.stub(auth as any, 'getServiceConnectionInfo').resolves({ + authType: AuthType.DeviceCode, + connectedAs: undefined, + connected: false, + identityId: undefined, + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: {}, + availableIdentities: [ + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: '123', + accessToken: 'abc' + } + } + } + ] + }); + + await command.action(logger, { options: { verbose: true } }); + assert(loggerLogToStderrSpy.calledWith('Logged out from Microsoft 365, signed in identities available')); }); it('shows logged out status when the refresh token is expired', async () => { @@ -78,8 +218,7 @@ describe(commands.STATUS, () => { accessToken: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ing0NTh4eU9wbHNNMkg3TlhrMlN4MTd4MXVwYyIsImtpZCI6Ing0NTh4eU9wbHNNMkg3TlhrN1N4MTd4MXVwYyJ9.eyJhdWQiOiJodHRwczovL2dyYXBoLndpbmRvd3MubmV0IiwiaXNzIjoiaHR0cHM6Ly9zdHMud2luZG93cy5uZXQvY2FlZTMyZTYtNDA1ZC00MjRhLTljZjEtMjA3MWQwNDdmMjk4LyIsImlhdCI6MTUxNTAwNDc4NCwibmJmIjoxNTE1MDA0Nzg0LCJleHAiOjE1MTUwMDg2ODQsImFjciI6IjEiLCJhaW8iOiJBQVdIMi84R0FBQUFPN3c0TDBXaHZLZ1kvTXAxTGJMWFdhd2NpOEpXUUpITmpKUGNiT2RBM1BvPSIsImFtciI6WyJwd2QiXSwiYXBwaWQiOiIwNGIwNzc5NS04ZGRiLTQ2MWEtYmJlZS0wMmY5ZTFiZjdiNDYiLCJhcHBpZGFjciI6IjAiLCJmYW1pbHlfbmFtZSI6IkRvZSIsImdpdmVuX25hbWUiOiJKb2huIiwiaXBhZGRyIjoiOC44LjguOCIsIm5hbWUiOiJKb2huIERvZSIsIm9pZCI6ImYzZTU5NDkxLWZjMWEtNDdjYy1hMWYwLTk1ZWQ0NTk4MzcxNyIsInB1aWQiOiIxMDk0N0ZGRUE2OEJDQ0NFIiwic2NwIjoiNjJlOTAzOTQtNjlmNS00MjM3LTkxOTAtMDEyMTc3MTQ1ZTEwIiwic3ViIjoiemZicmtUV1VQdEdWUUg1aGZRckpvVGp3TTBrUDRsY3NnLTJqeUFJb0JuOCIsInRlbmFudF9yZWdpb25fc2NvcGUiOiJOQSIsInRpZCI6ImNhZWUzM2U2LTQwNWQtNDU0YS05Y2YxLTMwNzFkMjQxYTI5OCIsInVuaXF1ZV9uYW1lIjoiYWRtaW5AY29udG9zby5vbm1pY3Jvc29mdC5jb20iLCJ1cG4iOiJhZG1pbkBjb250b3NvLm9ubWljcm9zb2Z0LmNvbSIsInV0aSI6ImFUZVdpelVmUTBheFBLMVRUVXhsQUEiLCJ2ZXIiOiIxLjAifQ==.abc' }; - auth.service.connected = true; - sinon.stub(auth, 'ensureAccessToken').callsFake(() => { return Promise.reject(new Error('Error')); }); + sinon.stub(auth, 'ensureAccessToken').rejects(new Error('Error')); await assert.rejects(command.action(logger, { options: {} }), new CommandError(`Your login has expired. Sign in again to continue. Error`)); }); @@ -89,83 +228,24 @@ describe(commands.STATUS, () => { accessToken: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ing0NTh4eU9wbHNNMkg3TlhrMlN4MTd4MXVwYyIsImtpZCI6Ing0NTh4eU9wbHNNMkg3TlhrN1N4MTd4MXVwYyJ9.eyJhdWQiOiJodHRwczovL2dyYXBoLndpbmRvd3MubmV0IiwiaXNzIjoiaHR0cHM6Ly9zdHMud2luZG93cy5uZXQvY2FlZTMyZTYtNDA1ZC00MjRhLTljZjEtMjA3MWQwNDdmMjk4LyIsImlhdCI6MTUxNTAwNDc4NCwibmJmIjoxNTE1MDA0Nzg0LCJleHAiOjE1MTUwMDg2ODQsImFjciI6IjEiLCJhaW8iOiJBQVdIMi84R0FBQUFPN3c0TDBXaHZLZ1kvTXAxTGJMWFdhd2NpOEpXUUpITmpKUGNiT2RBM1BvPSIsImFtciI6WyJwd2QiXSwiYXBwaWQiOiIwNGIwNzc5NS04ZGRiLTQ2MWEtYmJlZS0wMmY5ZTFiZjdiNDYiLCJhcHBpZGFjciI6IjAiLCJmYW1pbHlfbmFtZSI6IkRvZSIsImdpdmVuX25hbWUiOiJKb2huIiwiaXBhZGRyIjoiOC44LjguOCIsIm5hbWUiOiJKb2huIERvZSIsIm9pZCI6ImYzZTU5NDkxLWZjMWEtNDdjYy1hMWYwLTk1ZWQ0NTk4MzcxNyIsInB1aWQiOiIxMDk0N0ZGRUE2OEJDQ0NFIiwic2NwIjoiNjJlOTAzOTQtNjlmNS00MjM3LTkxOTAtMDEyMTc3MTQ1ZTEwIiwic3ViIjoiemZicmtUV1VQdEdWUUg1aGZRckpvVGp3TTBrUDRsY3NnLTJqeUFJb0JuOCIsInRlbmFudF9yZWdpb25fc2NvcGUiOiJOQSIsInRpZCI6ImNhZWUzM2U2LTQwNWQtNDU0YS05Y2YxLTMwNzFkMjQxYTI5OCIsInVuaXF1ZV9uYW1lIjoiYWRtaW5AY29udG9zby5vbm1pY3Jvc29mdC5jb20iLCJ1cG4iOiJhZG1pbkBjb250b3NvLm9ubWljcm9zb2Z0LmNvbSIsInV0aSI6ImFUZVdpelVmUTBheFBLMVRUVXhsQUEiLCJ2ZXIiOiIxLjAifQ==.abc' }; - auth.service.connected = true; const error = new Error('Error'); - sinon.stub(auth, 'ensureAccessToken').callsFake(() => { return Promise.reject(error); }); + sinon.stub(auth, 'ensureAccessToken').rejects(error); await assert.rejects(command.action(logger, { options: { debug: true } }), new CommandError(`Your login has expired. Sign in again to continue. Error`)); assert(loggerLogToStderrSpy.calledWith(error)); }); it('shows logged in status when logged in', async () => { - auth.service.accessTokens['https://graph.microsoft.com'] = { - expiresOn: 'abc', - accessToken: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ing0NTh4eU9wbHNNMkg3TlhrMlN4MTd4MXVwYyIsImtpZCI6Ing0NTh4eU9wbHNNMkg3TlhrN1N4MTd4MXVwYyJ9.eyJhdWQiOiJodHRwczovL2dyYXBoLndpbmRvd3MubmV0IiwiaXNzIjoiaHR0cHM6Ly9zdHMud2luZG93cy5uZXQvY2FlZTMyZTYtNDA1ZC00MjRhLTljZjEtMjA3MWQwNDdmMjk4LyIsImlhdCI6MTUxNTAwNDc4NCwibmJmIjoxNTE1MDA0Nzg0LCJleHAiOjE1MTUwMDg2ODQsImFjciI6IjEiLCJhaW8iOiJBQVdIMi84R0FBQUFPN3c0TDBXaHZLZ1kvTXAxTGJMWFdhd2NpOEpXUUpITmpKUGNiT2RBM1BvPSIsImFtciI6WyJwd2QiXSwiYXBwaWQiOiIwNGIwNzc5NS04ZGRiLTQ2MWEtYmJlZS0wMmY5ZTFiZjdiNDYiLCJhcHBpZGFjciI6IjAiLCJmYW1pbHlfbmFtZSI6IkRvZSIsImdpdmVuX25hbWUiOiJKb2huIiwiaXBhZGRyIjoiOC44LjguOCIsIm5hbWUiOiJKb2huIERvZSIsIm9pZCI6ImYzZTU5NDkxLWZjMWEtNDdjYy1hMWYwLTk1ZWQ0NTk4MzcxNyIsInB1aWQiOiIxMDk0N0ZGRUE2OEJDQ0NFIiwic2NwIjoiNjJlOTAzOTQtNjlmNS00MjM3LTkxOTAtMDEyMTc3MTQ1ZTEwIiwic3ViIjoiemZicmtUV1VQdEdWUUg1aGZRckpvVGp3TTBrUDRsY3NnLTJqeUFJb0JuOCIsInRlbmFudF9yZWdpb25fc2NvcGUiOiJOQSIsInRpZCI6ImNhZWUzM2U2LTQwNWQtNDU0YS05Y2YxLTMwNzFkMjQxYTI5OCIsInVuaXF1ZV9uYW1lIjoiYWRtaW5AY29udG9zby5vbm1pY3Jvc29mdC5jb20iLCJ1cG4iOiJhZG1pbkBjb250b3NvLm9ubWljcm9zb2Z0LmNvbSIsInV0aSI6ImFUZVdpelVmUTBheFBLMVRUVXhsQUEiLCJ2ZXIiOiIxLjAifQ==.abc' - }; - - auth.service.connected = true; - auth.service.authType = AuthType.DeviceCode; - auth.service.appId = '8dd76117-ab8e-472c-b5c1-a50e13b457cd'; - auth.service.tenant = 'common'; - auth.service.cloudType = CloudType.Public; - sinon.stub(auth, 'ensureAccessToken').callsFake(() => Promise.resolve('')); - sinon.stub(accessToken, 'getUserNameFromAccessToken').callsFake(() => { return 'admin@contoso.onmicrosoft.com'; }); - await command.action(logger, { options: {} }); - assert(loggerLogSpy.calledWith({ - connectedAs: 'admin@contoso.onmicrosoft.com', - authType: 'DeviceCode', - appId: '8dd76117-ab8e-472c-b5c1-a50e13b457cd', - appTenant: 'common', - cloudType: 'Public' - })); + sinon.stub(auth, 'ensureAccessToken').resolves(); + await assert.doesNotReject(command.action(logger, { options: {} })); + assert(loggerLogSpy.calledWith(mockUserIdentityResponse)); }); - it('correctly reports access token', async () => { - auth.service.connected = true; - auth.service.authType = AuthType.DeviceCode; - auth.service.appId = '8dd76117-ab8e-472c-b5c1-a50e13b457cd'; - auth.service.tenant = 'common'; - auth.service.cloudType = CloudType.Public; - sinon.stub(auth, 'ensureAccessToken').callsFake(() => Promise.resolve('')); - sinon.stub(accessToken, 'getUserNameFromAccessToken').callsFake(() => { return 'admin@contoso.onmicrosoft.com'; }); - auth.service.accessTokens = { - 'https://graph.microsoft.com': { - expiresOn: '123', - accessToken: 'abc' - } - }; - await command.action(logger, { options: { debug: true } }); + it('shows logged in status when logged in (debug)', async () => { + sinon.stub(auth, 'ensureAccessToken').resolves(); + await assert.doesNotReject(command.action(logger, { options: { debug: true } })); assert(loggerLogToStderrSpy.calledWith({ - connectedAs: 'admin@contoso.onmicrosoft.com', - authType: 'DeviceCode', - appId: '8dd76117-ab8e-472c-b5c1-a50e13b457cd', - appTenant: 'common', - accessTokens: '{\n "https://graph.microsoft.com": {\n "expiresOn": "123",\n "accessToken": "abc"\n }\n}', - cloudType: 'Public' - })); - }); - - it('correctly reports access token - no user', async () => { - auth.service.connected = true; - auth.service.authType = AuthType.DeviceCode; - auth.service.appId = '8dd76117-ab8e-472c-b5c1-a50e13b457cd'; - auth.service.tenant = 'common'; - auth.service.cloudType = CloudType.Public; - sinon.stub(auth, 'ensureAccessToken').callsFake(() => Promise.resolve('')); - auth.service.accessTokens = { - 'https://graph.microsoft.com': { - expiresOn: '123', - accessToken: 'abc' - } - }; - - await command.action(logger, { options: { debug: true } }); - assert(loggerLogToStderrSpy.calledWith({ - connectedAs: '', - authType: 'DeviceCode', - appId: '8dd76117-ab8e-472c-b5c1-a50e13b457cd', - appTenant: 'common', - accessTokens: '{\n "https://graph.microsoft.com": {\n "expiresOn": "123",\n "accessToken": "abc"\n }\n}', - cloudType: 'Public' + ...mockUserIdentityResponse, + accessTokens: '{\n "https://graph.microsoft.com": {\n "expiresOn": "123",\n "accessToken": "abc"\n }\n}' })); }); @@ -173,5 +253,6 @@ describe(commands.STATUS, () => { sinonUtil.restore(auth.restoreAuth); sinon.stub(auth, 'restoreAuth').callsFake(() => Promise.reject('An error has occurred')); await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + sinonUtil.restore(auth.restoreAuth); }); }); diff --git a/src/m365/commands/status.ts b/src/m365/commands/status.ts index b0a8b63cd7c..ab2455bfb19 100644 --- a/src/m365/commands/status.ts +++ b/src/m365/commands/status.ts @@ -1,7 +1,6 @@ -import auth, { AuthType, CloudType } from '../../Auth.js'; +import auth from '../../Auth.js'; import { Logger } from '../../cli/Logger.js'; import Command, { CommandArgs, CommandError } from '../../Command.js'; -import { accessToken } from '../../utils/accessToken.js'; import commands from './commands.js'; class StatusCommand extends Command { @@ -23,36 +22,31 @@ class StatusCommand extends Command { await logger.logToStderr(err); } - auth.service.logout(); + auth.service.deactivateIdentity(); throw new CommandError(`Your login has expired. Sign in again to continue. ${err.message}`); } + const response = auth.getIdentityDetails(auth.service, this.debug); + if (this.debug) { - await logger.logToStderr({ - connectedAs: accessToken.getUserNameFromAccessToken(auth.service.accessTokens[auth.defaultResource].accessToken), - authType: AuthType[auth.service.authType], - appId: auth.service.appId, - appTenant: auth.service.tenant, - accessTokens: JSON.stringify(auth.service.accessTokens, null, 2), - cloudType: CloudType[auth.service.cloudType] - }); + await logger.logToStderr(response); } else { - await logger.log({ - connectedAs: accessToken.getUserNameFromAccessToken(auth.service.accessTokens[auth.defaultResource].accessToken), - authType: AuthType[auth.service.authType], - appId: auth.service.appId, - appTenant: auth.service.tenant, - cloudType: CloudType[auth.service.cloudType] - }); + await logger.log(response); } } else { if (this.verbose) { - await logger.logToStderr('Logged out from Microsoft 365'); + const message = auth.service.availableIdentities!.length > 0 + ? `Logged out from Microsoft 365, signed in identities available` + : 'Logged out from Microsoft 365'; + await logger.logToStderr(message); } else { - await logger.log('Logged out'); + const message = auth.service.availableIdentities!.length > 0 + ? `Logged out, signed in identities available` + : 'Logged out'; + await logger.log(message); } } } diff --git a/src/m365/identity/commands.ts b/src/m365/identity/commands.ts new file mode 100644 index 00000000000..f0968ade0cf --- /dev/null +++ b/src/m365/identity/commands.ts @@ -0,0 +1,6 @@ +const prefix: string = 'identity'; + +export default { + LIST: `${prefix} list`, + SET: `${prefix} set` +}; \ No newline at end of file diff --git a/src/m365/identity/commands/identity-list.spec.ts b/src/m365/identity/commands/identity-list.spec.ts new file mode 100644 index 00000000000..4f7fd07f1f4 --- /dev/null +++ b/src/m365/identity/commands/identity-list.spec.ts @@ -0,0 +1,151 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth, { AuthType, CertificateType, CloudType } from '../../../Auth.js'; +import { Logger } from '../../../cli/Logger.js'; +import { telemetry } from '../../../telemetry.js'; +import { pid } from '../../../utils/pid.js'; +import { session } from '../../../utils/session.js'; +import { spo } from '../../../utils/spo.js'; +import commands from '../commands.js'; +import command from './identity-list.js'; +import { sinonUtil } from '../../../utils/sinonUtil.js'; +import { CommandError } from '../../../Command.js'; + +describe(commands.LIST, () => { + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + + const mockListResponse = [ + { + "connectedAs": "alexw@contoso.com", + "identityId": "028de82d-7fd9-476e-a9fd-be9714280ff3", + "authType": "DeviceCode", + "appId": "31359c7f-bd7e-475c-86db-fdb8c937548e", + "appTenant": "common", + "cloudType": "Public" + }, + { + "connectedAs": "Contoso Application", + "identityId": "acd6df42-10a9-4315-8928-53334f1c9d01", + "authType": "Secret", + "appId": "39446e2e-5081-4887-980c-f285919fccca", + "appTenant": "db308122-52f3-4241-af92-1734aa6e2e50", + "cloudType": "Public" + } + ]; + + before(() => { + sinon.stub(auth, 'clearConnectionInfo').resolves(); + sinon.stub(auth, 'storeConnectionInfo').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + sinon.stub(spo, 'ensureFormDigest').resolves({ FormDigestValue: 'abc', FormDigestTimeoutSeconds: 1800, FormDigestExpiresAt: new Date(), WebFullUrl: 'https://contoso.sharepoint.com' }); + + sinon.stub(auth as any, 'getServiceConnectionInfo').resolves({ + authType: AuthType.DeviceCode, + connectedAs: 'alexw@contoso.com', + connected: true, + identityId: '028de82d-7fd9-476e-a9fd-be9714280ff3', + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + }, + availableIdentities: [ + { + authType: AuthType.DeviceCode, + connectedAs: 'alexw@contoso.com', + connected: true, + identityId: '028de82d-7fd9-476e-a9fd-be9714280ff3', + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + }, + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + } + ] + }); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + }); + + after(() => { + sinon.restore(); + auth.service.logout(); + }); + + + it('has correct name', () => { + assert.strictEqual(command.name, commands.LIST); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('defines correct properties for the default output', () => { + assert.deepStrictEqual(command.defaultProperties(), ['connectedAs', 'authType']); + }); + + it('shows a list of signed in identities', async () => { + await assert.doesNotReject(command.action(logger, { options: {} })); + assert(loggerLogSpy.calledOnceWithExactly(mockListResponse)); + }); + + it('fails with error when restoring auth information leads to error', async () => { + sinonUtil.restore(auth.restoreAuth); + sinon.stub(auth, 'restoreAuth').callsFake(() => Promise.reject('An error has occurred')); + + try { + await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + } + finally { + sinonUtil.restore(auth.restoreAuth); + } + }); +}); \ No newline at end of file diff --git a/src/m365/identity/commands/identity-list.ts b/src/m365/identity/commands/identity-list.ts new file mode 100644 index 00000000000..5332034b4cb --- /dev/null +++ b/src/m365/identity/commands/identity-list.ts @@ -0,0 +1,37 @@ +import { Logger } from '../../../cli/Logger.js'; +import auth from '../../../Auth.js'; +import commands from "../commands.js"; +import Command, { CommandArgs, CommandError } from '../../../Command.js'; + +class IdentityListCommand extends Command { + public get name(): string { + return commands.LIST; + } + + public get description(): string { + return "Shows a list of currently signed in identities"; + } + + public defaultProperties(): string[] | undefined { + return ['connectedAs', 'authType']; + } + + public async commandAction(logger: Logger): Promise { + const availableIdentities = auth.service.availableIdentities!.map(i => auth.getIdentityDetails(i, this.debug)); + await logger.log(availableIdentities); + } + + public async action(logger: Logger, args: CommandArgs): Promise { + try { + await auth.restoreAuth(); + } + catch (error: any) { + throw new CommandError(error); + } + + this.initAction(args, logger); + await this.commandAction(logger); + } +} + +export default new IdentityListCommand(); \ No newline at end of file diff --git a/src/m365/identity/commands/identity-set.spec.ts b/src/m365/identity/commands/identity-set.spec.ts new file mode 100644 index 00000000000..e86421f0811 --- /dev/null +++ b/src/m365/identity/commands/identity-set.spec.ts @@ -0,0 +1,374 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth, { AuthType, CertificateType, CloudType, Identity } from '../../../Auth.js'; +import { Logger } from '../../../cli/Logger.js'; +import { telemetry } from '../../../telemetry.js'; +import { pid } from '../../../utils/pid.js'; +import { session } from '../../../utils/session.js'; +import commands from '../commands.js'; +import command from './identity-set.js'; +import { Cli } from '../../../cli/Cli.js'; +import { CommandInfo } from '../../../cli/CommandInfo.js'; +import { settingsNames } from '../../../settingsNames.js'; +import { sinonUtil } from '../../../utils/sinonUtil.js'; +import { CommandError } from '../../../Command.js'; + +describe(commands.SET, () => { + let cli: Cli; + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + + const mockContosoApplicationIdentityResponse = { + "connectedAs": "Contoso Application", + "identityId": "acd6df42-10a9-4315-8928-53334f1c9d01", + "authType": "Secret", + "appId": "39446e2e-5081-4887-980c-f285919fccca", + "appTenant": "db308122-52f3-4241-af92-1734aa6e2e50", + "cloudType": "Public" + }; + + const mockUserIdentityResponse = { + "connectedAs": "alexw@contoso.com", + "identityId": "028de82d-7fd9-476e-a9fd-be9714280ff3", + "authType": "DeviceCode", + "appId": "31359c7f-bd7e-475c-86db-fdb8c937548e", + "appTenant": "common", + "cloudType": "Public" + }; + + before(() => { + cli = Cli.getInstance(); + sinon.stub(auth, 'clearConnectionInfo').resolves(); + sinon.stub(auth, 'storeConnectionInfo').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + commandInfo = Cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + + sinon.stub(auth, 'ensureAccessToken').resolves(); + sinon.stub(auth as any, 'getServiceConnectionInfo').resolves({ + authType: AuthType.DeviceCode, + connectedAs: 'alexw@contoso.com', + connected: true, + identityId: '028de82d-7fd9-476e-a9fd-be9714280ff3', + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + }, + availableIdentities: [ + { + authType: AuthType.DeviceCode, + connectedAs: 'alexw@contoso.com', + connected: true, + identityId: '028de82d-7fd9-476e-a9fd-be9714280ff3', + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + }, + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + } + ] + }); + }); + + afterEach(() => { + auth.service.logout(); + sinonUtil.restore([ + cli.getSettingWithDefaultValue, + (auth as any).getServiceConnectionInfo, + auth.ensureAccessToken, + Cli.handleMultipleResultsFound + ]); + }); + + after(() => { + sinon.restore(); + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.SET); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if the id is not a valid guid', async () => { + const actual = await command.validate({ options: { id: 'abc' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if the id is a valid guid', async () => { + const actual = await command.validate({ options: { id: '0dbe7872-62f1-4b7c-b3c3-2bb71f2c63c4' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation if neither id or name is specified', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: {} }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if id and name are specified', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', name: 'My app' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it(`fails with error if the identity cannot be found`, async () => { + await assert.rejects(command.action(logger, { options: { name: 'Non-existent identity' } }), new CommandError(`The identity 'Non-existent identity' cannot be found`)); + }); + + it('fails with error when restoring auth information leads to error', async () => { + sinonUtil.restore(auth.restoreAuth); + sinon.stub(auth, 'restoreAuth').callsFake(() => Promise.reject('An error has occurred')); + + try { + await assert.rejects(command.action(logger, { options: {} } as any), new CommandError('An error has occurred')); + } + finally { + sinonUtil.restore(auth.restoreAuth); + } + }); + + it(`switches to the 'Contoso Application' identity using the name option`, async () => { + await assert.doesNotReject(command.action(logger, { options: { name: 'Contoso Application' } })); + assert(loggerLogSpy.calledOnceWithExactly(mockContosoApplicationIdentityResponse)); + }); + + it(`switches to the user identity using the name option`, async () => { + await assert.doesNotReject(command.action(logger, { options: { name: 'alexw@contoso.com' } })); + assert(loggerLogSpy.calledOnceWithExactly(mockUserIdentityResponse)); + }); + + it(`switches to the 'Contoso Application' identity using the id option`, async () => { + await assert.doesNotReject(command.action(logger, { options: { id: 'acd6df42-10a9-4315-8928-53334f1c9d01' } })); + assert(loggerLogSpy.calledOnceWithExactly(mockContosoApplicationIdentityResponse)); + }); + + it(`switches to the user identity using the id option`, async () => { + await assert.doesNotReject(command.action(logger, { options: { id: '028de82d-7fd9-476e-a9fd-be9714280ff3' } })); + assert(loggerLogSpy.calledOnceWithExactly(mockUserIdentityResponse)); + }); + + it(`switches to the user identity using the name option (debug)`, async () => { + await assert.doesNotReject(command.action(logger, { options: { name: 'alexw@contoso.com', debug: true } })); + const logged = loggerLogSpy.args[0][0] as unknown as Identity; + assert(loggerLogSpy.calledOnce); + assert.strictEqual(logged.connectedAs, mockUserIdentityResponse.connectedAs); + assert.strictEqual(logged.identityId, mockUserIdentityResponse.identityId); + }); + + it(`fails refreshing access token while switching (debug)`, async () => { + const mockError = new Error('MockErrorMessage'); + sinonUtil.restore(auth.ensureAccessToken); + sinon.stub(auth, 'ensureAccessToken').rejects(mockError); + + await assert.rejects(command.action(logger, { options: { id: '028de82d-7fd9-476e-a9fd-be9714280ff3', debug: true } }), new CommandError('Your login has expired. Sign in again to continue. MockErrorMessage')); + }); + + it('handles selecting single result when multiple identities with the specified name found', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + sinonUtil.restore((auth as any).getServiceConnectionInfo); + sinon.stub(auth as any, 'getServiceConnectionInfo').resolves({ + authType: AuthType.DeviceCode, + connectedAs: 'alexw@contoso.com', + connected: true, + identityId: '028de82d-7fd9-476e-a9fd-be9714280ff3', + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + }, + availableIdentities: [ + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + }, + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: '46657f7d-a133-43f1-8721-6e4f53b43c97', + appId: '0445b0a6-88ff-499b-b91b-c181d0c24772', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + } + ] + }); + + await assert.rejects(command.action(logger, { options: { name: 'Contoso Application' } }), new CommandError(`Multiple identities with 'Contoso Application' found. Found: acd6df42-10a9-4315-8928-53334f1c9d01, 46657f7d-a133-43f1-8721-6e4f53b43c97.`)); + }); + + it('handles selecting single result when multiple identities with the specified name found and cli is set to prompt', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return true; + } + + return defaultValue; + }); + + sinonUtil.restore((auth as any).getServiceConnectionInfo); + sinon.stub(auth as any, 'getServiceConnectionInfo').resolves({ + authType: AuthType.DeviceCode, + connectedAs: 'alexw@contoso.com', + connected: true, + identityId: '028de82d-7fd9-476e-a9fd-be9714280ff3', + appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', + tenant: 'common', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + }, + availableIdentities: [ + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + }, + { + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: '46657f7d-a133-43f1-8721-6e4f53b43c97', + appId: '0445b0a6-88ff-499b-b91b-c181d0c24772', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + } + ] + }); + + sinon.stub(Cli, 'handleMultipleResultsFound').resolves({ + authType: AuthType.Secret, + connectedAs: 'Contoso Application', + connected: true, + identityId: 'acd6df42-10a9-4315-8928-53334f1c9d01', + appId: '39446e2e-5081-4887-980c-f285919fccca', + tenant: 'db308122-52f3-4241-af92-1734aa6e2e50', + cloudType: CloudType.Public, + certificateType: CertificateType.Unknown, + accessTokens: { + 'https://graph.microsoft.com': { + expiresOn: (new Date()).toISOString(), + accessToken: 'abc' + } + } + }); + + await assert.doesNotReject(command.action(logger, { options: { name: 'Contoso Application' } })); + assert(loggerLogSpy.calledOnceWithExactly(mockContosoApplicationIdentityResponse)); + }); +}); \ No newline at end of file diff --git a/src/m365/identity/commands/identity-set.ts b/src/m365/identity/commands/identity-set.ts new file mode 100644 index 00000000000..bcfb408baca --- /dev/null +++ b/src/m365/identity/commands/identity-set.ts @@ -0,0 +1,136 @@ +import { Logger } from '../../../cli/Logger.js'; +import auth, { Identity } from '../../../Auth.js'; +import commands from "../commands.js"; +import Command, { CommandError } from '../../../Command.js'; +import GlobalOptions from '../../../GlobalOptions.js'; +import { validation } from '../../../utils/validation.js'; +import { formatting } from '../../../utils/formatting.js'; +import { Cli } from '../../../cli/Cli.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + id?: string; + name?: string; +} + +class IdentitySetCommand extends Command { + public get name(): string { + return commands.SET; + } + + public get description(): string { + return "Switches to another identity, when signed into multiple identities"; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initValidators(); + this.#initOptions(); + this.#initOptionSets(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + id: typeof args.options.id !== 'undefined', + name: typeof args.options.name !== 'undefined' + }); + }); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.id && !validation.isValidGuid(args.options.id as string)) { + return `${args.options.id} is not a valid GUID`; + } + + return true; + } + ); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-i, --id [id]' + }, + { + option: '-n, --name [name]' + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ options: ['id', 'name'] }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + const identity = await this.getIdentityToSwitchTo(args.options); + + if (this.verbose) { + await logger.logToStderr(`Switching to identity '${identity.connectedAs}'...`); + } + + await auth.switchToIdentity(identity); + + try { + + if (this.verbose) { + logger.logToStderr(`Ensuring identity access token valid...`); + } + + await auth.ensureAccessToken(auth.defaultResource, logger, this.debug); + } + catch (err: any) { + if (this.debug) { + await logger.logToStderr(err); + } + + auth.service.deactivateIdentity(); + throw new CommandError(`Your login has expired. Sign in again to continue. ${err.message}`); + } + + await logger.log(auth.getIdentityDetails(auth.service, this.debug)); + } + + public async action(logger: Logger, args: CommandArgs): Promise { + try { + await auth.restoreAuth(); + } + catch (error: any) { + throw new CommandError(error); + } + + this.initAction(args, logger); + await this.commandAction(logger, args); + } + + private async getIdentityToSwitchTo(options: Options): Promise { + try { + const identities = auth.service.availableIdentities!.filter(i => i.connectedAs === options.name || i.identityId === options.id); + + if (identities.length === 0) { + throw new Error(`The identity '${options.id || options.name}' cannot be found`); + } + + if (identities.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('identityId', identities); + const result = await Cli.handleMultipleResultsFound(`Multiple identities with '${options.name}' found.`, resultAsKeyValuePair); + return result; + } + + return identities[0]; + } + catch (error: any) { + throw new CommandError(error.message); + } + } +} + +export default new IdentitySetCommand(); \ No newline at end of file diff --git a/src/m365/spo/commands/file/file-get.spec.ts b/src/m365/spo/commands/file/file-get.spec.ts index d8263e1f406..cd2a8e6c0ec 100644 --- a/src/m365/spo/commands/file/file-get.spec.ts +++ b/src/m365/spo/commands/file/file-get.spec.ts @@ -441,6 +441,25 @@ describe(commands.FILE_GET, () => { assert.strictEqual(getStub.lastCall.args[0].url, `https://contoso.sharepoint.com/_api/web/GetFileByServerRelativePath(DecodedUrl=@f)?@f='%2FDocuments%2FTest1.docx'`); }); + it('uses correct API url when tenant root URL option is passed in combination with asListItem', async () => { + const getStub: any = sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).indexOf('/_api/web/GetFileByServerRelativePath(') > -1) { + return { ListItemAllFields: { Id: 1, ID: 1 } }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + url: '/Documents/Test1.docx', + webUrl: 'https://contoso.sharepoint.com', + asListItem: true + } + }); + assert.strictEqual(getStub.lastCall.args[0].url, `https://contoso.sharepoint.com/_api/web/GetFileByServerRelativePath(DecodedUrl=@f)?$expand=ListItemAllFields&@f='%2FDocuments%2FTest1.docx'`); + }); + it('should handle promise rejection', async () => { const error = { error: { diff --git a/src/m365/spo/commands/page/page-add.spec.ts b/src/m365/spo/commands/page/page-add.spec.ts index 45a4ccfc845..08792f87ea8 100644 --- a/src/m365/spo/commands/page/page-add.spec.ts +++ b/src/m365/spo/commands/page/page-add.spec.ts @@ -368,7 +368,18 @@ describe(commands.PAGE_ADD, () => { throw 'Invalid request'; }); - + sinon.stub(Cli, 'executeCommand').callsFake(async (command): Promise => { + if (command === spoListItemSetCommand) { + return; + } + throw 'Invalid request'; + }); + sinon.stub(Cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { + if (command === spoFileGetCommand) { + return { 'stdout': '{\"FileSystemObjectType\":0,\"Id\":6,\"ServerRedirectedEmbedUri\":null,\"ServerRedirectedEmbedUrl\":\"\",\"ContentTypeId\":\"0x0101009D1CB255DA76424F860D91F20E6C411800E2DAFA6353688E488147257C551A63BD\",\"ComplianceAssetId\":null,\"WikiField\":null,\"Title\":\"zzzz\",\"CanvasContent1\":\"
<\/div><\/div>\",\"BannerImageUrl\":{\"Description\":\"https:\/\/contoso.sharepoint.com\/_layouts\/15\/images\/sitepagethumbnail.png\",\"Url\":\"https:\/\/contoso.sharepoint.com\/_layouts\/15\/images\/sitepagethumbnail.png\"},\"Description\":null,\"PromotedState\":0,\"FirstPublishedDate\":\"2022-11-11T15:48:15\",\"LayoutWebpartsContent\":\"
<\/div><\/div>\",\"OData__AuthorBylineId\":[9],\"_AuthorBylineStringId\":[\"9\"],\"OData__TopicHeader\":null,\"OData__SPSitePageFlags\":null,\"OData__SPCallToAction\":null,\"OData__OriginalSourceUrl\":null,\"OData__OriginalSourceSiteId\":null,\"OData__OriginalSourceWebId\":null,\"OData__OriginalSourceListId\":null,\"OData__OriginalSourceItemId\":null,\"ID\":6,\"Created\":\"2022-11-11T15:48:00\",\"AuthorId\":9,\"Modified\":\"2022-11-12T02:03:12\",\"EditorId\":9,\"OData__CopySource\":null,\"CheckoutUserId\":9,\"OData__UIVersionString\":\"2.19\",\"GUID\":\"9a94cb88-019b-4a66-abd6-be7f5337f659\"}' }; + } + throw 'Invalid request'; + }); await assert.rejects(command.action(logger, { options: { name: 'page', webUrl: 'https://contoso.sharepoint.com/sites/team-a' } })); assert(loggerLogSpy.notCalled); }); @@ -426,7 +437,18 @@ describe(commands.PAGE_ADD, () => { throw 'Invalid request'; }); - + sinon.stub(Cli, 'executeCommand').callsFake(async (command): Promise => { + if (command === spoListItemSetCommand) { + return; + } + throw 'Invalid request'; + }); + sinon.stub(Cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { + if (command === spoFileGetCommand) { + return { 'stdout': '{\"FileSystemObjectType\":0,\"Id\":6,\"ServerRedirectedEmbedUri\":null,\"ServerRedirectedEmbedUrl\":\"\",\"ContentTypeId\":\"0x0101009D1CB255DA76424F860D91F20E6C411800E2DAFA6353688E488147257C551A63BD\",\"ComplianceAssetId\":null,\"WikiField\":null,\"Title\":\"zzzz\",\"CanvasContent1\":\"
<\/div><\/div>\",\"BannerImageUrl\":{\"Description\":\"https:\/\/contoso.sharepoint.com\/_layouts\/15\/images\/sitepagethumbnail.png\",\"Url\":\"https:\/\/contoso.sharepoint.com\/_layouts\/15\/images\/sitepagethumbnail.png\"},\"Description\":null,\"PromotedState\":0,\"FirstPublishedDate\":\"2022-11-11T15:48:15\",\"LayoutWebpartsContent\":\"
<\/div><\/div>\",\"OData__AuthorBylineId\":[9],\"_AuthorBylineStringId\":[\"9\"],\"OData__TopicHeader\":null,\"OData__SPSitePageFlags\":null,\"OData__SPCallToAction\":null,\"OData__OriginalSourceUrl\":null,\"OData__OriginalSourceSiteId\":null,\"OData__OriginalSourceWebId\":null,\"OData__OriginalSourceListId\":null,\"OData__OriginalSourceItemId\":null,\"ID\":6,\"Created\":\"2022-11-11T15:48:00\",\"AuthorId\":9,\"Modified\":\"2022-11-12T02:03:12\",\"EditorId\":9,\"OData__CopySource\":null,\"CheckoutUserId\":9,\"OData__UIVersionString\":\"2.19\",\"GUID\":\"9a94cb88-019b-4a66-abd6-be7f5337f659\"}' }; + } + throw 'Invalid request'; + }); await assert.rejects(command.action(logger, { options: { name: 'page.aspx', title: 'My page', webUrl: 'https://contoso.sharepoint.com/sites/team-a' } })); assert(loggerLogSpy.notCalled); }); @@ -841,7 +863,18 @@ describe(commands.PAGE_ADD, () => { throw 'Invalid request'; }); - + sinon.stub(Cli, 'executeCommand').callsFake(async (command): Promise => { + if (command === spoListItemSetCommand) { + return; + } + throw 'Invalid request'; + }); + sinon.stub(Cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { + if (command === spoFileGetCommand) { + return { 'stdout': '{\"FileSystemObjectType\":0,\"Id\":6,\"ServerRedirectedEmbedUri\":null,\"ServerRedirectedEmbedUrl\":\"\",\"ContentTypeId\":\"0x0101009D1CB255DA76424F860D91F20E6C411800E2DAFA6353688E488147257C551A63BD\",\"ComplianceAssetId\":null,\"WikiField\":null,\"Title\":\"zzzz\",\"CanvasContent1\":\"
<\/div><\/div>\",\"BannerImageUrl\":{\"Description\":\"https:\/\/contoso.sharepoint.com\/_layouts\/15\/images\/sitepagethumbnail.png\",\"Url\":\"https:\/\/contoso.sharepoint.com\/_layouts\/15\/images\/sitepagethumbnail.png\"},\"Description\":null,\"PromotedState\":0,\"FirstPublishedDate\":\"2022-11-11T15:48:15\",\"LayoutWebpartsContent\":\"
<\/div><\/div>\",\"OData__AuthorBylineId\":[9],\"_AuthorBylineStringId\":[\"9\"],\"OData__TopicHeader\":null,\"OData__SPSitePageFlags\":null,\"OData__SPCallToAction\":null,\"OData__OriginalSourceUrl\":null,\"OData__OriginalSourceSiteId\":null,\"OData__OriginalSourceWebId\":null,\"OData__OriginalSourceListId\":null,\"OData__OriginalSourceItemId\":null,\"ID\":6,\"Created\":\"2022-11-11T15:48:00\",\"AuthorId\":9,\"Modified\":\"2022-11-12T02:03:12\",\"EditorId\":9,\"OData__CopySource\":null,\"CheckoutUserId\":9,\"OData__UIVersionString\":\"2.19\",\"GUID\":\"9a94cb88-019b-4a66-abd6-be7f5337f659\"}' }; + } + throw 'Invalid request'; + }); await assert.rejects(command.action(logger, { options: { name: 'page.aspx', webUrl: 'https://contoso.sharepoint.com/sites/team-a', commentsEnabled: true } })); assert(loggerLogSpy.notCalled); }); @@ -1175,7 +1208,18 @@ describe(commands.PAGE_ADD, () => { throw 'Invalid request'; }); - + sinon.stub(Cli, 'executeCommand').callsFake(async (command): Promise => { + if (command === spoListItemSetCommand) { + return; + } + throw 'Invalid request'; + }); + sinon.stub(Cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { + if (command === spoFileGetCommand) { + return { 'stdout': '{\"FileSystemObjectType\":0,\"Id\":6,\"ServerRedirectedEmbedUri\":null,\"ServerRedirectedEmbedUrl\":\"\",\"ContentTypeId\":\"0x0101009D1CB255DA76424F860D91F20E6C411800E2DAFA6353688E488147257C551A63BD\",\"ComplianceAssetId\":null,\"WikiField\":null,\"Title\":\"zzzz\",\"CanvasContent1\":\"
<\/div><\/div>\",\"BannerImageUrl\":{\"Description\":\"https:\/\/contoso.sharepoint.com\/_layouts\/15\/images\/sitepagethumbnail.png\",\"Url\":\"https:\/\/contoso.sharepoint.com\/_layouts\/15\/images\/sitepagethumbnail.png\"},\"Description\":null,\"PromotedState\":0,\"FirstPublishedDate\":\"2022-11-11T15:48:15\",\"LayoutWebpartsContent\":\"
<\/div><\/div>\",\"OData__AuthorBylineId\":[9],\"_AuthorBylineStringId\":[\"9\"],\"OData__TopicHeader\":null,\"OData__SPSitePageFlags\":null,\"OData__SPCallToAction\":null,\"OData__OriginalSourceUrl\":null,\"OData__OriginalSourceSiteId\":null,\"OData__OriginalSourceWebId\":null,\"OData__OriginalSourceListId\":null,\"OData__OriginalSourceItemId\":null,\"ID\":6,\"Created\":\"2022-11-11T15:48:00\",\"AuthorId\":9,\"Modified\":\"2022-11-12T02:03:12\",\"EditorId\":9,\"OData__CopySource\":null,\"CheckoutUserId\":9,\"OData__UIVersionString\":\"2.19\",\"GUID\":\"9a94cb88-019b-4a66-abd6-be7f5337f659\"}' }; + } + throw 'Invalid request'; + }); await assert.rejects(command.action(logger, { options: { name: 'page.aspx', webUrl: 'https://contoso.sharepoint.com/sites/team-a', publish: true, publishMessage: 'Don\'t tell' } })); assert(loggerLogSpy.notCalled); }); diff --git a/src/m365/spo/commands/page/page-set.spec.ts b/src/m365/spo/commands/page/page-set.spec.ts index 598cda19a00..c233e796baf 100644 --- a/src/m365/spo/commands/page/page-set.spec.ts +++ b/src/m365/spo/commands/page/page-set.spec.ts @@ -209,7 +209,18 @@ describe(commands.PAGE_SET, () => { throw 'Invalid request'; }); - + sinon.stub(Cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { + if (command === spoFileGetCommand) { + return { 'stdout': '{\"FileSystemObjectType\":0,\"Id\":6,\"ServerRedirectedEmbedUri\":null,\"ServerRedirectedEmbedUrl\":\"\",\"ContentTypeId\":\"0x0101009D1CB255DA76424F860D91F20E6C411800E2DAFA6353688E488147257C551A63BD\",\"ComplianceAssetId\":null,\"WikiField\":null,\"Title\":\"zzzz\",\"CanvasContent1\":\"
<\/div><\/div>\",\"BannerImageUrl\":{\"Description\":\"https:\/\/contoso.sharepoint.com\/_layouts\/15\/images\/sitepagethumbnail.png\",\"Url\":\"https:\/\/contoso.sharepoint.com\/_layouts\/15\/images\/sitepagethumbnail.png\"},\"Description\":null,\"PromotedState\":0,\"FirstPublishedDate\":\"2022-11-11T15:48:15\",\"LayoutWebpartsContent\":\"
<\/div><\/div>\",\"OData__AuthorBylineId\":[9],\"_AuthorBylineStringId\":[\"9\"],\"OData__TopicHeader\":null,\"OData__SPSitePageFlags\":null,\"OData__SPCallToAction\":null,\"OData__OriginalSourceUrl\":null,\"OData__OriginalSourceSiteId\":null,\"OData__OriginalSourceWebId\":null,\"OData__OriginalSourceListId\":null,\"OData__OriginalSourceItemId\":null,\"ID\":6,\"Created\":\"2022-11-11T15:48:00\",\"AuthorId\":9,\"Modified\":\"2022-11-12T02:03:12\",\"EditorId\":9,\"OData__CopySource\":null,\"CheckoutUserId\":9,\"OData__UIVersionString\":\"2.19\",\"GUID\":\"9a94cb88-019b-4a66-abd6-be7f5337f659\"}' }; + } + throw 'Invalid request'; + }); + sinon.stub(Cli, 'executeCommand').callsFake(async (command): Promise => { + if (command === spoListItemSetCommand) { + return; + } + throw 'Invalid request'; + }); await assert.rejects(command.action(logger, { options: { name: 'page.aspx', webUrl: 'https://contoso.sharepoint.com/sites/team-a', layoutType: 'Home' } })); assert(loggerLogSpy.notCalled); }); diff --git a/src/m365/spo/commands/site/site-ensure.spec.ts b/src/m365/spo/commands/site/site-ensure.spec.ts index c2704487826..9356b838dd6 100644 --- a/src/m365/spo/commands/site/site-ensure.spec.ts +++ b/src/m365/spo/commands/site/site-ensure.spec.ts @@ -191,10 +191,4 @@ describe(commands.SITE_ENSURE, () => { await assert.rejects(command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team1', title: 'Team 1', type: 'CommunicationSite' } } as any)); }); - - it('returns error when no properties to update specified', async () => { - sinon.stub(spo, 'getWeb').resolves(webResponse); - - await assert.rejects(command.action(logger, { options: { url: 'https://contoso.sharepoint.com/sites/team1' } } as any)); - }); }); diff --git a/src/m365/spo/commands/spo-set.ts b/src/m365/spo/commands/spo-set.ts index b9195df935a..3138dcca331 100644 --- a/src/m365/spo/commands/spo-set.ts +++ b/src/m365/spo/commands/spo-set.ts @@ -45,6 +45,7 @@ class SpoSetCommand extends SpoCommand { public async commandAction(logger: Logger, args: CommandArgs): Promise { auth.service.spoUrl = args.options.url; + auth.updateAvailableIdentitiesList(); try { await auth.storeConnectionInfo();