diff --git a/Security.sln b/Security.sln index 0d249fe568..5ae54786fc 100644 --- a/Security.sln +++ b/Security.sln @@ -26,6 +26,8 @@ Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNet.Security.F EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "SocialSample", "samples\SocialSample\SocialSample.kproj", "{8C73D216-332D-41D8-BFD0-45BC4BC36552}" EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNet.Security.Google", "src\Microsoft.AspNet.Security.Google\Microsoft.AspNet.Security.Google.kproj", "{89BF8535-A849-458E-868A-A68FCF620486}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -96,6 +98,16 @@ Global {8C73D216-332D-41D8-BFD0-45BC4BC36552}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU {8C73D216-332D-41D8-BFD0-45BC4BC36552}.Release|Mixed Platforms.Build.0 = Release|Any CPU {8C73D216-332D-41D8-BFD0-45BC4BC36552}.Release|x86.ActiveCfg = Release|Any CPU + {89BF8535-A849-458E-868A-A68FCF620486}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {89BF8535-A849-458E-868A-A68FCF620486}.Debug|Any CPU.Build.0 = Debug|Any CPU + {89BF8535-A849-458E-868A-A68FCF620486}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {89BF8535-A849-458E-868A-A68FCF620486}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {89BF8535-A849-458E-868A-A68FCF620486}.Debug|x86.ActiveCfg = Debug|Any CPU + {89BF8535-A849-458E-868A-A68FCF620486}.Release|Any CPU.ActiveCfg = Release|Any CPU + {89BF8535-A849-458E-868A-A68FCF620486}.Release|Any CPU.Build.0 = Release|Any CPU + {89BF8535-A849-458E-868A-A68FCF620486}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {89BF8535-A849-458E-868A-A68FCF620486}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {89BF8535-A849-458E-868A-A68FCF620486}.Release|x86.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -107,5 +119,6 @@ Global {8DA26CD1-1302-4CFD-9270-9FA1B7C6138B} = {7BF11F3A-60B6-4796-B504-579C67FFBA34} {3984651C-FD44-4394-8793-3D14EE348C04} = {4D2B6A51-2F9F-44F5-8131-EA5CAC053652} {8C73D216-332D-41D8-BFD0-45BC4BC36552} = {F8C0AA27-F3FB-4286-8E4C-47EF86B539FF} + {89BF8535-A849-458E-868A-A68FCF620486} = {4D2B6A51-2F9F-44F5-8131-EA5CAC053652} EndGlobalSection EndGlobal diff --git a/samples/SocialSample/Project.json b/samples/SocialSample/Project.json index f5434cb1c4..525d9dd6a6 100644 --- a/samples/SocialSample/Project.json +++ b/samples/SocialSample/Project.json @@ -6,6 +6,7 @@ "Microsoft.AspNet.Security": "1.0.0-*", "Microsoft.AspNet.Security.Cookies": "1.0.0-*", "Microsoft.AspNet.Security.Facebook": "1.0.0-*", + "Microsoft.AspNet.Security.Google": "1.0.0-*", "Microsoft.AspNet.Server.WebListener": "1.0.0-*", "Microsoft.Framework.DependencyInjection": "1.0.0-*" }, diff --git a/samples/SocialSample/Startup.cs b/samples/SocialSample/Startup.cs index aec06926ea..68dd967e40 100644 --- a/samples/SocialSample/Startup.cs +++ b/samples/SocialSample/Startup.cs @@ -1,7 +1,9 @@ using Microsoft.AspNet.Builder; using Microsoft.AspNet.Http; +using Microsoft.AspNet.Http.Security; using Microsoft.AspNet.Security.Cookies; using Microsoft.AspNet.Security.Facebook; +using Microsoft.AspNet.Security.Google; namespace CookieSample { @@ -15,6 +17,7 @@ namespace CookieSample app.UseCookieAuthentication(new CookieAuthenticationOptions() { + LoginPath = new PathString("/login"), }); app.UseFacebookAuthentication(new FacebookAuthenticationOptions() @@ -23,16 +26,75 @@ namespace CookieSample AppSecret = "a124463c4719c94b4228d9a240e5dc1a", }); - app.Run(async context => + app.UseGoogleAuthentication(new GoogleAuthenticationOptions() + { + ClientId = "560027070069-37ldt4kfuohhu3m495hk2j4pjp92d382.apps.googleusercontent.com", + ClientSecret = "n2Q-GEw9RQjzcRbU3qhfTj8f", + }); + + // Choose an authentication type + app.Map("/login", signoutApp => + { + signoutApp.Run(async context => + { + string authType = context.Request.Query["authtype"]; + if (!string.IsNullOrEmpty(authType)) + { + // By default the client will be redirect back to the URL that issued the challenge (/login?authtype=foo), + // send them to the home page instead (/). + context.Response.Challenge(new AuthenticationProperties() { RedirectUri = "/" }, authType); + return; + } + + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync(""); + await context.Response.WriteAsync("Choose an authentication type:
"); + foreach (var type in context.GetAuthenticationTypes()) + { + await context.Response.WriteAsync("" + (type.Caption ?? "(suppressed)") + "
"); + } + await context.Response.WriteAsync(""); + }); + }); + + // Sign-out to remove the user cookie. + app.Map("/logout", signoutApp => + { + signoutApp.Run(async context => + { + context.Response.SignOut(CookieAuthenticationDefaults.AuthenticationType); + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync(""); + await context.Response.WriteAsync("You have been logged out. Goodbye " + context.User.Identity.Name + "
"); + await context.Response.WriteAsync("Home"); + await context.Response.WriteAsync(""); + }); + }); + + // Deny anonymous request beyond this point. + app.Use(async (context, next) => { if (!context.User.Identity.IsAuthenticated) { - context.Response.Challenge("Facebook"); + // The cookie middleware will intercept this 401 and redirect to /login + context.Response.Challenge(); return; } + await next(); + }); - context.Response.ContentType = "text/plain"; - await context.Response.WriteAsync("Hello " + context.User.Identity.Name); + // Display user information + app.Run(async context => + { + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync(""); + await context.Response.WriteAsync("Hello " + context.User.Identity.Name + "
"); + foreach (var claim in context.User.Claims) + { + await context.Response.WriteAsync(claim.Type + ": " + claim.Value + "
"); + } + await context.Response.WriteAsync("Logout"); + await context.Response.WriteAsync(""); }); } } diff --git a/src/Microsoft.AspNet.Security.Cookies/Notifications/CookieApplyRedirectContext.cs b/src/Microsoft.AspNet.Security.Cookies/Notifications/CookieApplyRedirectContext.cs index 28cdf713c9..34907eabab 100644 --- a/src/Microsoft.AspNet.Security.Cookies/Notifications/CookieApplyRedirectContext.cs +++ b/src/Microsoft.AspNet.Security.Cookies/Notifications/CookieApplyRedirectContext.cs @@ -16,7 +16,7 @@ namespace Microsoft.AspNet.Security.Cookies /// /// Creates a new context object. /// - /// The OWIN request context + /// The HTTP request context /// The cookie middleware options /// The initial redirect URI [SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "2#", Justification = "Represents header value")] diff --git a/src/Microsoft.AspNet.Security.Cookies/Notifications/CookieResponseSignInContext.cs b/src/Microsoft.AspNet.Security.Cookies/Notifications/CookieResponseSignInContext.cs index 598f985f54..28b47c6fe2 100644 --- a/src/Microsoft.AspNet.Security.Cookies/Notifications/CookieResponseSignInContext.cs +++ b/src/Microsoft.AspNet.Security.Cookies/Notifications/CookieResponseSignInContext.cs @@ -17,7 +17,7 @@ namespace Microsoft.AspNet.Security.Cookies /// /// Creates a new instance of the context object. /// - /// The OWIN request context + /// The HTTP request context /// The middleware options /// Initializes AuthenticationType property /// Initializes Identity property diff --git a/src/Microsoft.AspNet.Security.Facebook/FacebookAuthenticationMiddleware.cs b/src/Microsoft.AspNet.Security.Facebook/FacebookAuthenticationMiddleware.cs index 895c7b08d8..5a3ef41e63 100644 --- a/src/Microsoft.AspNet.Security.Facebook/FacebookAuthenticationMiddleware.cs +++ b/src/Microsoft.AspNet.Security.Facebook/FacebookAuthenticationMiddleware.cs @@ -26,12 +26,13 @@ namespace Microsoft.AspNet.Security.Facebook /// Initializes a /// /// The next middleware in the application pipeline to invoke + /// + /// /// Configuration options for the middleware public FacebookAuthenticationMiddleware( RequestDelegate next, IDataProtectionProvider dataProtectionProvider, ILoggerFactory loggerFactory, - IServiceProvider services, FacebookAuthenticationOptions options) : base(next, options) { diff --git a/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationDefaults.cs b/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationDefaults.cs new file mode 100644 index 0000000000..b135063840 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationDefaults.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Security.Google +{ + public static class GoogleAuthenticationDefaults + { + public const string AuthenticationType = "Google"; + } +} diff --git a/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationExtensions.cs b/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationExtensions.cs new file mode 100644 index 0000000000..1203735352 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationExtensions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Security.Google; + +namespace Microsoft.AspNet.Builder +{ + /// + /// Extension methods for using + /// + public static class GoogleAuthenticationExtensions + { + /// + /// Authenticate users using Google OAuth 2.0 + /// + /// The passed to the configure method + /// The google assigned client id + /// The google assigned client secret + /// The updated + public static IBuilder UseGoogleAuthentication([NotNull] this IBuilder app, [NotNull] string clientId, [NotNull] string clientSecret) + { + return app.UseGoogleAuthentication( + new GoogleAuthenticationOptions + { + ClientId = clientId, + ClientSecret = clientSecret + }); + } + + /// + /// Authenticate users using Google OAuth 2.0 + /// + /// The passed to the configure method + /// Middleware configuration options + /// The updated + public static IBuilder UseGoogleAuthentication([NotNull] this IBuilder app, [NotNull] GoogleAuthenticationOptions options) + { + if (string.IsNullOrEmpty(options.SignInAsAuthenticationType)) + { + options.SignInAsAuthenticationType = app.GetDefaultSignInAsAuthenticationType(); + } + return app.UseMiddleware(options); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationHandler.cs b/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationHandler.cs new file mode 100644 index 0000000000..fe2a4feab9 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationHandler.cs @@ -0,0 +1,313 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Threading.Tasks; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Http.Security; +using Microsoft.AspNet.Security.Infrastructure; +using Microsoft.AspNet.WebUtilities; +using Microsoft.Framework.Logging; +using Newtonsoft.Json.Linq; + +namespace Microsoft.AspNet.Security.Google +{ + internal class GoogleAuthenticationHandler : AuthenticationHandler + { + private const string TokenEndpoint = "https://accounts.google.com/o/oauth2/token"; + private const string UserInfoEndpoint = "https://www.googleapis.com/plus/v1/people/me"; + private const string AuthorizeEndpoint = "https://accounts.google.com/o/oauth2/auth"; + + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + public GoogleAuthenticationHandler(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + protected override AuthenticationTicket AuthenticateCore() + { + return AuthenticateCoreAsync().Result; + } + + protected override async Task AuthenticateCoreAsync() + { + AuthenticationProperties properties = null; + + try + { + string code = null; + string state = null; + + IReadableStringCollection query = Request.Query; + IList values = query.GetValues("code"); + if (values != null && values.Count == 1) + { + code = values[0]; + } + values = query.GetValues("state"); + if (values != null && values.Count == 1) + { + state = values[0]; + } + + properties = Options.StateDataFormat.Unprotect(state); + if (properties == null) + { + return null; + } + + // OAuth2 10.12 CSRF + if (!ValidateCorrelationId(properties, _logger)) + { + return new AuthenticationTicket(null, properties); + } + + string requestPrefix = Request.Scheme + "://" + Request.Host; + string redirectUri = requestPrefix + Request.PathBase + Options.CallbackPath; + + // Build up the body for the token request + var body = new List>(); + body.Add(new KeyValuePair("grant_type", "authorization_code")); + body.Add(new KeyValuePair("code", code)); + body.Add(new KeyValuePair("redirect_uri", redirectUri)); + body.Add(new KeyValuePair("client_id", Options.ClientId)); + body.Add(new KeyValuePair("client_secret", Options.ClientSecret)); + + // Request the token + HttpResponseMessage tokenResponse = + await _httpClient.PostAsync(TokenEndpoint, new FormUrlEncodedContent(body)); + tokenResponse.EnsureSuccessStatusCode(); + string text = await tokenResponse.Content.ReadAsStringAsync(); + + // Deserializes the token response + JObject response = JObject.Parse(text); + string accessToken = response.Value("access_token"); + string expires = response.Value("expires_in"); + string refreshToken = response.Value("refresh_token"); + + if (string.IsNullOrWhiteSpace(accessToken)) + { + _logger.WriteWarning("Access token was not found"); + return new AuthenticationTicket(null, properties); + } + + // Get the Google user + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, UserInfoEndpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + HttpResponseMessage graphResponse = await _httpClient.SendAsync(request, Context.RequestAborted); + graphResponse.EnsureSuccessStatusCode(); + text = await graphResponse.Content.ReadAsStringAsync(); + JObject user = JObject.Parse(text); + + var context = new GoogleAuthenticatedContext(Context, user, accessToken, refreshToken, expires); + context.Identity = new ClaimsIdentity( + Options.AuthenticationType, + ClaimsIdentity.DefaultNameClaimType, + ClaimsIdentity.DefaultRoleClaimType); + if (!string.IsNullOrEmpty(context.Id)) + { + context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, context.Id, + ClaimValueTypes.String, Options.AuthenticationType)); + } + if (!string.IsNullOrEmpty(context.GivenName)) + { + context.Identity.AddClaim(new Claim(ClaimTypes.GivenName, context.GivenName, + ClaimValueTypes.String, Options.AuthenticationType)); + } + if (!string.IsNullOrEmpty(context.FamilyName)) + { + context.Identity.AddClaim(new Claim(ClaimTypes.Surname, context.FamilyName, + ClaimValueTypes.String, Options.AuthenticationType)); + } + if (!string.IsNullOrEmpty(context.Name)) + { + context.Identity.AddClaim(new Claim(ClaimTypes.Name, context.Name, ClaimValueTypes.String, + Options.AuthenticationType)); + } + if (!string.IsNullOrEmpty(context.Email)) + { + context.Identity.AddClaim(new Claim(ClaimTypes.Email, context.Email, ClaimValueTypes.String, + Options.AuthenticationType)); + } + + if (!string.IsNullOrEmpty(context.Profile)) + { + context.Identity.AddClaim(new Claim("urn:google:profile", context.Profile, ClaimValueTypes.String, + Options.AuthenticationType)); + } + context.Properties = properties; + + await Options.Notifications.Authenticated(context); + + return new AuthenticationTicket(context.Identity, context.Properties); + } + catch (Exception ex) + { + _logger.WriteError("Authentication failed", ex); + return new AuthenticationTicket(null, properties); + } + } + + protected override void ApplyResponseChallenge() + { + if (Response.StatusCode != 401) + { + return; + } + + // Active middleware should redirect on 401 even if there wasn't an explicit challenge. + if (ChallengeContext == null && Options.AuthenticationMode == AuthenticationMode.Passive) + { + return; + } + + string baseUri = + Request.Scheme + + "://" + + Request.Host + + Request.PathBase; + + string currentUri = + baseUri + + Request.Path + + Request.QueryString; + + string redirectUri = + baseUri + + Options.CallbackPath; + + AuthenticationProperties properties; + if (ChallengeContext == null) + { + properties = new AuthenticationProperties(); + } + else + { + properties = new AuthenticationProperties(ChallengeContext.Properties); + } + if (string.IsNullOrEmpty(properties.RedirectUri)) + { + properties.RedirectUri = currentUri; + } + + // OAuth2 10.12 CSRF + GenerateCorrelationId(properties); + + var queryStrings = new Dictionary(StringComparer.OrdinalIgnoreCase); + queryStrings.Add("response_type", "code"); + queryStrings.Add("client_id", Options.ClientId); + queryStrings.Add("redirect_uri", redirectUri); + + // space separated + string scope = string.Join(" ", Options.Scope); + if (string.IsNullOrEmpty(scope)) + { + // Google OAuth 2.0 asks for non-empty scope. If user didn't set it, set default scope to + // "openid profile email" to get basic user information. + scope = "openid profile email"; + } + AddQueryString(queryStrings, properties, "scope", scope); + + AddQueryString(queryStrings, properties, "access_type", Options.AccessType); + AddQueryString(queryStrings, properties, "approval_prompt"); + AddQueryString(queryStrings, properties, "login_hint"); + + string state = Options.StateDataFormat.Protect(properties); + queryStrings.Add("state", state); + + string authorizationEndpoint = QueryHelpers.AddQueryString(AuthorizeEndpoint, queryStrings); + + var redirectContext = new GoogleApplyRedirectContext( + Context, Options, + properties, authorizationEndpoint); + Options.Notifications.ApplyRedirect(redirectContext); + } + + public override async Task InvokeAsync() + { + return await InvokeReplyPathAsync(); + } + + private async Task InvokeReplyPathAsync() + { + if (Options.CallbackPath.HasValue && Options.CallbackPath == Request.Path) + { + // TODO: error responses + + AuthenticationTicket ticket = await AuthenticateAsync(); + if (ticket == null) + { + _logger.WriteWarning("Invalid return state, unable to redirect."); + Response.StatusCode = 500; + return true; + } + + var context = new GoogleReturnEndpointContext(Context, ticket); + context.SignInAsAuthenticationType = Options.SignInAsAuthenticationType; + context.RedirectUri = ticket.Properties.RedirectUri; + + await Options.Notifications.ReturnEndpoint(context); + + if (context.SignInAsAuthenticationType != null && + context.Identity != null) + { + ClaimsIdentity grantIdentity = context.Identity; + if (!string.Equals(grantIdentity.AuthenticationType, context.SignInAsAuthenticationType, StringComparison.Ordinal)) + { + grantIdentity = new ClaimsIdentity(grantIdentity.Claims, context.SignInAsAuthenticationType, grantIdentity.NameClaimType, grantIdentity.RoleClaimType); + } + Context.Response.SignIn(context.Properties, grantIdentity); + } + + if (!context.IsRequestCompleted && context.RedirectUri != null) + { + string redirectUri = context.RedirectUri; + if (context.Identity == null) + { + // add a redirect hint that sign-in failed in some way + redirectUri = QueryHelpers.AddQueryString(redirectUri, "error", "access_denied"); + } + Response.Redirect(redirectUri); + context.RequestCompleted(); + } + + return context.IsRequestCompleted; + } + return false; + } + + private static void AddQueryString(IDictionary queryStrings, AuthenticationProperties properties, + string name, string defaultValue = null) + { + string value; + if (!properties.Dictionary.TryGetValue(name, out value)) + { + value = defaultValue; + } + else + { + // Remove the parameter from AuthenticationProperties so it won't be serialized to state parameter + properties.Dictionary.Remove(name); + } + + if (value == null) + { + return; + } + + queryStrings[name] = value; + } + + protected override void ApplyResponseGrant() + { + // N/A - No SignIn or SignOut support. + } + } +} diff --git a/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationMiddleware.cs b/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationMiddleware.cs new file mode 100644 index 0000000000..15c5c2a79c --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationMiddleware.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Http; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Security.DataHandler; +using Microsoft.AspNet.Security.DataProtection; +using Microsoft.AspNet.Security.Infrastructure; +using Microsoft.Framework.Logging; + +namespace Microsoft.AspNet.Security.Google +{ + /// + /// ASP.NET middleware for authenticating users using Google OAuth 2.0 + /// + [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable", Justification = "Middleware are not disposable.")] + public class GoogleAuthenticationMiddleware : AuthenticationMiddleware + { + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + /// + /// Initializes a + /// + /// The next middleware in the HTTP pipeline to invoke + /// + /// + /// Configuration options for the middleware + public GoogleAuthenticationMiddleware( + RequestDelegate next, + IDataProtectionProvider dataProtectionProvider, + ILoggerFactory loggerFactory, + GoogleAuthenticationOptions options) + : base(next, options) + { + if (string.IsNullOrWhiteSpace(Options.ClientId)) + { + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resources.Exception_OptionMustBeProvided, "ClientId")); + } + if (string.IsNullOrWhiteSpace(Options.ClientSecret)) + { + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resources.Exception_OptionMustBeProvided, "ClientSecret")); + } + + _logger = loggerFactory.Create(typeof(GoogleAuthenticationMiddleware).FullName); + + if (Options.Notifications == null) + { + Options.Notifications = new GoogleAuthenticationNotifications(); + } + if (Options.StateDataFormat == null) + { + IDataProtector dataProtector = DataProtectionHelpers.CreateDataProtector(dataProtectionProvider, + typeof(GoogleAuthenticationMiddleware).FullName, options.AuthenticationType, "v1"); + Options.StateDataFormat = new PropertiesDataFormat(dataProtector); + } + + _httpClient = new HttpClient(ResolveHttpMessageHandler(Options)); + _httpClient.Timeout = Options.BackchannelTimeout; + _httpClient.MaxResponseContentBufferSize = 1024 * 1024 * 10; // 10 MB + } + + /// + /// Provides the object for processing authentication-related requests. + /// + /// An configured with the supplied to the constructor. + protected override AuthenticationHandler CreateHandler() + { + return new GoogleAuthenticationHandler(_httpClient, _logger); + } + + [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "Managed by caller")] + private static HttpMessageHandler ResolveHttpMessageHandler(GoogleAuthenticationOptions options) + { + HttpMessageHandler handler = options.BackchannelHttpHandler ?? +#if NET45 + new WebRequestHandler(); + // If they provided a validator, apply it or fail. + if (options.BackchannelCertificateValidator != null) + { + // Set the cert validate callback + var webRequestHandler = handler as WebRequestHandler; + if (webRequestHandler == null) + { + throw new InvalidOperationException(Resources.Exception_ValidatorHandlerMismatch); + } + webRequestHandler.ServerCertificateValidationCallback = options.BackchannelCertificateValidator.Validate; + } +#else + new WinHttpHandler(); +#endif + return handler; + } + } +} diff --git a/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationOptions.cs b/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationOptions.cs new file mode 100644 index 0000000000..fd7b069990 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/GoogleAuthenticationOptions.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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 Microsoft.AspNet.Http; +using Microsoft.AspNet.Http.Security; + +namespace Microsoft.AspNet.Security.Google +{ + /// + /// Configuration options for + /// + public class GoogleAuthenticationOptions : AuthenticationOptions + { + /// + /// Initializes a new + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", + MessageId = "Microsoft.AspNet.Security.Google.GoogleAuthenticationOptions.set_Caption(System.String)", + Justification = "Not localizable.")] + public GoogleAuthenticationOptions() + : base(GoogleAuthenticationDefaults.AuthenticationType) + { + Caption = GoogleAuthenticationDefaults.AuthenticationType; + CallbackPath = new PathString("/signin-google"); + AuthenticationMode = AuthenticationMode.Passive; + Scope = new List(); + BackchannelTimeout = TimeSpan.FromSeconds(60); + } + + /// + /// Gets or sets the Google-assigned client id + /// + public string ClientId { get; set; } + + /// + /// Gets or sets the Google-assigned client secret + /// + public string ClientSecret { get; set; } +#if NET45 + /// + /// Gets or sets the a pinned certificate validator to use to validate the endpoints used + /// in back channel communications belong to Google. + /// + /// + /// The pinned certificate validator. + /// + /// If this property is null then the default certificate checks are performed, + /// validating the subject name and if the signing chain is a trusted party. + public ICertificateValidator BackchannelCertificateValidator { get; set; } +#endif + /// + /// Gets or sets timeout value in milliseconds for back channel communications with Google. + /// + /// + /// The back channel timeout in milliseconds. + /// + public TimeSpan BackchannelTimeout { get; set; } + + /// + /// The HttpMessageHandler used to communicate with Google. + /// This cannot be set at the same time as BackchannelCertificateValidator unless the value + /// can be downcast to a WebRequestHandler. + /// + public HttpMessageHandler BackchannelHttpHandler { get; set; } + + /// + /// Get or sets the text that the user can display on a sign in user interface. + /// + public string Caption + { + get { return Description.Caption; } + set { Description.Caption = value; } + } + + /// + /// The request path within the application's base path where the user-agent will be returned. + /// The middleware will process this request when it arrives. + /// Default value is "/signin-google". + /// + public PathString CallbackPath { get; set; } + + /// + /// Gets or sets the name of another authentication middleware which will be responsible for actually issuing a user . + /// + public string SignInAsAuthenticationType { get; set; } + + /// + /// Gets or sets the used to handle authentication events. + /// + public IGoogleAuthenticationNotifications Notifications { get; set; } + + /// + /// Gets or sets the type used to secure data handled by the middleware. + /// + public ISecureDataFormat StateDataFormat { get; set; } + + /// + /// A list of permissions to request. + /// + public IList Scope { get; private set; } + + /// + /// access_type. Set to 'offline' to request a refresh token. + /// + public string AccessType { get; set; } + } +} diff --git a/src/Microsoft.AspNet.Security.Google/Microsoft.AspNet.Security.Google.kproj b/src/Microsoft.AspNet.Security.Google/Microsoft.AspNet.Security.Google.kproj new file mode 100644 index 0000000000..111093c645 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/Microsoft.AspNet.Security.Google.kproj @@ -0,0 +1,28 @@ + + + + 12.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 89bf8535-a849-458e-868a-a68fcf620486 + Library + + + + ConsoleDebugger + + + WebDebugger + + + + + + + + 2.0 + + + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Security.Google/NotNullAttribute.cs b/src/Microsoft.AspNet.Security.Google/NotNullAttribute.cs new file mode 100644 index 0000000000..e87e361e4d --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/NotNullAttribute.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNet.Security.Google +{ + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] + internal sealed class NotNullAttribute : Attribute + { + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Security.Google/Notifications/GoogleApplyRedirectContext.cs b/src/Microsoft.AspNet.Security.Google/Notifications/GoogleApplyRedirectContext.cs new file mode 100644 index 0000000000..ac020b5639 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/Notifications/GoogleApplyRedirectContext.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Http.Security; +using Microsoft.AspNet.Security.Notifications; + +namespace Microsoft.AspNet.Security.Google +{ + /// + /// Context passed when a Challenge causes a redirect to authorize endpoint in the Google OAuth 2.0 middleware + /// + public class GoogleApplyRedirectContext : BaseContext + { + /// + /// Creates a new context object. + /// + /// The HTTP request context + /// The Google OAuth 2.0 middleware options + /// The authenticaiton properties of the challenge + /// The initial redirect URI + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "3#", + Justification = "Represents header value")] + public GoogleApplyRedirectContext(HttpContext context, GoogleAuthenticationOptions options, + AuthenticationProperties properties, string redirectUri) + : base(context, options) + { + RedirectUri = redirectUri; + Properties = properties; + } + + /// + /// Gets the URI used for the redirect operation. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1056:UriPropertiesShouldNotBeStrings", Justification = "Represents header value")] + public string RedirectUri { get; private set; } + + /// + /// Gets the authenticaiton properties of the challenge + /// + public AuthenticationProperties Properties { get; private set; } + } +} diff --git a/src/Microsoft.AspNet.Security.Google/Notifications/GoogleAuthenticatedContext.cs b/src/Microsoft.AspNet.Security.Google/Notifications/GoogleAuthenticatedContext.cs new file mode 100644 index 0000000000..ca89962769 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/Notifications/GoogleAuthenticatedContext.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Globalization; +using System.Security.Claims; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Http.Security; +using Microsoft.AspNet.Security.Notifications; +using Newtonsoft.Json.Linq; + +namespace Microsoft.AspNet.Security.Google +{ + /// + /// Contains information about the login session as well as the user . + /// + public class GoogleAuthenticatedContext : BaseContext + { + /// + /// Initializes a + /// + /// The HTTP environment + /// The JSON-serialized Google user info + /// Google OAuth 2.0 access token + /// Goolge OAuth 2.0 refresh token + /// Seconds until expiration + public GoogleAuthenticatedContext(HttpContext context, JObject user, string accessToken, + string refreshToken, string expires) + : base(context) + { + User = user; + AccessToken = accessToken; + RefreshToken = refreshToken; + + int expiresValue; + if (Int32.TryParse(expires, NumberStyles.Integer, CultureInfo.InvariantCulture, out expiresValue)) + { + ExpiresIn = TimeSpan.FromSeconds(expiresValue); + } + + Id = TryGetValue(user, "id"); + Name = TryGetValue(user, "displayName"); + GivenName = TryGetValue(user, "name", "givenName"); + FamilyName = TryGetValue(user, "name", "familyName"); + Profile = TryGetValue(user, "url"); + Email = TryGetFirstValue(user, "emails", "value"); + } + + /// + /// Gets the JSON-serialized user + /// + /// + /// Contains the Google user obtained from the endpoint https://www.googleapis.com/oauth2/v3/userinfo + /// + public JObject User { get; private set; } + + /// + /// Gets the Google access token + /// + public string AccessToken { get; private set; } + + /// + /// Gets the Google refresh token + /// + /// + /// This value is not null only when access_type authorize parameter is offline. + /// + public string RefreshToken { get; private set; } + + /// + /// Gets the Google access token expiration time + /// + public TimeSpan? ExpiresIn { get; set; } + + /// + /// Gets the Google user ID + /// + public string Id { get; private set; } + + /// + /// Gets the user's name + /// + public string Name { get; private set; } + + /// + /// Gets the user's given name + /// + public string GivenName { get; set; } + + /// + /// Gets the user's family name + /// + public string FamilyName { get; set; } + + /// + /// Gets the user's profile link + /// + public string Profile { get; private set; } + + /// + /// Gets the user's email + /// + public string Email { get; private set; } + + /// + /// Gets the representing the user + /// + public ClaimsIdentity Identity { get; set; } + + /// + /// Gets or sets a property bag for common authentication properties + /// + public AuthenticationProperties Properties { get; set; } + + private static string TryGetValue(JObject user, string propertyName) + { + JToken value; + return user.TryGetValue(propertyName, out value) ? value.ToString() : null; + } + + // Get the given subProperty from a property. + private static string TryGetValue(JObject user, string propertyName, string subProperty) + { + JToken value; + if (user.TryGetValue(propertyName, out value)) + { + var subObject = JObject.Parse(value.ToString()); + if (subObject != null && subObject.TryGetValue(subProperty, out value)) + { + return value.ToString(); + } + } + return null; + } + + // Get the given subProperty from a list property. + private static string TryGetFirstValue(JObject user, string propertyName, string subProperty) + { + JToken value; + if (user.TryGetValue(propertyName, out value)) + { + var array = JArray.Parse(value.ToString()); + if (array != null && array.Count > 0) + { + var subObject = JObject.Parse(array.First.ToString()); + if (subObject != null) + { + if (subObject.TryGetValue(subProperty, out value)) + { + return value.ToString(); + } + } + } + } + return null; + } + } +} diff --git a/src/Microsoft.AspNet.Security.Google/Notifications/GoogleAuthenticationNotifications.cs b/src/Microsoft.AspNet.Security.Google/Notifications/GoogleAuthenticationNotifications.cs new file mode 100644 index 0000000000..af93619ed4 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/Notifications/GoogleAuthenticationNotifications.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Threading.Tasks; + +namespace Microsoft.AspNet.Security.Google +{ + /// + /// Default implementation. + /// + public class GoogleAuthenticationNotifications : IGoogleAuthenticationNotifications + { + /// + /// Initializes a + /// + public GoogleAuthenticationNotifications() + { + OnAuthenticated = context => Task.FromResult(null); + OnReturnEndpoint = context => Task.FromResult(null); + OnApplyRedirect = context => + context.Response.Redirect(context.RedirectUri); + } + + /// + /// Gets or sets the function that is invoked when the Authenticated method is invoked. + /// + public Func OnAuthenticated { get; set; } + + /// + /// Gets or sets the function that is invoked when the ReturnEndpoint method is invoked. + /// + public Func OnReturnEndpoint { get; set; } + + /// + /// Gets or sets the delegate that is invoked when the ApplyRedirect method is invoked. + /// + public Action OnApplyRedirect { get; set; } + + /// + /// Invoked whenever Google succesfully authenticates a user + /// + /// Contains information about the login session as well as the user . + /// A representing the completed operation. + public virtual Task Authenticated(GoogleAuthenticatedContext context) + { + return OnAuthenticated(context); + } + + /// + /// Invoked prior to the being saved in a local cookie and the browser being redirected to the originally requested URL. + /// + /// Contains context information and authentication ticket of the return endpoint. + /// A representing the completed operation. + public virtual Task ReturnEndpoint(GoogleReturnEndpointContext context) + { + return OnReturnEndpoint(context); + } + + /// + /// Called when a Challenge causes a redirect to authorize endpoint in the Google OAuth 2.0 middleware + /// + /// Contains redirect URI and of the challenge + public virtual void ApplyRedirect(GoogleApplyRedirectContext context) + { + OnApplyRedirect(context); + } + } +} diff --git a/src/Microsoft.AspNet.Security.Google/Notifications/GoogleReturnEndpointContext.cs b/src/Microsoft.AspNet.Security.Google/Notifications/GoogleReturnEndpointContext.cs new file mode 100644 index 0000000000..7172455ad2 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/Notifications/GoogleReturnEndpointContext.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Security.Notifications; + +namespace Microsoft.AspNet.Security.Google +{ + /// + /// Provides context information to middleware notifications. + /// + public class GoogleReturnEndpointContext : ReturnEndpointContext + { + /// + /// Initialize a + /// + /// HTTP environment + /// The authentication ticket + public GoogleReturnEndpointContext( + HttpContext context, + AuthenticationTicket ticket) + : base(context, ticket) + { + } + } +} diff --git a/src/Microsoft.AspNet.Security.Google/Notifications/IGoogleAuthenticationNotifications.cs b/src/Microsoft.AspNet.Security.Google/Notifications/IGoogleAuthenticationNotifications.cs new file mode 100644 index 0000000000..7bf9a34ab3 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/Notifications/IGoogleAuthenticationNotifications.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; + +namespace Microsoft.AspNet.Security.Google +{ + /// + /// Specifies callback methods which the invokes to enable developer control over the authentication process. /> + /// + public interface IGoogleAuthenticationNotifications + { + /// + /// Invoked whenever Google succesfully authenticates a user + /// + /// Contains information about the login session as well as the user . + /// A representing the completed operation. + Task Authenticated(GoogleAuthenticatedContext context); + + /// + /// Invoked prior to the being saved in a local cookie and the browser being redirected to the originally requested URL. + /// + /// Contains context information and authentication ticket of the return endpoint. + /// A representing the completed operation. + Task ReturnEndpoint(GoogleReturnEndpointContext context); + + /// + /// Called when a Challenge causes a redirect to authorize endpoint in the Google OAuth 2.0 middleware + /// + /// Contains redirect URI and of the challenge + void ApplyRedirect(GoogleApplyRedirectContext context); + } +} diff --git a/src/Microsoft.AspNet.Security.Google/Project.json b/src/Microsoft.AspNet.Security.Google/Project.json new file mode 100644 index 0000000000..9f19015e3d --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/Project.json @@ -0,0 +1,44 @@ +{ + "version": "1.0.0-*", + "dependencies": { + "Microsoft.AspNet.Http": "1.0.0-*", + "Microsoft.AspNet.RequestContainer": "1.0.0-*", + "Microsoft.AspNet.Security": "1.0.0-*", + "Microsoft.AspNet.Security.DataProtection": "1.0.0-*", + "Microsoft.AspNet.WebUtilities": "1.0.0-*", + "Microsoft.Framework.Logging": "1.0.0-*", + "Newtonsoft.Json": "5.0.8", + "System.Net.Http": "4.0.0.0" + }, + "frameworks": { + "net45": { + "dependencies": { + "System.Net.Http.WebRequest": "" + } + }, + "k10": { + "dependencies": { + "System.Collections": "4.0.10.0", + "System.ComponentModel": "4.0.0.0", + "System.Console": "4.0.0.0", + "System.Diagnostics.Debug": "4.0.10.0", + "System.Diagnostics.Tools": "4.0.0.0", + "System.Globalization": "4.0.10.0", + "System.IO": "4.0.10.0", + "System.IO.Compression": "4.0.0.0", + "System.Linq": "4.0.0.0", + "System.Net.Http.WinHttpHandler": "4.0.0.0", + "System.Reflection": "4.0.10.0", + "System.Resources.ResourceManager": "4.0.0.0", + "System.Runtime": "4.0.20.0", + "System.Runtime.Extensions": "4.0.10.0", + "System.Runtime.InteropServices": "4.0.20.0", + "System.Security.Claims": "1.0.0-*", + "System.Security.Cryptography.Hashing.Algorithms": "4.0.0.0", + "System.Security.Principal": "4.0.0.0", + "System.Threading": "4.0.0.0", + "System.Threading.Tasks": "4.0.10.0" + } + } + } +} diff --git a/src/Microsoft.AspNet.Security.Google/Resources.Designer.cs b/src/Microsoft.AspNet.Security.Google/Resources.Designer.cs new file mode 100644 index 0000000000..235dcbeef7 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/Resources.Designer.cs @@ -0,0 +1,81 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.34003 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.AspNet.Security.Google { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.AspNet.Security.Google.Resources", System.Reflection.IntrospectionExtensions.GetTypeInfo(typeof(Resources)).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The '{0}' option must be provided.. + /// + internal static string Exception_OptionMustBeProvided { + get { + return ResourceManager.GetString("Exception_OptionMustBeProvided", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An ICertificateValidator cannot be specified at the same time as an HttpMessageHandler unless it is a WebRequestHandler.. + /// + internal static string Exception_ValidatorHandlerMismatch { + get { + return ResourceManager.GetString("Exception_ValidatorHandlerMismatch", resourceCulture); + } + } + } +} diff --git a/src/Microsoft.AspNet.Security.Google/Resources.resx b/src/Microsoft.AspNet.Security.Google/Resources.resx new file mode 100644 index 0000000000..2a19bea96a --- /dev/null +++ b/src/Microsoft.AspNet.Security.Google/Resources.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The '{0}' option must be provided. + + + An ICertificateValidator cannot be specified at the same time as an HttpMessageHandler unless it is a WebRequestHandler. + + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Security/Infrastructure/AuthenticationHandler.cs b/src/Microsoft.AspNet.Security/Infrastructure/AuthenticationHandler.cs index 57c1931f9c..7970caa3a2 100644 --- a/src/Microsoft.AspNet.Security/Infrastructure/AuthenticationHandler.cs +++ b/src/Microsoft.AspNet.Security/Infrastructure/AuthenticationHandler.cs @@ -351,9 +351,6 @@ namespace Microsoft.AspNet.Security.Infrastructure Response.Cookies.Append(correlationKey, correlationId, cookieOptions); } - [SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", - MessageId = "Microsoft.Owin.Logging.LoggerExtensions.WriteWarning(Microsoft.Owin.Logging.ILogger,System.String,System.String[])", - Justification = "Logging is not Localized")] protected bool ValidateCorrelationId([NotNull] AuthenticationProperties properties, [NotNull] ILogger logger) { string correlationKey = Constants.CorrelationPrefix + BaseOptions.AuthenticationType; diff --git a/test/Microsoft.AspNet.Security.Test/Google/GoogleMiddlewareTests.cs b/test/Microsoft.AspNet.Security.Test/Google/GoogleMiddlewareTests.cs new file mode 100644 index 0000000000..9dff1506d5 --- /dev/null +++ b/test/Microsoft.AspNet.Security.Test/Google/GoogleMiddlewareTests.cs @@ -0,0 +1,570 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Http.Security; +using Microsoft.AspNet.Security.Cookies; +using Microsoft.AspNet.TestHost; +using Newtonsoft.Json; +using Shouldly; +using Xunit; + +namespace Microsoft.AspNet.Security.Google +{ + public class GoogleMiddlewareTests + { + private const string CookieAuthenticationType = "Cookie"; + + [Fact] + public async Task ChallengeWillTriggerRedirection() + { + var server = CreateServer(new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret" + }); + var transaction = await SendAsync(server, "https://example.com/challenge"); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.Redirect); + var location = transaction.Response.Headers.Location.ToString(); + location.ShouldContain("https://accounts.google.com/o/oauth2/auth?response_type=code"); + location.ShouldContain("&client_id="); + location.ShouldContain("&redirect_uri="); + location.ShouldContain("&scope="); + location.ShouldContain("&state="); + + location.ShouldNotContain("access_type="); + location.ShouldNotContain("approval_prompt="); + location.ShouldNotContain("login_hint="); + } + + [Fact] + public async Task Challenge401WillTriggerRedirection() + { + var server = CreateServer(new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret", + AuthenticationMode = AuthenticationMode.Active, + }); + var transaction = await SendAsync(server, "https://example.com/401"); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.Redirect); + var location = transaction.Response.Headers.Location.ToString(); + location.ShouldContain("https://accounts.google.com/o/oauth2/auth?response_type=code"); + location.ShouldContain("&client_id="); + location.ShouldContain("&redirect_uri="); + location.ShouldContain("&scope="); + location.ShouldContain("&state="); + } + + [Fact] + public async Task ChallengeWillSetCorrelationCookie() + { + var server = CreateServer(new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret" + }); + var transaction = await SendAsync(server, "https://example.com/challenge"); + Console.WriteLine(transaction.SetCookie); + transaction.SetCookie.Single().ShouldContain(".AspNet.Correlation.Google="); + } + + [Fact] + public async Task Challenge401WillSetCorrelationCookie() + { + var server = CreateServer(new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret", + AuthenticationMode = AuthenticationMode.Active, + }); + var transaction = await SendAsync(server, "https://example.com/401"); + Console.WriteLine(transaction.SetCookie); + transaction.SetCookie.Single().ShouldContain(".AspNet.Correlation.Google="); + } + + [Fact] + public async Task ChallengeWillSetDefaultScope() + { + var server = CreateServer(new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret" + }); + var transaction = await SendAsync(server, "https://example.com/challenge"); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.Redirect); + var query = transaction.Response.Headers.Location.Query; + query.ShouldContain("&scope=" + Uri.EscapeDataString("openid profile email")); + } + + [Fact] + public async Task Challenge401WillSetDefaultScope() + { + var server = CreateServer(new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret", + AuthenticationMode = AuthenticationMode.Active, + }); + var transaction = await SendAsync(server, "https://example.com/401"); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.Redirect); + var query = transaction.Response.Headers.Location.Query; + query.ShouldContain("&scope=" + Uri.EscapeDataString("openid profile email")); + } + + [Fact] + public async Task ChallengeWillUseOptionsScope() + { + var options = new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret", + }; + options.Scope.Add("https://www.googleapis.com/auth/plus.login"); + var server = CreateServer(options); + var transaction = await SendAsync(server, "https://example.com/challenge"); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.Redirect); + var query = transaction.Response.Headers.Location.Query; + query.ShouldContain("&scope=" + Uri.EscapeDataString("https://www.googleapis.com/auth/plus.login")); + } + + [Fact] + public async Task ChallengeWillUseAuthenticationPropertiesAsParameters() + { + var options = new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret" + }; + var server = CreateServer(options, + context => + { + var req = context.Request; + var res = context.Response; + if (req.Path == new PathString("/challenge2")) + { + res.Challenge(new AuthenticationProperties( + new Dictionary() + { + { "scope", "https://www.googleapis.com/auth/plus.login" }, + { "access_type", "offline" }, + { "approval_prompt", "force" }, + { "login_hint", "test@example.com" } + }), "Google"); + res.StatusCode = 401; + } + + return Task.FromResult(null); + }); + var transaction = await SendAsync(server, "https://example.com/challenge2"); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.Redirect); + var query = transaction.Response.Headers.Location.Query; + query.ShouldContain("scope=" + Uri.EscapeDataString("https://www.googleapis.com/auth/plus.login")); + query.ShouldContain("access_type=offline"); + query.ShouldContain("approval_prompt=force"); + query.ShouldContain("login_hint=" + Uri.EscapeDataString("test@example.com")); + } + + [Fact] + public async Task ChallengeWillTriggerApplyRedirectEvent() + { + var options = new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret", + Notifications = new GoogleAuthenticationNotifications + { + OnApplyRedirect = context => + { + context.Response.Redirect(context.RedirectUri + "&custom=test"); + } + } + }; + var server = CreateServer(options); + var transaction = await SendAsync(server, "https://example.com/challenge"); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.Redirect); + var query = transaction.Response.Headers.Location.Query; + query.ShouldContain("custom=test"); + } + + [Fact] + public async Task ReplyPathWithoutStateQueryStringWillBeRejected() + { + var options = new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret" + }; + var server = CreateServer(options); + var transaction = await SendAsync(server, "https://example.com/signin-google?code=TestCode"); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + public async Task ReplyPathWillAuthenticateValidAuthorizeCodeAndState() + { + var options = new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret", + BackchannelHttpHandler = new TestHttpMessageHandler + { + Sender = async req => + { + if (req.RequestUri.AbsoluteUri == "https://accounts.google.com/o/oauth2/token") + { + return await ReturnJsonResponse(new + { + access_token = "Test Access Token", + expire_in = 3600, + token_type = "Bearer" + }); + } + else if (req.RequestUri.GetLeftPart(UriPartial.Path) == "https://www.googleapis.com/plus/v1/people/me") + { + return await ReturnJsonResponse(new + { + id = "Test User ID", + displayName = "Test Name", + name = new + { + familyName = "Test Family Name", + givenName = "Test Given Name" + }, + url = "Profile link", + emails = new[] + { + new + { + value = "Test email", + type = "account" + } + } + }); + } + + return null; + } + } + }; + var server = CreateServer(options); + var properties = new AuthenticationProperties(); + var correlationKey = ".AspNet.Correlation.Google"; + var correlationValue = "TestCorrelationId"; + properties.Dictionary.Add(correlationKey, correlationValue); + properties.RedirectUri = "/me"; + var state = options.StateDataFormat.Protect(properties); + var transaction = await SendAsync(server, + "https://example.com/signin-google?code=TestCode&state=" + Uri.EscapeDataString(state), + correlationKey + "=" + correlationValue); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.Redirect); + transaction.Response.Headers.Location.ToString().ShouldBe("/me"); + transaction.SetCookie.Count.ShouldBe(2); + transaction.SetCookie[0].ShouldContain(correlationKey); + transaction.SetCookie[1].ShouldContain(".AspNet.Cookie"); + + var authCookie = transaction.AuthenticationCookieValue; + transaction = await SendAsync(server, "https://example.com/me", authCookie); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.OK); + transaction.FindClaimValue(ClaimTypes.Name).ShouldBe("Test Name"); + transaction.FindClaimValue(ClaimTypes.NameIdentifier).ShouldBe("Test User ID"); + transaction.FindClaimValue(ClaimTypes.GivenName).ShouldBe("Test Given Name"); + transaction.FindClaimValue(ClaimTypes.Surname).ShouldBe("Test Family Name"); + transaction.FindClaimValue(ClaimTypes.Email).ShouldBe("Test email"); + } + + [Fact] + public async Task ReplyPathWillRejectIfCodeIsInvalid() + { + var options = new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret", + BackchannelHttpHandler = new TestHttpMessageHandler + { + Sender = req => + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)); + } + } + }; + var server = CreateServer(options); + var properties = new AuthenticationProperties(); + var correlationKey = ".AspNet.Correlation.Google"; + var correlationValue = "TestCorrelationId"; + properties.Dictionary.Add(correlationKey, correlationValue); + properties.RedirectUri = "/me"; + var state = options.StateDataFormat.Protect(properties); + var transaction = await SendAsync(server, + "https://example.com/signin-google?code=TestCode&state=" + Uri.EscapeDataString(state), + correlationKey + "=" + correlationValue); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.Redirect); + transaction.Response.Headers.Location.ToString().ShouldContain("error=access_denied"); + } + + [Fact] + public async Task ReplyPathWillRejectIfAccessTokenIsMissing() + { + var options = new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret", + BackchannelHttpHandler = new TestHttpMessageHandler + { + Sender = async req => + { + return await ReturnJsonResponse(new object()); + } + } + }; + var server = CreateServer(options); + var properties = new AuthenticationProperties(); + var correlationKey = ".AspNet.Correlation.Google"; + var correlationValue = "TestCorrelationId"; + properties.Dictionary.Add(correlationKey, correlationValue); + properties.RedirectUri = "/me"; + var state = options.StateDataFormat.Protect(properties); + var transaction = await SendAsync(server, + "https://example.com/signin-google?code=TestCode&state=" + Uri.EscapeDataString(state), + correlationKey + "=" + correlationValue); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.Redirect); + transaction.Response.Headers.Location.ToString().ShouldContain("error=access_denied"); + } + + [Fact] + public async Task AuthenticatedEventCanGetRefreshToken() + { + var options = new GoogleAuthenticationOptions() + { + ClientId = "Test Id", + ClientSecret = "Test Secret", + BackchannelHttpHandler = new TestHttpMessageHandler + { + Sender = async req => + { + if (req.RequestUri.AbsoluteUri == "https://accounts.google.com/o/oauth2/token") + { + return await ReturnJsonResponse(new + { + access_token = "Test Access Token", + expire_in = 3600, + token_type = "Bearer", + refresh_token = "Test Refresh Token" + }); + } + else if (req.RequestUri.GetLeftPart(UriPartial.Path) == "https://www.googleapis.com/plus/v1/people/me") + { + return await ReturnJsonResponse(new + { + id = "Test User ID", + displayName = "Test Name", + name = new + { + familyName = "Test Family Name", + givenName = "Test Given Name" + }, + url = "Profile link", + emails = new[] + { + new + { + value = "Test email", + type = "account" + } + } + }); + } + + return null; + } + }, + Notifications = new GoogleAuthenticationNotifications() + { + OnAuthenticated = context => + { + var refreshToken = context.RefreshToken; + context.Identity.AddClaim(new Claim("RefreshToken", refreshToken)); + return Task.FromResult(null); + } + } + }; + var server = CreateServer(options); + var properties = new AuthenticationProperties(); + var correlationKey = ".AspNet.Correlation.Google"; + var correlationValue = "TestCorrelationId"; + properties.Dictionary.Add(correlationKey, correlationValue); + properties.RedirectUri = "/me"; + var state = options.StateDataFormat.Protect(properties); + var transaction = await SendAsync(server, + "https://example.com/signin-google?code=TestCode&state=" + Uri.EscapeDataString(state), + correlationKey + "=" + correlationValue); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.Redirect); + transaction.Response.Headers.Location.ToString().ShouldBe("/me"); + transaction.SetCookie.Count.ShouldBe(2); + transaction.SetCookie[0].ShouldContain(correlationKey); + transaction.SetCookie[1].ShouldContain(".AspNet.Cookie"); + + var authCookie = transaction.AuthenticationCookieValue; + transaction = await SendAsync(server, "https://example.com/me", authCookie); + transaction.Response.StatusCode.ShouldBe(HttpStatusCode.OK); + transaction.FindClaimValue("RefreshToken").ShouldBe("Test Refresh Token"); + } + + private static async Task ReturnJsonResponse(object content) + { + var res = new HttpResponseMessage(HttpStatusCode.OK); + var text = await JsonConvert.SerializeObjectAsync(content); + res.Content = new StringContent(text, Encoding.UTF8, "application/json"); + return res; + } + + private static async Task SendAsync(TestServer server, string uri, string cookieHeader = null) + { + var request = new HttpRequestMessage(HttpMethod.Get, uri); + if (!string.IsNullOrEmpty(cookieHeader)) + { + request.Headers.Add("Cookie", cookieHeader); + } + var transaction = new Transaction + { + Request = request, + Response = await server.CreateClient().SendAsync(request), + }; + if (transaction.Response.Headers.Contains("Set-Cookie")) + { + transaction.SetCookie = transaction.Response.Headers.GetValues("Set-Cookie").ToList(); + } + transaction.ResponseText = await transaction.Response.Content.ReadAsStringAsync(); + + if (transaction.Response.Content != null && + transaction.Response.Content.Headers.ContentType != null && + transaction.Response.Content.Headers.ContentType.MediaType == "text/xml") + { + transaction.ResponseElement = XElement.Parse(transaction.ResponseText); + } + return transaction; + } + + private static TestServer CreateServer(GoogleAuthenticationOptions options, Func testpath = null) + { + return TestServer.Create(app => + { + app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationType); + app.UseCookieAuthentication(new CookieAuthenticationOptions() + { + AuthenticationType = CookieAuthenticationType + }); + app.UseGoogleAuthentication(options); + app.Use(async (context, next) => + { + var req = context.Request; + var res = context.Response; + if (req.Path == new PathString("/challenge")) + { + res.Challenge("Google"); + res.StatusCode = 401; + } + else if (req.Path == new PathString("/me")) + { + Describe(res, (ClaimsIdentity)context.User.Identity); + } + else if (req.Path == new PathString("/401")) + { + res.StatusCode = 401; + } + else if (testpath != null) + { + await testpath(context); + } + else + { + await next(); + } + }); + }); + } + + private static void Describe(HttpResponse res, ClaimsIdentity identity) + { + res.StatusCode = 200; + res.ContentType = "text/xml"; + var xml = new XElement("xml"); + if (identity != null) + { + xml.Add(identity.Claims.Select(claim => new XElement("claim", new XAttribute("type", claim.Type), new XAttribute("value", claim.Value)))); + } + using (var memory = new MemoryStream()) + { + using (var writer = new XmlTextWriter(memory, Encoding.UTF8)) + { + xml.WriteTo(writer); + } + res.Body.Write(memory.ToArray(), 0, memory.ToArray().Length); + } + } + + private class TestHttpMessageHandler : HttpMessageHandler + { + public Func> Sender { get; set; } + + protected override async Task SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) + { + if (Sender != null) + { + return await Sender(request); + } + + return null; + } + } + + private class Transaction + { + public HttpRequestMessage Request { get; set; } + public HttpResponseMessage Response { get; set; } + + public IList SetCookie { get; set; } + + public string ResponseText { get; set; } + public XElement ResponseElement { get; set; } + + public string AuthenticationCookieValue + { + get + { + if (SetCookie != null && SetCookie.Count > 0) + { + var authCookie = SetCookie.SingleOrDefault(c => c.Contains(".AspNet.Cookie=")); + if (authCookie != null) + { + return authCookie.Substring(0, authCookie.IndexOf(';')); + } + } + + return null; + } + } + + public string FindClaimValue(string claimType) + { + XElement claim = ResponseElement.Elements("claim").SingleOrDefault(elt => elt.Attribute("type").Value == claimType); + if (claim == null) + { + return null; + } + return claim.Attribute("value").Value; + } + } + } +} diff --git a/test/Microsoft.AspNet.Security.Test/project.json b/test/Microsoft.AspNet.Security.Test/project.json index d45c84b1ff..542d5ee259 100644 --- a/test/Microsoft.AspNet.Security.Test/project.json +++ b/test/Microsoft.AspNet.Security.Test/project.json @@ -4,9 +4,10 @@ }, "dependencies": { "Microsoft.AspNet.Http": "1.0.0-*", - "Microsoft.AspNet.Security" : "", - "Microsoft.AspNet.Security.Cookies" : "", - "Microsoft.AspNet.Security.Facebook" : "", + "Microsoft.AspNet.Security" : "1.0.0-*", + "Microsoft.AspNet.Security.Cookies" : "1.0.0-*", + "Microsoft.AspNet.Security.Facebook" : "1.0.0-*", + "Microsoft.AspNet.Security.Google" : "1.0.0-*", "Microsoft.AspNet.TestHost": "1.0.0-*", "Microsoft.Framework.DependencyInjection": "1.0.0-*", "System.Net.Http": "4.0.0.0",