Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New command: Revoke Sign-in Sessions. Closes #6514 #6544

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,17 @@ const dictionary = [
'property',
'records',
'recycle',
'registration',
'request',
'resolver',
'registration',
'retention',
'revoke',
'role',
'room',
'schema',
'sensitivity',
'service',
'session',
'set',
'setting',
'settings',
Expand Down
57 changes: 57 additions & 0 deletions docs/docs/cmd/entra/user/user-session-revoke.mdx
Original file line number Diff line number Diff line change
@@ -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, --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.
```

<Global />

## Remarks
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved

:::info

Only the user with Global Administrator role can revoke sign-in sessions of other users.
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved

:::

## 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 [email protected]
```

Revoke sign-in sessions of a user specified by its UPN without prompting for confirmation.
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved

```sh
m365 entra user session revoke --userName [email protected] --force
```

## Response

The command won't return a response on success
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved
5 changes: 5 additions & 0 deletions docs/src/config/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/m365/entra/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
};
184 changes: 184 additions & 0 deletions src/m365/entra/commands/user/user-session-revoke.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = '[email protected]';
const userNameWithDollar = "[email protected]";

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);
});
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved

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 () => {
await command.action(logger, { options: { userId: userId } });

assert(promptIssued);
});

it('aborts revoking all sign-in sessions when prompt not confirmed', async () => {
const deleteSpy = sinon.stub(request, 'delete').resolves();
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved

await command.action(logger, { options: { userId: userId } });
assert(deleteSpy.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) => {
if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/revokeSignInSessions`) {
return;
}

throw 'Invalid request';
});

await command.action(logger, { options: { userId: userId, force: true, verbose: true } });
assert(postRequestStub.called);
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved
});

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 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.`
}
};
}
throw `Invalid request`;
});
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved

sinonUtil.restore(cli.promptForConfirmation);
sinon.stub(cli, 'promptForConfirmation').resolves(true);

await assert.rejects(
command.action(logger, { options: { userId: userId } }),
new CommandError(`Resource '${userId}' does not exist or one of its queried reference-property objects are not present.`)
);
});
});
99 changes: 99 additions & 0 deletions src/m365/entra/commands/user/user-session-revoke.ts
Original file line number Diff line number Diff line change
@@ -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({
userId: 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<typeof options>;

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';
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved
}
public get schema(): z.ZodTypeAny | undefined {
return options;
}
public getRefinedSchema(schema: typeof options): z.ZodEffects<any> | undefined {
return schema
.refine(options => !options.userId !== !options.userName, {
message: 'Specify either userId or userName, but not both'
})
.refine(options => options.userId || options.userName, {
message: 'Specify either userId or userName'
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved
})
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved
.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']
}));
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved
}
public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
const revokeUserSessions = async (): Promise<void> => {
try {
let userIdOrPrincipalName = args.options.userId;

if (args.options.userName) {
// single user can be retrieved also by user principal name
userIdOrPrincipalName = formatting.encodeQueryParameter(args.options.userName);
}
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved

if (args.options.verbose) {
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved
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`;
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved

const requestOptions: CliRequestOptions = {
url: requestUrl,
headers: {
accept: 'application/json;odata.metadata=none'
}
};

await request.post(requestOptions);
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved
}
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.userId || args.options.userName}'?` });
MartinM85 marked this conversation as resolved.
Show resolved Hide resolved

if (result) {
await revokeUserSessions();
}
}
}
}

export default new EntraUserSessionRevokeCommand();
Loading