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..8ec9d996489 --- /dev/null +++ b/docs/docs/cmd/entra/user/user-session-revoke.mdx @@ -0,0 +1,65 @@ +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, --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 `userId` or `userName`, but not both. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Remarks + +:::info + +To use this command you must be either **User Administrator** or **Global Administrator**. + +::: + +:::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. + +::: + +## Examples + +Revoke sign-in sessions of a user specified by id + +```sh +m365 entra user session revoke --userId 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..73e591d9682 --- /dev/null +++ b/src/m365/entra/commands/user/user-session-revoke.spec.ts @@ -0,0 +1,173 @@ +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'; + + 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(async () => { + promptIssued = true; + return 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 userId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + userId: '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 userId and userName are provided', () => { + const actual = commandOptionsSchema.safeParse({ + userId: userId, + userName: userName + }); + assert.notStrictEqual(actual.success, true); + }); + + 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 () => { + const parsedSchema = commandOptionsSchema.safeParse({ userId: userId }); + await command.action(logger, { options: parsedSchema.data }); + + assert(promptIssued); + }); + + it('aborts revoking all sign-in sessions when prompt not confirmed', async () => { + const postStub = sinon.stub(request, 'post').resolves(); + + const parsedSchema = commandOptionsSchema.safeParse({ userId: userId }); + await command.action(logger, { options: parsedSchema.data }); + assert(postStub.notCalled); + }); + + it('revokes all sign-in sessions for a user specified by userId without prompting for confirmation', async () => { + 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 + }; + } + + throw 'Invalid request'; + }); + + const parsedSchema = commandOptionsSchema.safeParse({ userId: userId, force: true, verbose: true }); + await command.action(logger, { options: parsedSchema.data }); + 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('${formatting.encodeQueryParameter(userName)}')/revokeSignInSessions`) { + return { + value: true + }; + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + const parsedSchema = commandOptionsSchema.safeParse({ userName: userName }); + await command.action(logger, { options: parsedSchema.data }); + assert(postRequestStub.calledOnce); + }); + + it('handles error when user specified by userId was not found', async () => { + 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.` + } + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + const parsedSchema = commandOptionsSchema.safeParse({ userId: userId }); + await assert.rejects( + command.action(logger, { options: parsedSchema.data }), + 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..a5f4bdd5cca --- /dev/null +++ b/src/m365/entra/commands/user/user-session-revoke.ts @@ -0,0 +1,84 @@ +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 request, { CliRequestOptions } from '../../../../request.js'; +import { cli } from '../../../../cli/cli.js'; +import { formatting } from '../../../../utils/formatting.js'; + +const options = globalOptionsZod + .extend({ + 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(); + +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 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].filter(o => o !== undefined).length === 1, { + message: `Specify either 'userId' or 'userName'.` + }); + } + public async commandAction(logger: Logger, args: CommandArgs): Promise { + const revokeUserSessions = async (): Promise => { + try { + const userIdentifier = args.options.userId ?? args.options.userName; + + if (this.verbose) { + await logger.logToStderr(`Invalidating all the refresh tokens for user ${userIdentifier}...`); + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/users('${formatting.encodeQueryParameter(userIdentifier!)}')/revokeSignInSessions`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: {} + }; + + await request.post(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + if (args.options.force) { + await revokeUserSessions(); + } + else { + 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(); + } + } + } +} + +export default new EntraUserSessionRevokeCommand(); \ No newline at end of file