diff --git a/src/Security/Authentication/MicrosoftAccount/ref/Microsoft.AspNetCore.Authentication.MicrosoftAccount.netcoreapp3.0.cs b/src/Security/Authentication/MicrosoftAccount/ref/Microsoft.AspNetCore.Authentication.MicrosoftAccount.netcoreapp3.0.cs index fabc02cf4515..4337321df76e 100644 --- a/src/Security/Authentication/MicrosoftAccount/ref/Microsoft.AspNetCore.Authentication.MicrosoftAccount.netcoreapp3.0.cs +++ b/src/Security/Authentication/MicrosoftAccount/ref/Microsoft.AspNetCore.Authentication.MicrosoftAccount.netcoreapp3.0.cs @@ -14,6 +14,7 @@ public static partial class MicrosoftAccountDefaults public partial class MicrosoftAccountHandler : Microsoft.AspNetCore.Authentication.OAuth.OAuthHandler { public MicrosoftAccountHandler(Microsoft.Extensions.Options.IOptionsMonitor options, Microsoft.Extensions.Logging.ILoggerFactory logger, System.Text.Encodings.Web.UrlEncoder encoder, Microsoft.AspNetCore.Authentication.ISystemClock clock) : base (default(Microsoft.Extensions.Options.IOptionsMonitor), default(Microsoft.Extensions.Logging.ILoggerFactory), default(System.Text.Encodings.Web.UrlEncoder), default(Microsoft.AspNetCore.Authentication.ISystemClock)) { } + protected override string BuildChallengeUrl(Microsoft.AspNetCore.Authentication.AuthenticationProperties properties, string redirectUri) { throw null; } [System.Diagnostics.DebuggerStepThroughAttribute] protected override System.Threading.Tasks.Task CreateTicketAsync(System.Security.Claims.ClaimsIdentity identity, Microsoft.AspNetCore.Authentication.AuthenticationProperties properties, Microsoft.AspNetCore.Authentication.OAuth.OAuthTokenResponse tokens) { throw null; } } @@ -21,6 +22,20 @@ public partial class MicrosoftAccountOptions : Microsoft.AspNetCore.Authenticati { public MicrosoftAccountOptions() { } } + public partial class MicrosoftChallengeProperties : Microsoft.AspNetCore.Authentication.OAuth.OAuthChallengeProperties + { + public static readonly string DomainHintKey; + public static readonly string LoginHintKey; + public static readonly string PromptKey; + public static readonly string ResponseModeKey; + public MicrosoftChallengeProperties() { } + public MicrosoftChallengeProperties(System.Collections.Generic.IDictionary items) { } + public MicrosoftChallengeProperties(System.Collections.Generic.IDictionary items, System.Collections.Generic.IDictionary parameters) { } + public string DomainHint { get { throw null; } set { } } + public string LoginHint { get { throw null; } set { } } + public string Prompt { get { throw null; } set { } } + public string ResponseMode { get { throw null; } set { } } + } } namespace Microsoft.Extensions.DependencyInjection { diff --git a/src/Security/Authentication/MicrosoftAccount/src/MicrosoftAccountHandler.cs b/src/Security/Authentication/MicrosoftAccount/src/MicrosoftAccountHandler.cs index 910da8d9146e..6114b15394e8 100644 --- a/src/Security/Authentication/MicrosoftAccount/src/MicrosoftAccountHandler.cs +++ b/src/Security/Authentication/MicrosoftAccount/src/MicrosoftAccountHandler.cs @@ -1,13 +1,18 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -15,6 +20,8 @@ namespace Microsoft.AspNetCore.Authentication.MicrosoftAccount { public class MicrosoftAccountHandler : OAuthHandler { + private static readonly RandomNumberGenerator CryptoRandom = RandomNumberGenerator.Create(); + public MicrosoftAccountHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) { } @@ -38,5 +45,77 @@ protected override async Task CreateTicketAsync(ClaimsIden return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name); } } + + protected override string BuildChallengeUrl(AuthenticationProperties properties, string redirectUri) + { + var queryStrings = new Dictionary + { + { "client_id", Options.ClientId }, + { "response_type", "code" }, + { "redirect_uri", redirectUri } + }; + + AddQueryString(queryStrings, properties, MicrosoftChallengeProperties.ScopeKey, FormatScope, Options.Scope); + AddQueryString(queryStrings, properties, MicrosoftChallengeProperties.ResponseModeKey); + AddQueryString(queryStrings, properties, MicrosoftChallengeProperties.DomainHintKey); + AddQueryString(queryStrings, properties, MicrosoftChallengeProperties.LoginHintKey); + AddQueryString(queryStrings, properties, MicrosoftChallengeProperties.PromptKey); + + if (Options.UsePkce) + { + var bytes = new byte[32]; + CryptoRandom.GetBytes(bytes); + var codeVerifier = Base64UrlTextEncoder.Encode(bytes); + + // Store this for use during the code redemption. + properties.Items.Add(OAuthConstants.CodeVerifierKey, codeVerifier); + + using var sha256 = SHA256.Create(); + var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); + var codeChallenge = WebEncoders.Base64UrlEncode(challengeBytes); + + queryStrings[OAuthConstants.CodeChallengeKey] = codeChallenge; + queryStrings[OAuthConstants.CodeChallengeMethodKey] = OAuthConstants.CodeChallengeMethodS256; + } + + var state = Options.StateDataFormat.Protect(properties); + queryStrings.Add("state", state); + + return QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, queryStrings); + } + + private void AddQueryString( + IDictionary queryStrings, + AuthenticationProperties properties, + string name, + Func formatter, + T defaultValue) + { + string value = null; + var parameterValue = properties.GetParameter(name); + if (parameterValue != null) + { + value = formatter(parameterValue); + } + else if (!properties.Items.TryGetValue(name, out value)) + { + value = formatter(defaultValue); + } + + // Remove the parameter from AuthenticationProperties so it won't be serialized into the state + properties.Items.Remove(name); + + if (value != null) + { + queryStrings[name] = value; + } + } + + private void AddQueryString( + IDictionary queryStrings, + AuthenticationProperties properties, + string name, + string defaultValue = null) + => AddQueryString(queryStrings, properties, name, x => x, defaultValue); } } diff --git a/src/Security/Authentication/MicrosoftAccount/src/MicrosoftChallengeProperties.cs b/src/Security/Authentication/MicrosoftAccount/src/MicrosoftChallengeProperties.cs new file mode 100644 index 000000000000..8625e3f093e8 --- /dev/null +++ b/src/Security/Authentication/MicrosoftAccount/src/MicrosoftChallengeProperties.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Authentication.OAuth; + +namespace Microsoft.AspNetCore.Authentication.MicrosoftAccount +{ + /// + /// See https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code for reference + /// + public class MicrosoftChallengeProperties : OAuthChallengeProperties + { + /// + /// The parameter key for the "response_mode" argument being used for a challenge request. + /// + public static readonly string ResponseModeKey = "response_mode"; + + /// + /// The parameter key for the "domain_hint" argument being used for a challenge request. + /// + public static readonly string DomainHintKey = "domain_hint"; + + /// + /// The parameter key for the "login_hint" argument being used for a challenge request. + /// + public static readonly string LoginHintKey = "login_hint"; + + /// + /// The parameter key for the "prompt" argument being used for a challenge request. + /// + public static readonly string PromptKey = "prompt"; + + public MicrosoftChallengeProperties() + { } + + public MicrosoftChallengeProperties(IDictionary items) + : base(items) + { } + + public MicrosoftChallengeProperties(IDictionary items, IDictionary parameters) + : base(items, parameters) + { } + + /// + /// The "response_mode" parameter value being used for a challenge request. + /// + public string ResponseMode + { + get => GetParameter(ResponseModeKey); + set => SetParameter(ResponseModeKey, value); + } + + /// + /// The "domain_hint" parameter value being used for a challenge request. + /// + public string DomainHint + { + get => GetParameter(DomainHintKey); + set => SetParameter(DomainHintKey, value); + } + + /// + /// The "login_hint" parameter value being used for a challenge request. + /// + public string LoginHint + { + get => GetParameter(LoginHintKey); + set => SetParameter(LoginHintKey, value); + } + + /// + /// The "prompt" parameter value being used for a challenge request. + /// + public string Prompt + { + get => GetParameter(PromptKey); + set => SetParameter(PromptKey, value); + } + } +} diff --git a/src/Security/Authentication/test/MicrosoftAccountTests.cs b/src/Security/Authentication/test/MicrosoftAccountTests.cs index 650d9c294446..21105cedd9da 100644 --- a/src/Security/Authentication/test/MicrosoftAccountTests.cs +++ b/src/Security/Authentication/test/MicrosoftAccountTests.cs @@ -244,6 +244,36 @@ public async Task AuthenticatedEventCanGetRefreshToken() Assert.Equal("Test Refresh Token", transaction.FindClaimValue("RefreshToken")); } + [Fact] + public async Task ChallengeWillUseAuthenticationPropertiesParametersAsQueryArguments() + { + var stateFormat = new PropertiesDataFormat(new EphemeralDataProtectionProvider(NullLoggerFactory.Instance).CreateProtector("MicrosoftTest")); + var server = CreateServer(o => + { + o.ClientId = "Test Id"; + o.ClientSecret = "Test Secret"; + o.StateDataFormat = stateFormat; + }); + var transaction = await server.SendAsync("https://example.com/challenge"); + Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode); + + // verify query arguments + var query = QueryHelpers.ParseQuery(transaction.Response.Headers.Location.Query); + Assert.Equal("https://graph.microsoft.com/user.read", query["scope"]); + Assert.Equal("consumers", query["domain_hint"]); + Assert.Equal("username", query["login_hint"]); + Assert.Equal("select_account", query["prompt"]); + Assert.Equal("query", query["response_mode"]); + + // verify that the passed items were not serialized + var stateProperties = stateFormat.Unprotect(query["state"]); + Assert.DoesNotContain("scope", stateProperties.Items.Keys); + Assert.DoesNotContain("domain_hint", stateProperties.Items.Keys); + Assert.DoesNotContain("login_hint", stateProperties.Items.Keys); + Assert.DoesNotContain("prompt", stateProperties.Items.Keys); + Assert.DoesNotContain("response_mode", stateProperties.Items.Keys); + } + [Fact] public async Task PkceSentToTokenEndpoint() { @@ -324,7 +354,14 @@ private static TestServer CreateServer(Action configure var res = context.Response; if (req.Path == new PathString("/challenge")) { - await context.ChallengeAsync("Microsoft", new AuthenticationProperties() { RedirectUri = "/me" } ); + await context.ChallengeAsync("Microsoft", new MicrosoftChallengeProperties + { + Prompt = "select_account", + LoginHint = "username", + DomainHint = "consumers", + ResponseMode = "query", + RedirectUri = "/me" + }); } else if (req.Path == new PathString("/challengeWithOtherScope")) {