diff --git a/src/DotnetTool/DeveloperCredentials/MsalTokenCredential.cs b/src/DotnetTool/DeveloperCredentials/MsalTokenCredential.cs index 7eefd0f..06916f4 100644 --- a/src/DotnetTool/DeveloperCredentials/MsalTokenCredential.cs +++ b/src/DotnetTool/DeveloperCredentials/MsalTokenCredential.cs @@ -93,6 +93,7 @@ public override async ValueTask GetTokenAsync(TokenRequestContext r } catch (MsalUiRequiredException ex) { + Console.WriteLine("Please re-sign-in in Visual Studio"); result = await app.AcquireTokenInteractive(requestContext.Scopes) .WithLoginHint(Username) .WithClaims(ex.Claims) diff --git a/src/DotnetTool/MicrosoftIdentityPlatformApplication/MicrosoftIdentityPlatformApplicationManager.cs b/src/DotnetTool/MicrosoftIdentityPlatformApplication/MicrosoftIdentityPlatformApplicationManager.cs index 4e2c2a9..bc0e279 100644 --- a/src/DotnetTool/MicrosoftIdentityPlatformApplication/MicrosoftIdentityPlatformApplicationManager.cs +++ b/src/DotnetTool/MicrosoftIdentityPlatformApplication/MicrosoftIdentityPlatformApplicationManager.cs @@ -127,6 +127,38 @@ await AddPasswordCredentials( return effectiveApplicationParameters; } + internal async Task UpdateApplication(TokenCredential tokenCredential, ApplicationParameters reconcialedApplicationParameters) + { + var graphServiceClient = GetGraphServiceClient(tokenCredential); + + var existingApplication = (await graphServiceClient.Applications + .Request() + .Filter($"appId eq '{reconcialedApplicationParameters.ClientId}'") + .GetAsync()).First(); + + // Updates the redirect URIs + var updatedApp = new Application + { + Web = existingApplication.Web + }; + updatedApp.Web.RedirectUris = reconcialedApplicationParameters.WebRedirectUris; + + // TODO: update other fields. + // See https://github.com/jmprieur/app-provisonning-tool/issues/10 + await graphServiceClient.Applications[existingApplication.Id] + .Request() + .UpdateAsync(updatedApp); + + if (existingApplication.RequiredResourceAccess == null + || existingApplication.RequiredResourceAccess.Any() + || existingApplication.PasswordCredentials == null + || !existingApplication.PasswordCredentials.Any() + || !existingApplication.PasswordCredentials.Any(password => string.IsNullOrEmpty(password.SecretText))) + { + await AddPasswordCredentials(graphServiceClient, existingApplication, reconcialedApplicationParameters); + } + } + private async Task AddApiPermissionFromBlazorwasmHostedSpaToServerApi( GraphServiceClient graphServiceClient, Application createdApplication, @@ -253,6 +285,8 @@ private static async Task AddAdminConsentToApiPermissions( Scope = string.Join(" ", resourceAndScopes.Select(r => r.Scope)) }; + // TODO: See https://github.com/jmprieur/app-provisonning-tool/issues/9. + // We need to process the case where the developer is not a tenant admin await graphServiceClient.Oauth2PermissionGrants .Request() .AddAsync(oAuth2PermissionGrant); @@ -525,11 +559,11 @@ private ApplicationParameters GetEffectiveApplicationParameters( IsWebApp = application.Web != null, TenantId = tenant.Id, Domain = tenant.VerifiedDomains.FirstOrDefault(v => v.IsDefault.HasValue && v.IsDefault.Value)?.Name, - CallsMicrosoftGraph = application.RequiredResourceAccess.Any(r => r.ResourceAppId == MicrosoftGraphAppId), + CallsMicrosoftGraph = application.RequiredResourceAccess.Any(r => r.ResourceAppId == MicrosoftGraphAppId) && !isB2C, CallsDownstreamApi = application.RequiredResourceAccess.Any(r => r.ResourceAppId != MicrosoftGraphAppId), LogoutUrl = application.Web?.LogoutUrl, - // Parameters that cannot be infered from the app + // Parameters that cannot be infered from the registered app SusiPolicy = originalApplicationParameters.SusiPolicy, SecretsId = originalApplicationParameters.SecretsId, TargetFramework = originalApplicationParameters.TargetFramework, diff --git a/src/DotnetTool/Properties/launchSettings.json b/src/DotnetTool/Properties/launchSettings.json index 2c86ad8..00b26f8 100644 --- a/src/DotnetTool/Properties/launchSettings.json +++ b/src/DotnetTool/Properties/launchSettings.json @@ -22,6 +22,15 @@ "webapp-singleorg-callswebapi": { "commandName": "Project", "workingDirectory": "C:\\Users\\jmprieur\\source\\repos\\github\\jmprieur\\app-provisonning-tool\\src\\UnitTests\\bin\\Debug\\net5.0\\Tests\\webapp\\webapp-singleorg-callswebapi" + }, + "webapp-from-existing-app": { + "commandName": "Project", + "workingDirectory": "C:\\temp\\test-provisionning\\Tests\\webapp\\webapp-singleorg-callsgraph", + "commandLineArgs": "--client-id a727877b-0e1b-475c-afac-69bbca2779f6 --tenant-id testprovisionningtool.onmicrosoft.com" + }, + "update-webapp-from-existing-app": { + "commandName": "Project", + "workingDirectory": "C:\\temp\\test-provisionning\\Tests\\webapp\\webapp-singleorg-callsgraph" } } } \ No newline at end of file diff --git a/src/DotnetTool/Tool/AppProvisionningTool.cs b/src/DotnetTool/Tool/AppProvisionningTool.cs index 1d432b7..96e7a1c 100644 --- a/src/DotnetTool/Tool/AppProvisionningTool.cs +++ b/src/DotnetTool/Tool/AppProvisionningTool.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; namespace DotnetTool @@ -68,29 +69,27 @@ public async Task Run() tokenCredential, projectSettings.ApplicationParameters); - - - // Reconciliate code configuration and app registration - ApplicationParameters reconcialedApplicationParameters = Reconciliate( + bool appNeedsUpdate = Reconciliate( projectSettings.ApplicationParameters, effectiveApplicationParameters); - // Write code configuration and/or app registration + // Update appp registration if needed Summary summary = new Summary(); - WriteProjectConfiguration( - summary, - projectSettings, - reconcialedApplicationParameters); - - if (reconcialedApplicationParameters != effectiveApplicationParameters) + if (appNeedsUpdate) { - WriteApplicationRegistration( + await WriteApplicationRegistration( summary, - reconcialedApplicationParameters, + effectiveApplicationParameters, tokenCredential); } + // Write code configuration if needed + WriteProjectConfiguration( + summary, + projectSettings, + effectiveApplicationParameters); + // Summarizes what happened WriteSummary(summary); } @@ -104,9 +103,10 @@ private void WriteSummary(Summary summary) } } - private void WriteApplicationRegistration(Summary summary, ApplicationParameters reconcialedApplicationParameters, TokenCredential tokenCredential) + private async Task WriteApplicationRegistration(Summary summary, ApplicationParameters reconcialedApplicationParameters, TokenCredential tokenCredential) { summary.changes.Add(new Change($"Writing the project AppId = {reconcialedApplicationParameters.ClientId}")); + await MicrosoftIdentityPlatformApplicationManager.UpdateApplication(tokenCredential, reconcialedApplicationParameters); } private void WriteProjectConfiguration(Summary summary, ProjectAuthenticationSettings projectSettings, ApplicationParameters reconcialedApplicationParameters) @@ -115,10 +115,27 @@ private void WriteProjectConfiguration(Summary summary, ProjectAuthenticationSet codeWriter.WriteConfiguration(summary, projectSettings.Replacements, reconcialedApplicationParameters); } - private ApplicationParameters Reconciliate(ApplicationParameters applicationParameters, ApplicationParameters effectiveApplicationParameters) + private bool Reconciliate(ApplicationParameters applicationParameters, ApplicationParameters effectiveApplicationParameters) { - Console.WriteLine(nameof(Reconciliate)); - return effectiveApplicationParameters; + // Redirect Uris that are needed by the code, but not yet registered + IEnumerable missingRedirectUri = applicationParameters.WebRedirectUris.Except(effectiveApplicationParameters.WebRedirectUris); + + bool needUpdate = missingRedirectUri.Any(); + + if (needUpdate) + { + effectiveApplicationParameters.WebRedirectUris.AddRange(missingRedirectUri); + } + + // TODO: + // See also https://github.com/jmprieur/app-provisonning-tool/issues/10 + /* + string? audience = ComputeAudienceToSet(applicationParameters.SignInAudience, effectiveApplicationParameters.SignInAudience); + IEnumerable missingApiPermission = null; + IEnumerable missingExposedScopes = null; + bool needUpdate = missingRedirectUri != null || audience != null || missingApiPermission != null || missingExposedScopes != null; + */ + return needUpdate; } private async Task ReadOrProvisionMicrosoftIdentityApplication(TokenCredential tokenCredential, ApplicationParameters applicationParameters) @@ -150,6 +167,8 @@ private ProjectAuthenticationSettings InferApplicationParameters( CodeReader reader = new CodeReader(); ProjectAuthenticationSettings projectSettings = reader.ReadFromFiles(provisioningToolOptions.CodeFolder, projectDescription, projectDescriptions); projectSettings.ApplicationParameters.DisplayName ??= Path.GetFileName(provisioningToolOptions.CodeFolder); + projectSettings.ApplicationParameters.ClientId ??= provisioningToolOptions.ClientId; + projectSettings.ApplicationParameters.TenantId ??= provisioningToolOptions.TenantId; return projectSettings; } diff --git a/src/DotnetTool/msIdentityApp.csproj.user b/src/DotnetTool/msIdentityApp.csproj.user index e252fb7..92bc02f 100644 --- a/src/DotnetTool/msIdentityApp.csproj.user +++ b/src/DotnetTool/msIdentityApp.csproj.user @@ -2,7 +2,7 @@ false - webapp-singleorg-callswebapi + webapp-from-existing-app ProjectDebugger diff --git a/src/UnitTests/E2ETests.cs b/src/UnitTests/E2ETests.cs index b2ec2fc..2cf55b2 100644 --- a/src/UnitTests/E2ETests.cs +++ b/src/UnitTests/E2ETests.cs @@ -55,7 +55,7 @@ public E2ETests(ITestOutputHelper output) [InlineData("blazorwasm\\blazorwasm-b2c", "dotnet new blazorwasm --auth IndividualB2C")] [InlineData("blazorwasm2\\blazorwasm2-b2c-hosted", "dotnet new blazorwasm --auth IndividualB2C --hosted")] [Theory] - public async Task TestEndToEnd(string folder, string command) + public async Task TestNewAppEndToEnd(string folder, string command) { // Create the folder string executionFolder = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location); @@ -132,5 +132,94 @@ private void RunProcess(string command, string folderToCreate, string postFix="" testOutput.WriteLine(errors); Assert.Equal(string.Empty, errors); } + + //[InlineData("webapp\\webapp-noauth", "dotnet new webapp")] + //[InlineData("webapp\\webapp-singleorg", "dotnet new webapp --auth SingleOrg")] + [InlineData("webapp\\webapp-singleorg-callsgraph", "dotnet new webapp --auth SingleOrg --calls-graph")] + //[InlineData("webapp\\webapp-singleorg-callswebapi", "dotnet new webapp --auth SingleOrg --called-api-url \"https://graph.microsoft.com/beta/me\" --called-api-scopes \"user.read\"")] + //[InlineData("webapp\\webapp-b2c", "dotnet new webapp --auth IndividualB2C")] + //[InlineData("webapp\\webapp-b2c-callswebapi", "dotnet new webapp --auth IndividualB2C --called-api-url \"https://fabrikamb2chello.azurewebsites.net/hello\" --called-api-scopes \"https://fabrikamb2c.onmicrosoft.com/helloapi/demo.read\"")] + //[InlineData("webapi\\webapi-noauth", "dotnet new webapi")] + //[InlineData("webapi\\webapi-singleorg", "dotnet new webapi --auth SingleOrg")] + //[InlineData("webapi\\webapi-singleorg-callsgraph", "dotnet new webapi --auth SingleOrg --calls-graph")] + //[InlineData("webapi\\webapi-singleorg-callswebapi", "dotnet new webapi --auth SingleOrg --called-api-url \"https://graph.microsoft.com/beta/me\" --called-api-scopes \"user.read\"")] + //[InlineData("webapi\\webapi-b2c", "dotnet new webapi --auth IndividualB2C")] + //[InlineData("webapi\\webapi2-b2c-callswebapi", "dotnet new webapi --auth IndividualB2C --called-api-url \"https://fabrikamb2chello.azurewebsites.net/hello\" --called-api-scopes \"https://fabrikamb2c.onmicrosoft.com/helloapi/demo.read\"")] + //[InlineData("mvc\\mvc-noauth", "dotnet new mvc")] + //[InlineData("mvc\\mvc-singleorg", "dotnet new mvc --auth SingleOrg")] + //[InlineData("mvc\\mvc-singleorg-callsgraph", "dotnet new mvc --auth SingleOrg --calls-graph")] + //[InlineData("mvc\\mvc-singleorg-callswebapi", "dotnet new mvc --auth SingleOrg --called-api-url \"https://graph.microsoft.com/beta/me\" --called-api-scopes \"user.read\"")] + //[InlineData("mvc\\mvc-b2c", "dotnet new mvc --auth IndividualB2C")] + //[InlineData("mvc\\mvc-b2c-callswebapi", "dotnet new mvc --auth IndividualB2C --called-api-url \"https://fabrikamb2chello.azurewebsites.net/hello\" --called-api-scopes \"https://fabrikamb2c.onmicrosoft.com/helloapi/demo.read\"")] + //[InlineData("blazorserver\\blazorserver-noauth", "dotnet new blazorserver")] + //[InlineData("blazorserver\\blazorserver-singleorg", "dotnet new blazorserver --auth SingleOrg")] + //[InlineData("blazorserver\\blazorserver-singleorg-callsgraph", "dotnet new blazorserver --auth SingleOrg --calls-graph")] + //[InlineData("blazorserver\\blazorserver-singleorg-callswebapi", "dotnet new blazorserver --auth SingleOrg --called-api-url \"https://graph.microsoft.com/beta/me\" --called-api-scopes \"user.read\"")] + //[InlineData("blazorserver\\blazorserver-b2c", "dotnet new blazorserver --auth IndividualB2C")] + //[InlineData("blazorserver\\blazorserver-b2c-callswebapi", "dotnet new blazorserver --auth IndividualB2C --called-api-url \"https://fabrikamb2chello.azurewebsites.net/hello\" --called-api-scopes \"https://fabrikamb2c.onmicrosoft.com/helloapi/demo.read\"")] + //[InlineData("blazorwasm\\blazorwasm-noauth", "dotnet new blazorwasm")] + //[InlineData("blazorwasm\\blazorwasm-singleorg", "dotnet new blazorwasm --auth SingleOrg")] + //[InlineData("blazorwasm\\blazorwasm-singleorg-callsgraph", "dotnet new blazorwasm --auth SingleOrg --calls-graph")] + //[InlineData("blazorwasm\\blazorwasm-singleorg-callswebapi", "dotnet new blazorwasm --auth SingleOrg --called-api-url \"https://graph.microsoft.com/beta/me\" --called-api-scopes \"user.read\"")] + //[InlineData("blazorwasm\\blazorwasm-singleorg-hosted", "dotnet new blazorwasm --auth SingleOrg --hosted")] + //[InlineData("blazorwasm\\blazorwasm-singleorg-callsgraph-hosted", "dotnet new blazorwasm --auth SingleOrg --calls-graph --hosted")] + //[InlineData("blazorwasm\\blazorwasm-singleorg-callswebapi-hosted", "dotnet new blazorwasm --auth SingleOrg --called-api-url \"https://graph.microsoft.com/beta/me\" --called-api-scopes \"user.read\" --hosted")] + //[InlineData("blazorwasm\\blazorwasm-b2c", "dotnet new blazorwasm --auth IndividualB2C")] + //[InlineData("blazorwasm2\\blazorwasm2-b2c-hosted", "dotnet new blazorwasm --auth IndividualB2C --hosted")] + [Theory] + public async Task TestUpdateAppEndToEnd(string folder, string command) + { + // Create the folder + string executionFolder = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location); + executionFolder = "C:\\temp\\test-provisionning"; + string folderToCreate = Path.Combine(executionFolder, "Tests", folder); + + // dotnet new command to create the project + RunProcess(command, folderToCreate, " --force"); + + // Add a secret if needed + if (command.Contains("--calls")) + { + try + { + RunProcess("dotnet user-secrets init", folderToCreate); + } + catch + { + // Silent catch + } + } + + string currentDirectory = Directory.GetCurrentDirectory(); + + // Run the tool + try + { + Directory.SetCurrentDirectory(folderToCreate); + + List args = new List(); + args.Add("--tenant-id"); + if (folder.Contains("b2c")) + { + args.Add("fabrikamb2c.onmicrosoft.com"); + } + else + { + args.Add("testprovisionningtool.onmicrosoft.com"); + } + await Program.Main(args.ToArray()); + } + catch (Exception ex) + { + testOutput.WriteLine(ex.ToString()); + Assert.True(false); + } + finally + { + Directory.SetCurrentDirectory(currentDirectory); + } + + testOutput.WriteLine($"{folderToCreate}"); + } } }