From a4821882b2343d8e282daf2cfd4911db2f5ae9ab Mon Sep 17 00:00:00 2001 From: mkm17 Date: Tue, 12 Mar 2024 22:12:26 +0100 Subject: [PATCH] Adds allowPublicClientFlow option to 'entra app add/set' commands. Closes #5870 --- docs/docs/cmd/entra/app/app-add.mdx | 9 ++ docs/docs/cmd/entra/app/app-set.mdx | 9 ++ src/m365/entra/commands/app/app-add.spec.ts | 91 +++++++++++++++++++++ src/m365/entra/commands/app/app-add.ts | 11 ++- src/m365/entra/commands/app/app-set.spec.ts | 48 +++++++++++ src/m365/entra/commands/app/app-set.ts | 42 +++++++++- 6 files changed, 207 insertions(+), 3 deletions(-) diff --git a/docs/docs/cmd/entra/app/app-add.mdx b/docs/docs/cmd/entra/app/app-add.mdx index 9c15d294c09..f204a6094c8 100644 --- a/docs/docs/cmd/entra/app/app-add.mdx +++ b/docs/docs/cmd/entra/app/app-add.mdx @@ -78,6 +78,9 @@ m365 entra appregistration add [options] `--save` : Use to store the information about the created app in a local file. + +`--allowPublicClientFlows` +: Enable the allow public client flows feature on the app registration. ``` @@ -192,6 +195,12 @@ Create new Entra app registration with a certificate m365 entra app add --name 'My Entra app' --certificateDisplayName "Some certificate name" --certificateFile "c:\temp\some-certificate.cer" ``` +Create a new Entra app registration with the allow public client flows feature enabled. + +```sh +m365 entra app add --name 'My Entra app' --allowPublicClientFlows +``` + ## Response ### Standard response diff --git a/docs/docs/cmd/entra/app/app-set.mdx b/docs/docs/cmd/entra/app/app-set.mdx index c10b091c807..58fac2e47c7 100644 --- a/docs/docs/cmd/entra/app/app-set.mdx +++ b/docs/docs/cmd/entra/app/app-set.mdx @@ -49,6 +49,9 @@ m365 entra appregistration set [options] `--certificateDisplayName [certificateDisplayName]` : Display name for the certificate. If not given, the displayName will be set to the certificate subject. When specified, also specify either `certificateFile` or `certificateBase64Encoded`. + +`--allowPublicClientFlows [allowPublicClientFlows]` +: Set to `true` or `false` to toggle the allow public client flows feature on the app registration. ``` @@ -99,6 +102,12 @@ Add a certificate to the app m365 entra app set --appId e75be2e1-0204-4f95-857d-51a37cf40be8 --certificateDisplayName "Some certificate name" --certificateFile "c:\temp\some-certificate.cer" ``` +Enable the allow public client flows feature on the app registration + +```sh +m365 entra app set --appId e75be2e1-0204-4f95-857d-51a37cf40be8 --allowPublicClientFlows true +``` + ## Response The command won't return a response on success. diff --git a/src/m365/entra/commands/app/app-add.spec.ts b/src/m365/entra/commands/app/app-add.spec.ts index 8122e4abe0a..9b26f9275e3 100644 --- a/src/m365/entra/commands/app/app-add.spec.ts +++ b/src/m365/entra/commands/app/app-add.spec.ts @@ -7793,4 +7793,95 @@ describe(commands.APP_ADD, () => { tenantId: '' })); }); + + it('creates Entra app reg with defined name and allowPublicClientFlows option enabled', async () => { + sinon.stub(request, 'get').rejects('Issues GET request'); + sinon.stub(request, 'patch').rejects('Issued PATCH request'); + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/myorganization/applications' && + JSON.stringify(opts.data) === JSON.stringify({ + "displayName": "My AAD app", + "signInAudience": "AzureADMyOrg", + "isFallbackPublicClient": true + })) { + return { + "id": "5b31c38c-2584-42f0-aa47-657fb3a84230", + "deletedDateTime": null, + "appId": "bc724b77-da87-43a9-b385-6ebaaf969db8", + "applicationTemplateId": null, + "createdDateTime": "2020-12-31T14:44:13.7945807Z", + "displayName": "My AAD app", + "description": null, + "groupMembershipClaims": null, + "identifierUris": [], + "isDeviceOnlyAuthSupported": null, + "isFallbackPublicClient": true, + "notes": null, + "optionalClaims": null, + "publisherDomain": "contoso.onmicrosoft.com", + "signInAudience": "AzureADMyOrg", + "tags": [], + "tokenEncryptionKeyId": null, + "verifiedPublisher": { + "displayName": null, + "verifiedPublisherId": null, + "addedDateTime": null + }, + "spa": { + "redirectUris": [] + }, + "defaultRedirectUri": null, + "addIns": [], + "api": { + "acceptMappedClaims": null, + "knownClientApplications": [], + "requestedAccessTokenVersion": null, + "oauth2PermissionScopes": [], + "preAuthorizedApplications": [] + }, + "appRoles": [], + "info": { + "logoUrl": null, + "marketingUrl": null, + "privacyStatementUrl": null, + "supportUrl": null, + "termsOfServiceUrl": null + }, + "keyCredentials": [], + "parentalControlSettings": { + "countriesBlockedForMinors": [], + "legalAgeGroupRule": "Allow" + }, + "passwordCredentials": [], + "publicClient": { + "redirectUris": [] + }, + "requiredResourceAccess": [], + "web": { + "homePageUrl": null, + "logoutUrl": null, + "redirectUris": [], + "implicitGrantSettings": { + "enableAccessTokenIssuance": false, + "enableIdTokenIssuance": false + } + } + }; + } + + throw `Invalid POST request: ${JSON.stringify(opts, null, 2)}`; + }); + + await command.action(logger, { + options: { + name: 'My AAD app', + allowPublicClientFlows: true + } + }); + assert(loggerLogSpy.calledWith({ + appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', + objectId: '5b31c38c-2584-42f0-aa47-657fb3a84230', + tenantId: '' + })); + }); }); diff --git a/src/m365/entra/commands/app/app-add.ts b/src/m365/entra/commands/app/app-add.ts index e29f49aeee8..2a51964bceb 100644 --- a/src/m365/entra/commands/app/app-add.ts +++ b/src/m365/entra/commands/app/app-add.ts @@ -65,6 +65,7 @@ interface Options extends GlobalOptions { certificateFile?: string; certificateBase64Encoded?: string; certificateDisplayName?: string; + allowPublicClientFlows?: boolean; } interface AppPermissions { @@ -118,7 +119,8 @@ class EntraAppAddCommand extends GraphCommand { certificateFile: typeof args.options.certificateFile !== 'undefined', certificateBase64Encoded: typeof args.options.certificateBase64Encoded !== 'undefined', certificateDisplayName: typeof args.options.certificateDisplayName !== 'undefined', - grantAdminConsent: typeof args.options.grantAdminConsent !== 'undefined' + grantAdminConsent: typeof args.options.grantAdminConsent !== 'undefined', + allowPublicClientFlows: typeof args.options.allowPublicClientFlows !== 'undefined' }); }); } @@ -183,6 +185,9 @@ class EntraAppAddCommand extends GraphCommand { }, { option: '--grantAdminConsent' + }, + { + option: '--allowPublicClientFlows' } ); } @@ -330,6 +335,10 @@ class EntraAppAddCommand extends GraphCommand { applicationInfo.keyCredentials = [newKeyCredential]; } + if (args.options.allowPublicClientFlows) { + applicationInfo.isFallbackPublicClient = true; + } + if (this.verbose) { await logger.logToStderr(`Creating Microsoft Entra app registration...`); } diff --git a/src/m365/entra/commands/app/app-set.spec.ts b/src/m365/entra/commands/app/app-set.spec.ts index 8cbbd2527f6..88612d755f9 100644 --- a/src/m365/entra/commands/app/app-set.spec.ts +++ b/src/m365/entra/commands/app/app-set.spec.ts @@ -964,6 +964,38 @@ describe(commands.APP_SET, () => { }); }); + it('updates allowPublicClientFlows value for the specified appId', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/myorganization/applications?$filter=appId eq 'bc724b77-da87-43a9-b385-6ebaaf969db8'&$select=id`) { + return { + value: [{ + id: '5b31c38c-2584-42f0-aa47-657fb3a84230' + }] + }; + } + + throw `Invalid request ${JSON.stringify(opts)}`; + }); + sinon.stub(request, 'patch').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/myorganization/applications/5b31c38c-2584-42f0-aa47-657fb3a84230' && + opts.data && + opts.data.isFallbackPublicClient === true) { + return; + } + + throw `Invalid request ${JSON.stringify(opts)}`; + }); + + await command.action(logger, { + options: { + debug: true, + appId: 'bc724b77-da87-43a9-b385-6ebaaf969db8', + allowPublicClientFlows: true + } + }); + }); + + it('handles error when certificate file cannot be read', async () => { sinon.stub(request, 'get').callsFake(async opts => { if (opts.url === `https://graph.microsoft.com/v1.0/myorganization/applications/95cfe30d-ed44-4f9d-b73d-c66560f72e83`) { @@ -1339,4 +1371,20 @@ describe(commands.APP_SET, () => { const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', redirectUris: 'https://foo.com', platform: 'web' } }, commandInfo); assert.strictEqual(actual, true); }); + + it('passes validation when allowPublicClientFlows is specified as true', async () => { + const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowPublicClientFlows: true } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when allowPublicClientFlows is specified as false', async () => { + const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowPublicClientFlows: false } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when allowPublicClientFlows is not correct boolean value', async () => { + const actual = await command.validate({ options: { appId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', allowPublicClientFlows: 'foo' } }, commandInfo); + assert.strictEqual(actual, true); + }); + }); diff --git a/src/m365/entra/commands/app/app-set.ts b/src/m365/entra/commands/app/app-set.ts index ab173309a0f..070f6b9302d 100644 --- a/src/m365/entra/commands/app/app-set.ts +++ b/src/m365/entra/commands/app/app-set.ts @@ -24,6 +24,7 @@ interface Options extends GlobalOptions { certificateFile?: string; certificateBase64Encoded?: string; certificateDisplayName?: string; + allowPublicClientFlows?: boolean; } class EntraAppSetCommand extends GraphCommand { @@ -48,6 +49,7 @@ class EntraAppSetCommand extends GraphCommand { this.#initOptions(); this.#initValidators(); this.#initOptionSets(); + this.#initTypes(); } #initTelemetry(): void { @@ -62,7 +64,8 @@ class EntraAppSetCommand extends GraphCommand { uris: typeof args.options.uris !== 'undefined', certificateFile: typeof args.options.certificateFile !== 'undefined', certificateBase64Encoded: typeof args.options.certificateBase64Encoded !== 'undefined', - certificateDisplayName: typeof args.options.certificateDisplayName !== 'undefined' + certificateDisplayName: typeof args.options.certificateDisplayName !== 'undefined', + allowPublicClientFlows: typeof args.options.allowPublicClientFlows !== 'undefined' }); }); } @@ -81,7 +84,11 @@ class EntraAppSetCommand extends GraphCommand { option: '--platform [platform]', autocomplete: EntraAppSetCommand.aadApplicationPlatform }, - { option: '--redirectUrisToRemove [redirectUrisToRemove]' } + { option: '--redirectUrisToRemove [redirectUrisToRemove]' }, + { + option: '--allowPublicClientFlows [allowPublicClientFlows]', + autocomplete: ['true', 'false'] + } ); } @@ -118,6 +125,10 @@ class EntraAppSetCommand extends GraphCommand { this.optionSets.push({ options: ['appId', 'objectId', 'name'] }); } + #initTypes(): void { + this.types.boolean.push('allowPublicClientFlows'); + } + public async commandAction(logger: Logger, args: CommandArgs): Promise { await this.showDeprecationWarning(logger, aadCommands.APP_SET, commands.APP_SET); @@ -125,6 +136,7 @@ class EntraAppSetCommand extends GraphCommand { let objectId = await this.getAppObjectId(args, logger); objectId = await this.configureUri(args, objectId, logger); objectId = await this.configureRedirectUris(args, objectId, logger); + objectId = await this.updateAllowPublicClientFlows(args, objectId, logger); await this.configureCertificate(args, objectId, logger); } catch (err: any) { @@ -171,6 +183,32 @@ class EntraAppSetCommand extends GraphCommand { return result.id; } + private async updateAllowPublicClientFlows(args: CommandArgs, objectId: string, logger: Logger): Promise { + if (args.options.allowPublicClientFlows === undefined) { + return objectId; + } + + if (this.verbose) { + await logger.logToStderr(`Configuring Entra application AllowPublicClientFlows option...`); + } + + const applicationInfo: any = { + isFallbackPublicClient: args.options.allowPublicClientFlows + }; + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/myorganization/applications/${objectId}`, + headers: { + 'content-type': 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: applicationInfo + }; + + await request.patch(requestOptions); + return objectId; + } + private async configureUri(args: CommandArgs, objectId: string, logger: Logger): Promise { if (!args.options.uris) { return objectId;