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 all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
65 changes: 65 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,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.
```

<Global />

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

:::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.
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`
};
173 changes: 173 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,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.`)
);
});
});
84 changes: 84 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,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<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 all sign-in sessions for a given user';
}
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].filter(o => o !== undefined).length === 1, {
message: `Specify either 'userId' or 'userName'.`
});
}
public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
const revokeUserSessions = async (): Promise<void> => {
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();
Loading