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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Docusign/ "Download AspNet.Security.OAuth.Docusign from NuGet.org") | [](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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Dropbox/ "Download AspNet.Security.OAuth.Dropbox from NuGet.org") | [](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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Ebay/ "Download AspNet.Security.OAuth.Ebay from NuGet.org") | [](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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Etsy/ "Download AspNet.Security.OAuth.Etsy from NuGet.org") | [](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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.EVEOnline/ "Download AspNet.Security.OAuth.EVEOnline from NuGet.org") | [](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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.ExactOnline/ "Download AspNet.Security.OAuth.ExactOnline from NuGet.org") | [](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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Feishu/ "Download AspNet.Security.OAuth.Feishu from NuGet.org") | [](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"
+ }
+ }
+ ]
+}