-
Notifications
You must be signed in to change notification settings - Fork 331
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
New command: Revoke Sign-in Sessions
- Loading branch information
Showing
6 changed files
with
349 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
184 changes: 184 additions & 0 deletions
184
src/m365/entra/commands/user/user-session-revoke.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.`) | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |