From 08296cf72a060a0703daec038b90389c1da80eb8 Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Fri, 3 Jan 2025 07:39:06 +0100 Subject: [PATCH 1/6] New command: Revoke Sign-in Sessions --- .eslintrc.cjs | 4 +- .../cmd/entra/user/user-session-revoke.mdx | 57 ++++++ docs/src/config/sidebars.ts | 5 + src/m365/entra/commands.ts | 1 + .../commands/user/user-session-revoke.spec.ts | 184 ++++++++++++++++++ .../commands/user/user-session-revoke.ts | 99 ++++++++++ 6 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 docs/docs/cmd/entra/user/user-session-revoke.mdx create mode 100644 src/m365/entra/commands/user/user-session-revoke.spec.ts create mode 100644 src/m365/entra/commands/user/user-session-revoke.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 253f96a74b8..363542bca1f 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -90,15 +90,17 @@ const dictionary = [ 'property', 'records', 'recycle', + 'registration', 'request', 'resolver', - 'registration', 'retention', + 'revoke', 'role', 'room', 'schema', 'sensitivity', 'service', + 'session', 'set', 'setting', 'settings', diff --git a/docs/docs/cmd/entra/user/user-session-revoke.mdx b/docs/docs/cmd/entra/user/user-session-revoke.mdx new file mode 100644 index 00000000000..38e180974d6 --- /dev/null +++ b/docs/docs/cmd/entra/user/user-session-revoke.mdx @@ -0,0 +1,57 @@ +import Global from '/docs/cmd/_global.mdx'; + +# entra user session revoke + +Revokes all sign-in sessions for a given user + +## Usage + +```sh +m365 entra user session revoke [options] +``` + +## Options +```md definition-list +`-i, --id [id]` +: The id of the user. Specify either `id` or `userName`, but not both. + +`-n, --userName [userName]` +: The user principal name of the user. Specify either `id` or `userName`, but not both. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Remarks + +:::info + +Only the user with Global Administrator role can revoke sign-in sessions of other users. + +::: + +## Examples + +Revoke sign-in sessions of a user specified by id + +```sh +m365 entra user session revoke --id 4fb72b9b-d0b0-4a35-8bc1-83f9a6488c48 +``` + +Revoke sign-in sessions of a user specified by its UPN + +```sh +m365 entra user session revoke --userName john.doe@contoso.onmicrosoft.com +``` + +Revoke sign-in sessions of a user specified by its UPN without prompting for confirmation. + +```sh +m365 entra user session revoke --userName john.doe@contoso.onmicrosoft.com --force +``` + +## Response + +The command won't return a response on success diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 68fa5ec4249..f06c61b45ac 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -757,6 +757,11 @@ const sidebars: SidebarsConfig = { label: 'user registrationdetails list', id: 'cmd/entra/user/user-registrationdetails-list' }, + { + type: 'doc', + label: 'user session revoke', + id: 'cmd/entra/user/user-session-revoke' + }, { type: 'doc', label: 'user signin list', diff --git a/src/m365/entra/commands.ts b/src/m365/entra/commands.ts index dc270f50c70..a5c33dd320e 100644 --- a/src/m365/entra/commands.ts +++ b/src/m365/entra/commands.ts @@ -115,6 +115,7 @@ export default { USER_REGISTRATIONDETAILS_LIST: `${prefix} user registrationdetails list`, USER_REMOVE: `${prefix} user remove`, USER_RECYCLEBINITEM_RESTORE: `${prefix} user recyclebinitem restore`, + USER_SESSION_REVOKE: `${prefix} user session revoke`, USER_SET: `${prefix} user set`, USER_SIGNIN_LIST: `${prefix} user signin list` }; diff --git a/src/m365/entra/commands/user/user-session-revoke.spec.ts b/src/m365/entra/commands/user/user-session-revoke.spec.ts new file mode 100644 index 00000000000..4c470e82e58 --- /dev/null +++ b/src/m365/entra/commands/user/user-session-revoke.spec.ts @@ -0,0 +1,184 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import commands from '../../commands.js'; +import request from '../../../../request.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 command from './user-session-revoke.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { CommandError } from '../../../../Command.js'; +import { z } from 'zod'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { cli } from '../../../../cli/cli.js'; +import { formatting } from '../../../../utils/formatting.js'; + +describe(commands.USER_SESSION_REVOKE, () => { + const userId = 'abcd1234-de71-4623-b4af-96380a352509'; + const userName = 'john.doe@contoso.com'; + const userNameWithDollar = "$john.doe@contoso.com"; + + let log: string[]; + let logger: Logger; + let promptIssued: boolean; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + sinon.stub(cli, 'promptForConfirmation').callsFake(() => { + promptIssued = true; + return Promise.resolve(false); + }); + + promptIssued = false; + }); + + afterEach(() => { + sinonUtil.restore([ + request.post, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.USER_SESSION_REVOKE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if id is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'foo' + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userName is not a valid UPN', () => { + const actual = commandOptionsSchema.safeParse({ + userName: 'foo' + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if both id and userName are provided', () => { + const actual = commandOptionsSchema.safeParse({ + id: userId, + userName: userName + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if neither id nor userName is provided', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.notStrictEqual(actual.success, true); + }); + + it('prompts before revoking all sign-in sessions when confirm option not passed', async () => { + await command.action(logger, { options: { id: userId } }); + + assert(promptIssued); + }); + + it('aborts revoking all sign-in sessions when prompt not confirmed', async () => { + const deleteSpy = sinon.stub(request, 'delete').resolves(); + + await command.action(logger, { options: { id: userId } }); + assert(deleteSpy.notCalled); + }); + + it('revokes all sign-in sessions for a user specified by id without prompting for confirmation', async () => { + const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/revokeSignInSessions`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: userId, force: true, verbose: true } }); + assert(postRequestStub.called); + }); + + it('revokes all sign-in sessions for a user specified by UPN while prompting for confirmation', async () => { + const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(userName)}/revokeSignInSessions`) { + return; + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { options: { userName: userName } }); + assert(postRequestStub.called); + }); + + it('revokes all sign-in sessions for a user specified by UPN which starts with $ without prompting for confirmation', async () => { + const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${formatting.encodeQueryParameter(userNameWithDollar)}')/revokeSignInSessions`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { userName: userNameWithDollar, force: true, verbose: true } }); + assert(postRequestStub.called); + }); + + it('handles error when user specified by id was not found', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/revokeSignInSessions`) { + throw { + error: + { + code: 'Request_ResourceNotFound', + message: `Resource '${userId}' does not exist or one of its queried reference-property objects are not present.` + } + }; + } + throw `Invalid request`; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await assert.rejects( + command.action(logger, { options: { id: userId } }), + new CommandError(`Resource '${userId}' does not exist or one of its queried reference-property objects are not present.`) + ); + }); +}); \ No newline at end of file diff --git a/src/m365/entra/commands/user/user-session-revoke.ts b/src/m365/entra/commands/user/user-session-revoke.ts new file mode 100644 index 00000000000..784b77f4df5 --- /dev/null +++ b/src/m365/entra/commands/user/user-session-revoke.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; +import { zod } from '../../../../utils/zod.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import { validation } from '../../../../utils/validation.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { formatting } from '../../../../utils/formatting.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { cli } from '../../../../cli/cli.js'; + +const options = globalOptionsZod + .extend({ + id: zod.alias('i', z.string().optional()), + userName: zod.alias('n', z.string().optional()), + force: zod.alias('f', z.boolean().optional()) + }) + .strict(); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class EntraUserSessionRevokeCommand extends GraphCommand { + public get name(): string { + return commands.USER_SESSION_REVOKE; + } + public get description(): string { + return 'Revokes Microsoft Entra user sessions'; + } + public get schema(): z.ZodTypeAny | undefined { + return options; + } + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => !options.id !== !options.userName, { + message: 'Specify either id or userName, but not both' + }) + .refine(options => options.id || options.userName, { + message: 'Specify either id or userName' + }) + .refine(options => (!options.id && !options.userName) || options.userName || (options.id && validation.isValidGuid(options.id)), options => ({ + message: `The '${options.id}' must be a valid GUID`, + path: ['id'] + })) + .refine(options => (!options.id && !options.userName) || options.id || (options.userName && validation.isValidUserPrincipalName(options.userName)), options => ({ + message: `The '${options.userName}' must be a valid UPN`, + path: ['id'] + })); + } + public async commandAction(logger: Logger, args: CommandArgs): Promise { + const revokeUserSessions = async (): Promise => { + try { + let userIdOrPrincipalName = args.options.id; + + if (args.options.userName) { + // single user can be retrieved also by user principal name + userIdOrPrincipalName = formatting.encodeQueryParameter(args.options.userName); + } + + if (args.options.verbose) { + await logger.logToStderr(`Invalidating all the refresh tokens for user ${userIdOrPrincipalName}...`); + } + + // user principal name can start with $ but it violates the OData URL convention, so it must be enclosed in parenthesis and single quotes + const requestUrl = userIdOrPrincipalName!.startsWith('%24') + ? `${this.resource}/v1.0/users('${userIdOrPrincipalName}')/revokeSignInSessions` + : `${this.resource}/v1.0/users/${userIdOrPrincipalName}/revokeSignInSessions`; + + const requestOptions: CliRequestOptions = { + url: requestUrl, + headers: { + accept: 'application/json;odata.metadata=none' + } + }; + + await request.post(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + if (args.options.force) { + await revokeUserSessions(); + } + else { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to invalidate all the refresh tokens issued to applications for a user '${args.options.id || args.options.userName}'?` }); + + if (result) { + await revokeUserSessions(); + } + } + } +} + +export default new EntraUserSessionRevokeCommand(); \ No newline at end of file From ff5e7ea893fc7d47987c2941f2f69d8d2c5edc9d Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Sat, 4 Jan 2025 18:13:59 +0100 Subject: [PATCH 2/6] New command: Revoke Sign-in Sessions --- .../cmd/entra/user/user-session-revoke.mdx | 8 +++---- .../commands/user/user-session-revoke.spec.ts | 22 ++++++++--------- .../commands/user/user-session-revoke.ts | 24 +++++++++---------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/docs/cmd/entra/user/user-session-revoke.mdx b/docs/docs/cmd/entra/user/user-session-revoke.mdx index 38e180974d6..373fc92a10c 100644 --- a/docs/docs/cmd/entra/user/user-session-revoke.mdx +++ b/docs/docs/cmd/entra/user/user-session-revoke.mdx @@ -12,11 +12,11 @@ m365 entra user session revoke [options] ## Options ```md definition-list -`-i, --id [id]` -: The id of the user. Specify either `id` or `userName`, but not both. +`-i, --userId [userId]` +: The id of the user. Specify either `userId` or `userName`, but not both. `-n, --userName [userName]` -: The user principal name of the user. Specify either `id` or `userName`, but not both. +: The user principal name of the user. Specify either `userId` or `userName`, but not both. `-f, --force` : Don't prompt for confirmation. @@ -37,7 +37,7 @@ Only the user with Global Administrator role can revoke sign-in sessions of othe Revoke sign-in sessions of a user specified by id ```sh -m365 entra user session revoke --id 4fb72b9b-d0b0-4a35-8bc1-83f9a6488c48 +m365 entra user session revoke --userId 4fb72b9b-d0b0-4a35-8bc1-83f9a6488c48 ``` Revoke sign-in sessions of a user specified by its UPN diff --git a/src/m365/entra/commands/user/user-session-revoke.spec.ts b/src/m365/entra/commands/user/user-session-revoke.spec.ts index 4c470e82e58..c9f86b3d690 100644 --- a/src/m365/entra/commands/user/user-session-revoke.spec.ts +++ b/src/m365/entra/commands/user/user-session-revoke.spec.ts @@ -77,9 +77,9 @@ describe(commands.USER_SESSION_REVOKE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if id is not a valid GUID', () => { + it('fails validation if userId is not a valid GUID', () => { const actual = commandOptionsSchema.safeParse({ - id: 'foo' + userId: 'foo' }); assert.notStrictEqual(actual.success, true); }); @@ -91,21 +91,21 @@ describe(commands.USER_SESSION_REVOKE, () => { assert.notStrictEqual(actual.success, true); }); - it('fails validation if both id and userName are provided', () => { + it('fails validation if both userId and userName are provided', () => { const actual = commandOptionsSchema.safeParse({ - id: userId, + userId: userId, userName: userName }); assert.notStrictEqual(actual.success, true); }); - it('fails validation if neither id nor userName is provided', () => { + it('fails validation if neither userId nor userName is provided', () => { const actual = commandOptionsSchema.safeParse({}); assert.notStrictEqual(actual.success, true); }); it('prompts before revoking all sign-in sessions when confirm option not passed', async () => { - await command.action(logger, { options: { id: userId } }); + await command.action(logger, { options: { userId: userId } }); assert(promptIssued); }); @@ -113,11 +113,11 @@ describe(commands.USER_SESSION_REVOKE, () => { it('aborts revoking all sign-in sessions when prompt not confirmed', async () => { const deleteSpy = sinon.stub(request, 'delete').resolves(); - await command.action(logger, { options: { id: userId } }); + await command.action(logger, { options: { userId: userId } }); assert(deleteSpy.notCalled); }); - it('revokes all sign-in sessions for a user specified by id without prompting for confirmation', async () => { + it('revokes all sign-in sessions for a user specified by userId without prompting for confirmation', async () => { const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/revokeSignInSessions`) { return; @@ -126,7 +126,7 @@ describe(commands.USER_SESSION_REVOKE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: userId, force: true, verbose: true } }); + await command.action(logger, { options: { userId: userId, force: true, verbose: true } }); assert(postRequestStub.called); }); @@ -159,7 +159,7 @@ describe(commands.USER_SESSION_REVOKE, () => { assert(postRequestStub.called); }); - it('handles error when user specified by id was not found', async () => { + it('handles error when user specified by userId was not found', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/revokeSignInSessions`) { throw { @@ -177,7 +177,7 @@ describe(commands.USER_SESSION_REVOKE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await assert.rejects( - command.action(logger, { options: { id: userId } }), + command.action(logger, { options: { userId: userId } }), new CommandError(`Resource '${userId}' does not exist or one of its queried reference-property objects are not present.`) ); }); diff --git a/src/m365/entra/commands/user/user-session-revoke.ts b/src/m365/entra/commands/user/user-session-revoke.ts index 784b77f4df5..713211831cd 100644 --- a/src/m365/entra/commands/user/user-session-revoke.ts +++ b/src/m365/entra/commands/user/user-session-revoke.ts @@ -11,7 +11,7 @@ import { cli } from '../../../../cli/cli.js'; const options = globalOptionsZod .extend({ - id: zod.alias('i', z.string().optional()), + userId: zod.alias('i', z.string().optional()), userName: zod.alias('n', z.string().optional()), force: zod.alias('f', z.boolean().optional()) }) @@ -35,25 +35,25 @@ class EntraUserSessionRevokeCommand extends GraphCommand { } public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { return schema - .refine(options => !options.id !== !options.userName, { - message: 'Specify either id or userName, but not both' + .refine(options => !options.userId !== !options.userName, { + message: 'Specify either userId or userName, but not both' }) - .refine(options => options.id || options.userName, { - message: 'Specify either id or userName' + .refine(options => options.userId || options.userName, { + message: 'Specify either userId or userName' }) - .refine(options => (!options.id && !options.userName) || options.userName || (options.id && validation.isValidGuid(options.id)), options => ({ - message: `The '${options.id}' must be a valid GUID`, - path: ['id'] + .refine(options => (!options.userId && !options.userName) || options.userName || (options.userId && validation.isValidGuid(options.userId)), options => ({ + message: `The '${options.userId}' must be a valid GUID`, + path: ['userId'] })) - .refine(options => (!options.id && !options.userName) || options.id || (options.userName && validation.isValidUserPrincipalName(options.userName)), options => ({ + .refine(options => (!options.userId && !options.userName) || options.userId || (options.userName && validation.isValidUserPrincipalName(options.userName)), options => ({ message: `The '${options.userName}' must be a valid UPN`, - path: ['id'] + path: ['userId'] })); } public async commandAction(logger: Logger, args: CommandArgs): Promise { const revokeUserSessions = async (): Promise => { try { - let userIdOrPrincipalName = args.options.id; + let userIdOrPrincipalName = args.options.userId; if (args.options.userName) { // single user can be retrieved also by user principal name @@ -87,7 +87,7 @@ class EntraUserSessionRevokeCommand extends GraphCommand { await revokeUserSessions(); } else { - const result = await cli.promptForConfirmation({ message: `Are you sure you want to invalidate all the refresh tokens issued to applications for a user '${args.options.id || args.options.userName}'?` }); + const result = await cli.promptForConfirmation({ message: `Are you sure you want to invalidate all the refresh tokens issued to applications for a user '${args.options.userId || args.options.userName}'?` }); if (result) { await revokeUserSessions(); From ef2b82254ce40be86f1a3efb7434141ea38c0027 Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Sun, 5 Jan 2025 11:59:07 +0100 Subject: [PATCH 3/6] New command: Revoke Sign-in Sessions --- .../cmd/entra/user/user-session-revoke.mdx | 54 +++++++++++++++++-- .../commands/user/user-session-revoke.spec.ts | 51 +++++++++--------- .../commands/user/user-session-revoke.ts | 53 ++++++++---------- 3 files changed, 99 insertions(+), 59 deletions(-) diff --git a/docs/docs/cmd/entra/user/user-session-revoke.mdx b/docs/docs/cmd/entra/user/user-session-revoke.mdx index 373fc92a10c..a8f978a906f 100644 --- a/docs/docs/cmd/entra/user/user-session-revoke.mdx +++ b/docs/docs/cmd/entra/user/user-session-revoke.mdx @@ -1,4 +1,6 @@ import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; # entra user session revoke @@ -28,7 +30,15 @@ m365 entra user session revoke [options] :::info -Only the user with Global Administrator role can revoke sign-in sessions of other users. +The user with at least User Administrator role can revoke sign-in sessions of other users. + +::: + +:::note + +There might be a small delay of a few minutes before tokens are revoked. + +This API doesn't revoke sign-in sessions for external users, because external users sign in through their home tenant. ::: @@ -46,7 +56,7 @@ Revoke sign-in sessions of a user specified by its UPN m365 entra user session revoke --userName john.doe@contoso.onmicrosoft.com ``` -Revoke sign-in sessions of a user specified by its UPN without prompting for confirmation. +Revoke sign-in sessions of a user specified by its UPN without prompting for confirmation ```sh m365 entra user session revoke --userName john.doe@contoso.onmicrosoft.com --force @@ -54,4 +64,42 @@ m365 entra user session revoke --userName john.doe@contoso.onmicrosoft.com --for ## Response -The command won't return a response on success + + + + ```json + { + "value": true + } + ``` + + + + + ```text + value: true + ``` + + + + + ```csv + value + 1 + ``` + + + + + ```md + # entra user session revoke --userName "john.doe@contoso.com" --force "true" + + Date: 1/5/2025 + + Property | Value + ---------|------- + value | true + ``` + + + diff --git a/src/m365/entra/commands/user/user-session-revoke.spec.ts b/src/m365/entra/commands/user/user-session-revoke.spec.ts index c9f86b3d690..fe8d663f72b 100644 --- a/src/m365/entra/commands/user/user-session-revoke.spec.ts +++ b/src/m365/entra/commands/user/user-session-revoke.spec.ts @@ -13,7 +13,6 @@ import { CommandError } from '../../../../Command.js'; import { z } from 'zod'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { cli } from '../../../../cli/cli.js'; -import { formatting } from '../../../../utils/formatting.js'; describe(commands.USER_SESSION_REVOKE, () => { const userId = 'abcd1234-de71-4623-b4af-96380a352509'; @@ -22,6 +21,7 @@ describe(commands.USER_SESSION_REVOKE, () => { let log: string[]; let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; let promptIssued: boolean; let commandInfo: CommandInfo; let commandOptionsSchema: z.ZodTypeAny; @@ -49,12 +49,12 @@ describe(commands.USER_SESSION_REVOKE, () => { log.push(msg); } }; - sinon.stub(cli, 'promptForConfirmation').callsFake(() => { + sinon.stub(cli, 'promptForConfirmation').callsFake(async () => { promptIssued = true; - return Promise.resolve(false); + return false; }); - promptIssued = false; + loggerLogSpy = sinon.spy(logger, 'log'); }); afterEach(() => { @@ -111,29 +111,33 @@ describe(commands.USER_SESSION_REVOKE, () => { }); it('aborts revoking all sign-in sessions when prompt not confirmed', async () => { - const deleteSpy = sinon.stub(request, 'delete').resolves(); + const postStub = sinon.stub(request, 'post').resolves(); await command.action(logger, { options: { userId: userId } }); - assert(deleteSpy.notCalled); + assert(postStub.notCalled); }); it('revokes all sign-in sessions for a user specified by userId without prompting for confirmation', async () => { - const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => { + sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/revokeSignInSessions`) { - return; + return { + value: true + }; } throw 'Invalid request'; }); await command.action(logger, { options: { userId: userId, force: true, verbose: true } }); - assert(postRequestStub.called); + assert(loggerLogSpy.calledOnceWith({ value: true })); }); it('revokes all sign-in sessions for a user specified by UPN while prompting for confirmation', async () => { const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(userName)}/revokeSignInSessions`) { - return; + if (opts.url === `https://graph.microsoft.com/v1.0/users/${userName}/revokeSignInSessions`) { + return { + value: true + }; } throw 'Invalid request'; @@ -143,34 +147,31 @@ describe(commands.USER_SESSION_REVOKE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { options: { userName: userName } }); - assert(postRequestStub.called); + assert(postRequestStub.calledOnce); }); it('revokes all sign-in sessions for a user specified by UPN which starts with $ without prompting for confirmation', async () => { const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users('${formatting.encodeQueryParameter(userNameWithDollar)}')/revokeSignInSessions`) { - return; + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userNameWithDollar}')/revokeSignInSessions`) { + return { + value: true + }; } throw 'Invalid request'; }); await command.action(logger, { options: { userName: userNameWithDollar, force: true, verbose: true } }); - assert(postRequestStub.called); + assert(postRequestStub.calledOnce); }); it('handles error when user specified by userId was not found', async () => { - sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/revokeSignInSessions`) { - throw { - error: - { - code: 'Request_ResourceNotFound', - message: `Resource '${userId}' does not exist or one of its queried reference-property objects are not present.` - } - }; + sinon.stub(request, 'post').rejects({ + error: + { + code: 'Request_ResourceNotFound', + message: `Resource '${userId}' does not exist or one of its queried reference-property objects are not present.` } - throw `Invalid request`; }); sinonUtil.restore(cli.promptForConfirmation); diff --git a/src/m365/entra/commands/user/user-session-revoke.ts b/src/m365/entra/commands/user/user-session-revoke.ts index 713211831cd..1add61372c4 100644 --- a/src/m365/entra/commands/user/user-session-revoke.ts +++ b/src/m365/entra/commands/user/user-session-revoke.ts @@ -5,14 +5,17 @@ import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { validation } from '../../../../utils/validation.js'; import { Logger } from '../../../../cli/Logger.js'; -import { formatting } from '../../../../utils/formatting.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { cli } from '../../../../cli/cli.js'; const options = globalOptionsZod .extend({ - userId: zod.alias('i', z.string().optional()), - userName: zod.alias('n', z.string().optional()), + userId: zod.alias('i', z.string().refine(id => validation.isValidGuid(id), id => ({ + message: `'${id}' is not a valid GUID.` + })).optional()), + userName: zod.alias('n', z.string().refine(name => validation.isValidUserPrincipalName(name), name => ({ + message: `'${name}' is not a valid UPN.` + })).optional()), force: zod.alias('f', z.boolean().optional()) }) .strict(); @@ -28,55 +31,43 @@ class EntraUserSessionRevokeCommand extends GraphCommand { return commands.USER_SESSION_REVOKE; } public get description(): string { - return 'Revokes Microsoft Entra user sessions'; + return 'Revokes all sign-in sessions for a given user'; } public get schema(): z.ZodTypeAny | undefined { return options; } public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { return schema - .refine(options => !options.userId !== !options.userName, { - message: 'Specify either userId or userName, but not both' - }) - .refine(options => options.userId || options.userName, { + .refine(options => [options.userId, options.userName].filter(o => o !== undefined).length === 1, { message: 'Specify either userId or userName' - }) - .refine(options => (!options.userId && !options.userName) || options.userName || (options.userId && validation.isValidGuid(options.userId)), options => ({ - message: `The '${options.userId}' must be a valid GUID`, - path: ['userId'] - })) - .refine(options => (!options.userId && !options.userName) || options.userId || (options.userName && validation.isValidUserPrincipalName(options.userName)), options => ({ - message: `The '${options.userName}' must be a valid UPN`, - path: ['userId'] - })); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { const revokeUserSessions = async (): Promise => { try { - let userIdOrPrincipalName = args.options.userId; + const userIdentifier = args.options.userId ?? args.options.userName; - if (args.options.userName) { - // single user can be retrieved also by user principal name - userIdOrPrincipalName = formatting.encodeQueryParameter(args.options.userName); - } - - if (args.options.verbose) { - await logger.logToStderr(`Invalidating all the refresh tokens for user ${userIdOrPrincipalName}...`); + if (this.verbose) { + await logger.logToStderr(`Invalidating all the refresh tokens for user ${userIdentifier}...`); } // user principal name can start with $ but it violates the OData URL convention, so it must be enclosed in parenthesis and single quotes - const requestUrl = userIdOrPrincipalName!.startsWith('%24') - ? `${this.resource}/v1.0/users('${userIdOrPrincipalName}')/revokeSignInSessions` - : `${this.resource}/v1.0/users/${userIdOrPrincipalName}/revokeSignInSessions`; + const requestUrl = userIdentifier!.startsWith('$') + ? `${this.resource}/v1.0/users('${userIdentifier}')/revokeSignInSessions` + : `${this.resource}/v1.0/users/${userIdentifier}/revokeSignInSessions`; const requestOptions: CliRequestOptions = { url: requestUrl, headers: { accept: 'application/json;odata.metadata=none' - } + }, + responseType: 'json', + data: {} }; - await request.post(requestOptions); + const result = await request.post(requestOptions); + + await logger.log(result); } catch (err: any) { this.handleRejectedODataJsonPromise(err); @@ -87,7 +78,7 @@ class EntraUserSessionRevokeCommand extends GraphCommand { await revokeUserSessions(); } else { - const result = await cli.promptForConfirmation({ message: `Are you sure you want to invalidate all the refresh tokens issued to applications for a user '${args.options.userId || args.options.userName}'?` }); + const result = await cli.promptForConfirmation({ message: `This will revoke all sessions for the user '${args.options.userId || args.options.userName}', requiring the user to re-sign in from all devices. Are you sure?` }); if (result) { await revokeUserSessions(); From 3f223e26a2c0aab6abb1d017fcf56b81f4de94d9 Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Sun, 5 Jan 2025 19:16:38 +0100 Subject: [PATCH 4/6] New command: Revoke Sign-in Sessions --- .../commands/user/user-session-revoke.spec.ts | 20 ++----------------- .../commands/user/user-session-revoke.ts | 7 +------ 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/src/m365/entra/commands/user/user-session-revoke.spec.ts b/src/m365/entra/commands/user/user-session-revoke.spec.ts index fe8d663f72b..64b8f28f306 100644 --- a/src/m365/entra/commands/user/user-session-revoke.spec.ts +++ b/src/m365/entra/commands/user/user-session-revoke.spec.ts @@ -17,7 +17,6 @@ import { cli } from '../../../../cli/cli.js'; describe(commands.USER_SESSION_REVOKE, () => { const userId = 'abcd1234-de71-4623-b4af-96380a352509'; const userName = 'john.doe@contoso.com'; - const userNameWithDollar = "$john.doe@contoso.com"; let log: string[]; let logger: Logger; @@ -119,7 +118,7 @@ describe(commands.USER_SESSION_REVOKE, () => { it('revokes all sign-in sessions for a user specified by userId without prompting for confirmation', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/revokeSignInSessions`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/revokeSignInSessions`) { return { value: true }; @@ -134,7 +133,7 @@ describe(commands.USER_SESSION_REVOKE, () => { it('revokes all sign-in sessions for a user specified by UPN while prompting for confirmation', async () => { const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${userName}/revokeSignInSessions`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userName}')/revokeSignInSessions`) { return { value: true }; @@ -150,21 +149,6 @@ describe(commands.USER_SESSION_REVOKE, () => { assert(postRequestStub.calledOnce); }); - it('revokes all sign-in sessions for a user specified by UPN which starts with $ without prompting for confirmation', async () => { - const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users('${userNameWithDollar}')/revokeSignInSessions`) { - return { - value: true - }; - } - - throw 'Invalid request'; - }); - - await command.action(logger, { options: { userName: userNameWithDollar, force: true, verbose: true } }); - assert(postRequestStub.calledOnce); - }); - it('handles error when user specified by userId was not found', async () => { sinon.stub(request, 'post').rejects({ error: diff --git a/src/m365/entra/commands/user/user-session-revoke.ts b/src/m365/entra/commands/user/user-session-revoke.ts index 1add61372c4..75b9751dadb 100644 --- a/src/m365/entra/commands/user/user-session-revoke.ts +++ b/src/m365/entra/commands/user/user-session-revoke.ts @@ -51,13 +51,8 @@ class EntraUserSessionRevokeCommand extends GraphCommand { await logger.logToStderr(`Invalidating all the refresh tokens for user ${userIdentifier}...`); } - // user principal name can start with $ but it violates the OData URL convention, so it must be enclosed in parenthesis and single quotes - const requestUrl = userIdentifier!.startsWith('$') - ? `${this.resource}/v1.0/users('${userIdentifier}')/revokeSignInSessions` - : `${this.resource}/v1.0/users/${userIdentifier}/revokeSignInSessions`; - const requestOptions: CliRequestOptions = { - url: requestUrl, + url: `${this.resource}/v1.0/users('${userIdentifier}')/revokeSignInSessions`, headers: { accept: 'application/json;odata.metadata=none' }, From f1553f67fe55c1d7f3e970cab663cc53aab10b51 Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Tue, 7 Jan 2025 09:08:46 +0100 Subject: [PATCH 5/6] New command: Revoke Sign-in Sessions --- .../commands/user/user-session-revoke.spec.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/m365/entra/commands/user/user-session-revoke.spec.ts b/src/m365/entra/commands/user/user-session-revoke.spec.ts index 64b8f28f306..6008521fc3f 100644 --- a/src/m365/entra/commands/user/user-session-revoke.spec.ts +++ b/src/m365/entra/commands/user/user-session-revoke.spec.ts @@ -104,7 +104,8 @@ describe(commands.USER_SESSION_REVOKE, () => { }); it('prompts before revoking all sign-in sessions when confirm option not passed', async () => { - await command.action(logger, { options: { userId: userId } }); + const parsedSchema = commandOptionsSchema.safeParse({ userId: userId }); + await command.action(logger, { options: parsedSchema.data }); assert(promptIssued); }); @@ -112,7 +113,8 @@ describe(commands.USER_SESSION_REVOKE, () => { it('aborts revoking all sign-in sessions when prompt not confirmed', async () => { const postStub = sinon.stub(request, 'post').resolves(); - await command.action(logger, { options: { userId: userId } }); + const parsedSchema = commandOptionsSchema.safeParse({ userId: userId }); + await command.action(logger, { options: parsedSchema.data }); assert(postStub.notCalled); }); @@ -127,7 +129,8 @@ describe(commands.USER_SESSION_REVOKE, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userId: userId, force: true, verbose: true } }); + const parsedSchema = commandOptionsSchema.safeParse({ userId: userId, force: true, verbose: true }); + await command.action(logger, { options: parsedSchema.data }); assert(loggerLogSpy.calledOnceWith({ value: true })); }); @@ -145,7 +148,8 @@ describe(commands.USER_SESSION_REVOKE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: { userName: userName } }); + const parsedSchema = commandOptionsSchema.safeParse({ userName: userName }); + await command.action(logger, { options: parsedSchema.data }); assert(postRequestStub.calledOnce); }); @@ -161,8 +165,9 @@ describe(commands.USER_SESSION_REVOKE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); + const parsedSchema = commandOptionsSchema.safeParse({ userId: userId }); await assert.rejects( - command.action(logger, { options: { userId: userId } }), + command.action(logger, { options: parsedSchema.data }), new CommandError(`Resource '${userId}' does not exist or one of its queried reference-property objects are not present.`) ); }); From 6dfbf6ce196af8c3f674c534d8a5747004a0100c Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Mon, 13 Jan 2025 07:21:39 +0100 Subject: [PATCH 6/6] New command: Revoke Sign-in Sessions --- .../cmd/entra/user/user-session-revoke.mdx | 44 +------------------ .../commands/user/user-session-revoke.spec.ts | 11 +++-- .../commands/user/user-session-revoke.ts | 9 ++-- 3 files changed, 11 insertions(+), 53 deletions(-) diff --git a/docs/docs/cmd/entra/user/user-session-revoke.mdx b/docs/docs/cmd/entra/user/user-session-revoke.mdx index a8f978a906f..8ec9d996489 100644 --- a/docs/docs/cmd/entra/user/user-session-revoke.mdx +++ b/docs/docs/cmd/entra/user/user-session-revoke.mdx @@ -1,6 +1,4 @@ import Global from '/docs/cmd/_global.mdx'; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; # entra user session revoke @@ -30,7 +28,7 @@ m365 entra user session revoke [options] :::info -The user with at least User Administrator role can revoke sign-in sessions of other users. +To use this command you must be either **User Administrator** or **Global Administrator**. ::: @@ -64,42 +62,4 @@ m365 entra user session revoke --userName john.doe@contoso.onmicrosoft.com --for ## Response - - - - ```json - { - "value": true - } - ``` - - - - - ```text - value: true - ``` - - - - - ```csv - value - 1 - ``` - - - - - ```md - # entra user session revoke --userName "john.doe@contoso.com" --force "true" - - Date: 1/5/2025 - - Property | Value - ---------|------- - value | true - ``` - - - +The command won't return a response on success. diff --git a/src/m365/entra/commands/user/user-session-revoke.spec.ts b/src/m365/entra/commands/user/user-session-revoke.spec.ts index 6008521fc3f..73e591d9682 100644 --- a/src/m365/entra/commands/user/user-session-revoke.spec.ts +++ b/src/m365/entra/commands/user/user-session-revoke.spec.ts @@ -13,6 +13,7 @@ import { CommandError } from '../../../../Command.js'; import { z } from 'zod'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { cli } from '../../../../cli/cli.js'; +import { formatting } from '../../../../utils/formatting.js'; describe(commands.USER_SESSION_REVOKE, () => { const userId = 'abcd1234-de71-4623-b4af-96380a352509'; @@ -20,7 +21,6 @@ describe(commands.USER_SESSION_REVOKE, () => { let log: string[]; let logger: Logger; - let loggerLogSpy: sinon.SinonSpy; let promptIssued: boolean; let commandInfo: CommandInfo; let commandOptionsSchema: z.ZodTypeAny; @@ -53,7 +53,6 @@ describe(commands.USER_SESSION_REVOKE, () => { return false; }); promptIssued = false; - loggerLogSpy = sinon.spy(logger, 'log'); }); afterEach(() => { @@ -119,8 +118,8 @@ describe(commands.USER_SESSION_REVOKE, () => { }); it('revokes all sign-in sessions for a user specified by userId without prompting for confirmation', async () => { - sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/revokeSignInSessions`) { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${formatting.encodeQueryParameter(userId)}')/revokeSignInSessions`) { return { value: true }; @@ -131,12 +130,12 @@ describe(commands.USER_SESSION_REVOKE, () => { const parsedSchema = commandOptionsSchema.safeParse({ userId: userId, force: true, verbose: true }); await command.action(logger, { options: parsedSchema.data }); - assert(loggerLogSpy.calledOnceWith({ value: true })); + assert(postStub.calledOnce); }); it('revokes all sign-in sessions for a user specified by UPN while prompting for confirmation', async () => { const postRequestStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users('${userName}')/revokeSignInSessions`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${formatting.encodeQueryParameter(userName)}')/revokeSignInSessions`) { return { value: true }; diff --git a/src/m365/entra/commands/user/user-session-revoke.ts b/src/m365/entra/commands/user/user-session-revoke.ts index 75b9751dadb..a5f4bdd5cca 100644 --- a/src/m365/entra/commands/user/user-session-revoke.ts +++ b/src/m365/entra/commands/user/user-session-revoke.ts @@ -7,6 +7,7 @@ import { validation } from '../../../../utils/validation.js'; import { Logger } from '../../../../cli/Logger.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { cli } from '../../../../cli/cli.js'; +import { formatting } from '../../../../utils/formatting.js'; const options = globalOptionsZod .extend({ @@ -39,7 +40,7 @@ class EntraUserSessionRevokeCommand extends GraphCommand { public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { return schema .refine(options => [options.userId, options.userName].filter(o => o !== undefined).length === 1, { - message: 'Specify either userId or userName' + message: `Specify either 'userId' or 'userName'.` }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -52,7 +53,7 @@ class EntraUserSessionRevokeCommand extends GraphCommand { } const requestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/users('${userIdentifier}')/revokeSignInSessions`, + url: `${this.resource}/v1.0/users('${formatting.encodeQueryParameter(userIdentifier!)}')/revokeSignInSessions`, headers: { accept: 'application/json;odata.metadata=none' }, @@ -60,9 +61,7 @@ class EntraUserSessionRevokeCommand extends GraphCommand { data: {} }; - const result = await request.post(requestOptions); - - await logger.log(result); + await request.post(requestOptions); } catch (err: any) { this.handleRejectedODataJsonPromise(err);