Skip to content

Commit

Permalink
New command: Revoke Sign-in Sessions
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinM85 committed Jan 3, 2025
1 parent cd20f0c commit 08296cf
Show file tree
Hide file tree
Showing 6 changed files with 349 additions and 1 deletion.
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, --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.
```

<Global />

## 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
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 = '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.`)
);
});
});
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({
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<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';
}
public get schema(): z.ZodTypeAny | undefined {
return options;
}
public getRefinedSchema(schema: typeof options): z.ZodEffects<any> | 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<void> {
const revokeUserSessions = async (): Promise<void> => {
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();

0 comments on commit 08296cf

Please sign in to comment.