diff --git a/AspNet.Security.OAuth.Providers.slnx b/AspNet.Security.OAuth.Providers.slnx index 6e45900e7..d289d3758 100644 --- a/AspNet.Security.OAuth.Providers.slnx +++ b/AspNet.Security.OAuth.Providers.slnx @@ -49,6 +49,7 @@ + @@ -120,6 +121,7 @@ + diff --git a/README.md b/README.md index 064ebc419..edf4e84b3 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ We would love it if you could help contributing to this repository. * [Robert Shade](https://github.com/robert-shade) * [saber-wang](https://github.com/saber-wang) * [Sinan](https://github.com/SH2015) +* [Sonja Schweitzer](https://github.com/DevTKSS) * [Stefan](https://github.com/Schlurcher) * [Steffen Wenz](https://github.com/swenz) * [Tathagata Chakraborty](https://github.com/tatx) @@ -182,6 +183,7 @@ If a provider you're looking for does not exist, consider making a PR to add one | Docusign | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Docusign?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Docusign/ "Download AspNet.Security.OAuth.Docusign from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Docusign?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Docusign "Download AspNet.Security.OAuth.Docusign from MyGet.org") | [Documentation](https://developers.docusign.com/platform/auth/ "Docusign developer documentation") | | Dropbox | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Dropbox?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Dropbox/ "Download AspNet.Security.OAuth.Dropbox from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Dropbox?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Dropbox "Download AspNet.Security.OAuth.Dropbox from MyGet.org") | [Documentation](https://www.dropbox.com/developers/reference/oauth-guide?_tk=guides_lp&_ad=deepdive2&_camp=oauth "Dropbox developer documentation") | | eBay | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Ebay?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Ebay/ "Download AspNet.Security.OAuth.Ebay from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Ebay?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Ebay "Download AspNet.Security.OAuth.Ebay from MyGet.org") | [Documentation](https://developer.ebay.com/api-docs/static/oauth-tokens.html "eBay developer documentation") | +| Etsy | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Etsy?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Etsy/ "Download AspNet.Security.OAuth.Etsy from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Etsy?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Etsy "Download AspNet.Security.OAuth.Etsy from MyGet.org") | [Documentation](https://developers.etsy.com/documentation/essentials/authentication "Etsy developer documentation") | | EVEOnline | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.EVEOnline?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.EVEOnline/ "Download AspNet.Security.OAuth.EVEOnline from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.EVEOnline?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.EVEOnline "Download AspNet.Security.OAuth.EVEOnline from MyGet.org") | [Documentation](https://github.com/esi/esi-docs/blob/master/docs/sso/web_based_sso_flow.md "EVEOnline developer documentation") | | ExactOnline | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.ExactOnline?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.ExactOnline/ "Download AspNet.Security.OAuth.ExactOnline from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.ExactOnline?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.ExactOnline "Download AspNet.Security.OAuth.ExactOnline from MyGet.org") | [Documentation](https://support.exactonline.com/community/s/knowledge-base#All-All-DNO-Content-gettingstarted "ExactOnline developer documentation") | | Feishu | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Feishu?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Feishu/ "Download AspNet.Security.OAuth.Feishu from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Feishu?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Feishu "Download AspNet.Security.OAuth.Feishu from MyGet.org") | [Documentation](https://open.feishu.cn/document/common-capabilities/sso/web-application-sso/web-app-overview "Feishu developer documentation") | diff --git a/docs/README.md b/docs/README.md index a4f21d8ab..24b9853d5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,7 @@ covered by the section above. | Discord | _Optional_ | [Documentation](discord.md "Discord provider documentation") | | Docusign | **Required** | [Documentation](docusign.md "Docusign provider documentation") | | eBay | **Required** | [Documentation](ebay.md "eBay provider documentation") | +| Etsy | _Optional_ | [Documentation](etsy.md "Etsy provider documentation") | | EVEOnline | _Optional_ | [Documentation](eveonline.md "EVEOnline provider documentation") | | Foursquare | _Optional_ | [Documentation](foursquare.md "Foursquare provider documentation") | | GitCode | _Optional_ | [Documentation](gitcode.md "GitCode provider documentation") | diff --git a/docs/etsy.md b/docs/etsy.md new file mode 100644 index 000000000..435bfce49 --- /dev/null +++ b/docs/etsy.md @@ -0,0 +1,55 @@ +# Integrating the Etsy Provider + +Etsy's OAuth implementation uses Authorization Code with **PKCE** and issues **refresh tokens**. + +This provider enables PKCE by default and validates scopes to match Etsy's requirements. + +- [Integrating the Etsy Provider](#integrating-the-etsy-provider) + - [Example](#example) + - [Required Additional Settings](#required-additional-settings) + - [Optional Settings](#optional-settings) + - [Quick Links](#quick-links) + +## Example + +```csharp +using AspNet.Security.OAuth.Etsy; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddAuthentication(options => { /* Authentication options */ }) + .AddEtsy(options => + { + options.ClientId = "my-etsy-client-id"; + options.ClientSecret = "my-etsy-client-secret"; // Optional as Etsy requires PKCE + options.IncludeDetailedUserInfo = true; // Optional to get first name, last name, email claims + options.ClaimActions.MapImageClaim(); // Optional Extension to map the image_url_75x75 claim, will not be mapped automatically + }); +``` + +## Required Additional Settings + +- You can obtain the Client ID (`keystring`) for your app by registering your application on [Etsy's developer portal](https://www.etsy.com/developers/your-apps). +- The ClientSecret (`shared secret` in the Etsy app details) is optional for public clients using PKCE. + +## Optional Settings + +| Property Name | Property Type | Description | Default Value | +|:--|:--|:--|:--| +| `IncludeDetailedUserInfo` | `bool` | Fetch extended profile data with auto-mapped claims (Email, GivenName, Surname). | `false` | +| `ClaimActions.MapImageClaim()` | Extension method | Map the `image_url_75x75` claim to `EtsyAuthenticationConstants.Claims.ImageUrl`. | Not mapped automatically | +| `DetailedUserInfoEndpoint` | `string` | Endpoint to retrieve detailed user information. | `https://openapi.etsy.com/v3/application/users/` | + +Additional helpers are available via `EtsyAuthenticationConstants.Scopes.*` for Etsy OAuth scopes and `EtsyAuthenticationConstants.Claims.*` for claim type constants used for the `getMe` and `getUser` endpoints. + +## Quick Links + +| Resource | Link | +|:--|:--| +| Register your App on Etsy: | [Apps You've Made](https://www.etsy.com/developers/your-apps) | +| Official Etsy Authentication API Documentation: | [Etsy Developer Documentation](https://developers.etsy.com/documentation/essentials/authentication) | +| Requesting a Refresh OAuth Token: | [Etsy Refresh Token Guide](https://developers.etsy.com/documentation/essentials/authentication#requesting-a-refresh-oauth-token) | +| Etsy API Reference: | [Etsy API Reference](https://developers.etsy.com/documentation/reference) | diff --git a/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj b/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj new file mode 100644 index 000000000..fc3e75fbd --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj @@ -0,0 +1,21 @@ + + + + 10.1.0 + $(DefaultNetCoreTargetFramework) + + true + + + + ASP.NET Core security middleware enabling Etsy authentication. + Sonja Schweitzer + aspnetcore;authentication;etsy;oauth;security + + + + + + + + diff --git a/src/AspNet.Security.OAuth.Etsy/ClaimActionCollectionExtensions.cs b/src/AspNet.Security.OAuth.Etsy/ClaimActionCollectionExtensions.cs new file mode 100644 index 000000000..d8f26be1b --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/ClaimActionCollectionExtensions.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. + */ + +using AspNet.Security.OAuth.Etsy; +using Microsoft.AspNetCore.Authentication.OAuth.Claims; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides extension methods for to map Etsy API specific user claims. +/// +public static class ClaimActionCollectionExtensions +{ + /// + /// Maps the Etsy user's profile image URL (75x75) to the claim. + /// + public static void MapImageClaim(this ClaimActionCollection collection) + { + collection.MapJsonKey(EtsyAuthenticationConstants.Claims.ImageUrl, "image_url_75x75"); + } +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs new file mode 100644 index 000000000..ebe9b7579 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs @@ -0,0 +1,94 @@ +/* + * 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.Etsy; + +/// +/// Contains constants specific to the . +/// +public static class EtsyAuthenticationConstants +{ + /// + /// Contains claim type constants specific to Etsy authentication. + /// + public static class Claims + { + /// The claim type for the user's Etsy user ID. + public static readonly string UserId = "urn:etsy:user_id"; + + /// The claim type for the user's Etsy shop ID. + public static readonly string ShopId = "urn:etsy:shop_id"; + + /// The claim type for the user's profile image URL. + public static readonly string ImageUrl = "urn:etsy:image_url"; + } + + /// + /// Contains Etsy OAuth Scopes constants for Etsy authentication. + /// + public static class Scopes + { + /// See billing and shipping addresses + public static readonly string AddressRead = "address_r"; + + /// Update billing and shipping addresses + public static readonly string AddressWrite = "address_w"; + + /// See all billing statement data + public static readonly string BillingRead = "billing_r"; + + /// Read shopping carts + public static readonly string CartRead = "cart_r"; + + /// Add/Remove from shopping carts + public static readonly string CartWrite = "cart_w"; + + /// Read a user profile + public static readonly string EmailRead = "email_r"; + + /// See private favorites + public static readonly string FavoritesRead = "favorites_r"; + + /// Add/Remove favorites + public static readonly string FavoritesWrite = "favorites_w"; + + /// See purchase info in feedback + public static readonly string FeedbackRead = "feedback_r"; + + /// Delete listings + public static readonly string ListingsDelete = "listings_d"; + + /// See all listings (including expired etc) + public static readonly string ListingsRead = "listings_r"; + + /// Create/Edit listings + public static readonly string ListingsWrite = "listings_w"; + + /// See all profile data + public static readonly string ProfileRead = "profile_r"; + + /// Update user profile, avatar, etc + public static readonly string ProfileWrite = "profile_w"; + + /// See recommended listings + public static readonly string RecommendRead = "recommend_r"; + + /// Accept/Reject recommended listings + public static readonly string RecommendWrite = "recommend_w"; + + /// See private shop info + public static readonly string ShopsRead = "shops_r"; + + /// Update shop + public static readonly string ShopsWrite = "shops_w"; + + /// See all checkout/payment data + public static readonly string TransactionsRead = "transactions_r"; + + /// Update receipts + public static readonly string TransactionsWrite = "transactions_w"; + } +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs new file mode 100644 index 000000000..3d0255699 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs @@ -0,0 +1,53 @@ +/* + * 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.Etsy; + +/// +/// Default values used by the Etsy authentication middleware. +/// +public static class EtsyAuthenticationDefaults +{ + /// + /// Default value for . + /// + public const string AuthenticationScheme = "Etsy"; + + /// + /// Default value for . + /// + public static readonly string DisplayName = "Etsy"; + + /// + /// Default value for . + /// + public static readonly string Issuer = "Etsy"; + + /// + /// Default value for . + /// + public static readonly string CallbackPath = "/signin-etsy"; + + /// + /// Default value for . + /// + public static readonly string AuthorizationEndpoint = "https://www.etsy.com/oauth/connect"; + + /// + /// Default value for . + /// + public static readonly string TokenEndpoint = "https://openapi.etsy.com/v3/public/oauth/token"; + + /// + /// Default value for Etsy getMe Endpoint. + /// + public static readonly string UserInformationEndpoint = "https://openapi.etsy.com/v3/application/users/me"; + + /// + /// Default value for receiving the user profile based upon a unique user ID getUser. + /// + public static readonly string DetailedUserInfoEndpoint = "https://openapi.etsy.com/v3/application/users/"; +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs new file mode 100644 index 000000000..5b9831dd2 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs @@ -0,0 +1,79 @@ +/* + * 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 AspNet.Security.OAuth.Etsy; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods to add Etsy authentication capabilities to an HTTP application pipeline. +/// +public static class EtsyAuthenticationExtensions +{ + /// + /// Adds to the specified + /// , which enables Etsy authentication capabilities. + /// + /// The authentication builder. + /// The . + public static AuthenticationBuilder AddEtsy([NotNull] this AuthenticationBuilder builder) + { + return builder.AddEtsy(EtsyAuthenticationDefaults.AuthenticationScheme, options => { }); + } + + /// + /// Adds to the specified + /// , which enables Etsy authentication capabilities. + /// + /// The authentication builder. + /// The delegate used to configure the Etsy options. + /// The . + public static AuthenticationBuilder AddEtsy( + [NotNull] this AuthenticationBuilder builder, + [NotNull] Action configuration) + { + return builder.AddEtsy(EtsyAuthenticationDefaults.AuthenticationScheme, configuration); + } + + /// + /// Adds to the specified + /// , which enables Etsy authentication capabilities. + /// + /// The authentication builder. + /// The authentication scheme associated with this instance. + /// The delegate used to configure the Etsy options. + /// The . + public static AuthenticationBuilder AddEtsy( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [NotNull] Action configuration) + { + return builder.AddEtsy(scheme, EtsyAuthenticationDefaults.DisplayName, configuration); + } + + /// + /// Adds to the specified + /// , which enables Etsy 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 Etsy options. + /// The . + public static AuthenticationBuilder AddEtsy( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [CanBeNull] string caption, + [NotNull] Action configuration) + { + // Ensure Etsy-specific post-configuration runs after the base OAuth configuration + builder.Services.TryAddSingleton, EtsyPostConfigureOptions>(); + + return builder.AddOAuth(scheme, caption, configuration); + } +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs new file mode 100644 index 000000000..0256188c0 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs @@ -0,0 +1,156 @@ +/* + * 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.Http.Headers; +using System.Net.Mime; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AspNet.Security.OAuth.Etsy; + +public partial class EtsyAuthenticationHandler : OAuthHandler +{ + public EtsyAuthenticationHandler( + [NotNull] IOptionsMonitor options, + [NotNull] ILoggerFactory logger, + [NotNull] UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + /// + /// Creates an from the OAuth tokens and Etsy user information. + /// + /// The claims identity to populate. + /// The authentication properties. + /// The OAuth token response containing the access token. + /// An containing the user claims and properties. + /// Thrown when an error occurs while retrieving user information from Etsy. + protected override async Task CreateTicketAsync( + [NotNull] ClaimsIdentity identity, + [NotNull] AuthenticationProperties properties, + [NotNull] OAuthTokenResponse tokens) + { + // Get the basic user info (user_id and shop_id) + using var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + request.Headers.Add("x-api-key", Options.ClientId); + + using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); + if (!response.IsSuccessStatusCode) + { + await Log.BasicUserInfoErrorAsync(Logger, response, Context.RequestAborted); + throw new HttpRequestException("An error occurred while retrieving basic user information from Etsy."); + } + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); + var meRoot = payload.RootElement; + + var principal = new ClaimsPrincipal(identity); + var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, meRoot); + + // Map claims from the basic payload first + context.RunClaimActions(); + + // Optionally enrich with detailed user info if requested + if (Options.IncludeDetailedUserInfo) + { + // Extract user_id from the /me response + var userId = meRoot.GetProperty("user_id").GetInt64(); + + using var detailedPayload = await GetDetailedUserInfoAsync(tokens, userId); + var detailedRoot = detailedPayload.RootElement; + + // Apply claim actions for fields that are only in the detailed payload + // We filter the ClaimActions to exclude those for user_id and shop_id + // since they were already processed from the basic /users/me endpoint + foreach (var action in Options.ClaimActions) + { + // Skip the action if it's a JsonKeyClaimAction for user_id or shop_id + if (action is Microsoft.AspNetCore.Authentication.OAuth.Claims.JsonKeyClaimAction { ClaimType: var t } && + (t == ClaimTypes.NameIdentifier + || t == EtsyAuthenticationConstants.Claims.ShopId)) + { + continue; + } + + action.Run(detailedRoot, identity, Options.ClaimsIssuer ?? ClaimsIssuer); + } + } + + await Events.CreatingTicket(context); + return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name); + } + + /// + /// Retrieves detailed user information from Etsy. + /// + /// The OAuth token response. + /// The user ID to retrieve details for. + /// A containing the detailed user information. + protected virtual async Task GetDetailedUserInfoAsync([NotNull] OAuthTokenResponse tokens, long userId) + { + var userDetailsUrl = Options.DetailedUserInfoEndpoint.EndsWith('/') ? $"{Options.DetailedUserInfoEndpoint}{userId}" : $"{Options.DetailedUserInfoEndpoint}/{userId}"; + + using var request = new HttpRequestMessage(HttpMethod.Get, userDetailsUrl); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + request.Headers.Add("x-api-key", Options.ClientId); + + using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); + if (!response.IsSuccessStatusCode) + { + await Log.DetailedUserInfoErrorAsync(Logger, response, Context.RequestAborted); + throw new HttpRequestException("An error occurred while retrieving detailed user info from Etsy."); + } + + return JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); + } + + private static partial class Log + { + internal static async Task BasicUserInfoErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken) + { + BasicUserInfoError( + logger, + response.RequestMessage?.RequestUri?.ToString() ?? string.Empty, + response.StatusCode, + response.Headers.ToString(), + await response.Content.ReadAsStringAsync(cancellationToken)); + } + + internal static async Task DetailedUserInfoErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken) + { + DetailedUserInfoError( + logger, + response.RequestMessage?.RequestUri?.ToString() ?? string.Empty, + response.StatusCode, + response.Headers.ToString(), + await response.Content.ReadAsStringAsync(cancellationToken)); + } + + [LoggerMessage(1, LogLevel.Error, "Etsy basic user info request failed for '{RequestUri}': remote server returned a {Status} response with: {Headers} {Body}.")] + private static partial void BasicUserInfoError( + ILogger logger, + string requestUri, + System.Net.HttpStatusCode status, + string headers, + string body); + + [LoggerMessage(2, LogLevel.Error, "Etsy detailed user info request failed for '{RequestUri}': remote server returned a {Status} response with: {Headers} {Body}.")] + private static partial void DetailedUserInfoError( + ILogger logger, + string requestUri, + System.Net.HttpStatusCode status, + string headers, + string body); + } +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs new file mode 100644 index 000000000..232a93785 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -0,0 +1,95 @@ +/* + * 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; +using static AspNet.Security.OAuth.Etsy.EtsyAuthenticationConstants; + +namespace AspNet.Security.OAuth.Etsy; + +/// +/// Defines a set of options used by . +/// +public class EtsyAuthenticationOptions : OAuthOptions +{ + public EtsyAuthenticationOptions() + { + ClaimsIssuer = EtsyAuthenticationDefaults.Issuer; + CallbackPath = EtsyAuthenticationDefaults.CallbackPath; + + AuthorizationEndpoint = EtsyAuthenticationDefaults.AuthorizationEndpoint; + TokenEndpoint = EtsyAuthenticationDefaults.TokenEndpoint; + UserInformationEndpoint = EtsyAuthenticationDefaults.UserInformationEndpoint; + + UsePkce = true; + SaveTokens = true; + + // Etsy requires at least one scope and this is the one for basic user info + Scope.Add(Scopes.ShopsRead); + + ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "user_id"); + ClaimActions.MapJsonKey(Claims.UserId, "user_id"); + ClaimActions.MapJsonKey(Claims.ShopId, "shop_id"); + ClaimActions.MapJsonKey(ClaimTypes.Email, "primary_email"); + ClaimActions.MapJsonKey(ClaimTypes.GivenName, "first_name"); + ClaimActions.MapJsonKey(ClaimTypes.Surname, "last_name"); + } + + /// + /// Gets or sets a value indicating whether to fetch detailed user information + /// from the getUser Endpoint. + /// + public bool IncludeDetailedUserInfo { get; set; } + + /// + /// Gets or sets the endpoint used to retrieve detailed user information. + /// + public string DetailedUserInfoEndpoint { get; set; } = EtsyAuthenticationDefaults.DetailedUserInfoEndpoint; + + /// + public override void Validate() + { + if (IncludeDetailedUserInfo && !Scope.Contains(Scopes.EmailRead)) + { + Scope.Add(Scopes.EmailRead); + } + + try + { + // HACK We want all of the base validation except for ClientSecret, + // so rather than re-implement it all, catch the exception thrown + // for that being null and only throw if we aren't using public client access type + PKCE. + // Etsy's OAuth implementation does not require a client secret referring to the Documentation using PKCE (Proof Key for Code Exchange). + // This does mean that three checks have to be re-implemented + // because they won't be validated if the ClientSecret validation fails. + base.Validate(); + } + catch (ArgumentException ex) when (ex.ParamName == nameof(ClientSecret)) + { + // No client secret is required for Etsy API, which uses Authorization Code Flow https://datatracker.ietf.org/doc/html/rfc6749#section-1.3.1 with: + // See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers/issues/610. + } + + if (string.IsNullOrEmpty(AuthorizationEndpoint)) + { + throw new ArgumentNullException(nameof(AuthorizationEndpoint), $"The '{nameof(AuthorizationEndpoint)}' option must be provided."); + } + + if (string.IsNullOrEmpty(TokenEndpoint)) + { + throw new ArgumentNullException(nameof(TokenEndpoint), $"The '{nameof(TokenEndpoint)}' option must be provided."); + } + + if (string.IsNullOrEmpty(UserInformationEndpoint)) + { + throw new ArgumentNullException(nameof(UserInformationEndpoint), $"The '{nameof(UserInformationEndpoint)}' option must be provided."); + } + + if (!CallbackPath.HasValue) + { + throw new ArgumentNullException(nameof(CallbackPath), $"The '{nameof(CallbackPath)}' option must be provided."); + } + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs new file mode 100644 index 000000000..9db6d5ac5 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs @@ -0,0 +1,87 @@ +/* + * 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.Etsy; + +public static class EtsyAuthenticationOptionsTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + public static void Validate_Does_Not_Throw_If_ClientSecret_Is_Not_Provided(string? clientSecret) + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecret = clientSecret!, + }; + + // Act (no Assert) + options.Validate(); + } + + [Fact] + public static void Validate_Throws_If_AuthorizationEndpoint_Is_Null() + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + AuthorizationEndpoint = null!, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + }; + + // Act and Assert + _ = Assert.Throws(nameof(options.AuthorizationEndpoint), options.Validate); + } + + [Fact] + public static void Validate_Throws_If_TokenEndpoint_Is_Null() + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + TokenEndpoint = null!, + }; + + // Act and Assert + _ = Assert.Throws(nameof(options.TokenEndpoint), options.Validate); + } + + [Fact] + public static void Validate_Throws_If_UserInformationEndpoint_Is_Null() + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + UserInformationEndpoint = null!, + }; + + // Act and Assert + _ = Assert.Throws(nameof(options.UserInformationEndpoint), options.Validate); + } + + [Fact] + public static void Validate_Throws_If_CallbackPath_Is_Null() + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + CallbackPath = null, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + }; + + // Act and Assert + var ex = Assert.Throws(options.Validate); + ex.ParamName.ShouldBe(nameof(options.CallbackPath)); + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs new file mode 100644 index 000000000..90446b0cd --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs @@ -0,0 +1,67 @@ +/* + * 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 static AspNet.Security.OAuth.Etsy.EtsyAuthenticationConstants; + +namespace AspNet.Security.OAuth.Etsy; + +public class EtsyTests(ITestOutputHelper outputHelper) : OAuthTests(outputHelper) +{ + public override string DefaultScheme => EtsyAuthenticationDefaults.AuthenticationScheme; + + protected internal override void RegisterAuthentication(AuthenticationBuilder builder) + { + builder.AddEtsy(options => ConfigureDefaults(builder, options)); + } + + [Theory] + [InlineData(ClaimTypes.NameIdentifier, "123456")] + [InlineData("urn:etsy:user_id", "123456")] + [InlineData("urn:etsy:shop_id", "789012")] + public async Task Can_Sign_In_Using_Etsy(string claimType, string claimValue) + => await AuthenticateUserAndAssertClaimValue(claimType, claimValue); + + [Fact] + public async Task Does_Not_Include_Detailed_Claims_When_IncludeDetailedUserInfo_Is_False() + { + // Arrange: disable detailed user info enrichment + static void ConfigureServices(IServiceCollection services) => services.PostConfigureAll(o => o.IncludeDetailedUserInfo = false); + + using var server = CreateTestServer(ConfigureServices); + + // Act + var claims = await AuthenticateUserAsync(server); + + // Assert basic claims are present + claims.ShouldContainKey(ClaimTypes.NameIdentifier); + claims.ShouldContainKey(Claims.UserId); + claims.ShouldContainKey(Claims.ShopId); + + // Detailed claims should be absent when flag is false + claims.Keys.ShouldNotContain(ClaimTypes.Email); + claims.Keys.ShouldNotContain(ClaimTypes.GivenName); + claims.Keys.ShouldNotContain(ClaimTypes.Surname); + claims.Keys.ShouldNotContain(Claims.ImageUrl); + } + + [Fact] + public async Task Includes_Detailed_Claims_When_IncludeDetailedUserInfo_Is_True() + { + // Arrange: enable detailed user info, configure claims to map. + static void ConfigureServices(IServiceCollection services) => services.PostConfigureAll(o => o.IncludeDetailedUserInfo = true); + + using var server = CreateTestServer(ConfigureServices); + + // Act + var claims = await AuthenticateUserAsync(server); + + // Assert detailed claims are present + claims.ShouldContainKey(ClaimTypes.Email); + claims.ShouldContainKey(ClaimTypes.GivenName); + claims.ShouldContainKey(ClaimTypes.Surname); + claims.ShouldContainKey(Claims.ImageUrl); + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json new file mode 100644 index 000000000..c304d6551 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", + "items": [ + { + "comment": "Etsy OAuth 2.0 token exchange endpoint response - returns sample token values from Etsy API Docs (domain aligned to openapi.etsy.com to match provider defaults)", + "uri": "https://openapi.etsy.com/v3/public/oauth/token", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "access_token": "12345678.12345678.O1zLuwveeKjpIqCQFfmR-PaMMpBmagH6DljRAkK9qt05OtRKiANJOyZlMx3WQ_o2FdComQGuoiAWy3dxyGI4Ke_76PR", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "12345678.JNGIJtvLmwfDMhlYoOJl8aLR1BWottyHC6yhNcET-eC7RogSR5e1GTIXGrgrelWZalvh3YvvyLfKYYqvymd-u37Sjtx" + } + }, + { + "comment": "Etsy /v3/application/users/me endpoint - returns basic user and shop IDs", + "uri": "https://openapi.etsy.com/v3/application/users/me", + "contentFormat": "json", + "contentJson": { + "user_id": 123456, + "shop_id": 789012 + } + }, + { + "comment": "Etsy /v3/application/users/{user_id} endpoint - returns detailed user information", + "uri": "https://openapi.etsy.com/v3/application/users/123456", + "contentFormat": "json", + "contentJson": { + "user_id": 123456, + "primary_email": "test@example.com", + "first_name": "Test", + "last_name": "User", + "image_url_75x75": "https://i.etsystatic.com/test/test_75x75.jpg" + } + } + ] +}