Add PKCE support in OIDC & OAuth #7734 (#10928)

This commit is contained in:
Chris Ross 2019-06-07 11:27:44 -07:00 committed by GitHub
parent 4300f498c7
commit 75e0115de9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 492 additions and 185 deletions

View File

@ -21,6 +21,7 @@ namespace Microsoft.AspNetCore.Authentication.MicrosoftAccount
AuthorizationEndpoint = MicrosoftAccountDefaults.AuthorizationEndpoint;
TokenEndpoint = MicrosoftAccountDefaults.TokenEndpoint;
UserInformationEndpoint = MicrosoftAccountDefaults.UserInformationEndpoint;
UsePkce = true;
Scope.Add("https://graph.microsoft.com/user.read");
ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");

View File

@ -28,6 +28,20 @@ namespace Microsoft.AspNetCore.Authentication.OAuth
public System.Collections.Generic.ICollection<string> Scope { get { throw null; } set { } }
public virtual void SetScope(params string[] scopes) { }
}
public partial class OAuthCodeExchangeContext
{
public OAuthCodeExchangeContext(Microsoft.AspNetCore.Authentication.AuthenticationProperties properties, string code, string redirectUri) { }
public string Code { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
public Microsoft.AspNetCore.Authentication.AuthenticationProperties Properties { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
public string RedirectUri { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
}
public static partial class OAuthConstants
{
public static readonly string CodeChallengeKey;
public static readonly string CodeChallengeMethodKey;
public static readonly string CodeChallengeMethodS256;
public static readonly string CodeVerifierKey;
}
public partial class OAuthCreatingTicketContext : Microsoft.AspNetCore.Authentication.ResultContext<Microsoft.AspNetCore.Authentication.OAuth.OAuthOptions>
{
public OAuthCreatingTicketContext(System.Security.Claims.ClaimsPrincipal principal, Microsoft.AspNetCore.Authentication.AuthenticationProperties properties, Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Authentication.AuthenticationScheme scheme, Microsoft.AspNetCore.Authentication.OAuth.OAuthOptions options, System.Net.Http.HttpClient backchannel, Microsoft.AspNetCore.Authentication.OAuth.OAuthTokenResponse tokens, System.Text.Json.JsonElement user) : base (default(Microsoft.AspNetCore.Http.HttpContext), default(Microsoft.AspNetCore.Authentication.AuthenticationScheme), default(Microsoft.AspNetCore.Authentication.OAuth.OAuthOptions)) { }
@ -64,7 +78,7 @@ namespace Microsoft.AspNetCore.Authentication.OAuth
[System.Diagnostics.DebuggerStepThroughAttribute]
protected virtual System.Threading.Tasks.Task<Microsoft.AspNetCore.Authentication.AuthenticationTicket> CreateTicketAsync(System.Security.Claims.ClaimsIdentity identity, Microsoft.AspNetCore.Authentication.AuthenticationProperties properties, Microsoft.AspNetCore.Authentication.OAuth.OAuthTokenResponse tokens) { throw null; }
[System.Diagnostics.DebuggerStepThroughAttribute]
protected virtual System.Threading.Tasks.Task<Microsoft.AspNetCore.Authentication.OAuth.OAuthTokenResponse> ExchangeCodeAsync(string code, string redirectUri) { throw null; }
protected virtual System.Threading.Tasks.Task<Microsoft.AspNetCore.Authentication.OAuth.OAuthTokenResponse> ExchangeCodeAsync(Microsoft.AspNetCore.Authentication.OAuth.OAuthCodeExchangeContext context) { throw null; }
protected virtual string FormatScope() { throw null; }
protected virtual string FormatScope(System.Collections.Generic.IEnumerable<string> scopes) { throw null; }
[System.Diagnostics.DebuggerStepThroughAttribute]
@ -83,6 +97,7 @@ namespace Microsoft.AspNetCore.Authentication.OAuth
public System.Collections.Generic.ICollection<string> Scope { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
public Microsoft.AspNetCore.Authentication.ISecureDataFormat<Microsoft.AspNetCore.Authentication.AuthenticationProperties> StateDataFormat { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public string TokenEndpoint { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public bool UsePkce { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public string UserInformationEndpoint { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public override void Validate() { }
}

View File

@ -0,0 +1,39 @@
// 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.
namespace Microsoft.AspNetCore.Authentication.OAuth
{
/// <summary>
/// Contains information used to perform the code exchange.
/// </summary>
public class OAuthCodeExchangeContext
{
/// <summary>
/// Initializes a new <see cref="OAuthCodeExchangeContext"/>.
/// </summary>
/// <param name="properties">The <see cref="AuthenticationProperties"/>.</param>
/// <param name="code">The code returned from the authorization endpoint.</param>
/// <param name="redirectUri">The redirect uri used in the authorization request.</param>
public OAuthCodeExchangeContext(AuthenticationProperties properties, string code, string redirectUri)
{
Properties = properties;
Code = code;
RedirectUri = redirectUri;
}
/// <summary>
/// State for the authentication flow.
/// </summary>
public AuthenticationProperties Properties { get; }
/// <summary>
/// The code returned from the authorization endpoint.
/// </summary>
public string Code { get; }
/// <summary>
/// The redirect uri used in the authorization request.
/// </summary>
public string RedirectUri { get; }
}
}

View File

@ -0,0 +1,31 @@
// 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.
namespace Microsoft.AspNetCore.Authentication.OAuth
{
/// <summary>
/// Constants used in the OAuth protocol
/// </summary>
public static class OAuthConstants
{
/// <summary>
/// code_verifier defined in https://tools.ietf.org/html/rfc7636
/// </summary>
public static readonly string CodeVerifierKey = "code_verifier";
/// <summary>
/// code_challenge defined in https://tools.ietf.org/html/rfc7636
/// </summary>
public static readonly string CodeChallengeKey = "code_challenge";
/// <summary>
/// code_challenge_method defined in https://tools.ietf.org/html/rfc7636
/// </summary>
public static readonly string CodeChallengeMethodKey = "code_challenge_method";
/// <summary>
/// S256 defined in https://tools.ietf.org/html/rfc7636
/// </summary>
public static readonly string CodeChallengeMethodS256 = "S256";
}
}

View File

@ -7,6 +7,7 @@ using System.Globalization;
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;
@ -21,6 +22,7 @@ namespace Microsoft.AspNetCore.Authentication.OAuth
{
public class OAuthHandler<TOptions> : RemoteAuthenticationHandler<TOptions> where TOptions : OAuthOptions, new()
{
private static readonly RandomNumberGenerator CryptoRandom = RandomNumberGenerator.Create();
protected HttpClient Backchannel => Options.Backchannel;
/// <summary>
@ -99,77 +101,84 @@ namespace Microsoft.AspNetCore.Authentication.OAuth
return HandleRequestResult.Fail("Code was not found.", properties);
}
using (var tokens = await ExchangeCodeAsync(code, BuildRedirectUri(Options.CallbackPath)))
var codeExchangeContext = new OAuthCodeExchangeContext(properties, code, BuildRedirectUri(Options.CallbackPath));
using var tokens = await ExchangeCodeAsync(codeExchangeContext);
if (tokens.Error != null)
{
if (tokens.Error != null)
return HandleRequestResult.Fail(tokens.Error, properties);
}
if (string.IsNullOrEmpty(tokens.AccessToken))
{
return HandleRequestResult.Fail("Failed to retrieve access token.", properties);
}
var identity = new ClaimsIdentity(ClaimsIssuer);
if (Options.SaveTokens)
{
var authTokens = new List<AuthenticationToken>();
authTokens.Add(new AuthenticationToken { Name = "access_token", Value = tokens.AccessToken });
if (!string.IsNullOrEmpty(tokens.RefreshToken))
{
return HandleRequestResult.Fail(tokens.Error, properties);
authTokens.Add(new AuthenticationToken { Name = "refresh_token", Value = tokens.RefreshToken });
}
if (string.IsNullOrEmpty(tokens.AccessToken))
if (!string.IsNullOrEmpty(tokens.TokenType))
{
return HandleRequestResult.Fail("Failed to retrieve access token.", properties);
authTokens.Add(new AuthenticationToken { Name = "token_type", Value = tokens.TokenType });
}
var identity = new ClaimsIdentity(ClaimsIssuer);
if (Options.SaveTokens)
if (!string.IsNullOrEmpty(tokens.ExpiresIn))
{
var authTokens = new List<AuthenticationToken>();
authTokens.Add(new AuthenticationToken { Name = "access_token", Value = tokens.AccessToken });
if (!string.IsNullOrEmpty(tokens.RefreshToken))
int value;
if (int.TryParse(tokens.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
{
authTokens.Add(new AuthenticationToken { Name = "refresh_token", Value = tokens.RefreshToken });
}
if (!string.IsNullOrEmpty(tokens.TokenType))
{
authTokens.Add(new AuthenticationToken { Name = "token_type", Value = tokens.TokenType });
}
if (!string.IsNullOrEmpty(tokens.ExpiresIn))
{
int value;
if (int.TryParse(tokens.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
// https://www.w3.org/TR/xmlschema-2/#dateTime
// https://msdn.microsoft.com/en-us/library/az4se3k1(v=vs.110).aspx
var expiresAt = Clock.UtcNow + TimeSpan.FromSeconds(value);
authTokens.Add(new AuthenticationToken
{
// https://www.w3.org/TR/xmlschema-2/#dateTime
// https://msdn.microsoft.com/en-us/library/az4se3k1(v=vs.110).aspx
var expiresAt = Clock.UtcNow + TimeSpan.FromSeconds(value);
authTokens.Add(new AuthenticationToken
{
Name = "expires_at",
Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
});
}
Name = "expires_at",
Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
});
}
properties.StoreTokens(authTokens);
}
var ticket = await CreateTicketAsync(identity, properties, tokens);
if (ticket != null)
{
return HandleRequestResult.Success(ticket);
}
else
{
return HandleRequestResult.Fail("Failed to retrieve user information from remote server.", properties);
}
properties.StoreTokens(authTokens);
}
var ticket = await CreateTicketAsync(identity, properties, tokens);
if (ticket != null)
{
return HandleRequestResult.Success(ticket);
}
else
{
return HandleRequestResult.Fail("Failed to retrieve user information from remote server.", properties);
}
}
protected virtual async Task<OAuthTokenResponse> ExchangeCodeAsync(string code, string redirectUri)
protected virtual async Task<OAuthTokenResponse> ExchangeCodeAsync(OAuthCodeExchangeContext context)
{
var tokenRequestParameters = new Dictionary<string, string>()
{
{ "client_id", Options.ClientId },
{ "redirect_uri", redirectUri },
{ "redirect_uri", context.RedirectUri },
{ "client_secret", Options.ClientSecret },
{ "code", code },
{ "code", context.Code },
{ "grant_type", "authorization_code" },
};
// PKCE https://tools.ietf.org/html/rfc7636#section-4.5, see BuildChallengeUrl
if (context.Properties.Items.TryGetValue(OAuthConstants.CodeVerifierKey, out var codeVerifier))
{
tokenRequestParameters.Add(OAuthConstants.CodeVerifierKey, codeVerifier);
context.Properties.Items.Remove(OAuthConstants.CodeVerifierKey);
}
var requestContent = new FormUrlEncodedContent(tokenRequestParameters);
var requestMessage = new HttpRequestMessage(HttpMethod.Post, Options.TokenEndpoint);
@ -241,15 +250,33 @@ namespace Microsoft.AspNetCore.Authentication.OAuth
var scopeParameter = properties.GetParameter<ICollection<string>>(OAuthChallengeProperties.ScopeKey);
var scope = scopeParameter != null ? FormatScope(scopeParameter) : FormatScope();
var state = Options.StateDataFormat.Protect(properties);
var parameters = new Dictionary<string, string>
{
{ "client_id", Options.ClientId },
{ "scope", scope },
{ "response_type", "code" },
{ "redirect_uri", redirectUri },
{ "state", state },
};
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);
parameters[OAuthConstants.CodeChallengeKey] = codeChallenge;
parameters[OAuthConstants.CodeChallengeMethodKey] = OAuthConstants.CodeChallengeMethodS256;
}
parameters["state"] = Options.StateDataFormat.Protect(properties);
return QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, parameters);
}

View File

@ -102,5 +102,11 @@ namespace Microsoft.AspNetCore.Authentication.OAuth
/// Gets or sets the type used to secure data handled by the middleware.
/// </summary>
public ISecureDataFormat<AuthenticationProperties> StateDataFormat { get; set; }
/// <summary>
/// Enables or disables the use of the Proof Key for Code Exchange (PKCE) standard. See https://tools.ietf.org/html/rfc7636.
/// The default value is `false` but derived handlers should enable this if their provider supports it.
/// </summary>
public bool UsePkce { get; set; } = false;
}
}

View File

@ -135,6 +135,7 @@ namespace Microsoft.AspNetCore.Authentication.OpenIdConnect
public Microsoft.AspNetCore.Authentication.ISecureDataFormat<Microsoft.AspNetCore.Authentication.AuthenticationProperties> StateDataFormat { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public Microsoft.AspNetCore.Authentication.ISecureDataFormat<string> StringDataFormat { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public Microsoft.IdentityModel.Tokens.TokenValidationParameters TokenValidationParameters { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public bool UsePkce { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public bool UseTokenLifetime { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public override void Validate() { }
}

View File

@ -15,7 +15,7 @@
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"SocialSample": {
"OpenIdConnectSample": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {

View File

@ -45,16 +45,22 @@ namespace OpenIdConnectSample
.AddCookie()
.AddOpenIdConnect(o =>
{
/*
o.ClientId = Configuration["oidc:clientid"];
o.ClientSecret = Configuration["oidc:clientsecret"]; // for code flow
o.Authority = Configuration["oidc:authority"];
*/
// https://github.com/IdentityServer/IdentityServer4.Demo/blob/master/src/IdentityServer4Demo/Config.cs
o.ClientId = "server.hybrid";
o.ClientSecret = "secret"; // for code flow
o.Authority = "https://demo.identityserver.io/";
o.ResponseType = OpenIdConnectResponseType.CodeIdToken;
o.SaveTokens = true;
o.GetClaimsFromUserInfoEndpoint = true;
o.AccessDeniedPath = "/access-denied-from-remote";
o.ClaimActions.MapAllExcept("aud", "iss", "iat", "nbf", "exp", "aio", "c_hash", "uti", "nonce");
// o.ClaimActions.MapAllExcept("aud", "iss", "iat", "nbf", "exp", "aio", "c_hash", "uti", "nonce");
o.Events = new OpenIdConnectEvents()
{

View File

@ -14,7 +14,9 @@ using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
@ -30,7 +32,6 @@ namespace Microsoft.AspNetCore.Authentication.OpenIdConnect
public class OpenIdConnectHandler : RemoteAuthenticationHandler<OpenIdConnectOptions>, IAuthenticationSignOutHandler
{
private const string NonceProperty = "N";
private const string HeaderValueEpocDate = "Thu, 01 Jan 1970 00:00:00 GMT";
private static readonly RandomNumberGenerator CryptoRandom = RandomNumberGenerator.Create();
@ -366,6 +367,24 @@ namespace Microsoft.AspNetCore.Authentication.OpenIdConnect
Scope = string.Join(" ", properties.GetParameter<ICollection<string>>(OpenIdConnectParameterNames.Scope) ?? Options.Scope),
};
// https://tools.ietf.org/html/rfc7636
if (Options.UsePkce && Options.ResponseType == OpenIdConnectResponseType.Code)
{
var bytes = new byte[32];
CryptoRandom.GetBytes(bytes);
var codeVerifier = Base64UrlTextEncoder.Encode(bytes);
// Store this for use during the code redemption. See RunAuthorizationCodeReceivedEventAsync.
properties.Items.Add(OAuthConstants.CodeVerifierKey, codeVerifier);
using var sha256 = SHA256.Create();
var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
var codeChallenge = WebEncoders.Base64UrlEncode(challengeBytes);
message.Parameters.Add(OAuthConstants.CodeChallengeKey, codeChallenge);
message.Parameters.Add(OAuthConstants.CodeChallengeMethodKey, OAuthConstants.CodeChallengeMethodS256);
}
// Add the 'max_age' parameter to the authentication request if MaxAge is not null.
// See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
var maxAge = properties.GetParameter<TimeSpan?>(OpenIdConnectParameterNames.MaxAge) ?? Options.MaxAge;
@ -1097,6 +1116,13 @@ namespace Microsoft.AspNetCore.Authentication.OpenIdConnect
RedirectUri = properties.Items[OpenIdConnectDefaults.RedirectUriForCodePropertiesKey]
};
// PKCE https://tools.ietf.org/html/rfc7636#section-4.5, see HandleChallengeAsyncInternal
if (properties.Items.TryGetValue(OAuthConstants.CodeVerifierKey, out var codeVerifier))
{
tokenEndpointRequest.Parameters.Add(OAuthConstants.CodeVerifierKey, codeVerifier);
properties.Items.Remove(OAuthConstants.CodeVerifierKey);
}
var context = new AuthorizationCodeReceivedContext(Context, Scheme, Options, properties)
{
ProtocolMessage = authorizationResponse,

View File

@ -299,6 +299,14 @@ namespace Microsoft.AspNetCore.Authentication.OpenIdConnect
set => _nonceCookieBuilder = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// Enables or disables the use of the Proof Key for Code Exchange (PKCE) standard.
/// This only applies when the <see cref="ResponseType"/> is set to <see cref="OpenIdConnectResponseType.Code"/>.
/// See https://tools.ietf.org/html/rfc7636.
/// The default value is `true`.
/// </summary>
public bool UsePkce { get; set; } = true;
private class OpenIdConnectNonceCookieBuilder : RequestPathBaseCookieBuilder
{
private readonly OpenIdConnectOptions _options;

View File

@ -50,159 +50,168 @@ namespace SocialSample
// https://developers.facebook.com/apps/
// https://developers.facebook.com/docs/facebook-login/manually-build-a-login-flow#login
.AddFacebook(o =>
{
o.AppId = Configuration["facebook:appid"];
o.AppSecret = Configuration["facebook:appsecret"];
o.Scope.Add("email");
o.Fields.Add("name");
o.Fields.Add("email");
o.SaveTokens = true;
o.Events = new OAuthEvents()
{
OnRemoteFailure = HandleOnRemoteFailure
};
})
// You must first create an app with Google and add its ID and Secret to your user-secrets.
// https://console.developers.google.com/project
// https://developers.google.com/identity/protocols/OAuth2WebServer
// https://developers.google.com/+/web/people/
.AddOAuth("Google-AccessToken", "Google AccessToken only", o =>
{
o.ClientId = Configuration["google:clientid"];
o.ClientSecret = Configuration["google:clientsecret"];
o.CallbackPath = new PathString("/signin-google-token");
o.AuthorizationEndpoint = GoogleDefaults.AuthorizationEndpoint;
o.TokenEndpoint = GoogleDefaults.TokenEndpoint;
o.Scope.Add("openid");
o.Scope.Add("profile");
o.Scope.Add("email");
o.SaveTokens = true;
o.Events = new OAuthEvents()
{
OnRemoteFailure = HandleOnRemoteFailure
};
})
o.AppId = Configuration["facebook:appid"];
o.AppSecret = Configuration["facebook:appsecret"];
o.Scope.Add("email");
o.Fields.Add("name");
o.Fields.Add("email");
o.SaveTokens = true;
o.Events = new OAuthEvents()
{
OnRemoteFailure = HandleOnRemoteFailure
};
})
// You must first create an app with Google and add its ID and Secret to your user-secrets.
// https://console.developers.google.com/project
// https://developers.google.com/identity/protocols/OAuth2WebServer
// https://developers.google.com/+/web/people/
.AddGoogle(o =>
{
o.ClientId = Configuration["google:clientid"];
o.ClientSecret = Configuration["google:clientsecret"];
o.AuthorizationEndpoint += "?prompt=consent"; // Hack so we always get a refresh token, it only comes on the first authorization response
o.AccessType = "offline";
o.SaveTokens = true;
o.Events = new OAuthEvents()
{
OnRemoteFailure = HandleOnRemoteFailure
};
o.ClaimActions.MapJsonSubKey("urn:google:image", "image", "url");
o.ClaimActions.Remove(ClaimTypes.GivenName);
})
o.ClientId = Configuration["google:clientid"];
o.ClientSecret = Configuration["google:clientsecret"];
o.AuthorizationEndpoint += "?prompt=consent"; // Hack so we always get a refresh token, it only comes on the first authorization response
o.AccessType = "offline";
o.SaveTokens = true;
o.Events = new OAuthEvents()
{
OnRemoteFailure = HandleOnRemoteFailure
};
o.ClaimActions.MapJsonSubKey("urn:google:image", "image", "url");
o.ClaimActions.Remove(ClaimTypes.GivenName);
})
// You must first create an app with Twitter and add its key and Secret to your user-secrets.
// https://apps.twitter.com/
// https://developer.twitter.com/en/docs/basics/authentication/api-reference/access_token
.AddTwitter(o =>
{
o.ConsumerKey = Configuration["twitter:consumerkey"];
o.ConsumerSecret = Configuration["twitter:consumersecret"];
// http://stackoverflow.com/questions/22627083/can-we-get-email-id-from-twitter-oauth-api/32852370#32852370
// http://stackoverflow.com/questions/36330675/get-users-email-from-twitter-api-for-external-login-authentication-asp-net-mvc?lq=1
o.RetrieveUserDetails = true;
o.SaveTokens = true;
o.ClaimActions.MapJsonKey("urn:twitter:profilepicture", "profile_image_url", ClaimTypes.Uri);
o.Events = new TwitterEvents()
{
OnRemoteFailure = HandleOnRemoteFailure
};
})
o.ConsumerKey = Configuration["twitter:consumerkey"];
o.ConsumerSecret = Configuration["twitter:consumersecret"];
// http://stackoverflow.com/questions/22627083/can-we-get-email-id-from-twitter-oauth-api/32852370#32852370
// http://stackoverflow.com/questions/36330675/get-users-email-from-twitter-api-for-external-login-authentication-asp-net-mvc?lq=1
o.RetrieveUserDetails = true;
o.SaveTokens = true;
o.ClaimActions.MapJsonKey("urn:twitter:profilepicture", "profile_image_url", ClaimTypes.Uri);
o.Events = new TwitterEvents()
{
OnRemoteFailure = HandleOnRemoteFailure
};
})
/* Azure AD app model v2 has restrictions that prevent the use of plain HTTP for redirect URLs.
Therefore, to authenticate through microsoft accounts, tryout the sample using the following URL:
Therefore, to authenticate through microsoft accounts, try out the sample using the following URL:
https://localhost:44318/
*/
// You must first create an app with Microsoft Account and add its ID and Secret to your user-secrets.
// https://apps.dev.microsoft.com/
.AddOAuth("Microsoft-AccessToken", "Microsoft AccessToken only", o =>
{
o.ClientId = Configuration["microsoftaccount:clientid"];
o.ClientSecret = Configuration["microsoftaccount:clientsecret"];
o.CallbackPath = new PathString("/signin-microsoft-token");
o.AuthorizationEndpoint = MicrosoftAccountDefaults.AuthorizationEndpoint;
o.TokenEndpoint = MicrosoftAccountDefaults.TokenEndpoint;
o.Scope.Add("https://graph.microsoft.com/user.read");
o.SaveTokens = true;
o.Events = new OAuthEvents()
{
OnRemoteFailure = HandleOnRemoteFailure
};
})
// You must first create an app with Microsoft Account and add its ID and Secret to your user-secrets.
// https://azure.microsoft.com/en-us/documentation/articles/active-directory-v2-app-registration/
// https://apps.dev.microsoft.com/
.AddMicrosoftAccount(o =>
{
o.ClientId = Configuration["microsoftaccount:clientid"];
o.ClientSecret = Configuration["microsoftaccount:clientsecret"];
o.SaveTokens = true;
o.Scope.Add("offline_access");
o.Events = new OAuthEvents()
{
OnRemoteFailure = HandleOnRemoteFailure
};
})
// You must first create an app with GitHub and add its ID and Secret to your user-secrets.
// https://github.com/settings/applications/
.AddOAuth("GitHub-AccessToken", "GitHub AccessToken only", o =>
{
o.ClientId = Configuration["github-token:clientid"];
o.ClientSecret = Configuration["github-token:clientsecret"];
o.CallbackPath = new PathString("/signin-github-token");
o.AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
o.TokenEndpoint = "https://github.com/login/oauth/access_token";
o.SaveTokens = true;
o.Events = new OAuthEvents()
{
OnRemoteFailure = HandleOnRemoteFailure
};
})
o.ClientId = Configuration["microsoftaccount:clientid"];
o.ClientSecret = Configuration["microsoftaccount:clientsecret"];
o.SaveTokens = true;
o.Scope.Add("offline_access");
o.Events = new OAuthEvents()
{
OnRemoteFailure = HandleOnRemoteFailure
};
})
// You must first create an app with GitHub and add its ID and Secret to your user-secrets.
// https://github.com/settings/applications/
.AddOAuth("GitHub", "Github", o =>
{
o.ClientId = Configuration["github:clientid"];
o.ClientSecret = Configuration["github:clientsecret"];
o.CallbackPath = new PathString("/signin-github");
o.AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
o.TokenEndpoint = "https://github.com/login/oauth/access_token";
o.UserInformationEndpoint = "https://api.github.com/user";
o.ClaimsIssuer = "OAuth2-Github";
o.SaveTokens = true;
// Retrieving user information is unique to each provider.
o.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
o.ClaimActions.MapJsonKey(ClaimTypes.Name, "login");
o.ClaimActions.MapJsonKey("urn:github:name", "name");
o.ClaimActions.MapJsonKey(ClaimTypes.Email, "email", ClaimValueTypes.Email);
o.ClaimActions.MapJsonKey("urn:github:url", "url");
o.Events = new OAuthEvents
{
OnRemoteFailure = HandleOnRemoteFailure,
OnCreatingTicket = async context =>
o.ClientId = Configuration["github:clientid"];
o.ClientSecret = Configuration["github:clientsecret"];
o.CallbackPath = new PathString("/signin-github");
o.AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
o.TokenEndpoint = "https://github.com/login/oauth/access_token";
o.UserInformationEndpoint = "https://api.github.com/user";
o.ClaimsIssuer = "OAuth2-Github";
o.SaveTokens = true;
// Retrieving user information is unique to each provider.
o.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
o.ClaimActions.MapJsonKey(ClaimTypes.Name, "login");
o.ClaimActions.MapJsonKey("urn:github:name", "name");
o.ClaimActions.MapJsonKey(ClaimTypes.Email, "email", ClaimValueTypes.Email);
o.ClaimActions.MapJsonKey("urn:github:url", "url");
o.Events = new OAuthEvents
{
// Get the GitHub user
var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
using (var user = JsonDocument.Parse(await response.Content.ReadAsStringAsync()))
OnRemoteFailure = HandleOnRemoteFailure,
OnCreatingTicket = async context =>
{
context.RunClaimActions(user.RootElement);
// Get the GitHub user
var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
using (var user = JsonDocument.Parse(await response.Content.ReadAsStringAsync()))
{
context.RunClaimActions(user.RootElement);
}
}
}
};
});
};
})
// You must first create an app with GitHub and add its ID and Secret to your user-secrets.
// https://github.com/settings/applications/
.AddOAuth("GitHub-AccessToken", "GitHub AccessToken only", o =>
{
o.ClientId = Configuration["github-token:clientid"];
o.ClientSecret = Configuration["github-token:clientsecret"];
o.CallbackPath = new PathString("/signin-github-token");
o.AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
o.TokenEndpoint = "https://github.com/login/oauth/access_token";
o.SaveTokens = true;
o.Events = new OAuthEvents()
{
OnRemoteFailure = HandleOnRemoteFailure
};
})
// https://demo.identityserver.io/
.AddOAuth("IdentityServer", "Identity Server", o =>
{
o.ClientId = "server.code";
o.ClientSecret = "secret";
o.CallbackPath = new PathString("/signin-identityserver");
o.AuthorizationEndpoint = "https://demo.identityserver.io/connect/authorize";
o.TokenEndpoint = "https://demo.identityserver.io/connect/token";
o.UserInformationEndpoint = "https://demo.identityserver.io/connect/userinfo";
o.ClaimsIssuer = "IdentityServer";
o.SaveTokens = true;
o.UsePkce = true;
// Retrieving user information is unique to each provider.
o.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "sub");
o.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
o.ClaimActions.MapJsonKey(ClaimTypes.Email, "email", ClaimValueTypes.Email);
o.ClaimActions.MapJsonKey(ClaimTypes.GivenName, "given_name");
o.ClaimActions.MapJsonKey(ClaimTypes.Surname, "family_name");
o.ClaimActions.MapJsonKey("email_verified", "email_verified");
o.ClaimActions.MapJsonKey(ClaimTypes.Uri, "website");
o.Scope.Add("openid");
o.Scope.Add("profile");
o.Scope.Add("email");
o.Scope.Add("offline_access");
o.Events = new OAuthEvents
{
OnRemoteFailure = HandleOnRemoteFailure,
OnCreatingTicket = async context =>
{
// Get the user
var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
using (var user = JsonDocument.Parse(await response.Content.ReadAsStringAsync()))
{
context.RunClaimActions(user.RootElement);
}
}
};
});
}
private async Task HandleOnRemoteFailure(RemoteFailureContext context)
@ -210,14 +219,15 @@ namespace SocialSample
context.Response.StatusCode = 500;
context.Response.ContentType = "text/html";
await context.Response.WriteAsync("<html><body>");
await context.Response.WriteAsync("A remote failure has occurred: " + UrlEncoder.Default.Encode(context.Failure.Message) + "<br>");
await context.Response.WriteAsync("A remote failure has occurred: <br>" +
context.Failure.Message.Split(Environment.NewLine).Select(s => HtmlEncoder.Default.Encode(s) + "<br>").Aggregate((s1, s2) => s1 + s2));
if (context.Properties != null)
{
await context.Response.WriteAsync("Properties:<br>");
foreach (var pair in context.Properties.Items)
{
await context.Response.WriteAsync($"-{ UrlEncoder.Default.Encode(pair.Key)}={ UrlEncoder.Default.Encode(pair.Value)}<br>");
await context.Response.WriteAsync($"-{ HtmlEncoder.Default.Encode(pair.Key)}={ HtmlEncoder.Default.Encode(pair.Value)}<br>");
}
}
@ -295,7 +305,8 @@ namespace SocialSample
var currentAuthType = user.Identities.First().AuthenticationType;
if (string.Equals(GoogleDefaults.AuthenticationScheme, currentAuthType)
|| string.Equals(MicrosoftAccountDefaults.AuthenticationScheme, currentAuthType))
|| string.Equals(MicrosoftAccountDefaults.AuthenticationScheme, currentAuthType)
|| string.Equals("IdentityServer", currentAuthType))
{
var refreshToken = authProperties.GetTokenValue("refresh_token");
@ -472,6 +483,10 @@ namespace SocialSample
{
return Task.FromResult<OAuthOptions>(context.RequestServices.GetRequiredService<IOptionsMonitor<FacebookOptions>>().Get(currentAuthType));
}
else if (string.Equals("IdentityServer", currentAuthType))
{
return Task.FromResult<OAuthOptions>(context.RequestServices.GetRequiredService<IOptionsMonitor<OAuthOptions>>().Get(currentAuthType));
}
throw new NotImplementedException(currentAuthType);
}

View File

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using System;
@ -119,6 +120,8 @@ namespace Microsoft.AspNetCore.Authentication.Tests.MicrosoftAccount
Assert.Contains("redirect_uri=", location);
Assert.Contains("scope=", location);
Assert.Contains("state=", location);
Assert.Contains("code_challenge=", location);
Assert.Contains("code_challenge_method=S256", location);
}
[Fact]
@ -241,6 +244,74 @@ namespace Microsoft.AspNetCore.Authentication.Tests.MicrosoftAccount
Assert.Equal("Test Refresh Token", transaction.FindClaimValue("RefreshToken"));
}
[Fact]
public async Task PkceSentToTokenEndpoint()
{
var server = CreateServer(o =>
{
o.ClientId = "Test Client Id";
o.ClientSecret = "Test Client Secret";
o.BackchannelHttpHandler = new TestHttpMessageHandler
{
Sender = req =>
{
if (req.RequestUri.AbsoluteUri == "https://login.microsoftonline.com/common/oauth2/v2.0/token")
{
var body = req.Content.ReadAsStringAsync().Result;
var form = new FormReader(body);
var entries = form.ReadForm();
Assert.Equal("Test Client Id", entries["client_id"]);
Assert.Equal("https://example.com/signin-microsoft", entries["redirect_uri"]);
Assert.Equal("Test Client Secret", entries["client_secret"]);
Assert.Equal("TestCode", entries["code"]);
Assert.Equal("authorization_code", entries["grant_type"]);
Assert.False(string.IsNullOrEmpty(entries["code_verifier"]));
return ReturnJsonResponse(new
{
access_token = "Test Access Token",
expire_in = 3600,
token_type = "Bearer",
});
}
else if (req.RequestUri.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped) == "https://graph.microsoft.com/v1.0/me")
{
return ReturnJsonResponse(new
{
id = "Test User ID",
displayName = "Test Name",
givenName = "Test Given Name",
surname = "Test Family Name",
mail = "Test email"
});
}
return null;
}
};
});
var transaction = await server.SendAsync("https://example.com/challenge");
Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode);
var locationUri = transaction.Response.Headers.Location;
Assert.StartsWith("https://login.microsoftonline.com/common/oauth2/v2.0/authorize", locationUri.AbsoluteUri);
var queryParams = QueryHelpers.ParseQuery(locationUri.Query);
Assert.False(string.IsNullOrEmpty(queryParams["code_challenge"]));
Assert.Equal("S256", queryParams["code_challenge_method"]);
var nonceCookie = transaction.SetCookie.Single();
nonceCookie = nonceCookie.Substring(0, nonceCookie.IndexOf(';'));
transaction = await server.SendAsync(
"https://example.com/signin-microsoft?code=TestCode&state=" + queryParams["state"],
nonceCookie);
Assert.Equal(HttpStatusCode.Redirect, transaction.Response.StatusCode);
Assert.Equal("/me", transaction.Response.Headers.GetValues("Location").First());
Assert.Equal(2, transaction.SetCookie.Count);
Assert.StartsWith(".AspNetCore.Correlation.Microsoft.", transaction.SetCookie[0]);
Assert.StartsWith(".AspNetCore." + TestExtensions.CookieAuthenticationScheme, transaction.SetCookie[1]);
}
private static TestServer CreateServer(Action<MicrosoftAccountOptions> configureOptions)
{
var builder = new WebHostBuilder()
@ -253,7 +324,7 @@ namespace Microsoft.AspNetCore.Authentication.Tests.MicrosoftAccount
var res = context.Response;
if (req.Path == new PathString("/challenge"))
{
await context.ChallengeAsync("Microsoft");
await context.ChallengeAsync("Microsoft", new AuthenticationProperties() { RedirectUri = "/me" } );
}
else if (req.Path == new PathString("/challengeWithOtherScope"))
{

View File

@ -48,6 +48,67 @@ namespace Microsoft.AspNetCore.Authentication.Test.OpenIdConnect
OpenIdConnectParameterNames.VersionTelemetry);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task ChallengeIncludesPkceIfRequested(bool include)
{
var settings = new TestSettings(
opt =>
{
opt.Authority = TestServerBuilder.DefaultAuthority;
opt.AuthenticationMethod = OpenIdConnectRedirectBehavior.RedirectGet;
opt.ResponseType = OpenIdConnectResponseType.Code;
opt.ClientId = "Test Id";
opt.UsePkce = include;
});
var server = settings.CreateTestServer();
var transaction = await server.SendAsync(ChallengeEndpoint);
var res = transaction.Response;
Assert.Equal(HttpStatusCode.Redirect, res.StatusCode);
Assert.NotNull(res.Headers.Location);
if (include)
{
Assert.Contains("code_challenge=", res.Headers.Location.Query);
Assert.Contains("code_challenge_method=S256", res.Headers.Location.Query);
}
else
{
Assert.DoesNotContain("code_challenge=", res.Headers.Location.Query);
Assert.DoesNotContain("code_challenge_method=", res.Headers.Location.Query);
}
}
[Theory]
[InlineData(OpenIdConnectResponseType.Token)]
[InlineData(OpenIdConnectResponseType.IdToken)]
[InlineData(OpenIdConnectResponseType.CodeIdToken)]
public async Task ChallengeDoesNotIncludePkceForOtherResponseTypes(string responseType)
{
var settings = new TestSettings(
opt =>
{
opt.Authority = TestServerBuilder.DefaultAuthority;
opt.AuthenticationMethod = OpenIdConnectRedirectBehavior.RedirectGet;
opt.ResponseType = responseType;
opt.ClientId = "Test Id";
opt.UsePkce = true;
});
var server = settings.CreateTestServer();
var transaction = await server.SendAsync(ChallengeEndpoint);
var res = transaction.Response;
Assert.Equal(HttpStatusCode.Redirect, res.StatusCode);
Assert.NotNull(res.Headers.Location);
Assert.DoesNotContain("code_challenge=", res.Headers.Location.Query);
Assert.DoesNotContain("code_challenge_method=", res.Headers.Location.Query);
}
[Fact]
public async Task AuthorizationRequestDoesNotIncludeTelemetryParametersWhenDisabled()
{
@ -613,4 +674,4 @@ namespace Microsoft.AspNetCore.Authentication.Test.OpenIdConnect
Assert.Contains("max_age=1234", res.Headers.Location.Query);
}
}
}
}