From 6c65350b883d7da5ec07d85ab92c8bec6f4a9d44 Mon Sep 17 00:00:00 2001 From: HScokies <102805473+HScokies@users.noreply.github.com> Date: Thu, 7 Nov 2024 09:58:44 +0000 Subject: [PATCH] Add VK ID provider (#962) Add new provider for VK ID. --- AspNet.Security.OAuth.Providers.sln | 7 + README.md | 1 + eng/Versions.props | 4 +- .../AspNet.Security.OAuth.VkId.csproj | 23 ++ .../VkIdAuthenticationConstants.cs | 24 ++ .../VkIdAuthenticationDefaults.cs | 48 +++ .../VkIdAuthenticationExtensions.cs | 74 ++++ .../VkIdAuthenticationHandler.cs | 327 ++++++++++++++++++ .../VkIdAuthenticationOptions.cs | 38 ++ .../VkIdAuthenticationScopes.cs | 98 ++++++ .../VkId/VkIdTests.cs | 39 +++ .../VkId/bundle.json | 37 ++ 12 files changed, 718 insertions(+), 2 deletions(-) create mode 100644 src/AspNet.Security.OAuth.VkId/AspNet.Security.OAuth.VkId.csproj create mode 100644 src/AspNet.Security.OAuth.VkId/VkIdAuthenticationConstants.cs create mode 100644 src/AspNet.Security.OAuth.VkId/VkIdAuthenticationDefaults.cs create mode 100644 src/AspNet.Security.OAuth.VkId/VkIdAuthenticationExtensions.cs create mode 100644 src/AspNet.Security.OAuth.VkId/VkIdAuthenticationHandler.cs create mode 100644 src/AspNet.Security.OAuth.VkId/VkIdAuthenticationOptions.cs create mode 100644 src/AspNet.Security.OAuth.VkId/VkIdAuthenticationScopes.cs create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/VkId/VkIdTests.cs create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/VkId/bundle.json diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln index d81fac018..e5b64b67c 100644 --- a/AspNet.Security.OAuth.Providers.sln +++ b/AspNet.Security.OAuth.Providers.sln @@ -311,6 +311,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Docus EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Zoho", "src\AspNet.Security.OAuth.Zoho\AspNet.Security.OAuth.Zoho.csproj", "{CD56ABE4-1CD2-4029-B556-E110A31A2CC4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.VkId", "src\AspNet.Security.OAuth.VkId\AspNet.Security.OAuth.VkId.csproj", "{F3E62C24-5F82-4CF5-A994-0E10D04FB495}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -721,6 +723,10 @@ Global {CD56ABE4-1CD2-4029-B556-E110A31A2CC4}.Debug|Any CPU.Build.0 = Debug|Any CPU {CD56ABE4-1CD2-4029-B556-E110A31A2CC4}.Release|Any CPU.ActiveCfg = Release|Any CPU {CD56ABE4-1CD2-4029-B556-E110A31A2CC4}.Release|Any CPU.Build.0 = Release|Any CPU + {F3E62C24-5F82-4CF5-A994-0E10D04FB495}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3E62C24-5F82-4CF5-A994-0E10D04FB495}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3E62C24-5F82-4CF5-A994-0E10D04FB495}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3E62C24-5F82-4CF5-A994-0E10D04FB495}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -833,6 +839,7 @@ Global {55975423-C9C0-4C47-AD00-0F012F30AD3C} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {4E96BD06-04CD-4014-BA42-10D2CDB820D6} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {CD56ABE4-1CD2-4029-B556-E110A31A2CC4} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} + {F3E62C24-5F82-4CF5-A994-0E10D04FB495} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C7B54DE2-6407-4802-AD9C-CE54BF414C8C} diff --git a/README.md b/README.md index d38df715a..3b5492d9c 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,7 @@ If a provider you're looking for does not exist, consider making a PR to add one | Untappd | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Untappd?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Untappd/ "Download AspNet.Security.OAuth.Untappd from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Untappd?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Untappd "Download AspNet.Security.OAuth.Untappd from MyGet.org") | [Documentation](https://untappd.com/api/docs#authentication "Untappd developer documentation") | | Vimeo | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Vimeo?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Vimeo/ "Download AspNet.Security.OAuth.Vimeo from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Vimeo?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Vimeo "Download AspNet.Security.OAuth.Vimeo from MyGet.org") | [Documentation](https://developer.vimeo.com/api/authentication "Vimeo developer documentation") | | Visual Studio (Azure DevOps) | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.VisualStudio?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.VisualStudio/ "Download AspNet.Security.OAuth.VisualStudio from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.VisualStudio?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.VisualStudio "Download AspNet.Security.OAuth.VisualStudio from MyGet.org") | [Documentation](https://docs.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops "Azure DevOps developer documentation") | +| VK ID | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.VkId?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.VkId/ "Download AspNet.Security.OAuth.VkId from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.VkId?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.VkId "Download AspNet.Security.OAuth.VkId from MyGet.org") | [Documentation](https://id.vk.com/about/business/go/docs/en/vkid/latest/vk-id/connection/start-integration/auth-without-sdk-web/ "VK ID developer documentation") | | Vkontakte | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Vkontakte?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Vkontakte/ "Download AspNet.Security.OAuth.Vkontakte from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Vkontakte?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Vkontakte "Download AspNet.Security.OAuth.Vkontakte from MyGet.org") | [Documentation](https://vk.com/dev/access_token "Vkontakte developer documentation") | | Weibo | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Weibo?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Weibo/ "Download AspNet.Security.OAuth.Weibo from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Weibo?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Weibo "Download AspNet.Security.OAuth.Weibo from MyGet.org") | [Documentation](https://open.weibo.com/wiki/Oauth/en "Weibo developer documentation") | | Weixin (WeChat) | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Weixin?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Weixin/ "Download AspNet.Security.OAuth.Weixin from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Weixin?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Weixin "Download AspNet.Security.OAuth.Weixin from MyGet.org") | [Documentation](https://open.wechat.com/cgi-bin/newreadtemplate?t=overseas_open/docs/web/login/login "WeChat developer documentation") | diff --git a/eng/Versions.props b/eng/Versions.props index b019c500d..8961f773e 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -2,8 +2,8 @@ 8 - 2 - 1 + 3 + 0 $(MajorVersion).$(MinorVersion).$(PatchVersion) 8.0.0 preview diff --git a/src/AspNet.Security.OAuth.VkId/AspNet.Security.OAuth.VkId.csproj b/src/AspNet.Security.OAuth.VkId/AspNet.Security.OAuth.VkId.csproj new file mode 100644 index 000000000..d65be167f --- /dev/null +++ b/src/AspNet.Security.OAuth.VkId/AspNet.Security.OAuth.VkId.csproj @@ -0,0 +1,23 @@ + + + + 8.3.0 + $(DefaultNetCoreTargetFramework) + + + + ASP.NET Core security middleware enabling VK ID authentication. + hscokies + aspnetcore;authentication;oauth;security;vkontakte;vkid + + + + + + + + + + true + + diff --git a/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationConstants.cs b/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationConstants.cs new file mode 100644 index 000000000..a76b2d5db --- /dev/null +++ b/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationConstants.cs @@ -0,0 +1,24 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. +*/ + +namespace AspNet.Security.OAuth.VkId; + +/// +/// Contains constants specific to the . +/// +public static class VkIdAuthenticationConstants +{ + public static class Claims + { + public const string Avatar = "urn:vkid:avatar:link"; + public const string IsVerified = "urn:vkid:verified"; + } + + public static class AuthenticationProperties + { + public const string DeviceId = "DeviceId"; + } +} diff --git a/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationDefaults.cs new file mode 100644 index 000000000..75d7a48fe --- /dev/null +++ b/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationDefaults.cs @@ -0,0 +1,48 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +namespace AspNet.Security.OAuth.VkId; + +/// +/// Default values used by the VK ID authentication middleware. +/// +public static class VkIdAuthenticationDefaults +{ + /// + /// Default value for . + /// + public static readonly string AuthenticationScheme = "VK ID"; + + /// + /// Default value for . + /// + public static readonly string DisplayName = "VK ID"; + + /// + /// Default value for . + /// + public static readonly string ClaimsIssuer = "VK ID"; + + /// + /// Default value for . + /// + public static readonly string CallbackPath = "/signin-vkid"; + + /// + /// Default value for . + /// + public static readonly string AuthorizationEndpoint = "https://id.vk.com/authorize"; + + /// + /// Default value for . + /// + public static readonly string TokenEndpoint = "https://id.vk.com/oauth2/auth"; + + /// + /// Default value for . + /// + public static readonly string UserInformationEndpoint = "https://id.vk.com/oauth2/user_info"; +} diff --git a/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationExtensions.cs new file mode 100644 index 000000000..e77b6ba52 --- /dev/null +++ b/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationExtensions.cs @@ -0,0 +1,74 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using Microsoft.Extensions.DependencyInjection; + +namespace AspNet.Security.OAuth.VkId; + +/// +/// Extension methods to add VK ID authentication capabilities to an HTTP application pipeline. +/// +public static class VkIdAuthenticationExtensions +{ + /// + /// Adds to the specified + /// , which enables VK ID authentication capabilities. + /// + /// The authentication builder. + /// The . + public static AuthenticationBuilder AddVkId([NotNull] this AuthenticationBuilder builder) + { + return builder.AddVkId(VkIdAuthenticationDefaults.AuthenticationScheme, _ => { }); + } + + /// + /// Adds to the specified + /// , which enables VK ID authentication capabilities. + /// + /// The authentication builder. + /// The delegate used to configure the options. + /// The . + public static AuthenticationBuilder AddVkId( + [NotNull] this AuthenticationBuilder builder, + [NotNull] Action configuration) + { + return builder.AddVkId(VkIdAuthenticationDefaults.AuthenticationScheme, configuration); + } + + /// + /// Adds to the specified + /// , which enables VK ID authentication capabilities. + /// + /// The authentication builder. + /// The authentication scheme associated with this instance. + /// The delegate used to configure the options. + /// The . + public static AuthenticationBuilder AddVkId( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [NotNull] Action configuration) + { + return builder.AddVkId(scheme, VkIdAuthenticationDefaults.DisplayName, configuration); + } + + /// + /// Adds to the specified + /// , which enables VK ID authentication capabilities. + /// + /// The authentication builder. + /// The authentication scheme associated with this instance. + /// The optional display name associated with this instance. + /// The delegate used to configure the options. + /// The . + public static AuthenticationBuilder AddVkId( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [CanBeNull] string caption, + [NotNull] Action configuration) + { + return builder.AddOAuth(scheme, caption, configuration); + } +} diff --git a/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationHandler.cs b/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationHandler.cs new file mode 100644 index 000000000..a7f3a16f5 --- /dev/null +++ b/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationHandler.cs @@ -0,0 +1,327 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Base64UrlEncoder = Microsoft.AspNetCore.Authentication.Base64UrlTextEncoder; + +namespace AspNet.Security.OAuth.VkId; + +public sealed partial class VkIdAuthenticationHandler : OAuthHandler +{ + public VkIdAuthenticationHandler( + [NotNull] IOptionsMonitor options, + [NotNull] ILoggerFactory logger, + [NotNull] UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override string BuildChallengeUrl( + [NotNull] AuthenticationProperties properties, + [NotNull] string redirectUri) + { + // It's mandatory to use PKCE + var data = RandomNumberGenerator.GetBytes(32); + var codeVerifierKey = Base64UrlEncoder.Encode(data); + properties.Items.Add(OAuthConstants.CodeVerifierKey, codeVerifierKey); + + var query = new Dictionary + { + ["response_type"] = "code", + ["client_id"] = Options.ClientId, + ["scope"] = FormatScope(Options.Scope), + ["redirect_uri"] = redirectUri, + ["state"] = Options.StateDataFormat.Protect(properties), + ["code_challenge"] = WebEncoders.Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifierKey))), + ["code_challenge_method"] = OAuthConstants.CodeChallengeMethodS256 + }; + return QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, query); + } + + protected override async Task HandleRemoteAuthenticateAsync() + { + var query = Request.Query; + var properties = Options.StateDataFormat.Unprotect(query["state"]); + if (properties is null) + { + return HandleRequestResult.Fail("The oauth state was missing or invalid."); + } + + // OAuth2 10.12 CSRF + if (!ValidateCorrelationId(properties)) + { + return HandleRequestResult.Fail("Correlation failed.", properties); + } + + // According to docs query cannot contain errors but VK documentation tends to lie so debug log here + Log.CodeResponse(Logger, query); + + var code = Request.Query["code"]; + if (StringValues.IsNullOrEmpty(code)) + { + return HandleRequestResult.Fail("Code was not found.", properties); + } + + var deviceId = Request.Query["device_id"]; + if (StringValues.IsNullOrEmpty(deviceId)) + { + return HandleRequestResult.Fail("Device ID was not found.", properties); + } + + properties.Items.Add(VkIdAuthenticationConstants.AuthenticationProperties.DeviceId, deviceId); + var codeExchangeContext = new OAuthCodeExchangeContext( + properties, + code!, + BuildRedirectUri(Options.CallbackPath)); + + using var tokens = await ExchangeCodeAsync(codeExchangeContext); + if (tokens.Error is not null) + { + return HandleRequestResult.Fail(tokens.Error, properties); + } + + if (string.IsNullOrEmpty(tokens.AccessToken)) + { + return HandleRequestResult.Fail("Failed to retrieve access token.", properties); + } + + if (Options.SaveTokens) + { + var tokensToStore = new List + { + new() + { + Name = "access_token", + Value = tokens.AccessToken, + }, + }; + + if (!string.IsNullOrEmpty(tokens.RefreshToken)) + { + tokensToStore.Add(new AuthenticationToken + { + Name = "refresh_token", + Value = tokens.RefreshToken, + }); + } + + if (int.TryParse(tokens.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out var expiresIn)) + { + var expiresAt = TimeProvider + .GetUtcNow() + .AddSeconds(expiresIn); + + tokensToStore.Add(new AuthenticationToken + { + Name = "expires_at", + Value = expiresAt.ToString("o", CultureInfo.InvariantCulture) + }); + } + + if (!string.IsNullOrEmpty(tokens.TokenType)) + { + tokensToStore.Add(new AuthenticationToken + { + Name = "token_type", + Value = tokens.TokenType + }); + } + + properties.StoreTokens(tokensToStore); + } + + var identity = new ClaimsIdentity(ClaimsIssuer); + var ticket = await CreateTicketAsync(identity, properties, tokens); + return HandleRequestResult.Success(ticket); + } + + protected override async Task ExchangeCodeAsync([NotNull] OAuthCodeExchangeContext context) + { + // Both device_id and code_verifier are required to get access token + if (!context.Properties.Items.TryGetValue(VkIdAuthenticationConstants.AuthenticationProperties.DeviceId, out var deviceId) || + string.IsNullOrEmpty(deviceId)) + { + return OAuthTokenResponse.Failed(new Exception("Device ID was not found.")); + } + + if (!context.Properties.Items.TryGetValue(OAuthConstants.CodeVerifierKey, out var codeVerifier) || + string.IsNullOrEmpty(codeVerifier)) + { + return OAuthTokenResponse.Failed(new Exception("Code verifier key was not found.")); + } + + context.Properties.Items.Remove(OAuthConstants.CodeVerifierKey); + var query = new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = context.Code, + ["code_verifier"] = codeVerifier, + ["client_id"] = Options.ClientId, + ["device_id"] = deviceId, + ["redirect_uri"] = context.RedirectUri, + ["state"] = Options.StateDataFormat.Protect(context.Properties), + }; + + using var request = new HttpRequestMessage(HttpMethod.Post, Options.TokenEndpoint); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Content = new FormUrlEncodedContent(query); + + var response = await Backchannel.SendAsync(request, Context.RequestAborted); + + // According to docs even error response should be 200 + if (response.StatusCode is not HttpStatusCode.OK) + { + await Log.ExchangeCodeErrorAsync(Logger, response, Context.RequestAborted); + return OAuthTokenResponse.Failed(new Exception("Invalid remote server response during code exchange.")); + } + + var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); + + // ReSharper disable once InvertIf + if (payload.RootElement.TryGetProperty("error", out var errorElement)) + { + var errorCode = errorElement.GetString()!; + var errorDescription = errorElement.GetProperty("error_description").GetString()!; + return OAuthTokenResponse.Failed(new Exception($"{errorCode}: {errorDescription}")); + } + + return OAuthTokenResponse.Success(payload); + } + + protected override async Task CreateTicketAsync( + [NotNull] ClaimsIdentity identity, + [NotNull] AuthenticationProperties properties, + [NotNull] OAuthTokenResponse tokens) + { + var query = new Dictionary + { + ["access_token"] = tokens.AccessToken!, + ["client_id"] = Options.ClientId + }; + using var request = new HttpRequestMessage(HttpMethod.Post, Options.UserInformationEndpoint); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Content = new FormUrlEncodedContent(query); + request.Version = Backchannel.DefaultRequestVersion; + + var response = await Backchannel.SendAsync(request, Context.RequestAborted); + if (!response.IsSuccessStatusCode) + { + await Log.UserProfileErrorAsync(Logger, response, Context.RequestAborted); + throw new HttpRequestException("An error occurred while retrieving the user profile."); + } + + var content = await response.Content.ReadAsStringAsync(Context.RequestAborted); + var body = JsonDocument.Parse(content); + + if (body.RootElement.TryGetProperty("error", out var errorElement)) + { + var errorCode = errorElement.GetString(); + var errorDescription = body.RootElement + .GetProperty("error_description") + .GetString(); + + throw new Exception($"{errorCode}: {errorDescription}"); + } + + if (!body.RootElement.TryGetProperty("user", out var payload)) + { + Log.FailedToRetrieveUserInformation(Logger, response, content); + throw new Exception("Failed to retrieve user information."); + } + + var principal = new ClaimsPrincipal(identity); + var context = new OAuthCreatingTicketContext( + principal, + properties, + Context, + Scheme, + Options, + Backchannel, + tokens, + payload); + context.RunClaimActions(); + + await Events.CreatingTicket(context); + return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name); + } + + private static partial class Log + { + internal static void CodeResponse(ILogger logger, IQueryCollection? query) + { + CodeResponse(logger, query?.ToString() ?? string.Empty); + } + + internal static async Task ExchangeCodeErrorAsync( + ILogger logger, + HttpResponseMessage response, + CancellationToken cancellationToken) + { + ExchangeCodeErrorAsync( + logger, + response.StatusCode, + response.Headers.ToString(), + await response.Content.ReadAsStringAsync(cancellationToken)); + } + + internal static async Task UserProfileErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken) + { + UserProfileError( + logger, + response.StatusCode, + response.Headers.ToString(), + await response.Content.ReadAsStringAsync(cancellationToken)); + } + + internal static void FailedToRetrieveUserInformation( + ILogger logger, + HttpResponseMessage response, + string content) + { + FailedToRetrieveUserInformation( + logger, + response.StatusCode, + response.Headers.ToString(), + content); + } + + [LoggerMessage(1, LogLevel.Debug, "Authorization endpoint callback query: {Query}.")] + internal static partial void CodeResponse(ILogger logger, string query); + + [LoggerMessage(2, LogLevel.Error, "Invalid server response while retrieving an OAuth token: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")] + private static partial void ExchangeCodeErrorAsync( + ILogger logger, + HttpStatusCode status, + string headers, + string body); + + [LoggerMessage(3, LogLevel.Error, "An error occurred while retrieving the user profile: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")] + private static partial void UserProfileError( + ILogger logger, + HttpStatusCode status, + string headers, + string body); + + [LoggerMessage(4, LogLevel.Error, "Failed to retrieve user information: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")] + private static partial void FailedToRetrieveUserInformation( + ILogger logger, + HttpStatusCode status, + string headers, + string body); + } +} diff --git a/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationOptions.cs b/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationOptions.cs new file mode 100644 index 000000000..cf8cb9056 --- /dev/null +++ b/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationOptions.cs @@ -0,0 +1,38 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System.Security.Claims; + +namespace AspNet.Security.OAuth.VkId; + +/// +/// Defines a set of options used by . +/// +public sealed class VkIdAuthenticationOptions : OAuthOptions +{ + public VkIdAuthenticationOptions() + { + ClaimsIssuer = VkIdAuthenticationDefaults.ClaimsIssuer; + CallbackPath = VkIdAuthenticationDefaults.CallbackPath; + + AuthorizationEndpoint = VkIdAuthenticationDefaults.AuthorizationEndpoint; + TokenEndpoint = VkIdAuthenticationDefaults.TokenEndpoint; + UserInformationEndpoint = VkIdAuthenticationDefaults.UserInformationEndpoint; + + // It's mandatory to use PKCE + UsePkce = true; + + Scope.Add(VkIdAuthenticationScopes.PersonalInfo); + + ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "user_id"); + ClaimActions.MapJsonKey(ClaimTypes.GivenName, "first_name"); + ClaimActions.MapJsonKey(ClaimTypes.Surname, "last_name"); + ClaimActions.MapJsonKey(VkIdAuthenticationConstants.Claims.Avatar, "avatar"); + ClaimActions.MapJsonKey(ClaimTypes.Gender, "sex"); + ClaimActions.MapJsonKey(VkIdAuthenticationConstants.Claims.IsVerified, "verified"); + ClaimActions.MapJsonKey(ClaimTypes.DateOfBirth, "birthday"); + } +} diff --git a/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationScopes.cs b/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationScopes.cs new file mode 100644 index 000000000..7a8ace514 --- /dev/null +++ b/src/AspNet.Security.OAuth.VkId/VkIdAuthenticationScopes.cs @@ -0,0 +1,98 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +namespace AspNet.Security.OAuth.VkId; + +/// +/// List of scopes . +/// +public static class VkIdAuthenticationScopes +{ + /// + /// Grants access to personal information. + /// + public const string PersonalInfo = "vkid.personal_info"; + + /// + /// Grants access to user's email. + /// + public const string Email = "email"; + + /// + /// Grants access to user's phone number. + /// + public const string Phone = "phone"; + + /// + /// Grants access to Friends API. + /// + public const string Friends = "friends"; + + /// + /// Grants access to Posts API. + /// + public const string Posts = "wall"; + + /// + /// Grants access to Groups API. + /// + public const string Groups = "groups"; + + /// + /// Grants access to Stories API. + /// + public const string Stories = "stories"; + + /// + /// Grants access to Docs API. + /// + public const string Docs = "docs"; + + /// + /// Grants access to Photos API. + /// + public const string Photos = "photos"; + + /// + /// Grants access to Ads API. + /// + public const string Ads = "ads"; + + /// + /// Grants access to Video API. + /// + public const string Video = "video"; + + /// + /// Grants access to Status API. + /// + public const string Status = "status"; + + /// + /// Grants access to Market API. + /// + public const string Market = "market"; + + /// + /// Grants access to Pages API. + /// + public const string Pages = "pages"; + + /// + /// Grants access to Notifications API. + /// + public const string Notifications = "notifications"; + + /// + /// Grants access to Stats API. + /// + public const string Stats = "stats"; + + /// + /// Grants access to Notes API. + /// + public const string Notes = "notes"; +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/VkId/VkIdTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/VkId/VkIdTests.cs new file mode 100644 index 000000000..e0099878c --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/VkId/VkIdTests.cs @@ -0,0 +1,39 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using Microsoft.AspNetCore.DataProtection; +using Microsoft.IdentityModel.Tokens; +using NSubstitute; + +namespace AspNet.Security.OAuth.VkId; + +public class VkIdTests : OAuthTests +{ + public VkIdTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + LoopbackRedirectHandler.LoopbackParameters.Add("device_id", "1111"); + LoopbackRedirectHandler.LoopbackParameters.Add("type", "code_v2"); + } + + public override string DefaultScheme => VkIdAuthenticationDefaults.AuthenticationScheme; + + protected internal override void RegisterAuthentication(AuthenticationBuilder builder) + { + builder.AddVkId(options => ConfigureDefaults(builder, options)); + } + + [Theory] + [InlineData(ClaimTypes.NameIdentifier, "1234567890")] + [InlineData(ClaimTypes.GivenName, "Ivan")] + [InlineData(ClaimTypes.Surname, "Ivanov")] + [InlineData(VkIdAuthenticationConstants.Claims.Avatar, "https://pp.userapi.com/60tZWMo4SmwcploUVl9XEt8ufnTTvDUmQ6Bj1g/mmv1pcj63C4.png")] + [InlineData(ClaimTypes.Gender, "2")] + [InlineData(VkIdAuthenticationConstants.Claims.IsVerified, "False")] + [InlineData(ClaimTypes.DateOfBirth, "01.01.2000")] + public async Task Can_Sign_In_Using_VkId(string claimType, string claimValue) + => await AuthenticateUserAndAssertClaimValue(claimType, claimValue); +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/VkId/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/VkId/bundle.json new file mode 100644 index 000000000..a86d6e5fc --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/VkId/bundle.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", + "items": [ + { + "uri": "https://id.vk.com/oauth2/auth", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "access_token": "XXXXX", + "refresh_token": "XXXXX", + "id_token": "XXXXX", + "expires_in": 0, + "user_id": 1234567890, + "state": "ZmFrZS1zdGF0ZS1zdHJpbmc", + "scope": "email phone" + } + }, + { + "uri": "https://id.vk.com/oauth2/user_info", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "user": { + "user_id": "1234567890", + "first_name": "Ivan", + "last_name": "Ivanov", + "phone": "79991234567", + "avatar": "https://pp.userapi.com/60tZWMo4SmwcploUVl9XEt8ufnTTvDUmQ6Bj1g/mmv1pcj63C4.png", + "email": "ivan_i123@vk.com", + "sex": 2, + "verified": false, + "birthday": "01.01.2000" + } + } + } + ] +}