From 6149d74ba5150d0b6fe65bc0874c7cbf4988da48 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Mon, 3 Nov 2025 23:22:21 +0100 Subject: [PATCH 01/37] feat: add Etsy OAuth provider --- .../AspNet.Security.OAuth.Etsy.csproj | 18 +++ .../EtsyAuthenticationConstants.cs | 92 +++++++++++++++ .../EtsyAuthenticationDefaults.cs | 48 ++++++++ .../EtsyAuthenticationExtensions.cs | 77 +++++++++++++ .../EtsyAuthenticationHandler.cs | 108 ++++++++++++++++++ .../EtsyAuthenticationOptions.cs | 48 ++++++++ .../EtsyPostConfigureOptions.cs | 45 ++++++++ 7 files changed, 436 insertions(+) create mode 100644 src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj create mode 100644 src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs create mode 100644 src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs create mode 100644 src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs create mode 100644 src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs create mode 100644 src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs create mode 100644 src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs 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..105c515ab --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj @@ -0,0 +1,18 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + ASP.NET Core security middleware enabling Etsy authentication. + Sonja + aspnetcore;authentication;etsy;oauth;security + + + + + + + + \ No newline at end of file diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs new file mode 100644 index 000000000..cbec6b902 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs @@ -0,0 +1,92 @@ +/* + * 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 +{ + public static class Claims + { + public const string UserId = "urn:etsy:user_id"; + public const string ShopId = "urn:etsy:shop_id"; + public const string PrimaryEmail = "urn:etsy:primary_email"; + public const string FirstName = "urn:etsy:first_name"; + public const string LastName = "urn:etsy:last_name"; + public const string ImageUrl = "urn:etsy:image_url"; + } + + public static class Scopes + { + /// Read user profile and email address + public const string EmailRead = "email_r"; + + /// Read user's listings + public const string ListingsRead = "listings_r"; + + /// Create and edit listings + public const string ListingsWrite = "listings_w"; + + /// Delete listings + public const string ListingsDelete = "listings_d"; + + /// Read shop information + public const string ShopsRead = "shops_r"; + + /// Update shop information + public const string ShopsWrite = "shops_w"; + + /// Delete shop information + public const string ShopsDelete = "shops_d"; + + /// Read transaction data + public const string TransactionsRead = "transactions_r"; + + /// Update transaction data + public const string TransactionsWrite = "transactions_w"; + + /// Read billing information + public const string BillingRead = "billing_r"; + + /// Read private profile information + public const string ProfileRead = "profile_r"; + + /// Update profile information + public const string ProfileWrite = "profile_w"; + + /// Read user's addresses + public const string AddressRead = "address_r"; + + /// Write user's addresses + public const string AddressWrite = "address_w"; + + /// Read user's favorites + public const string FavoritesRead = "favorites_r"; + + /// Write user's favorites + public const string FavoritesWrite = "favorites_w"; + + /// Read user's feedback + public const string FeedbackRead = "feedback_r"; + + /// Read user's shops + public const string ShopsMyRead = "shops_my_r"; + + /// Read user's cart + public const string CartRead = "cart_r"; + + /// Write user's cart + public const string CartWrite = "cart_w"; + + /// Read user's recommendations + public const string RecommendRead = "recommend_r"; + + /// Write user's recommendations + public const string RecommendWrite = "recommend_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..ee43c6a8e --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.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.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://api.etsy.com/v3/public/oauth/token"; + + /// + /// Default value for . + /// + public static readonly string UserInformationEndpoint = "https://openapi.etsy.com/v3/application/users/me"; +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs new file mode 100644 index 000000000..a515cf39c --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs @@ -0,0 +1,77 @@ +/* + * 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) + { + 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..5473a9673 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs @@ -0,0 +1,108 @@ +/* + * 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.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) + { + } + + protected override async Task CreateTicketAsync( + [NotNull] ClaimsIdentity identity, + [NotNull] AuthenticationProperties properties, + [NotNull] OAuthTokenResponse tokens) + { + // First, get the basic user info and shop_id from /v3/application/users/me + using var meRequest = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint); + meRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + meRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + meRequest.Headers.Add("x-api-key", Options.ClientId); + + using var meResponse = await Backchannel.SendAsync(meRequest, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); + if (!meResponse.IsSuccessStatusCode) + { + await Log.UserProfileErrorAsync(Logger, meResponse, Context.RequestAborted); + throw new HttpRequestException("An error occurred while retrieving basic user info from Etsy."); + } + + using var mePayload = JsonDocument.Parse(await meResponse.Content.ReadAsStringAsync(Context.RequestAborted)); + var meRoot = mePayload.RootElement; + + // Extract user_id and shop_id from the /me response + // Both fields should always be present in a successful Etsy OAuth response + var userId = meRoot.GetProperty("user_id").GetInt64(); + var shopId = meRoot.GetProperty("shop_id").GetInt64(); + + // Add the basic claims from the /me endpoint + // Use shop_id as the primary identifier for Etsy (required for most API operations) + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, shopId.ToString(CultureInfo.InvariantCulture), ClaimValueTypes.String, Options.ClaimsIssuer)); + identity.AddClaim(new Claim(EtsyAuthenticationConstants.Claims.UserId, userId.ToString(CultureInfo.InvariantCulture), ClaimValueTypes.String, Options.ClaimsIssuer)); + identity.AddClaim(new Claim(EtsyAuthenticationConstants.Claims.ShopId, shopId.ToString(CultureInfo.InvariantCulture), ClaimValueTypes.String, Options.ClaimsIssuer)); + + // Now get additional user details from /v3/application/users/{user_id} + var userDetailEndpoint = $"https://openapi.etsy.com/v3/application/users/{userId}"; + using var userRequest = new HttpRequestMessage(HttpMethod.Get, userDetailEndpoint); + userRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + userRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + userRequest.Headers.Add("x-api-key", Options.ClientId); + + using var userResponse = await Backchannel.SendAsync(userRequest, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); + if (userResponse.IsSuccessStatusCode) + { + using var userPayload = JsonDocument.Parse(await userResponse.Content.ReadAsStringAsync(Context.RequestAborted)); + + // Create context with the detailed user data for claim mapping + var principal = new ClaimsPrincipal(identity); + var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, userPayload.RootElement); + context.RunClaimActions(); + + await Events.CreatingTicket(context); + return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name); + } + else + { + // If detailed user info call fails, just create ticket with basic info + var principal = new ClaimsPrincipal(identity); + var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, meRoot); + + await Events.CreatingTicket(context); + return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name); + } + } + + private static partial class Log + { + internal static async Task UserProfileErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken) + { + UserProfileError( + logger, + response.StatusCode, + response.Headers.ToString(), + await response.Content.ReadAsStringAsync(cancellationToken)); + } + + [LoggerMessage(1, LogLevel.Error, "An error occurred while retrieving the user profile from Etsy: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")] + private static partial void UserProfileError( + ILogger logger, + 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..6e3f075a3 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.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. + */ + +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; + + // Enable PKCE by default (required by Etsy) + UsePkce = true; + + // Enable refresh token support + SaveTokens = true; + + // Default scopes - Etsy requires at least one scope + Scope.Add(Scopes.EmailRead); + Scope.Add(Scopes.ShopsRead); + + // Map Etsy user fields to standard and custom claims + // These mappings apply to the /v3/application/users/{user_id} endpoint response + // Note: ClaimTypes.NameIdentifier, UserId, and ShopId are set programmatically + // in the handler from the /v3/application/users/me endpoint + ClaimActions.MapJsonKey(ClaimTypes.Email, "primary_email"); + ClaimActions.MapJsonKey(ClaimTypes.GivenName, "first_name"); + ClaimActions.MapJsonKey(ClaimTypes.Surname, "last_name"); + ClaimActions.MapJsonKey(Claims.PrimaryEmail, "primary_email"); + ClaimActions.MapJsonKey(Claims.FirstName, "first_name"); + ClaimActions.MapJsonKey(Claims.LastName, "last_name"); + ClaimActions.MapJsonKey(Claims.ImageUrl, "image_url_75x75"); + } +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs new file mode 100644 index 000000000..bb19761de --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs @@ -0,0 +1,45 @@ +/* + * 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.Options; + +namespace AspNet.Security.OAuth.Etsy; + +/// +/// Contains the methods required to ensure that the Etsy configuration is valid. +/// +public class EtsyPostConfigureOptions : IPostConfigureOptions +{ + /// + /// Invoked to post-configure a TOptions instance. + /// + /// The name of the options instance being configured. + /// The options instance to configure. + public void PostConfigure(string? name, EtsyAuthenticationOptions options) + { + if (string.IsNullOrEmpty(options.ClientId)) + { + throw new ArgumentException("The Etsy Client ID cannot be null or empty.", nameof(options)); + } + + // Note: Client Secret validation removed - Etsy uses mandatory PKCE which provides + // cryptographic proof of authorization code ownership, potentially eliminating the + // need for client_secret in the token exchange. The ClientId (keystring) is used + // in the x-api-key header for API authentication. + + // Ensure PKCE is enabled (required by Etsy) + if (!options.UsePkce) + { + throw new ArgumentException("PKCE is required by Etsy and cannot be disabled.", nameof(options)); + } + + // Ensure at least one scope is requested (required by Etsy) + if (options.Scope.Count == 0) + { + throw new ArgumentException("At least one scope must be specified for Etsy authentication.", nameof(options)); + } + } +} From cbfe539fb480bc42316b80bd68aef8ee35f3ff60 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Mon, 3 Nov 2025 23:22:26 +0100 Subject: [PATCH 02/37] docs(EtsyProvider): Add Provider usage guide with samples and specifics to this API --- docs/etsy.md | 308 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 docs/etsy.md diff --git a/docs/etsy.md b/docs/etsy.md new file mode 100644 index 000000000..6c2a57d03 --- /dev/null +++ b/docs/etsy.md @@ -0,0 +1,308 @@ +# 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. + +## Quick Links + +- Register your App at [Apps You've Made](https://www.etsy.com/developers/your-apps) on Etsy. +- 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) + +## Quick start + +Add the Etsy provider in your authentication configuration and request any additional scopes you need ("shops_r" is added by default): + +```csharp +services.AddAuthentication(options => /* Auth configuration */) + .AddEtsy(options => + { + options.ClientId = builder.Configuration["Etsy:ClientId"]!; + + // Optional: request additional scopes + options.Scope.Add(AspNet.Security.OAuth.Etsy.EtsyAuthenticationConstants.Scopes.ListingsRead); + + // Optional: fetch extended profile (requires email_r) + // options.IncludeDetailedUserInfo = true; + // options.Scope.Add(AspNet.Security.OAuth.Etsy.EtsyAuthenticationConstants.Scopes.EmailRead); + }); +``` + +## Required Additional Settings + +_None._ + +> [!NOTE] +> +> - ClientSecret is optional for apps registered with Personal Access (public client); Etsy's flow uses Authorization Code with PKCE. +> - PKCE is required and is enabled by default. +> - The default callback path is `/signin-etsy`. +> - Etsy requires at least one scope; `shops_r` must always be included and is added by default. +> - To call the [`getUser` endpoint](https://developers.etsy.com/documentation/reference/#operation/getUser) or when `IncludeDetailedUserInfo` is enabled, add `email_r`. + +## Optional Settings + +| Property Name | Property Type | Description | Default Value | +|:--|:--|:--|:--| +| `Scope` | `ICollection` | Scopes to request. At least one scope is required and `shops_r` must be included (it is added by default). Add `email_r` if you enable `IncludeDetailedUserInfo`. | `["shops_r"]` | +| `IncludeDetailedUserInfo` | `bool` | Makes a second API call to fetch extended profile data (requires `email_r`). | `false` | +| `AccessType` | `EtsyAuthenticationAccessType` | Apps registered as `Personal Access` don't require the client secret in [Authorization Code Flow](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1). | `Personal` | +| `SaveTokens` | `bool` | Persists access/refresh tokens (required by Etsy and validated). | `true` | + +### Scope constants + +Use `EtsyAuthenticationConstants.Scopes.*` instead of string literals. Common values: + +- `EmailRead` → `email_r` +- `ListingsRead` → `listings_r` +- `ListingsWrite` → `listings_w` +- `ShopsRead` → `shops_r` +- `TransactionsRead` → `transactions_r` + +## Validation behavior + +- PKCE and token saving are required and enforced by the options validator. +- Validation fails if no scopes are requested or if `shops_r` is missing. +- If `IncludeDetailedUserInfo` is true, `email_r` must be present. + +## Refreshing tokens + +This provider saves tokens by default (`SaveTokens = true`). Etsy issues a refresh token; you are responsible for performing the refresh flow using the saved token when the access token expires. + +```csharp +var refreshToken = await HttpContext.GetTokenAsync("refresh_token"); +``` + +See [Requesting a Refresh OAuth Token](#quick-links) in the Quick Links above for the HTTP details. + +## Claims + +Basic claims are populated from `/v3/application/users/me`. When `IncludeDetailedUserInfo` is enabled and `email_r` is granted, additional claims are populated from `/v3/application/users/{user_id}`. + +| Claim Type | Value Source | Description | +|:--|:--|:--| +| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` | `user_id` | Primary user identifier | +| `urn:etsy:user_id` | `user_id` | Etsy-specific user ID claim (in addition to NameIdentifier) | +| `urn:etsy:shop_id` | `shop_id` | User's shop ID | +| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | `primary_email` | Primary email address | +| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` | `first_name` | First name | +| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` | `last_name` | Last name | +| `urn:etsy:primary_email` | `primary_email` | Etsy-specific email claim | +| `urn:etsy:first_name` | `first_name` | Etsy-specific first name claim | +| `urn:etsy:last_name` | `last_name` | Etsy-specific last name claim | +| `urn:etsy:image_url` | `image_url_75x75` | 75x75 profile image URL | + +## Configuration + +### Minimal configuration + +#### [Program.cs](#tab/minimal-configuration-program) + +```csharp +using AspNet.Security.OAuth.Etsy; +using Microsoft.AspNetCore.Authentication.Cookies; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = EtsyAuthenticationDefaults.AuthenticationScheme; +}) +.AddCookie() +.AddEtsy(options => +{ + options.ClientId = builder.Configuration["Etsy:ClientId"]!; + + // Enable extended profile (requires email_r) + // options.IncludeDetailedUserInfo = true; + // options.Scope.Add(EtsyAuthenticationConstants.Scopes.EmailRead); + + // Add other optional scopes (shops_r is added by default) +}); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.Run(); +``` + +#### [appsettings.json or appsettings.Development.json](#tab/minimal-configuration-appsettings) + +```json +{ + "Etsy": { + "ClientId": "your-etsy-api-key" + } +} +``` + +*** + +### Advanced using App Settings + +You can keep using code-based configuration, or bind from configuration values. Here is a comprehensive `appsettings.json` example covering supported options and common scopes: + +```json +{ + "Etsy": { + "ClientId": "your-etsy-api-key", + "AccessType": "Personal", + "IncludeDetailedUserInfo": true, + "SaveTokens": true, + "Scopes": [ "shops_r", "email_r" ] + }, + "Logging": { + "LogLevel": { "Default": "Information" } + } +} +``` + +If you bind from configuration, set the options in code, for example: + +```csharp +.AddEtsy(options => +{ + var section = builder.Configuration.GetSection("Etsy"); + options.ClientId = section["ClientId"]!; + options.AccessType = Enum.Parse(section["AccessType"] ?? "Personal", true); + options.IncludeDetailedUserInfo = bool.TryParse(section["IncludeDetailedUserInfo"], out var detailed) && detailed; + options.SaveTokens = !bool.TryParse(section["SaveTokens"], out var save) || save; // defaults to true + + // Apply scopes from config if present + var scopes = section.GetSection("Scopes").Get(); + if (scopes is { Length: > 0 }) + { + foreach (var scope in scopes) + { + options.Scope.Add(scope); + } + } +}) +``` + +> [!NOTE] +> Make sure to use proper [Secret Management for production applications](https://learn.microsoft.com/aspnet/core/security/app-secrets). + +## Accessing claims + +**Using Minimal API:** + +```csharp +using AspNet.Security.OAuth.Etsy; +using System.Security.Claims; + +app.MapGet("/profile", (ClaimsPrincipal user) => +{ + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + var shopId = user.FindFirstValue(EtsyAuthenticationConstants.Claims.ShopId); + var email = user.FindFirstValue(ClaimTypes.Email); + var firstName = user.FindFirstValue(ClaimTypes.GivenName); + var lastName = user.FindFirstValue(ClaimTypes.Surname); + var imageUrl = user.FindFirstValue(EtsyAuthenticationConstants.Claims.ImageUrl); + + return Results.Ok(new { userId, shopId, email, firstName, lastName, imageUrl }); +}).RequireAuthorization(); +``` + +## Feature-style typed Minimal API endpoints with MapGroup + +```csharp +using AspNet.Security.OAuth.Etsy; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http.HttpResults; +using System.Security.Claims; + +namespace MyApi.Features.Authorization; + +public static class EtsyAuthEndpoints +{ + public static IEndpointRouteBuilder MapEtsyAuth(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/etsy") + .WithTags("Etsy Authentication"); + + // Sign-in: triggers the Etsy OAuth handler + group.MapGet("/signin", SignInAsync) + .WithName("EtsySignIn") + .WithSummary("Initiate Etsy OAuth authentication"); + + // Sign-out: removes the auth cookie/session + group.MapGet("/signout", SignOutAsync) + .WithName("EtsySignOut") + .WithSummary("Sign out from Etsy authentication"); + + // Protected: returns the authenticated user's profile + group.MapGet("/user-info", GetProfileAsync) + .RequireAuthorization() + .WithName("User Info") + .WithSummary("Get authenticated user's information"); + + // Protected: returns saved OAuth tokens + group.MapGet("/tokens", GetTokensAsync) + .RequireAuthorization() + .WithName("EtsyTokens") + .WithSummary("Get OAuth access and refresh tokens"); + + return app; + } + + private static Results SignInAsync(string? returnUrl) + => TypedResults.Challenge( + new AuthenticationProperties { RedirectUri = returnUrl ?? "/" }, + new[] { EtsyAuthenticationDefaults.AuthenticationScheme }); + + private static async Task SignOutAsync(HttpContext context) + { + await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return TypedResults.Redirect("/"); + } + + private static Task> GetProfileAsync(ClaimsPrincipal user) + { + var profile = new UserInfo + { + UserId = user.FindFirstValue(ClaimTypes.NameIdentifier)!, + ShopId = user.FindFirstValue(EtsyAuthenticationConstants.Claims.ShopId)!, + Email = user.FindFirstValue(ClaimTypes.Email), + FirstName = user.FindFirstValue(ClaimTypes.GivenName), + LastName = user.FindFirstValue(ClaimTypes.Surname), + ImageUrl = user.FindFirstValue(EtsyAuthenticationConstants.Claims.ImageUrl) + }; + + return Task.FromResult(TypedResults.Ok(profile)); + } + + private static async Task> GetTokensAsync(HttpContext context) + { + var tokenInfo = new TokenInfo + { + AccessToken = await context.GetTokenAsync("access_token"), + RefreshToken = await context.GetTokenAsync("refresh_token"), + ExpiresAt = await context.GetTokenAsync("expires_at") + }; + + return TypedResults.Ok(tokenInfo); + } + + public sealed record UserInfo + { + public required string UserId { get; init; } + public required string ShopId { get; init; } + public string? Email { get; init; } + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? ImageUrl { get; init; } + } + + public sealed record TokenInfo + { + public string? AccessToken { get; init; } + public string? RefreshToken { get; init; } + public string? ExpiresAt { get; init; } + } +} +``` From f14ca12154d2f4c9134ce12d33f96351cd725c57 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Mon, 3 Nov 2025 23:22:36 +0100 Subject: [PATCH 03/37] test: add tests for Etsy OAuth provider --- .../Etsy/EtsyTests.cs | 42 +++++++++++++++++++ .../Etsy/bundle.json | 39 +++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json 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..8243a8866 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs @@ -0,0 +1,42 @@ +/* + * 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 AspNet.Security.OAuth.Etsy; +using Microsoft.AspNetCore.Authentication; +using Xunit; +using Xunit.Abstractions; + +namespace AspNet.Security.OAuth.Providers.Tests.Etsy; + +public class EtsyTests : OAuthTests +{ + public EtsyTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + public override string DefaultScheme => EtsyAuthenticationDefaults.AuthenticationScheme; + + protected internal override void RegisterAuthentication(AuthenticationBuilder builder) + { + builder.AddEtsy(options => ConfigureDefaults(builder, options)); + } + + [Theory] + [InlineData(ClaimTypes.NameIdentifier, "789012")] // shop_id and user_id is used as primary identifier! + [InlineData(ClaimTypes.Email, "test@example.com")] + [InlineData(ClaimTypes.GivenName, "Test")] + [InlineData(ClaimTypes.Surname, "User")] + [InlineData("urn:etsy:user_id", "123456")] + [InlineData("urn:etsy:shop_id", "789012")] + [InlineData("urn:etsy:primary_email", "test@example.com")] + [InlineData("urn:etsy:first_name", "Test")] + [InlineData("urn:etsy:last_name", "User")] + [InlineData("urn:etsy:image_url", "https://i.etsystatic.com/test/test_75x75.jpg")] + public async Task Can_Sign_In_Using_Etsy(string claimType, string claimValue) + => await AuthenticateUserAndAssertClaimValue(claimType, claimValue); +} 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..79c73d7b1 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json @@ -0,0 +1,39 @@ +{ + "$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", + "uri": "https://api.etsy.com/v3/public/oauth/token", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "access_token": "secret-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "secret-refresh-token", + "scope": "email_r shops_r" + } + }, + { + "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" + } + } + ] +} \ No newline at end of file From 7f5731132d95cab48787e48a4bc61155df0609a1 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Thu, 6 Nov 2025 20:35:11 +0100 Subject: [PATCH 04/37] chore: Compare and align to other existing Providers - Added `EtsyAuthenticationDefaults` default value fields like `EtsyBaseUri` and `UserDetailsPath` - Added xml docs onto all `EtsyAuthenticationDefaults` fields - Added `EtsyAuthenticationOptions.IncludeDetailedUserInfo` Property - removed `EtsyPostConfigureOptions` as Etsy does not have dynamic endpoints or domain like GitHub - integrated validation logik into `EtsyAuthenticationOptions.Validate` override, like used for issue 610 fix --- .../EtsyAuthenticationAccessType.cs | 18 ++++ .../EtsyAuthenticationDefaults.cs | 23 ++++- .../EtsyAuthenticationExtensions.cs | 3 +- .../EtsyAuthenticationHandler.cs | 63 ++++++------- .../EtsyAuthenticationOptions.cs | 90 +++++++++++++++++-- .../EtsyPostConfigureOptions.cs | 45 ---------- 6 files changed, 156 insertions(+), 86 deletions(-) create mode 100644 src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs delete mode 100644 src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs new file mode 100644 index 000000000..5341140c4 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs @@ -0,0 +1,18 @@ +// 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 enum EtsyAuthenticationAccessType +{ + /// + /// Public client access type aka 'private usage access' in Etsy App Registration. + /// + Public, + + //// + //// Confidential client access type aka 'commercial usage access' in Etsy App Registration. // TODO: Uncomment if someone can verify that commercial usage access supports confidential clients. + //// + // Confidential +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs index ee43c6a8e..c098e2d19 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs @@ -1,4 +1,4 @@ -/* +/* * 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. @@ -29,6 +29,9 @@ public static class EtsyAuthenticationDefaults /// /// Default value for . /// + /// + /// "/signin-etsy" + /// public static readonly string CallbackPath = "/signin-etsy"; /// @@ -42,7 +45,23 @@ public static class EtsyAuthenticationDefaults public static readonly string TokenEndpoint = "https://api.etsy.com/v3/public/oauth/token"; /// - /// Default value for . + /// Default value for Etsy getMe Endpoint. /// public static readonly string UserInformationEndpoint = "https://openapi.etsy.com/v3/application/users/me"; + + /// + /// Default value for the Etsy v3 API Base URI + /// + /// + /// https://openapi.etsy.com/v3/application/ + /// + public static readonly string EtsyBaseUri = "https://openapi.etsy.com/v3/application/"; + + /// + /// Default value for Etsy user details endpoint path getUser. + /// + /// + /// "/users/{user_id}" + /// + public static readonly string UserDetailsPath = "/users/{user_id}"; } diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs index a515cf39c..231205fdd 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs @@ -1,4 +1,4 @@ -/* +/* * 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. @@ -71,7 +71,6 @@ public static AuthenticationBuilder AddEtsy( [CanBeNull] string caption, [NotNull] Action 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 index 5473a9673..898177289 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs @@ -1,4 +1,4 @@ -/* +/* * 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. @@ -6,6 +6,7 @@ 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; @@ -29,9 +30,9 @@ protected override async Task CreateTicketAsync( [NotNull] AuthenticationProperties properties, [NotNull] OAuthTokenResponse tokens) { - // First, get the basic user info and shop_id from /v3/application/users/me + // Get the basic user info (user_id and shop_id) using var meRequest = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint); - meRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + meRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); meRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); meRequest.Headers.Add("x-api-key", Options.ClientId); @@ -50,41 +51,43 @@ protected override async Task CreateTicketAsync( var userId = meRoot.GetProperty("user_id").GetInt64(); var shopId = meRoot.GetProperty("shop_id").GetInt64(); - // Add the basic claims from the /me endpoint - // Use shop_id as the primary identifier for Etsy (required for most API operations) - identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, shopId.ToString(CultureInfo.InvariantCulture), ClaimValueTypes.String, Options.ClaimsIssuer)); - identity.AddClaim(new Claim(EtsyAuthenticationConstants.Claims.UserId, userId.ToString(CultureInfo.InvariantCulture), ClaimValueTypes.String, Options.ClaimsIssuer)); - identity.AddClaim(new Claim(EtsyAuthenticationConstants.Claims.ShopId, shopId.ToString(CultureInfo.InvariantCulture), ClaimValueTypes.String, Options.ClaimsIssuer)); + var principal = new ClaimsPrincipal(identity); + var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, meRoot); - // Now get additional user details from /v3/application/users/{user_id} - var userDetailEndpoint = $"https://openapi.etsy.com/v3/application/users/{userId}"; - using var userRequest = new HttpRequestMessage(HttpMethod.Get, userDetailEndpoint); - userRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + // Map claims from the basic payload first + context.RunClaimActions(); + + // Optionally enrich with detailed user info + if (Options.IncludeDetailedUserInfo) + { + using var detailedPayload = await GetDetailedUserInfoAsync(tokens); + context.RunClaimActions(detailedPayload.RootElement); + } + + await Events.CreatingTicket(context); + return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name); + } + + /// + /// Retrieves detailed user information from Etsy. + /// + /// The OAuth token response. + /// A JSON document containing the detailed user information. + protected virtual async Task GetDetailedUserInfoAsync([NotNull] OAuthTokenResponse tokens) + { + using var userRequest = new HttpRequestMessage(HttpMethod.Get, EtsyAuthenticationDefaults.EtsyBaseUri + EtsyAuthenticationDefaults.UserDetailsPath); + userRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); userRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); userRequest.Headers.Add("x-api-key", Options.ClientId); using var userResponse = await Backchannel.SendAsync(userRequest, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); - if (userResponse.IsSuccessStatusCode) + if (!userResponse.IsSuccessStatusCode) { - using var userPayload = JsonDocument.Parse(await userResponse.Content.ReadAsStringAsync(Context.RequestAborted)); - - // Create context with the detailed user data for claim mapping - var principal = new ClaimsPrincipal(identity); - var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, userPayload.RootElement); - context.RunClaimActions(); - - await Events.CreatingTicket(context); - return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name); + await Log.UserProfileErrorAsync(Logger, userResponse, Context.RequestAborted); + throw new HttpRequestException("An error occurred while retrieving detailed user info from Etsy."); } - else - { - // If detailed user info call fails, just create ticket with basic info - var principal = new ClaimsPrincipal(identity); - var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, meRoot); - await Events.CreatingTicket(context); - return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name); - } + return JsonDocument.Parse(await userResponse.Content.ReadAsStringAsync(Context.RequestAborted)); } private static partial class Log diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs index 6e3f075a3..d975bc546 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -1,10 +1,11 @@ -/* +/* * 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 Microsoft.Extensions.Options; using static AspNet.Security.OAuth.Etsy.EtsyAuthenticationConstants; namespace AspNet.Security.OAuth.Etsy; @@ -14,6 +15,8 @@ namespace AspNet.Security.OAuth.Etsy; /// public class EtsyAuthenticationOptions : OAuthOptions { + public bool IncludeDetailedUserInfo { get; set; } + public EtsyAuthenticationOptions() { ClaimsIssuer = EtsyAuthenticationDefaults.Issuer; @@ -29,14 +32,14 @@ public EtsyAuthenticationOptions() // Enable refresh token support SaveTokens = true; - // Default scopes - Etsy requires at least one scope - Scope.Add(Scopes.EmailRead); + // Default scopes - Etsy requires at least one scope and this is the one for basic user info Scope.Add(Scopes.ShopsRead); - // Map Etsy user fields to standard and custom claims - // These mappings apply to the /v3/application/users/{user_id} endpoint response - // Note: ClaimTypes.NameIdentifier, UserId, and ShopId are set programmatically - // in the handler from the /v3/application/users/me endpoint + // Map basic user claims + ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "user_id"); + ClaimActions.MapJsonKey(Claims.ShopId, "shop_id"); + + // Map detailed user claims for detailed user info /v3/application/users/{user_id} ClaimActions.MapJsonKey(ClaimTypes.Email, "primary_email"); ClaimActions.MapJsonKey(ClaimTypes.GivenName, "first_name"); ClaimActions.MapJsonKey(ClaimTypes.Surname, "last_name"); @@ -45,4 +48,77 @@ public EtsyAuthenticationOptions() ClaimActions.MapJsonKey(Claims.LastName, "last_name"); ClaimActions.MapJsonKey(Claims.ImageUrl, "image_url_75x75"); } + + /// + /// Gets or sets the value for the Etsy client's access type. + /// + public EtsyAuthenticationAccessType AccessType { get; set; } + + /// + public override void Validate() + { + 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 access type. + // This does mean that three checks have to be re-implemented + // because the won't be validated if the ClientSecret validation fails. + base.Validate(); + } + catch (ArgumentException ex) when (ex.ParamName == nameof(ClientSecret) && AccessType == EtsyAuthenticationAccessType.Public) + { + // 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. + } + + // Ensure PKCE is enabled (required by Etsy) + if (!UsePkce) + { + throw new ArgumentException("PKCE is required by Etsy Authentication and must be enabled.", nameof(UsePkce)); + } + + if (!SaveTokens) + { + throw new ArgumentException("Saving tokens is required by Etsy Authentication and must be enabled.", nameof(SaveTokens)); + } + + if (string.IsNullOrEmpty(AuthorizationEndpoint)) + { + throw new ArgumentNullException($"The '{nameof(AuthorizationEndpoint)}' option must be provided.", nameof(AuthorizationEndpoint)); + } + + if (string.IsNullOrEmpty(TokenEndpoint)) + { + throw new ArgumentNullException($"The '{nameof(TokenEndpoint)}' option must be provided.", nameof(TokenEndpoint)); + } + + if (string.IsNullOrEmpty(UserInformationEndpoint)) + { + throw new ArgumentNullException($"The '{nameof(UserInformationEndpoint)}' option must be provided.", nameof(UserInformationEndpoint)); + } + + // Ensure at least one scope is requested (required by Etsy) + if (Scope.Count == 0) + { + throw new ArgumentOutOfRangeException(nameof(Scope), string.Join(',', Scope), "At least one scope must be specified for Etsy authentication."); + } + + if (!Scope.Contains(Scopes.ShopsRead)) + { + // ShopsRead scope is required to access basic user info + throw new ArgumentOutOfRangeException(nameof(Scope), string.Join(',', Scope), $"The '{Scopes.ShopsRead}' scope must be specified for Etsy authentication UserInfoEndpoint: https://developers.etsy.com/documentation/reference#operation/getMe"); + } + + if (IncludeDetailedUserInfo && !Scope.Contains(Scopes.EmailRead)) + { + // EmailRead scope is required to access detailed user info + throw new ArgumentOutOfRangeException(nameof(Scope), string.Join(',', Scope), $"The '{Scopes.EmailRead}' scope must be specified for Etsy authentication when '{nameof(IncludeDetailedUserInfo)}' is enabled."); + } + + if (!CallbackPath.HasValue) + { + throw new ArgumentException($"The '{nameof(CallbackPath)}' option must be provided.", nameof(CallbackPath)); + } + } } diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs deleted file mode 100644 index bb19761de..000000000 --- a/src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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.Options; - -namespace AspNet.Security.OAuth.Etsy; - -/// -/// Contains the methods required to ensure that the Etsy configuration is valid. -/// -public class EtsyPostConfigureOptions : IPostConfigureOptions -{ - /// - /// Invoked to post-configure a TOptions instance. - /// - /// The name of the options instance being configured. - /// The options instance to configure. - public void PostConfigure(string? name, EtsyAuthenticationOptions options) - { - if (string.IsNullOrEmpty(options.ClientId)) - { - throw new ArgumentException("The Etsy Client ID cannot be null or empty.", nameof(options)); - } - - // Note: Client Secret validation removed - Etsy uses mandatory PKCE which provides - // cryptographic proof of authorization code ownership, potentially eliminating the - // need for client_secret in the token exchange. The ClientId (keystring) is used - // in the x-api-key header for API authentication. - - // Ensure PKCE is enabled (required by Etsy) - if (!options.UsePkce) - { - throw new ArgumentException("PKCE is required by Etsy and cannot be disabled.", nameof(options)); - } - - // Ensure at least one scope is requested (required by Etsy) - if (options.Scope.Count == 0) - { - throw new ArgumentException("At least one scope must be specified for Etsy authentication.", nameof(options)); - } - } -} From 72267d1e4a2361b19068e12d9376bf7157a7acb9 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 21:57:12 +0100 Subject: [PATCH 05/37] chore: update const string to static readonly string --- .../EtsyAuthenticationConstants.cs | 100 +++++++++--------- 1 file changed, 48 insertions(+), 52 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs index cbec6b902..26c9053e4 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs @@ -13,80 +13,76 @@ public static class EtsyAuthenticationConstants { public static class Claims { - public const string UserId = "urn:etsy:user_id"; - public const string ShopId = "urn:etsy:shop_id"; - public const string PrimaryEmail = "urn:etsy:primary_email"; - public const string FirstName = "urn:etsy:first_name"; - public const string LastName = "urn:etsy:last_name"; - public const string ImageUrl = "urn:etsy:image_url"; + /// 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 the Etsy OAuth Scopes constants for Etsy authentication. + /// public static class Scopes { - /// Read user profile and email address - public const string EmailRead = "email_r"; - - /// Read user's listings - public const string ListingsRead = "listings_r"; - - /// Create and edit listings - public const string ListingsWrite = "listings_w"; + /// Read billing and shipping addresses. + public static readonly string AddressRead = "address_r"; - /// Delete listings - public const string ListingsDelete = "listings_d"; + /// Update billing and shipping addresses. + public static readonly string AddressWrite = "address_w"; - /// Read shop information - public const string ShopsRead = "shops_r"; + /// Read all billing statement data. + public static readonly string BillingRead = "billing_r"; - /// Update shop information - public const string ShopsWrite = "shops_w"; + /// Read shopping carts. + public static readonly string CartRead = "cart_r"; - /// Delete shop information - public const string ShopsDelete = "shops_d"; + /// Add and remove items from shopping carts. + public static readonly string CartWrite = "cart_w"; - /// Read transaction data - public const string TransactionsRead = "transactions_r"; + /// Read user profile and email address. + public static readonly string EmailRead = "email_r"; - /// Update transaction data - public const string TransactionsWrite = "transactions_w"; + /// Read private favorites. + public static readonly string FavoritesRead = "favorites_r"; - /// Read billing information - public const string BillingRead = "billing_r"; + /// Add and remove favorites. + public static readonly string FavoritesWrite = "favorites_w"; - /// Read private profile information - public const string ProfileRead = "profile_r"; + /// Read purchase information in feedback. + public static readonly string FeedbackRead = "feedback_r"; - /// Update profile information - public const string ProfileWrite = "profile_w"; + /// Delete listings. + public static readonly string ListingsDelete = "listings_d"; - /// Read user's addresses - public const string AddressRead = "address_r"; + /// Read all listings, including expired listings. + public static readonly string ListingsRead = "listings_r"; - /// Write user's addresses - public const string AddressWrite = "address_w"; + /// Create and edit listings. + public static readonly string ListingsWrite = "listings_w"; - /// Read user's favorites - public const string FavoritesRead = "favorites_r"; + /// Read all profile data. + public static readonly string ProfileRead = "profile_r"; - /// Write user's favorites - public const string FavoritesWrite = "favorites_w"; + /// Update user profile, avatar, and related data. + public static readonly string ProfileWrite = "profile_w"; - /// Read user's feedback - public const string FeedbackRead = "feedback_r"; + /// Read recommended listings. + public static readonly string RecommendRead = "recommend_r"; - /// Read user's shops - public const string ShopsMyRead = "shops_my_r"; + /// Accept and reject recommended listings. + public static readonly string RecommendWrite = "recommend_w"; - /// Read user's cart - public const string CartRead = "cart_r"; + /// Read private shop information. + public static readonly string ShopsRead = "shops_r"; - /// Write user's cart - public const string CartWrite = "cart_w"; + /// Update shop information. + public static readonly string ShopsWrite = "shops_w"; - /// Read user's recommendations - public const string RecommendRead = "recommend_r"; + /// Read all checkout and payment data. + public static readonly string TransactionsRead = "transactions_r"; - /// Write user's recommendations - public const string RecommendWrite = "recommend_w"; + /// Update receipts. + public static readonly string TransactionsWrite = "transactions_w"; } } From 84929154748297d45d2585b34739212c57a8f3a7 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Thu, 6 Nov 2025 20:39:02 +0100 Subject: [PATCH 06/37] test(EtsyProvider): Added unit tests for EtsyAuthenticationOptions and IncludeDetailedUserInfo Handler --- .../Etsy/EtsyAuthenticationOptionsTests.cs | 161 ++++++++++++++++++ .../Etsy/EtsyTests.cs | 54 +++++- 2 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs 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..fa00cb319 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs @@ -0,0 +1,161 @@ +/* + * 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 +{ + public static TheoryData AccessTypes => new() + { + { EtsyAuthenticationAccessType.Public }, // Private Etsy API access does not use client secret (aka 'shared secret' in Etsy App Registration) https://developers.etsy.com/documentation/essentials/authentication + + // { EtsyAuthenticationAccessType.Confidential } // TODO: Verify commercial access app registration Authentication does support confidential clients. Etsy docs do not indicate this to be used at all but support stated this would be required for token refresh + }; + + [Theory] + [InlineData(null, EtsyAuthenticationAccessType.Public)] + [InlineData("", EtsyAuthenticationAccessType.Public)] + public static void Validate_Does_Not_Throw_If_ClientSecret_Is_Not_Provided_For_Public_Access_Type(string? clientSecret, EtsyAuthenticationAccessType accessType) + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + AccessType = accessType, + ClientId = "my-client-id", + ClientSecret = clientSecret!, + }; + + // Act (no Assert) + options.Validate(); + } + + // Leaving this test commented out to for case that ClientSecret is used with commercial access type - so this can be reactivated + // [Theory] + // [InlineData(EtsyAuthenticationAccessType.Commercial)] + // public static void Validate_Throws_If_ClientSecret_Is_Null(EtsyAuthenticationAccessType accessType) + // { + // // Arrange + // var options = new EtsyAuthenticationOptions() + // { + // AccessType = accessType, + // ClientId = "my-client-id", + // ClientSecret = null!, + // }; + // + // // Act and Assert + // _ = Assert.Throws("ClientSecret", options.Validate); + // } + [Theory] + [InlineData(EtsyAuthenticationAccessType.Public, true, false)] + [InlineData(EtsyAuthenticationAccessType.Public, false, false)] + [InlineData(EtsyAuthenticationAccessType.Public, false, true)] + public static void Validate_Throws_If_SaveTokens_Or_Pkce_Is_Disabled(EtsyAuthenticationAccessType accessType, bool usePkce, bool saveTokens) + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + AccessType = accessType, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + SaveTokens = saveTokens, + UsePkce = usePkce, + }; + + // Act and Assert + _ = Assert.Throws(options.Validate); + } + + [Theory] + [MemberData(nameof(AccessTypes))] + public static void Validate_Throws_If_Scope_Is_Empty(EtsyAuthenticationAccessType accessType) + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + AccessType = accessType, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + Scope = { }, + }; + + // Act and Assert + _ = Assert.Throws(options.Validate); + } + + [Theory] + [MemberData(nameof(AccessTypes))] + public static void Validate_Throws_If_Scope_Does_Not_Contain_Scope_shop_r(EtsyAuthenticationAccessType accessType) + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + AccessType = accessType, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + }; + options.Scope.Clear(); + options.Scope.Add(ClaimTypes.Email); + + // Act and Assert + _ = Assert.Throws(options.Validate); + } + + [Theory] + [MemberData(nameof(AccessTypes))] + public static void Validate_Throws_If_IncludeDetailedUserInfo_Is_True_But_Does_Not_Contain_Scope_email_r(EtsyAuthenticationAccessType accessType) + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + AccessType = accessType, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + IncludeDetailedUserInfo = true, + }; + + // Not Adding email scope, shop scope is already added by default + + // Act and Assert + _ = Assert.Throws(options.Validate); + } + + [Theory] + [MemberData(nameof(AccessTypes))] + public static void Validate_Does_Not_Throw_When_IncludeDetailedUserInfo_Is_False_And_Contains_Scope_email_r(EtsyAuthenticationAccessType accessType) + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + AccessType = accessType, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + IncludeDetailedUserInfo = false, + }; + + // Adding email scope + options.Scope.Add(ClaimTypes.Email); + + // Act (no Assert) + options.Validate(); + } + + [Theory] + [MemberData(nameof(AccessTypes))] + public static void Validate_Throws_If_CallbackPath_Is_Null(EtsyAuthenticationAccessType accessType) + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + AccessType = accessType, + CallbackPath = null, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + }; + + // Act and Assert + _ = Assert.Throws(nameof(EtsyAuthenticationOptions.CallbackPath), options.Validate); + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs index 8243a8866..49a013890 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs @@ -1,14 +1,10 @@ -/* +/* * 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 AspNet.Security.OAuth.Etsy; -using Microsoft.AspNetCore.Authentication; -using Xunit; -using Xunit.Abstractions; namespace AspNet.Security.OAuth.Providers.Tests.Etsy; @@ -27,7 +23,7 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu } [Theory] - [InlineData(ClaimTypes.NameIdentifier, "789012")] // shop_id and user_id is used as primary identifier! + [InlineData(ClaimTypes.NameIdentifier, "789012")] [InlineData(ClaimTypes.Email, "test@example.com")] [InlineData(ClaimTypes.GivenName, "Test")] [InlineData(ClaimTypes.Surname, "User")] @@ -39,4 +35,50 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu [InlineData("urn:etsy:image_url", "https://i.etsystatic.com/test/test_75x75.jpg")] 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 + 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("urn:etsy:user_id"); + claims.ShouldContainKey("urn:etsy:shop_id"); + + // 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("urn:etsy:primary_email"); + claims.Keys.ShouldNotContain("urn:etsy:first_name"); + claims.Keys.ShouldNotContain("urn:etsy:last_name"); + claims.Keys.ShouldNotContain("urn:etsy:image_url"); + } + + [Fact] + public async Task Includes_Detailed_Claims_When_IncludeDetailedUserInfo_Is_True() + { + // Arrange: explicitly enable detailed user info enrichment (default may already be true, set explicitly for clarity) + 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("urn:etsy:primary_email"); + claims.ShouldContainKey("urn:etsy:first_name"); + claims.ShouldContainKey("urn:etsy:last_name"); + claims.ShouldContainKey("urn:etsy:image_url"); + } } From 0c3861bf919044849ed13380802f3fe509b893da Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Thu, 6 Nov 2025 20:39:33 +0100 Subject: [PATCH 07/37] chore: updated and documented test data in `bundle.json` --- .../Etsy/bundle.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json index 79c73d7b1..44544e3a6 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json @@ -2,16 +2,15 @@ "$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", + "comment": "Etsy OAuth 2.0 token exchange endpoint response - returns sample token values from Etsy API Docs", "uri": "https://api.etsy.com/v3/public/oauth/token", "method": "POST", "contentFormat": "json", "contentJson": { - "access_token": "secret-access-token", + "access_token": "12345678.12345678.O1zLuwveeKjpIqCQFfmR-PaMMpBmagH6DljRAkK9qt05OtRKiANJOyZlMx3WQ_o2FdComQGuoiAWy3dxyGI4Ke_76PR", "token_type": "Bearer", "expires_in": 3600, - "refresh_token": "secret-refresh-token", - "scope": "email_r shops_r" + "refresh_token": "12345678.JNGIJtvLmwfDMhlYoOJl8aLR1BWottyHC6yhNcET-eC7RogSR5e1GTIXGrgrelWZalvh3YvvyLfKYYqvymd-u37Sjtx" } }, { @@ -36,4 +35,4 @@ } } ] -} \ No newline at end of file +} From df01a95dcdfa464553c175eb3b6b63d0d8e19b4f Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Thu, 6 Nov 2025 23:08:37 +0100 Subject: [PATCH 08/37] chore: Rename Public to Personal Access Type, to match the Etsy Api naming for the app Access level chore: applying PR rewording suggestion Co-authored-by: Martin Costello chore: remove comments Co-authored-by: Martin Costello --- .../EtsyAuthenticationAccessType.cs | 4 ++-- .../EtsyAuthenticationHandler.cs | 2 +- .../EtsyAuthenticationOptions.cs | 6 ++---- .../Etsy/EtsyAuthenticationOptionsTests.cs | 14 +++++++------- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs index 5341140c4..645e7bc84 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs @@ -9,10 +9,10 @@ public enum EtsyAuthenticationAccessType /// /// Public client access type aka 'private usage access' in Etsy App Registration. /// - Public, + Personal, //// //// Confidential client access type aka 'commercial usage access' in Etsy App Registration. // TODO: Uncomment if someone can verify that commercial usage access supports confidential clients. //// - // Confidential + // Commercial } diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs index 898177289..887486d0b 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs @@ -40,7 +40,7 @@ protected override async Task CreateTicketAsync( if (!meResponse.IsSuccessStatusCode) { await Log.UserProfileErrorAsync(Logger, meResponse, Context.RequestAborted); - throw new HttpRequestException("An error occurred while retrieving basic user info from Etsy."); + throw new HttpRequestException("An error occurred while retrieving basic user information from Etsy."); } using var mePayload = JsonDocument.Parse(await meResponse.Content.ReadAsStringAsync(Context.RequestAborted)); diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs index d975bc546..e082e1ccb 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -26,10 +26,7 @@ public EtsyAuthenticationOptions() TokenEndpoint = EtsyAuthenticationDefaults.TokenEndpoint; UserInformationEndpoint = EtsyAuthenticationDefaults.UserInformationEndpoint; - // Enable PKCE by default (required by Etsy) UsePkce = true; - - // Enable refresh token support SaveTokens = true; // Default scopes - Etsy requires at least one scope and this is the one for basic user info @@ -37,6 +34,7 @@ public EtsyAuthenticationOptions() // Map basic user claims ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "user_id"); + ClaimActions.MapJsonKey(Claims.UserId, "user_id"); ClaimActions.MapJsonKey(Claims.ShopId, "shop_id"); // Map detailed user claims for detailed user info /v3/application/users/{user_id} @@ -66,7 +64,7 @@ public override void Validate() // because the won't be validated if the ClientSecret validation fails. base.Validate(); } - catch (ArgumentException ex) when (ex.ParamName == nameof(ClientSecret) && AccessType == EtsyAuthenticationAccessType.Public) + catch (ArgumentException ex) when (ex.ParamName == nameof(ClientSecret) && AccessType == EtsyAuthenticationAccessType.Personal) { // 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. diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs index fa00cb319..03ae0d1a7 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs @@ -10,14 +10,14 @@ public static class EtsyAuthenticationOptionsTests { public static TheoryData AccessTypes => new() { - { EtsyAuthenticationAccessType.Public }, // Private Etsy API access does not use client secret (aka 'shared secret' in Etsy App Registration) https://developers.etsy.com/documentation/essentials/authentication + { EtsyAuthenticationAccessType.Personal }, // Private Etsy API access does not use client secret (aka 'shared secret' in Etsy App Registration) https://developers.etsy.com/documentation/essentials/authentication - // { EtsyAuthenticationAccessType.Confidential } // TODO: Verify commercial access app registration Authentication does support confidential clients. Etsy docs do not indicate this to be used at all but support stated this would be required for token refresh + // { EtsyAuthenticationAccessType.Commercial } // TODO: Verify commercial access app registration Authentication does support confidential clients. Etsy docs do not indicate this to be used at all but support stated this would be required for token refresh }; [Theory] - [InlineData(null, EtsyAuthenticationAccessType.Public)] - [InlineData("", EtsyAuthenticationAccessType.Public)] + [InlineData(null, EtsyAuthenticationAccessType.Personal)] + [InlineData("", EtsyAuthenticationAccessType.Personal)] public static void Validate_Does_Not_Throw_If_ClientSecret_Is_Not_Provided_For_Public_Access_Type(string? clientSecret, EtsyAuthenticationAccessType accessType) { // Arrange @@ -49,9 +49,9 @@ public static void Validate_Does_Not_Throw_If_ClientSecret_Is_Not_Provided_For_P // _ = Assert.Throws("ClientSecret", options.Validate); // } [Theory] - [InlineData(EtsyAuthenticationAccessType.Public, true, false)] - [InlineData(EtsyAuthenticationAccessType.Public, false, false)] - [InlineData(EtsyAuthenticationAccessType.Public, false, true)] + [InlineData(EtsyAuthenticationAccessType.Personal, true, false)] + [InlineData(EtsyAuthenticationAccessType.Personal, false, false)] + [InlineData(EtsyAuthenticationAccessType.Personal, false, true)] public static void Validate_Throws_If_SaveTokens_Or_Pkce_Is_Disabled(EtsyAuthenticationAccessType accessType, bool usePkce, bool saveTokens) { // Arrange From 2b0e3abf68f95f54b77cb41132045b40b64a69ca Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 14:26:09 +0100 Subject: [PATCH 09/37] chore(EtsyAccessTypes): Remove commented member and test/-cases that are not needed chore(EtsyAccessType): Remove AccessType --- .../EtsyAuthenticationAccessType.cs | 18 ----- .../EtsyAuthenticationOptions.cs | 12 +++- .../Etsy/EtsyAuthenticationOptionsTests.cs | 71 +++++-------------- 3 files changed, 27 insertions(+), 74 deletions(-) delete mode 100644 src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs deleted file mode 100644 index 645e7bc84..000000000 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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 enum EtsyAuthenticationAccessType -{ - /// - /// Public client access type aka 'private usage access' in Etsy App Registration. - /// - Personal, - - //// - //// Confidential client access type aka 'commercial usage access' in Etsy App Registration. // TODO: Uncomment if someone can verify that commercial usage access supports confidential clients. - //// - // Commercial -} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs index e082e1ccb..531f63dbd 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -48,9 +48,15 @@ public EtsyAuthenticationOptions() } /// - /// Gets or sets the value for the Etsy client's access type. + /// Gets or sets a value indicating whether to fetch detailed user information + /// from the getUser endpoint. /// - public EtsyAuthenticationAccessType AccessType { get; set; } + /// + /// When enabled, requires the email_r scope to be added to the Scope collection. + /// Users must also configure which claims to map via ClaimActions.MapJsonKey(). + /// See for available claims. + /// + public bool IncludeDetailedUserInfo { get; set; } /// public override void Validate() @@ -64,7 +70,7 @@ public override void Validate() // because the won't be validated if the ClientSecret validation fails. base.Validate(); } - catch (ArgumentException ex) when (ex.ParamName == nameof(ClientSecret) && AccessType == EtsyAuthenticationAccessType.Personal) + 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. diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs index 03ae0d1a7..5c4478364 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs @@ -8,22 +8,14 @@ namespace AspNet.Security.OAuth.Etsy; public static class EtsyAuthenticationOptionsTests { - public static TheoryData AccessTypes => new() - { - { EtsyAuthenticationAccessType.Personal }, // Private Etsy API access does not use client secret (aka 'shared secret' in Etsy App Registration) https://developers.etsy.com/documentation/essentials/authentication - - // { EtsyAuthenticationAccessType.Commercial } // TODO: Verify commercial access app registration Authentication does support confidential clients. Etsy docs do not indicate this to be used at all but support stated this would be required for token refresh - }; - [Theory] - [InlineData(null, EtsyAuthenticationAccessType.Personal)] - [InlineData("", EtsyAuthenticationAccessType.Personal)] - public static void Validate_Does_Not_Throw_If_ClientSecret_Is_Not_Provided_For_Public_Access_Type(string? clientSecret, EtsyAuthenticationAccessType accessType) + [InlineData(null)] + [InlineData("")] + public static void Validate_Does_Not_Throw_If_ClientSecret_Is_Not_Provided(string? clientSecret) { // Arrange var options = new EtsyAuthenticationOptions() { - AccessType = accessType, ClientId = "my-client-id", ClientSecret = clientSecret!, }; @@ -32,32 +24,15 @@ public static void Validate_Does_Not_Throw_If_ClientSecret_Is_Not_Provided_For_P options.Validate(); } - // Leaving this test commented out to for case that ClientSecret is used with commercial access type - so this can be reactivated - // [Theory] - // [InlineData(EtsyAuthenticationAccessType.Commercial)] - // public static void Validate_Throws_If_ClientSecret_Is_Null(EtsyAuthenticationAccessType accessType) - // { - // // Arrange - // var options = new EtsyAuthenticationOptions() - // { - // AccessType = accessType, - // ClientId = "my-client-id", - // ClientSecret = null!, - // }; - // - // // Act and Assert - // _ = Assert.Throws("ClientSecret", options.Validate); - // } [Theory] - [InlineData(EtsyAuthenticationAccessType.Personal, true, false)] - [InlineData(EtsyAuthenticationAccessType.Personal, false, false)] - [InlineData(EtsyAuthenticationAccessType.Personal, false, true)] - public static void Validate_Throws_If_SaveTokens_Or_Pkce_Is_Disabled(EtsyAuthenticationAccessType accessType, bool usePkce, bool saveTokens) + [InlineData(true, false)] + [InlineData(false, false)] + [InlineData(false, true)] + public static void Validate_Throws_If_SaveTokens_Or_Pkce_Is_Disabled(bool usePkce, bool saveTokens) { // Arrange var options = new EtsyAuthenticationOptions() { - AccessType = accessType, ClientId = "my-client-id", ClientSecret = "my-client-secret", SaveTokens = saveTokens, @@ -68,14 +43,12 @@ public static void Validate_Throws_If_SaveTokens_Or_Pkce_Is_Disabled(EtsyAuthent _ = Assert.Throws(options.Validate); } - [Theory] - [MemberData(nameof(AccessTypes))] - public static void Validate_Throws_If_Scope_Is_Empty(EtsyAuthenticationAccessType accessType) + [Fact] + public static void Validate_Throws_If_Scope_Is_Empty() { // Arrange var options = new EtsyAuthenticationOptions() { - AccessType = accessType, ClientId = "my-client-id", ClientSecret = "my-client-secret", Scope = { }, @@ -85,14 +58,12 @@ public static void Validate_Throws_If_Scope_Is_Empty(EtsyAuthenticationAccessTyp _ = Assert.Throws(options.Validate); } - [Theory] - [MemberData(nameof(AccessTypes))] - public static void Validate_Throws_If_Scope_Does_Not_Contain_Scope_shop_r(EtsyAuthenticationAccessType accessType) + [Fact] + public static void Validate_Throws_If_Scope_Does_Not_Contain_Scope_shop_r() { // Arrange var options = new EtsyAuthenticationOptions() { - AccessType = accessType, ClientId = "my-client-id", ClientSecret = "my-client-secret", }; @@ -103,14 +74,12 @@ public static void Validate_Throws_If_Scope_Does_Not_Contain_Scope_shop_r(EtsyAu _ = Assert.Throws(options.Validate); } - [Theory] - [MemberData(nameof(AccessTypes))] - public static void Validate_Throws_If_IncludeDetailedUserInfo_Is_True_But_Does_Not_Contain_Scope_email_r(EtsyAuthenticationAccessType accessType) + [Fact] + public static void Validate_Throws_If_IncludeDetailedUserInfo_Is_True_But_Does_Not_Contain_Scope_email_r() { // Arrange var options = new EtsyAuthenticationOptions() { - AccessType = accessType, ClientId = "my-client-id", ClientSecret = "my-client-secret", IncludeDetailedUserInfo = true, @@ -122,14 +91,12 @@ public static void Validate_Throws_If_IncludeDetailedUserInfo_Is_True_But_Does_N _ = Assert.Throws(options.Validate); } - [Theory] - [MemberData(nameof(AccessTypes))] - public static void Validate_Does_Not_Throw_When_IncludeDetailedUserInfo_Is_False_And_Contains_Scope_email_r(EtsyAuthenticationAccessType accessType) + [Fact] + public static void Validate_Does_Not_Throw_When_IncludeDetailedUserInfo_Is_False_And_Contains_Scope_email_r() { // Arrange var options = new EtsyAuthenticationOptions() { - AccessType = accessType, ClientId = "my-client-id", ClientSecret = "my-client-secret", IncludeDetailedUserInfo = false, @@ -142,20 +109,18 @@ public static void Validate_Does_Not_Throw_When_IncludeDetailedUserInfo_Is_False options.Validate(); } - [Theory] - [MemberData(nameof(AccessTypes))] - public static void Validate_Throws_If_CallbackPath_Is_Null(EtsyAuthenticationAccessType accessType) + [Fact] + public static void Validate_Throws_If_CallbackPath_Is_Null() { // Arrange var options = new EtsyAuthenticationOptions() { - AccessType = accessType, CallbackPath = null, ClientId = "my-client-id", ClientSecret = "my-client-secret", }; // Act and Assert - _ = Assert.Throws(nameof(EtsyAuthenticationOptions.CallbackPath), options.Validate); + _ = Assert.Throws(nameof(EtsyAuthenticationOptions.CallbackPath), options.Validate); } } From 1269f319fd8d5182b692900841bb0c56a75d9072 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 13:30:47 +0100 Subject: [PATCH 10/37] chore(EtsyProvider): tfm version bump Co-authored-by: Martin Costello --- .../AspNet.Security.OAuth.Etsy.csproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj b/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj index 105c515ab..c6b7b37ad 100644 --- a/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj +++ b/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj @@ -1,7 +1,10 @@ + 9.5.0 $(DefaultNetCoreTargetFramework) + + true From ee9eba09797156bef7094e873bb9facb67c9ceeb Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 16:18:43 +0100 Subject: [PATCH 11/37] chore: Add DetailedUserInfoClaimMappings and add xml docs --- .../EtsyAuthenticationConstants.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs index 26c9053e4..054cdd330 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs @@ -4,6 +4,8 @@ * for more information concerning the license and the contributors participating to this project. */ +using System.Security.Claims; + namespace AspNet.Security.OAuth.Etsy; /// @@ -11,6 +13,9 @@ namespace AspNet.Security.OAuth.Etsy; /// public static class EtsyAuthenticationConstants { + /// + /// Contains claim type constants specific to Etsy authentication. + /// public static class Claims { /// The claim type for the user's Etsy shop ID. From 5374092cd14d85fa10c9cf7ee92b651aa8e70922 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 16:21:42 +0100 Subject: [PATCH 12/37] chore(Etsy): align oauth scopes with the docs table there is no endpoint this could get from and as its in the table format in etsy api, it gets also not inserted into the api spec .json for CLI tools like Kiota --- .../EtsyAuthenticationConstants.cs | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs index 054cdd330..9d0acb0fb 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs @@ -30,64 +30,64 @@ public static class Claims /// public static class Scopes { - /// Read billing and shipping addresses. + /// See billing and shipping addresses public static readonly string AddressRead = "address_r"; - /// Update billing and shipping addresses. + /// Update billing and shipping addresses public static readonly string AddressWrite = "address_w"; - /// Read all billing statement data. + /// See all billing statement data public static readonly string BillingRead = "billing_r"; - /// Read shopping carts. + /// Read shopping carts public static readonly string CartRead = "cart_r"; - /// Add and remove items from shopping carts. + /// Add/Remove from shopping carts public static readonly string CartWrite = "cart_w"; - /// Read user profile and email address. + /// Read a user profile public static readonly string EmailRead = "email_r"; - /// Read private favorites. + /// See private favorites public static readonly string FavoritesRead = "favorites_r"; - /// Add and remove favorites. + /// Add/Remove favorites public static readonly string FavoritesWrite = "favorites_w"; - /// Read purchase information in feedback. + /// See purchase info in feedback public static readonly string FeedbackRead = "feedback_r"; - /// Delete listings. + /// Delete listings public static readonly string ListingsDelete = "listings_d"; - /// Read all listings, including expired listings. + /// See all listings (including expired etc) public static readonly string ListingsRead = "listings_r"; - /// Create and edit listings. + /// Create/Edit listings public static readonly string ListingsWrite = "listings_w"; - /// Read all profile data. + /// See all profile data public static readonly string ProfileRead = "profile_r"; - /// Update user profile, avatar, and related data. + /// Update user profile, avatar, etc public static readonly string ProfileWrite = "profile_w"; - /// Read recommended listings. + /// See recommended listings public static readonly string RecommendRead = "recommend_r"; - /// Accept and reject recommended listings. + /// Accept/Reject recommended listings public static readonly string RecommendWrite = "recommend_w"; - /// Read private shop information. + /// See private shop info public static readonly string ShopsRead = "shops_r"; - /// Update shop information. + /// Update shop public static readonly string ShopsWrite = "shops_w"; - /// Read all checkout and payment data. + /// See all checkout/payment data public static readonly string TransactionsRead = "transactions_r"; - /// Update receipts. + /// Update receipts public static readonly string TransactionsWrite = "transactions_w"; } } From 2a791c1412875650556abe7251df8f4c917ef81a Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 18:16:28 +0100 Subject: [PATCH 13/37] chore(EtsyAuthenticationHandler): rename variables and formating apply CA1863 suggestions, apply xml docs changes --- .../EtsyAuthenticationDefaults.cs | 22 ++------ .../EtsyAuthenticationHandler.cs | 54 +++++++++++++------ .../EtsyAuthenticationOptions.cs | 14 +---- 3 files changed, 43 insertions(+), 47 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs index c098e2d19..9882b34bd 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs @@ -4,6 +4,8 @@ * for more information concerning the license and the contributors participating to this project. */ +using System.Text; + namespace AspNet.Security.OAuth.Etsy; /// @@ -29,9 +31,6 @@ public static class EtsyAuthenticationDefaults /// /// Default value for . /// - /// - /// "/signin-etsy" - /// public static readonly string CallbackPath = "/signin-etsy"; /// @@ -42,7 +41,7 @@ public static class EtsyAuthenticationDefaults /// /// Default value for . /// - public static readonly string TokenEndpoint = "https://api.etsy.com/v3/public/oauth/token"; + public static readonly string TokenEndpoint = "https://openapi.etsy.com/v3/public/oauth/token"; /// /// Default value for Etsy getMe Endpoint. @@ -50,18 +49,7 @@ public static class EtsyAuthenticationDefaults public static readonly string UserInformationEndpoint = "https://openapi.etsy.com/v3/application/users/me"; /// - /// Default value for the Etsy v3 API Base URI - /// - /// - /// https://openapi.etsy.com/v3/application/ - /// - public static readonly string EtsyBaseUri = "https://openapi.etsy.com/v3/application/"; - - /// - /// Default value for Etsy user details endpoint path getUser. + /// Default value for receiving the user profile based upon a unique user IDgetUser. /// - /// - /// "/users/{user_id}" - /// - public static readonly string UserDetailsPath = "/users/{user_id}"; + public static readonly CompositeFormat DetailedUserInfoEndpoint = CompositeFormat.Parse("https://openapi.etsy.com/v3/application/users/{0}"); } diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs index 887486d0b..ff3ee4331 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs @@ -31,20 +31,20 @@ protected override async Task CreateTicketAsync( [NotNull] OAuthTokenResponse tokens) { // Get the basic user info (user_id and shop_id) - using var meRequest = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint); - meRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); - meRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); - meRequest.Headers.Add("x-api-key", Options.ClientId); + 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 meResponse = await Backchannel.SendAsync(meRequest, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); - if (!meResponse.IsSuccessStatusCode) + using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); + if (!response.IsSuccessStatusCode) { - await Log.UserProfileErrorAsync(Logger, meResponse, Context.RequestAborted); + await Log.UserProfileErrorAsync(Logger, response, Context.RequestAborted); throw new HttpRequestException("An error occurred while retrieving basic user information from Etsy."); } - using var mePayload = JsonDocument.Parse(await meResponse.Content.ReadAsStringAsync(Context.RequestAborted)); - var meRoot = mePayload.RootElement; + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); + var meRoot = payload.RootElement; // Extract user_id and shop_id from the /me response // Both fields should always be present in a successful Etsy OAuth response @@ -60,8 +60,26 @@ protected override async Task CreateTicketAsync( // Optionally enrich with detailed user info if (Options.IncludeDetailedUserInfo) { - using var detailedPayload = await GetDetailedUserInfoAsync(tokens); - context.RunClaimActions(detailedPayload.RootElement); + 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 jsonAction) + { + if (jsonAction.ClaimType == ClaimTypes.NameIdentifier || + jsonAction.ClaimType == EtsyAuthenticationConstants.Claims.ShopId) + { + continue; + } + } + + action.Run(detailedRoot, identity, Options.ClaimsIssuer ?? ClaimsIssuer); + } } await Events.CreatingTicket(context); @@ -72,15 +90,17 @@ protected override async Task CreateTicketAsync( /// Retrieves detailed user information from Etsy. /// /// The OAuth token response. + /// The user ID to retrieve details for. /// A JSON document containing the detailed user information. - protected virtual async Task GetDetailedUserInfoAsync([NotNull] OAuthTokenResponse tokens) + protected virtual async Task GetDetailedUserInfoAsync([NotNull] OAuthTokenResponse tokens, long userId) { - using var userRequest = new HttpRequestMessage(HttpMethod.Get, EtsyAuthenticationDefaults.EtsyBaseUri + EtsyAuthenticationDefaults.UserDetailsPath); - userRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); - userRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); - userRequest.Headers.Add("x-api-key", Options.ClientId); + var userDetailsUrl = string.Format(null, EtsyAuthenticationDefaults.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 userResponse = await Backchannel.SendAsync(userRequest, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); + using var userResponse = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); if (!userResponse.IsSuccessStatusCode) { await Log.UserProfileErrorAsync(Logger, userResponse, Context.RequestAborted); diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs index 531f63dbd..74c85415a 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -15,8 +15,6 @@ namespace AspNet.Security.OAuth.Etsy; /// public class EtsyAuthenticationOptions : OAuthOptions { - public bool IncludeDetailedUserInfo { get; set; } - public EtsyAuthenticationOptions() { ClaimsIssuer = EtsyAuthenticationDefaults.Issuer; @@ -34,17 +32,7 @@ public EtsyAuthenticationOptions() // Map basic user claims ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "user_id"); - ClaimActions.MapJsonKey(Claims.UserId, "user_id"); ClaimActions.MapJsonKey(Claims.ShopId, "shop_id"); - - // Map detailed user claims for detailed user info /v3/application/users/{user_id} - ClaimActions.MapJsonKey(ClaimTypes.Email, "primary_email"); - ClaimActions.MapJsonKey(ClaimTypes.GivenName, "first_name"); - ClaimActions.MapJsonKey(ClaimTypes.Surname, "last_name"); - ClaimActions.MapJsonKey(Claims.PrimaryEmail, "primary_email"); - ClaimActions.MapJsonKey(Claims.FirstName, "first_name"); - ClaimActions.MapJsonKey(Claims.LastName, "last_name"); - ClaimActions.MapJsonKey(Claims.ImageUrl, "image_url_75x75"); } /// @@ -54,7 +42,7 @@ public EtsyAuthenticationOptions() /// /// When enabled, requires the email_r scope to be added to the Scope collection. /// Users must also configure which claims to map via ClaimActions.MapJsonKey(). - /// See for available claims. + /// See for available claims. /// public bool IncludeDetailedUserInfo { get; set; } From e3500203e93c6d670b3e1bf596d6beffb29c5693 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 21:56:02 +0100 Subject: [PATCH 14/37] chore(EtsyPostConfigureOptions): add DetailedUserInfo Config via PostConfiguration and add Extension for simpler ImageUriClaim --- .../ClaimActionCollectionExtensions.cs | 18 +++++ .../EtsyAuthenticationExtensions.cs | 3 + .../EtsyAuthenticationOptions.cs | 10 +-- .../EtsyPostConfigureOptions.cs | 32 ++++++++ .../Etsy/EtsyPostConfigureOptionsTests.cs | 76 +++++++++++++++++++ 5 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 src/AspNet.Security.OAuth.Etsy/ClaimActionCollectionExtensions.cs create mode 100644 src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyPostConfigureOptionsTests.cs diff --git a/src/AspNet.Security.OAuth.Etsy/ClaimActionCollectionExtensions.cs b/src/AspNet.Security.OAuth.Etsy/ClaimActionCollectionExtensions.cs new file mode 100644 index 000000000..a9e5c8cc2 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/ClaimActionCollectionExtensions.cs @@ -0,0 +1,18 @@ +/* + * 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; + +public static class ClaimActionCollectionExtensions +{ + public static void MapImageClaim(this ClaimActionCollection collection) + { + collection.MapJsonKey(EtsyAuthenticationConstants.Claims.ImageUrl, "image_url_75x75"); + } +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs index 231205fdd..5b9831dd2 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs @@ -71,6 +71,9 @@ public static AuthenticationBuilder AddEtsy( [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/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs index 74c85415a..e41a3a5fd 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -27,23 +27,17 @@ public EtsyAuthenticationOptions() UsePkce = true; SaveTokens = true; - // Default scopes - Etsy requires at least one scope and this is the one for basic user info + // Etsy requires at least one scope and this is the one for basic user info Scope.Add(Scopes.ShopsRead); - // Map basic user claims ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "user_id"); ClaimActions.MapJsonKey(Claims.ShopId, "shop_id"); } /// /// Gets or sets a value indicating whether to fetch detailed user information - /// from the getUser endpoint. + /// from the getUser Endpoint. /// - /// - /// When enabled, requires the email_r scope to be added to the Scope collection. - /// Users must also configure which claims to map via ClaimActions.MapJsonKey(). - /// See for available claims. - /// public bool IncludeDetailedUserInfo { get; set; } /// diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs new file mode 100644 index 000000000..e48e8d6d8 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs @@ -0,0 +1,32 @@ +/* + * 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 Microsoft.Extensions.Options; + +namespace AspNet.Security.OAuth.Etsy; + +/// +/// Applies Etsy-specific post-configuration logic after user configuration and base OAuth setup. +/// +public sealed class EtsyPostConfigureOptions : IPostConfigureOptions +{ + public void PostConfigure(string? name, EtsyAuthenticationOptions options) + { + // Auto-add the email_r scope if detailed user info was requested but the scope not explicitly supplied. + if (options.IncludeDetailedUserInfo && !options.Scope.Contains(EtsyAuthenticationConstants.Scopes.EmailRead)) + { + options.Scope.Add(EtsyAuthenticationConstants.Scopes.EmailRead); + + options.ClaimActions.MapJsonKey(ClaimTypes.Email, "primary_email"); + options.ClaimActions.MapJsonKey(ClaimTypes.GivenName, "first_name"); + options.ClaimActions.MapJsonKey(ClaimTypes.Surname, "last_name"); + } + + // NOTE: We intentionally DO NOT auto-map the image to reduce data bloat, + // as the image data can be quite large and is not always needed. + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyPostConfigureOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyPostConfigureOptionsTests.cs new file mode 100644 index 000000000..1af573cf1 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyPostConfigureOptionsTests.cs @@ -0,0 +1,76 @@ +/* + * 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 EtsyPostConfigureOptionsTests +{ + [Fact] + public static void PostConfigure_Adds_EmailRead_Scope_When_DetailedUserInfo_Enabled_And_Not_Contains_Scope_email_r() + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + IncludeDetailedUserInfo = true, + }; + + // Ensure email_r not already present + options.Scope.Remove(EtsyAuthenticationConstants.Scopes.EmailRead); + + var postConfigure = new EtsyPostConfigureOptions(); + + // Act + postConfigure.PostConfigure(EtsyAuthenticationDefaults.AuthenticationScheme, options); + + // Assert + options.Scope.ShouldContain(EtsyAuthenticationConstants.Scopes.EmailRead); + } + + [Fact] + public static void PostConfigure_Does_Not_Add_EmailRead_When_DetailedUserInfo_Disabled() + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + IncludeDetailedUserInfo = false, + }; + + var postConfigure = new EtsyPostConfigureOptions(); + + // Act + postConfigure.PostConfigure(EtsyAuthenticationDefaults.AuthenticationScheme, options); + + // Assert + options.Scope.ShouldNotContain(EtsyAuthenticationConstants.Scopes.EmailRead); + } + + [Fact] + public static void PostConfigure_Does_Not_Duplicate_EmailRead_Scope() + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + IncludeDetailedUserInfo = true, + }; + + // Add the email scope manually + options.Scope.Add(EtsyAuthenticationConstants.Scopes.EmailRead); + + var postConfigure = new EtsyPostConfigureOptions(); + + // Act + postConfigure.PostConfigure(EtsyAuthenticationDefaults.AuthenticationScheme, options); + + // Assert (will throw if duplicate exists) + options.Scope.ShouldBeUnique(); + } +} From 0dc3821db1217086d8cf2587e24806ef31e4d42a Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 22:03:08 +0100 Subject: [PATCH 15/37] chore: Update xml docs and refactor to Property pattern with declaration pattern --- .../EtsyAuthenticationHandler.cs | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs index ff3ee4331..984321572 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs @@ -25,6 +25,14 @@ public EtsyAuthenticationHandler( { } + /// + /// 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, @@ -49,7 +57,6 @@ protected override async Task CreateTicketAsync( // Extract user_id and shop_id from the /me response // Both fields should always be present in a successful Etsy OAuth response var userId = meRoot.GetProperty("user_id").GetInt64(); - var shopId = meRoot.GetProperty("shop_id").GetInt64(); var principal = new ClaimsPrincipal(identity); var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, meRoot); @@ -69,13 +76,11 @@ protected override async Task CreateTicketAsync( 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 jsonAction) + if (action is Microsoft.AspNetCore.Authentication.OAuth.Claims.JsonKeyClaimAction { ClaimType: var t } && + (t == ClaimTypes.NameIdentifier + || t == EtsyAuthenticationConstants.Claims.ShopId)) { - if (jsonAction.ClaimType == ClaimTypes.NameIdentifier || - jsonAction.ClaimType == EtsyAuthenticationConstants.Claims.ShopId) - { - continue; - } + continue; } action.Run(detailedRoot, identity, Options.ClaimsIssuer ?? ClaimsIssuer); @@ -91,7 +96,7 @@ protected override async Task CreateTicketAsync( /// /// The OAuth token response. /// The user ID to retrieve details for. - /// A JSON document containing the detailed user information. + /// A containing the detailed user information. protected virtual async Task GetDetailedUserInfoAsync([NotNull] OAuthTokenResponse tokens, long userId) { var userDetailsUrl = string.Format(null, EtsyAuthenticationDefaults.DetailedUserInfoEndpoint, userId); From a272fde8918cf5a0678e6f71212d534e8b1b9adc Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 22:05:47 +0100 Subject: [PATCH 16/37] chore(EtsyOptionsValidation): apply Review suggestions --- .../EtsyAuthenticationOptions.cs | 31 +++++-------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs index e41a3a5fd..87210929a 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -58,53 +58,36 @@ public override void Validate() // See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers/issues/610. } - // Ensure PKCE is enabled (required by Etsy) - if (!UsePkce) - { - throw new ArgumentException("PKCE is required by Etsy Authentication and must be enabled.", nameof(UsePkce)); - } - - if (!SaveTokens) - { - throw new ArgumentException("Saving tokens is required by Etsy Authentication and must be enabled.", nameof(SaveTokens)); - } - if (string.IsNullOrEmpty(AuthorizationEndpoint)) { - throw new ArgumentNullException($"The '{nameof(AuthorizationEndpoint)}' option must be provided.", nameof(AuthorizationEndpoint)); + throw new ArgumentNullException(nameof(AuthorizationEndpoint), $"The '{nameof(AuthorizationEndpoint)}' option must be provided."); } if (string.IsNullOrEmpty(TokenEndpoint)) { - throw new ArgumentNullException($"The '{nameof(TokenEndpoint)}' option must be provided.", nameof(TokenEndpoint)); + throw new ArgumentNullException(nameof(TokenEndpoint), $"The '{nameof(TokenEndpoint)}' option must be provided."); } if (string.IsNullOrEmpty(UserInformationEndpoint)) { - throw new ArgumentNullException($"The '{nameof(UserInformationEndpoint)}' option must be provided.", nameof(UserInformationEndpoint)); - } - - // Ensure at least one scope is requested (required by Etsy) - if (Scope.Count == 0) - { - throw new ArgumentOutOfRangeException(nameof(Scope), string.Join(',', Scope), "At least one scope must be specified for Etsy authentication."); + throw new ArgumentNullException(nameof(UserInformationEndpoint), $"The '{nameof(UserInformationEndpoint)}' option must be provided."); } if (!Scope.Contains(Scopes.ShopsRead)) { - // ShopsRead scope is required to access basic user info - throw new ArgumentOutOfRangeException(nameof(Scope), string.Join(',', Scope), $"The '{Scopes.ShopsRead}' scope must be specified for Etsy authentication UserInfoEndpoint: https://developers.etsy.com/documentation/reference#operation/getMe"); + // shops_r scope is required to access basic user info. + throw new ArgumentOutOfRangeException(nameof(Scope), string.Join(',', Scope), $"The '{Scopes.ShopsRead}' scope must be specified."); } if (IncludeDetailedUserInfo && !Scope.Contains(Scopes.EmailRead)) { // EmailRead scope is required to access detailed user info - throw new ArgumentOutOfRangeException(nameof(Scope), string.Join(',', Scope), $"The '{Scopes.EmailRead}' scope must be specified for Etsy authentication when '{nameof(IncludeDetailedUserInfo)}' is enabled."); + throw new ArgumentOutOfRangeException(nameof(Scope), string.Join(',', Scope), $"The '{Scopes.EmailRead}' scope must be specified when '{nameof(IncludeDetailedUserInfo)}' is enabled."); } if (!CallbackPath.HasValue) { - throw new ArgumentException($"The '{nameof(CallbackPath)}' option must be provided.", nameof(CallbackPath)); + throw new ArgumentNullException(nameof(CallbackPath), $"The '{nameof(CallbackPath)}' option must be provided."); } } } From 6a3fb4c2139af07f828d9eb535d8497c9abc441b Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 22:09:20 +0100 Subject: [PATCH 17/37] test(EtsyProvider): Update tests accordingly to review suggestions and code changes in Etsy Provider --- .../Etsy/EtsyAuthenticationOptionsTests.cs | 55 +++++++++---------- .../Etsy/EtsyTests.cs | 38 ++++++------- 2 files changed, 41 insertions(+), 52 deletions(-) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs index 5c4478364..40235dd15 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs @@ -24,89 +24,83 @@ public static void Validate_Does_Not_Throw_If_ClientSecret_Is_Not_Provided(strin options.Validate(); } - [Theory] - [InlineData(true, false)] - [InlineData(false, false)] - [InlineData(false, true)] - public static void Validate_Throws_If_SaveTokens_Or_Pkce_Is_Disabled(bool usePkce, bool saveTokens) + [Fact] + public static void Validate_Does_Throw_If_Scope_Does_Not_Contain_Scope_shop_r() { // Arrange var options = new EtsyAuthenticationOptions() { ClientId = "my-client-id", ClientSecret = "my-client-secret", - SaveTokens = saveTokens, - UsePkce = usePkce, }; + options.Scope.Clear(); + options.Scope.Add(EtsyAuthenticationConstants.Scopes.EmailRead); - // Act and Assert - _ = Assert.Throws(options.Validate); + // Act + _ = Assert.Throws(options.Validate); } [Fact] - public static void Validate_Throws_If_Scope_Is_Empty() + public static void Validate_Does_Not_Throw_When_IncludeDetailedUserInfo_Is_False_And_Contains_Scope_email_r() { // Arrange var options = new EtsyAuthenticationOptions() { ClientId = "my-client-id", ClientSecret = "my-client-secret", - Scope = { }, + IncludeDetailedUserInfo = false, }; - // Act and Assert - _ = Assert.Throws(options.Validate); + // Adding email scope should be harmless when IncludeDetailedUserInfo is false + options.Scope.Add(EtsyAuthenticationConstants.Scopes.EmailRead); + + // Act (no Assert) + options.Validate(); } [Fact] - public static void Validate_Throws_If_Scope_Does_Not_Contain_Scope_shop_r() + public static void Validate_Throws_If_AuthorizationEndpoint_Is_Null() { // Arrange var options = new EtsyAuthenticationOptions() { + AuthorizationEndpoint = null!, ClientId = "my-client-id", ClientSecret = "my-client-secret", }; - options.Scope.Clear(); - options.Scope.Add(ClaimTypes.Email); // Act and Assert - _ = Assert.Throws(options.Validate); + _ = Assert.Throws(nameof(options.AuthorizationEndpoint), options.Validate); } [Fact] - public static void Validate_Throws_If_IncludeDetailedUserInfo_Is_True_But_Does_Not_Contain_Scope_email_r() + public static void Validate_Throws_If_TokenEndpoint_Is_Null() { // Arrange var options = new EtsyAuthenticationOptions() { ClientId = "my-client-id", ClientSecret = "my-client-secret", - IncludeDetailedUserInfo = true, + TokenEndpoint = null!, }; - // Not Adding email scope, shop scope is already added by default - // Act and Assert - _ = Assert.Throws(options.Validate); + _ = Assert.Throws(nameof(options.TokenEndpoint), options.Validate); } [Fact] - public static void Validate_Does_Not_Throw_When_IncludeDetailedUserInfo_Is_False_And_Contains_Scope_email_r() + public static void Validate_Throws_If_UserInformationEndpoint_Is_Null() { // Arrange var options = new EtsyAuthenticationOptions() { ClientId = "my-client-id", ClientSecret = "my-client-secret", - IncludeDetailedUserInfo = false, + TokenEndpoint = null!, }; - // Adding email scope - options.Scope.Add(ClaimTypes.Email); - - // Act (no Assert) - options.Validate(); + // Act and Assert + _ = Assert.Throws(nameof(options.UserInformationEndpoint), options.Validate); } [Fact] @@ -121,6 +115,7 @@ public static void Validate_Throws_If_CallbackPath_Is_Null() }; // Act and Assert - _ = Assert.Throws(nameof(EtsyAuthenticationOptions.CallbackPath), options.Validate); + 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 index 49a013890..5855db247 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs @@ -5,6 +5,7 @@ */ using AspNet.Security.OAuth.Etsy; +using static AspNet.Security.OAuth.Etsy.EtsyAuthenticationConstants; namespace AspNet.Security.OAuth.Providers.Tests.Etsy; @@ -23,16 +24,8 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu } [Theory] - [InlineData(ClaimTypes.NameIdentifier, "789012")] - [InlineData(ClaimTypes.Email, "test@example.com")] - [InlineData(ClaimTypes.GivenName, "Test")] - [InlineData(ClaimTypes.Surname, "User")] - [InlineData("urn:etsy:user_id", "123456")] - [InlineData("urn:etsy:shop_id", "789012")] - [InlineData("urn:etsy:primary_email", "test@example.com")] - [InlineData("urn:etsy:first_name", "Test")] - [InlineData("urn:etsy:last_name", "User")] - [InlineData("urn:etsy:image_url", "https://i.etsystatic.com/test/test_75x75.jpg")] + [InlineData(ClaimTypes.NameIdentifier, "123456")] + [InlineData("shop_id", "789012")] public async Task Can_Sign_In_Using_Etsy(string claimType, string claimValue) => await AuthenticateUserAndAssertClaimValue(claimType, claimValue); @@ -48,24 +41,28 @@ public async Task Does_Not_Include_Detailed_Claims_When_IncludeDetailedUserInfo_ var claims = await AuthenticateUserAsync(server); // Assert basic claims are present - claims.ShouldContainKey("urn:etsy:user_id"); - claims.ShouldContainKey("urn:etsy:shop_id"); + claims.ShouldContainKey(ClaimTypes.NameIdentifier); + 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("urn:etsy:primary_email"); - claims.Keys.ShouldNotContain("urn:etsy:first_name"); - claims.Keys.ShouldNotContain("urn:etsy:last_name"); - claims.Keys.ShouldNotContain("urn:etsy:image_url"); + claims.Keys.ShouldNotContain(Claims.ImageUrl); } [Fact] public async Task Includes_Detailed_Claims_When_IncludeDetailedUserInfo_Is_True() { - // Arrange: explicitly enable detailed user info enrichment (default may already be true, set explicitly for clarity) - void ConfigureServices(IServiceCollection services) => services.PostConfigureAll(o => o.IncludeDetailedUserInfo = true); + // Arrange: enable detailed user info, configure claims to map. + // Note: email_r will be auto-added by the provider's post-configure step. + void ConfigureServices(IServiceCollection services) => services.PostConfigureAll(o => + { + o.IncludeDetailedUserInfo = true; + + // User to include image claim + o.ClaimActions.MapImageClaim(); + }); using var server = CreateTestServer(ConfigureServices); @@ -76,9 +73,6 @@ public async Task Includes_Detailed_Claims_When_IncludeDetailedUserInfo_Is_True( claims.ShouldContainKey(ClaimTypes.Email); claims.ShouldContainKey(ClaimTypes.GivenName); claims.ShouldContainKey(ClaimTypes.Surname); - claims.ShouldContainKey("urn:etsy:primary_email"); - claims.ShouldContainKey("urn:etsy:first_name"); - claims.ShouldContainKey("urn:etsy:last_name"); - claims.ShouldContainKey("urn:etsy:image_url"); + claims.ShouldContainKey(Claims.ImageUrl); } } From 7eab6855f177baec051507870c8e51b87c9faac5 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 23:40:26 +0100 Subject: [PATCH 18/37] chore: xml docs updates and update bundle.json with the placeholder value --- .../EtsyAuthenticationDefaults.cs | 2 +- .../EtsyAuthenticationOptions.cs | 8 ++++++++ .../Etsy/bundle.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs index 9882b34bd..f33ef54dc 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs @@ -49,7 +49,7 @@ public static class EtsyAuthenticationDefaults 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 IDgetUser. + /// Default value for receiving the user profile based upon a unique user ID getUser. /// public static readonly CompositeFormat DetailedUserInfoEndpoint = CompositeFormat.Parse("https://openapi.etsy.com/v3/application/users/{0}"); } diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs index 87210929a..ee43b2cae 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -40,6 +40,14 @@ public EtsyAuthenticationOptions() /// public bool IncludeDetailedUserInfo { get; set; } + /// + /// Gets or sets the endpoint used to retrieve detailed user information. + /// + /// + /// The placeholder for client_id needs to be "{0}" and will be replaced with the authenticated user's ID. + /// + public string? DetailedUserInfoEndpoint { get; set; } + /// public override void Validate() { diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json index 44544e3a6..bcbf4ab8c 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json @@ -24,7 +24,7 @@ }, { "comment": "Etsy /v3/application/users/{user_id} endpoint - returns detailed user information", - "uri": "https://openapi.etsy.com/v3/application/users/123456", + "uri": "https://openapi.etsy.com/v3/application/users/{0}", "contentFormat": "json", "contentJson": { "user_id": 123456, From 9a8035ecba40b474ec488d6bdda959a6ae7acbff Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 23:43:51 +0100 Subject: [PATCH 19/37] chore: implement Options fed DetailedUserInfoEndpoint and set fallback to defaults in handler --- .../EtsyAuthenticationHandler.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs index 984321572..4a4fd51ba 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs @@ -99,20 +99,29 @@ protected override async Task CreateTicketAsync( /// A containing the detailed user information. protected virtual async Task GetDetailedUserInfoAsync([NotNull] OAuthTokenResponse tokens, long userId) { - var userDetailsUrl = string.Format(null, EtsyAuthenticationDefaults.DetailedUserInfoEndpoint, userId); + string userDetailsUrl; + if (!string.IsNullOrWhiteSpace(Options.DetailedUserInfoEndpoint)) + { + userDetailsUrl = string.Format(CultureInfo.InvariantCulture, Options.DetailedUserInfoEndpoint, userId); + } + else + { + userDetailsUrl = string.Format(CultureInfo.InvariantCulture, EtsyAuthenticationDefaults.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 userResponse = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); - if (!userResponse.IsSuccessStatusCode) + using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); + if (!response.IsSuccessStatusCode) { - await Log.UserProfileErrorAsync(Logger, userResponse, Context.RequestAborted); + await Log.UserProfileErrorAsync(Logger, response, Context.RequestAborted); throw new HttpRequestException("An error occurred while retrieving detailed user info from Etsy."); } - return JsonDocument.Parse(await userResponse.Content.ReadAsStringAsync(Context.RequestAborted)); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); } private static partial class Log From 293ad3a6146b338f9d1ca05fe08885de042296f9 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Sat, 8 Nov 2025 00:59:03 +0100 Subject: [PATCH 20/37] chore: fix test builds - openapi instead of just api - reverted formated string in bundle.json --- .../EtsyAuthenticationOptions.cs | 2 +- .../Etsy/EtsyAuthenticationOptionsTests.cs | 17 ++++++++++++++++- .../Etsy/bundle.json | 6 +++--- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs index ee43b2cae..a1adb5dac 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -89,7 +89,7 @@ public override void Validate() if (IncludeDetailedUserInfo && !Scope.Contains(Scopes.EmailRead)) { - // EmailRead scope is required to access detailed user info + // EmailRead scope is required to access detailed user info. As the post configure action should have added it, we need to ensure it's present. throw new ArgumentOutOfRangeException(nameof(Scope), string.Join(',', Scope), $"The '{Scopes.EmailRead}' scope must be specified when '{nameof(IncludeDetailedUserInfo)}' is enabled."); } diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs index 40235dd15..b39556353 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs @@ -96,13 +96,28 @@ public static void Validate_Throws_If_UserInformationEndpoint_Is_Null() { ClientId = "my-client-id", ClientSecret = "my-client-secret", - TokenEndpoint = null!, + UserInformationEndpoint = null!, }; // Act and Assert _ = Assert.Throws(nameof(options.UserInformationEndpoint), options.Validate); } + [Fact] + public static void Validate_Dont_Throws_If_DetailedUserInformationEndpoint_Is_Null() + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + DetailedUserInfoEndpoint = null!, + }; + + // Act (no Assert) + options.Validate(); + } + [Fact] public static void Validate_Throws_If_CallbackPath_Is_Null() { diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json index bcbf4ab8c..c304d6551 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json @@ -2,8 +2,8 @@ "$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", - "uri": "https://api.etsy.com/v3/public/oauth/token", + "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": { @@ -24,7 +24,7 @@ }, { "comment": "Etsy /v3/application/users/{user_id} endpoint - returns detailed user information", - "uri": "https://openapi.etsy.com/v3/application/users/{0}", + "uri": "https://openapi.etsy.com/v3/application/users/123456", "contentFormat": "json", "contentJson": { "user_id": 123456, From 523e8d8e986ec66248643e11034359eb12d96ec8 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Sat, 8 Nov 2025 01:00:26 +0100 Subject: [PATCH 21/37] chore: set InlineData to magic string "urn:etsy:shop_id" because only const strings could be used in attributes, not static readonly strings --- test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs index 5855db247..a23b7c63e 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs @@ -25,7 +25,7 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu [Theory] [InlineData(ClaimTypes.NameIdentifier, "123456")] - [InlineData("shop_id", "789012")] + [InlineData("urn:etsy:shop_id", "789012")] public async Task Can_Sign_In_Using_Etsy(string claimType, string claimValue) => await AuthenticateUserAndAssertClaimValue(claimType, claimValue); From f9358a7d490968e964a7d8693caf9522f9b077d5 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Sat, 8 Nov 2025 01:05:13 +0100 Subject: [PATCH 22/37] chore(EtsyTests): apply workaround into PostConfigure test User side Post configure is later than provider side post configure. this causes the Scope not to be evaluated and the Mappings are not applyed automatically HACK: add them all manually for the test postConfigure until decision is made this could be put in validate override or always consumer side? --- .../Etsy/EtsyTests.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs index a23b7c63e..e19c71abb 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs @@ -60,7 +60,17 @@ void ConfigureServices(IServiceCollection services) => services.PostConfigureAll { o.IncludeDetailedUserInfo = true; - // User to include image claim + // Ensure the required scope is present before Validate() executes. + // BUG: This should not be necessary as the post-configure should add it. Assuming test Arrange should simulate eventual user setup. + if (!o.Scope.Contains(Scopes.EmailRead)) + { + o.Scope.Add(Scopes.EmailRead); + o.ClaimActions.MapJsonKey(ClaimTypes.Email, "primary_email"); + o.ClaimActions.MapJsonKey(ClaimTypes.GivenName, "first_name"); + o.ClaimActions.MapJsonKey(ClaimTypes.Surname, "last_name"); + } + + // Opt-in to include image claim (not auto-mapped by provider to reduce payload size) o.ClaimActions.MapImageClaim(); }); From b9f8c391584b01bf7c1c53c2b5cf69a1f9a67af8 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Sat, 8 Nov 2025 01:25:58 +0100 Subject: [PATCH 23/37] chore: create seperate named log methods TODO: Should be checked, we still need the full methods when using the attribute LoggerMessage. Maybe we can delete the non attribute using method then? --- .../EtsyAuthenticationHandler.cs | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs index 4a4fd51ba..e14721531 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs @@ -47,7 +47,7 @@ protected override async Task CreateTicketAsync( using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); if (!response.IsSuccessStatusCode) { - await Log.UserProfileErrorAsync(Logger, response, Context.RequestAborted); + await Log.BasicUserInfoErrorAsync(Logger, response, Context.RequestAborted); throw new HttpRequestException("An error occurred while retrieving basic user information from Etsy."); } @@ -117,7 +117,7 @@ protected virtual async Task GetDetailedUserInfoAsync([NotNull] OA using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); if (!response.IsSuccessStatusCode) { - await Log.UserProfileErrorAsync(Logger, response, Context.RequestAborted); + await Log.DetailedUserInfoErrorAsync(Logger, response, Context.RequestAborted); throw new HttpRequestException("An error occurred while retrieving detailed user info from Etsy."); } @@ -126,18 +126,38 @@ protected virtual async Task GetDetailedUserInfoAsync([NotNull] OA private static partial class Log { - internal static async Task UserProfileErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken) + internal static async Task BasicUserInfoErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken) { - UserProfileError( + BasicUserInfoError( logger, + response.RequestMessage?.RequestUri?.ToString() ?? string.Empty, response.StatusCode, response.Headers.ToString(), await response.Content.ReadAsStringAsync(cancellationToken)); } - [LoggerMessage(1, LogLevel.Error, "An error occurred while retrieving the user profile from Etsy: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")] - private static partial void UserProfileError( + 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); From 30c5534e968d4e30dfa08e4af8e53febb2cdb542 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Fri, 7 Nov 2025 23:34:42 +0100 Subject: [PATCH 24/37] docs(EtsyProvider): Add links to etsy provider docs and author, update docs docs(PostConfigure): Add Warning that he needs to add the claims himself for detailed user info if he wants to use `PostConfigure` himself, which runs after the Provider side chore: apply PR reword suggestions chore: Update Etsy.md TOC chore: Apply PR suggestions --- README.md | 2 + docs/README.md | 1 + docs/assets/Etsy-find-your-client_id.png | Bin 0 -> 15779 bytes docs/etsy.md | 368 ++++++++++++------ .../AspNet.Security.OAuth.Etsy.csproj | 4 +- .../ClaimActionCollectionExtensions.cs | 6 + 6 files changed, 256 insertions(+), 125 deletions(-) create mode 100644 docs/assets/Etsy-find-your-client_id.png 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/assets/Etsy-find-your-client_id.png b/docs/assets/Etsy-find-your-client_id.png new file mode 100644 index 0000000000000000000000000000000000000000..44805b48c99a1c58577676f68b2209dac04f207a GIT binary patch literal 15779 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 J4aWk0{%>A7{~-VX literal 0 HcmV?d00001 diff --git a/docs/etsy.md b/docs/etsy.md index 6c2a57d03..ec894f42c 100644 --- a/docs/etsy.md +++ b/docs/etsy.md @@ -2,6 +2,27 @@ 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) + - [Quick Links](#quick-links) + - [Quick start](#quick-start) + - [Minimal configuration](#minimal-configuration) + - [Required Additional Settings](#required-additional-settings) + - [Optional Settings](#optional-settings) + - [Scope constants](#scope-constants) + - [Refreshing tokens](#refreshing-tokens) + - [Claims](#claims) + - [Basic User Information claims](#basic-user-information-claims) + - [Detailed User Information claims](#detailed-user-information-claims) + - [Automapped claims](#automapped-claims) + - [Manually Added Claims](#manually-added-claims) + - [Advanced Configuration](#advanced-configuration) + - [Accessing claims (Minimal API Sample)](#accessing-claims-minimal-api-sample) + - [Minimalistic directly in Program.cs](#minimalistic-directly-in-programcs) + - [Extended in a Feature-style Minimal API with endpoints using MapGroup](#extended-in-a-feature-style-minimal-api-with-endpoints-using-mapgroup) + - [Define record types for Typed Results](#define-record-types-for-typed-results) + - [Extension class anywhere in your project](#extension-class-anywhere-in-your-project) + - [Register the endpoints in Program.cs](#register-the-endpoints-in-programcs) + ## Quick Links - Register your App at [Apps You've Made](https://www.etsy.com/developers/your-apps) on Etsy. @@ -11,59 +32,103 @@ Etsy's OAuth implementation uses Authorization Code with PKCE and issues refresh ## Quick start -Add the Etsy provider in your authentication configuration and request any additional scopes you need ("shops_r" is added by default): - ```csharp -services.AddAuthentication(options => /* Auth configuration */) - .AddEtsy(options => - { - options.ClientId = builder.Configuration["Etsy:ClientId"]!; +using AspNet.Security.OAuth.Etsy; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = EtsyAuthenticationDefaults.AuthenticationScheme; + }) + .AddCookie() + .AddEtsy(options => + { + options.ClientId = builder.Configuration["Etsy:ClientId"]!; + }); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +// Route to start the Etsy OAuth flow (challenge) +app.MapGet("/signin/etsy", (HttpContext ctx, string? returnUrl) => + Results.Challenge(new AuthenticationProperties + { + RedirectUri = returnUrl ?? "/" + }, new[] { EtsyAuthenticationDefaults.AuthenticationScheme })); + +// NOTE: The callback path '/signin-etsy' is handled automatically by the middleware. +// Do NOT map a route for it unless you change CallbackPath in options. + +app.Run(); +``` + +### Minimal configuration + +**In your appsettings.json or appsettings.Development.json file:** + +```json +{ + "Etsy": { + "ClientId": "your-etsy-api-key" + } +} +``` - // Optional: request additional scopes - options.Scope.Add(AspNet.Security.OAuth.Etsy.EtsyAuthenticationConstants.Scopes.ListingsRead); +**In your `Program.cs` or `Startup.cs` file:** - // Optional: fetch extended profile (requires email_r) - // options.IncludeDetailedUserInfo = true; - // options.Scope.Add(AspNet.Security.OAuth.Etsy.EtsyAuthenticationConstants.Scopes.EmailRead); - }); +```csharp +builder.Services.Configure( + builder.Configuration.GetSection("Etsy")); ``` ## Required Additional Settings -_None._ +- `ClientId` is required. + + You can obtain it by registering your application on [Etsy's developer portal](https://www.etsy.com/developers/your-apps). + + It will be stated as `keystring` in your app settings: + + ![Etsy-find-your-client_id](./assets/Etsy-find-your-client_id.png) > [!NOTE] > -> - ClientSecret is optional for apps registered with Personal Access (public client); Etsy's flow uses Authorization Code with PKCE. -> - PKCE is required and is enabled by default. -> - The default callback path is `/signin-etsy`. -> - Etsy requires at least one scope; `shops_r` must always be included and is added by default. -> - To call the [`getUser` endpoint](https://developers.etsy.com/documentation/reference/#operation/getUser) or when `IncludeDetailedUserInfo` is enabled, add `email_r`. +> - ClientSecret is optional for public clients using PKCE. +> - When `IncludeDetailedUserInfo` is enabled, `email_r` scope and standard claims are auto-mapped. +> - The `EtsyAuthenticationConstants.Claims.ImageUrl` claim must be [added if needed](#manually-added-claims). ## Optional Settings | Property Name | Property Type | Description | Default Value | -|:--|:--|:--|:--| -| `Scope` | `ICollection` | Scopes to request. At least one scope is required and `shops_r` must be included (it is added by default). Add `email_r` if you enable `IncludeDetailedUserInfo`. | `["shops_r"]` | -| `IncludeDetailedUserInfo` | `bool` | Makes a second API call to fetch extended profile data (requires `email_r`). | `false` | -| `AccessType` | `EtsyAuthenticationAccessType` | Apps registered as `Personal Access` don't require the client secret in [Authorization Code Flow](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1). | `Personal` | -| `SaveTokens` | `bool` | Persists access/refresh tokens (required by Etsy and validated). | `true` | +|:--:|:--:|:--:|:--:| +| `Scope` | `ICollection` | Scopes to request. Use `EtsyAuthenticationConstants.Scopes.*` constants. | `["shops_r"]` | +| `IncludeDetailedUserInfo` | `bool` | Fetch extended profile data with auto-mapped claims (Email, GivenName, Surname). | `false` | +| `UsePkce` | `bool` | Enable PKCE (required by Etsy). | `true` | +| `SaveTokens` | `bool` | Persist access and refresh tokens. | `true` | +| `CallbackPath` | `PathString` | The request path within your application where the user-agent will be returned after Etsy has authenticated the user. | `/signin-etsy` | +| `DetailedUserInfoEndpoint` | `string` | The endpoint to retrieve detailed user information. | `https://openapi.etsy.com/v3/application/users/{0}` | + +> [!NOTE] +> The `DetailedUserInfoEndpoint` uses `{0}` as a placeholder for the `user_id`. It's replaced automatically when fetching detailed user info. ### Scope constants Use `EtsyAuthenticationConstants.Scopes.*` instead of string literals. Common values: -- `EmailRead` → `email_r` -- `ListingsRead` → `listings_r` -- `ListingsWrite` → `listings_w` -- `ShopsRead` → `shops_r` -- `TransactionsRead` → `transactions_r` - -## Validation behavior - -- PKCE and token saving are required and enforced by the options validator. -- Validation fails if no scopes are requested or if `shops_r` is missing. -- If `IncludeDetailedUserInfo` is true, `email_r` must be present. +| Constant | Scope Value | +|:--|:--| +| `EmailRead` | `email_r` | +| `ListingsRead` | `listings_r` | +| `ListingsWrite` | `listings_w` | +| `ShopsRead` | `shops_r` | +| `TransactionsRead` | `transactions_r` | ## Refreshing tokens @@ -77,124 +142,155 @@ See [Requesting a Refresh OAuth Token](#quick-links) in the Quick Links above fo ## Claims -Basic claims are populated from `/v3/application/users/me`. When `IncludeDetailedUserInfo` is enabled and `email_r` is granted, additional claims are populated from `/v3/application/users/{user_id}`. +### Basic User Information claims + +**Endpoint:** [`/v3/application/users/me` `getMe`](https://developers.etsy.com/documentation/reference#operation/getMe) | Claim Type | Value Source | Description | -|:--|:--|:--| +|:--|:--:|:--:| | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` | `user_id` | Primary user identifier | -| `urn:etsy:user_id` | `user_id` | Etsy-specific user ID claim (in addition to NameIdentifier) | | `urn:etsy:shop_id` | `shop_id` | User's shop ID | -| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | `primary_email` | Primary email address | -| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` | `first_name` | First name | -| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` | `last_name` | Last name | -| `urn:etsy:primary_email` | `primary_email` | Etsy-specific email claim | -| `urn:etsy:first_name` | `first_name` | Etsy-specific first name claim | -| `urn:etsy:last_name` | `last_name` | Etsy-specific last name claim | -| `urn:etsy:image_url` | `image_url_75x75` | 75x75 profile image URL | -## Configuration +### Detailed User Information claims -### Minimal configuration +Endpoint: [`/v3/application/users/{user_id}` `getUser`](https://developers.etsy.com/documentation/reference#operation/getUser) -#### [Program.cs](#tab/minimal-configuration-program) +#### Automapped claims -```csharp -using AspNet.Security.OAuth.Etsy; -using Microsoft.AspNetCore.Authentication.Cookies; +_Requires `EtsyAuthenticationOptions.IncludeDetailedUserInfo = true`_ -var builder = WebApplication.CreateBuilder(args); +| Claim Type | JSON Key | Auto-mapped | +|:--|:--:|:--:| +| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | `primary_email` | ✓ | +| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` | `first_name` | ✓ | +| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` | `last_name` | ✓ | +| `urn:etsy:image_url` | `image_url_75x75` | [Manual](#manually-added-claims) | -builder.Services.AddAuthentication(options => -{ - options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = EtsyAuthenticationDefaults.AuthenticationScheme; -}) -.AddCookie() -.AddEtsy(options => -{ - options.ClientId = builder.Configuration["Etsy:ClientId"]!; +> [!WARNING] +> As those claims are set in Provider side `PostConfigureOptions`, you have to include them yourself if you bind from `PostConfigure` also. - // Enable extended profile (requires email_r) - // options.IncludeDetailedUserInfo = true; - // options.Scope.Add(EtsyAuthenticationConstants.Scopes.EmailRead); +#### Manually Added Claims - // Add other optional scopes (shops_r is added by default) -}); +The `image_url_75x75` claim is not auto-mapped to reduce data bloat. You can add it manually via either: -var app = builder.Build(); +**Direct JSON key mapping:** -app.UseAuthentication(); -app.UseAuthorization(); +This sample does also work for regular JSON key mapping: -app.Run(); +```csharp +options.ClaimActions.MapJsonKey(EtsyAuthenticationConstants.Claims.ImageUrl, "image_url_75x75"); ``` -#### [appsettings.json or appsettings.Development.json](#tab/minimal-configuration-appsettings) +**Claim Image using predefined extension method:** -```json -{ - "Etsy": { - "ClientId": "your-etsy-api-key" - } -} +```csharp +options.ClaimActions.MapImageClaim(); ``` -*** +## Advanced Configuration -### Advanced using App Settings +The Etsy authentication handler can be configured in code or via configuration files. + +> [!NOTE] +> Always make sure to use proper [Secret Management for production applications](https://learn.microsoft.com/aspnet/core/security/app-secrets). -You can keep using code-based configuration, or bind from configuration values. Here is a comprehensive `appsettings.json` example covering supported options and common scopes: +You can keep using code-based configuration, or bind from configuration values. + +> [!WARNING] +> Avoid setting `UsePkce` from configuration, as Etsy requires PKCE for all OAuth flows. + +Here is a comprehensive `appsettings.json` example covering supported options and common scopes: ```json { "Etsy": { "ClientId": "your-etsy-api-key", - "AccessType": "Personal", "IncludeDetailedUserInfo": true, + "DetailedUserInfoEndpoint": "https://openapi.etsy.com/v3/application/users/{0}", + "AuthorizationEndpoint": "https://www.etsy.com/oauth/connect", + "TokenEndpoint": "https://openapi.etsy.com/v3/public/oauth/token", + "UserInformationEndpoint": "https://openapi.etsy.com/v3/application/users/me", + "CallbackPath": "/signin/etsy", "SaveTokens": true, "Scopes": [ "shops_r", "email_r" ] - }, - "Logging": { - "LogLevel": { "Default": "Information" } } } ``` -If you bind from configuration, set the options in code, for example: +> [!NOTE] +> We recommend saving tokens (`SaveTokens = true`) to facilitate token refresh, so the user does not need to re-authenticate frequently. +> [!NOTE] +> If `IncludeDetailedUserInfo` is set to `true` and the scopes `shops_r` and `email_r` scopes are sufficient, you don't need to set additional scopes in `appsettings.json`, they are added automatically. +> [!TIP] +> We recommend using the `EtsyAuthenticationDefaults` class in your `.AddEtsy` call which contains the default endpoint URLs. + +If you bind then from configuration, set the options in code, for example: ```csharp +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + // If you only have Etsy as external provider you can apply it as default challenge scheme + options.DefaultChallengeScheme = EtsyAuthenticationDefaults.AuthenticationScheme; +}) +.AddCookie(options => +{ + options.LoginPath = "/signin"; + options.LogoutPath = "/signout"; +}) .AddEtsy(options => { - var section = builder.Configuration.GetSection("Etsy"); - options.ClientId = section["ClientId"]!; - options.AccessType = Enum.Parse(section["AccessType"] ?? "Personal", true); - options.IncludeDetailedUserInfo = bool.TryParse(section["IncludeDetailedUserInfo"], out var detailed) && detailed; - options.SaveTokens = !bool.TryParse(section["SaveTokens"], out var save) || save; // defaults to true - - // Apply scopes from config if present - var scopes = section.GetSection("Scopes").Get(); - if (scopes is { Length: > 0 }) + var section = builder.Configuration.GetSection("Etsy").Get()!; + if (section is not EtsyAuthenticationOptions + // Check if the values from appsettings.json has been properly overridden + || section.ClientId is "client-id-from-user-secrets") + { + throw new InvalidOperationException("Etsy configuration section is missing or invalid."); + } + + options.ClientId = section.ClientId; + // Optional: The Etsy App registration provides the `Shared Secret` but it's not documented to be used/required for PKCE flows. + options.ClientSecret = section.ClientSecret; + // Optional: Include detailed user info and auto-mapped claims to get e.g. email, first and last name + options.IncludeDetailedUserInfo = section.IncludeDetailedUserInfo; + + // Optional: Override the defaults from EtsyAuthenticationDefaults with your own values (not recommended! Will potentially break the handler) + // Here we just re-assign the defaults for demonstration + options.AuthorizationEndpoint = EtsyAuthenticationDefaults.AuthorizationEndpoint; + options.TokenEndpoint = EtsyAuthenticationDefaults.TokenEndpoint; + options.UserInformationEndpoint = EtsyAuthenticationDefaults.UserInformationEndpoint; + + // Optional: Override SaveTokens setting from configuration (not recommended to disable! as Etsy API uses refresh tokens) + options.SaveTokens = section.SaveTokens; + + // Optional: Add scopes from configuration + foreach (var scope in section.Scopes) { - foreach (var scope in scopes) - { - options.Scope.Add(scope); - } + options.Scope.Add(scope); } + + // Optional: Or add scopes manually with provided constants + options.Scope.Add(EtsyAuthenticationConstants.Scopes.TransactionsRead); + + // Optional: Map the image claim + options.ClaimActions.MapImageClaim(); + + // Map other Claims + options.ClaimActions.MapJsonKey("urn:etsy:listingsWrite", EtsyAuthenticationConstants.Claims.ListingsWrite); }) ``` -> [!NOTE] -> Make sure to use proper [Secret Management for production applications](https://learn.microsoft.com/aspnet/core/security/app-secrets). +## Accessing claims (Minimal API Sample) -## Accessing claims +If you want to access the claims provided by the Etsy provider, you can set up some Minimal API endpoints like this: -**Using Minimal API:** +### Minimalistic directly in Program.cs ```csharp using AspNet.Security.OAuth.Etsy; using System.Security.Claims; -app.MapGet("/profile", (ClaimsPrincipal user) => +app.MapGet("/etsy/profile", (ClaimsPrincipal user) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var shopId = user.FindFirstValue(EtsyAuthenticationConstants.Claims.ShopId); @@ -204,10 +300,45 @@ app.MapGet("/profile", (ClaimsPrincipal user) => var imageUrl = user.FindFirstValue(EtsyAuthenticationConstants.Claims.ImageUrl); return Results.Ok(new { userId, shopId, email, firstName, lastName, imageUrl }); -}).RequireAuthorization(); +}) +.RequireAuthorization() +.WithName("EtsyProfile") +.WithSummary("Get authenticated user's Etsy profile information"); ``` -## Feature-style typed Minimal API endpoints with MapGroup +### Extended in a Feature-style Minimal API with endpoints using MapGroup + +This sample assumes you not only have Etsy as external provider and use cookie authentication for session management. + +#### Define record types for Typed Results + +Before we can start, we need some record types to hold the user profile and token information. + +The following ones are created from the json-objects returned by Etsy's API. + +```csharp +public sealed record UserInfo +{ + public required string UserId { get; init; } + public required string ShopId { get; init; } + public string? Email { get; init; } + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? ImageUrl { get; init; } +} + +public sealed record TokenInfo +{ + public string? AccessToken { get; init; } + public string? RefreshToken { get; init; } + public string? ExpiresAt { get; init; } +} +``` + +> [!NOTE] +> Make sure to add proper JSON serialization attributes if you use System.Text.Json or Newtonsoft.Json to serialize those records to JSON in the HTTP responses. + +#### Extension class anywhere in your project ```csharp using AspNet.Security.OAuth.Etsy; @@ -252,12 +383,11 @@ public static class EtsyAuthEndpoints private static Results SignInAsync(string? returnUrl) => TypedResults.Challenge( - new AuthenticationProperties { RedirectUri = returnUrl ?? "/" }, - new[] { EtsyAuthenticationDefaults.AuthenticationScheme }); + new AuthenticationProperties { RedirectUri = returnUrl ?? "/" }, EtsyAuthenticationDefaults.AuthenticationScheme); private static async Task SignOutAsync(HttpContext context) { - await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + await context.SignOutAsync(new AuthenticationProperties { RedirectUri = "/" }, CookieAuthenticationDefaults.AuthenticationScheme); return TypedResults.Redirect("/"); } @@ -287,22 +417,14 @@ public static class EtsyAuthEndpoints return TypedResults.Ok(tokenInfo); } +} +``` - public sealed record UserInfo - { - public required string UserId { get; init; } - public required string ShopId { get; init; } - public string? Email { get; init; } - public string? FirstName { get; init; } - public string? LastName { get; init; } - public string? ImageUrl { get; init; } - } +#### Register the endpoints in Program.cs - public sealed record TokenInfo - { - public string? AccessToken { get; init; } - public string? RefreshToken { get; init; } - public string? ExpiresAt { get; init; } - } -} +Now that we have defined the extension method to map the Etsy authentication endpoints, we need to register them in our `Program.cs` file. + +```csharp +using MyApi.Features.Authorization; +app.MapEtsyAuth(); ``` diff --git a/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj b/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj index c6b7b37ad..025968218 100644 --- a/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj +++ b/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj @@ -1,7 +1,7 @@ - 9.5.0 + 10.1.0 $(DefaultNetCoreTargetFramework) true @@ -9,7 +9,7 @@ ASP.NET Core security middleware enabling Etsy authentication. - Sonja + 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 index a9e5c8cc2..d8f26be1b 100644 --- a/src/AspNet.Security.OAuth.Etsy/ClaimActionCollectionExtensions.cs +++ b/src/AspNet.Security.OAuth.Etsy/ClaimActionCollectionExtensions.cs @@ -9,8 +9,14 @@ 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"); From 646527c160ae2c63581bd8999b780b7db5b9e13a Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Sun, 9 Nov 2025 19:03:23 +0100 Subject: [PATCH 25/37] chore: applying PR rewording suggestion Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../EtsyAuthenticationOptions.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs index a1adb5dac..2cd528ae5 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -44,7 +44,7 @@ public EtsyAuthenticationOptions() /// Gets or sets the endpoint used to retrieve detailed user information. /// /// - /// The placeholder for client_id needs to be "{0}" and will be replaced with the authenticated user's ID. + /// The placeholder for user_id needs to be "{0}" and will be replaced with the authenticated user's ID. /// public string? DetailedUserInfoEndpoint { get; set; } @@ -55,9 +55,10 @@ public override void Validate() { // 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 access type. + // 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 the won't be validated if the ClientSecret validation fails. + // because they won't be validated if the ClientSecret validation fails. base.Validate(); } catch (ArgumentException ex) when (ex.ParamName == nameof(ClientSecret)) @@ -89,7 +90,8 @@ public override void Validate() if (IncludeDetailedUserInfo && !Scope.Contains(Scopes.EmailRead)) { - // EmailRead scope is required to access detailed user info. As the post configure action should have added it, we need to ensure it's present. + // EmailRead scope is required to access detailed user info when IncludeDetailedUserInfo is enabled. + // The post configure action should have added it at this stage, so we need to ensure it's present. throw new ArgumentOutOfRangeException(nameof(Scope), string.Join(',', Scope), $"The '{Scopes.EmailRead}' scope must be specified when '{nameof(IncludeDetailedUserInfo)}' is enabled."); } From 967dee91f8b6d5180230a904051514be289e69b1 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Thu, 27 Nov 2025 14:42:13 +0100 Subject: [PATCH 26/37] chore: Resolve Merge Conflicts from sln to slnx migration --- AspNet.Security.OAuth.Providers.slnx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AspNet.Security.OAuth.Providers.slnx b/AspNet.Security.OAuth.Providers.slnx index 6e45900e7..af3f185c5 100644 --- a/AspNet.Security.OAuth.Providers.slnx +++ b/AspNet.Security.OAuth.Providers.slnx @@ -39,6 +39,9 @@ + + + @@ -49,6 +52,7 @@ + @@ -120,6 +124,7 @@ + From fc30df3f4d1ce4decf8e6399754ac877943b7091 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Tue, 2 Dec 2025 18:17:07 +0100 Subject: [PATCH 27/37] refactor: Add blank line in project file, remove unused usings and update xml docs --- .../AspNet.Security.OAuth.Etsy.csproj | 2 +- .../EtsyAuthenticationConstants.cs | 4 +--- .../EtsyAuthenticationDefaults.cs | 2 -- .../EtsyAuthenticationHandler.cs | 5 ++-- .../EtsyAuthenticationOptions.cs | 23 ++++++++----------- 5 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj b/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj index 025968218..fc3e75fbd 100644 --- a/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj +++ b/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj @@ -18,4 +18,4 @@ - \ No newline at end of file + diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs index 9d0acb0fb..be7859de9 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs @@ -4,8 +4,6 @@ * for more information concerning the license and the contributors participating to this project. */ -using System.Security.Claims; - namespace AspNet.Security.OAuth.Etsy; /// @@ -26,7 +24,7 @@ public static class Claims } /// - /// Contains the Etsy OAuth Scopes constants for Etsy authentication. + /// Contains Etsy OAuth Scopes constants for Etsy authentication. /// public static class Scopes { diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs index f33ef54dc..719efa700 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs @@ -4,8 +4,6 @@ * for more information concerning the license and the contributors participating to this project. */ -using System.Text; - namespace AspNet.Security.OAuth.Etsy; /// diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs index e14721531..f6d6b52aa 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs @@ -54,8 +54,7 @@ protected override async Task CreateTicketAsync( using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); var meRoot = payload.RootElement; - // Extract user_id and shop_id from the /me response - // Both fields should always be present in a successful Etsy OAuth response + // Extract user_id from the /me response required to get detailed user info. shop_id is mapped later via ClaimActions var userId = meRoot.GetProperty("user_id").GetInt64(); var principal = new ClaimsPrincipal(identity); @@ -64,7 +63,7 @@ protected override async Task CreateTicketAsync( // Map claims from the basic payload first context.RunClaimActions(); - // Optionally enrich with detailed user info + // Optionally enrich with detailed user info if requested if (Options.IncludeDetailedUserInfo) { using var detailedPayload = await GetDetailedUserInfoAsync(tokens, userId); diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs index 2cd528ae5..b17840041 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -5,7 +5,6 @@ */ using System.Security.Claims; -using Microsoft.Extensions.Options; using static AspNet.Security.OAuth.Etsy.EtsyAuthenticationConstants; namespace AspNet.Security.OAuth.Etsy; @@ -31,7 +30,11 @@ public EtsyAuthenticationOptions() 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"); } /// @@ -51,6 +54,11 @@ public EtsyAuthenticationOptions() /// 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, @@ -82,19 +90,6 @@ public override void Validate() throw new ArgumentNullException(nameof(UserInformationEndpoint), $"The '{nameof(UserInformationEndpoint)}' option must be provided."); } - if (!Scope.Contains(Scopes.ShopsRead)) - { - // shops_r scope is required to access basic user info. - throw new ArgumentOutOfRangeException(nameof(Scope), string.Join(',', Scope), $"The '{Scopes.ShopsRead}' scope must be specified."); - } - - if (IncludeDetailedUserInfo && !Scope.Contains(Scopes.EmailRead)) - { - // EmailRead scope is required to access detailed user info when IncludeDetailedUserInfo is enabled. - // The post configure action should have added it at this stage, so we need to ensure it's present. - throw new ArgumentOutOfRangeException(nameof(Scope), string.Join(',', Scope), $"The '{Scopes.EmailRead}' scope must be specified when '{nameof(IncludeDetailedUserInfo)}' is enabled."); - } - if (!CallbackPath.HasValue) { throw new ArgumentNullException(nameof(CallbackPath), $"The '{nameof(CallbackPath)}' option must be provided."); From ffa225a635bc7a8f0d3b6880a5b09e82d781aeca Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Tue, 2 Dec 2025 18:19:14 +0100 Subject: [PATCH 28/37] chore: include user_id in Defaults Claims --- src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs index be7859de9..ebe9b7579 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs @@ -16,6 +16,9 @@ public static class EtsyAuthenticationConstants /// 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"; From 7d98335859c88f25b6c30f2dc2fffa8c005b9464 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Tue, 2 Dec 2025 18:21:36 +0100 Subject: [PATCH 29/37] chore: change DetailedUserInfoEndpoint to concatenated string instead of CompositeFormat --- .../EtsyAuthenticationDefaults.cs | 2 +- .../EtsyAuthenticationHandler.cs | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs index 719efa700..3d0255699 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs @@ -49,5 +49,5 @@ public static class EtsyAuthenticationDefaults /// /// Default value for receiving the user profile based upon a unique user ID getUser. /// - public static readonly CompositeFormat DetailedUserInfoEndpoint = CompositeFormat.Parse("https://openapi.etsy.com/v3/application/users/{0}"); + public static readonly string DetailedUserInfoEndpoint = "https://openapi.etsy.com/v3/application/users/"; } diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs index f6d6b52aa..ea6946756 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs @@ -98,15 +98,7 @@ protected override async Task CreateTicketAsync( /// A containing the detailed user information. protected virtual async Task GetDetailedUserInfoAsync([NotNull] OAuthTokenResponse tokens, long userId) { - string userDetailsUrl; - if (!string.IsNullOrWhiteSpace(Options.DetailedUserInfoEndpoint)) - { - userDetailsUrl = string.Format(CultureInfo.InvariantCulture, Options.DetailedUserInfoEndpoint, userId); - } - else - { - userDetailsUrl = string.Format(CultureInfo.InvariantCulture, EtsyAuthenticationDefaults.DetailedUserInfoEndpoint, userId); - } + var userDetailsUrl = $"{Options.DetailedUserInfoEndpoint}{userId}"; using var request = new HttpRequestMessage(HttpMethod.Get, userDetailsUrl); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); From 19dfe9c755b77b7265d85ba28ded4a2bce643aa2 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Tue, 2 Dec 2025 18:25:01 +0100 Subject: [PATCH 30/37] revert(EtsyPostConfigureOptions): apply DetailedUserInfo Config via PostConfiguration and validate with tests --- .../EtsyPostConfigureOptions.cs | 32 -------- .../Etsy/EtsyPostConfigureOptionsTests.cs | 76 ------------------- 2 files changed, 108 deletions(-) delete mode 100644 src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs delete mode 100644 test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyPostConfigureOptionsTests.cs diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs deleted file mode 100644 index e48e8d6d8..000000000 --- a/src/AspNet.Security.OAuth.Etsy/EtsyPostConfigureOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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 Microsoft.Extensions.Options; - -namespace AspNet.Security.OAuth.Etsy; - -/// -/// Applies Etsy-specific post-configuration logic after user configuration and base OAuth setup. -/// -public sealed class EtsyPostConfigureOptions : IPostConfigureOptions -{ - public void PostConfigure(string? name, EtsyAuthenticationOptions options) - { - // Auto-add the email_r scope if detailed user info was requested but the scope not explicitly supplied. - if (options.IncludeDetailedUserInfo && !options.Scope.Contains(EtsyAuthenticationConstants.Scopes.EmailRead)) - { - options.Scope.Add(EtsyAuthenticationConstants.Scopes.EmailRead); - - options.ClaimActions.MapJsonKey(ClaimTypes.Email, "primary_email"); - options.ClaimActions.MapJsonKey(ClaimTypes.GivenName, "first_name"); - options.ClaimActions.MapJsonKey(ClaimTypes.Surname, "last_name"); - } - - // NOTE: We intentionally DO NOT auto-map the image to reduce data bloat, - // as the image data can be quite large and is not always needed. - } -} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyPostConfigureOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyPostConfigureOptionsTests.cs deleted file mode 100644 index 1af573cf1..000000000 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyPostConfigureOptionsTests.cs +++ /dev/null @@ -1,76 +0,0 @@ -/* - * 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 EtsyPostConfigureOptionsTests -{ - [Fact] - public static void PostConfigure_Adds_EmailRead_Scope_When_DetailedUserInfo_Enabled_And_Not_Contains_Scope_email_r() - { - // Arrange - var options = new EtsyAuthenticationOptions() - { - ClientId = "my-client-id", - ClientSecret = "my-client-secret", - IncludeDetailedUserInfo = true, - }; - - // Ensure email_r not already present - options.Scope.Remove(EtsyAuthenticationConstants.Scopes.EmailRead); - - var postConfigure = new EtsyPostConfigureOptions(); - - // Act - postConfigure.PostConfigure(EtsyAuthenticationDefaults.AuthenticationScheme, options); - - // Assert - options.Scope.ShouldContain(EtsyAuthenticationConstants.Scopes.EmailRead); - } - - [Fact] - public static void PostConfigure_Does_Not_Add_EmailRead_When_DetailedUserInfo_Disabled() - { - // Arrange - var options = new EtsyAuthenticationOptions() - { - ClientId = "my-client-id", - ClientSecret = "my-client-secret", - IncludeDetailedUserInfo = false, - }; - - var postConfigure = new EtsyPostConfigureOptions(); - - // Act - postConfigure.PostConfigure(EtsyAuthenticationDefaults.AuthenticationScheme, options); - - // Assert - options.Scope.ShouldNotContain(EtsyAuthenticationConstants.Scopes.EmailRead); - } - - [Fact] - public static void PostConfigure_Does_Not_Duplicate_EmailRead_Scope() - { - // Arrange - var options = new EtsyAuthenticationOptions() - { - ClientId = "my-client-id", - ClientSecret = "my-client-secret", - IncludeDetailedUserInfo = true, - }; - - // Add the email scope manually - options.Scope.Add(EtsyAuthenticationConstants.Scopes.EmailRead); - - var postConfigure = new EtsyPostConfigureOptions(); - - // Act - postConfigure.PostConfigure(EtsyAuthenticationDefaults.AuthenticationScheme, options); - - // Assert (will throw if duplicate exists) - options.Scope.ShouldBeUnique(); - } -} From 8a97cf34a08964d411925964f655a8fd1536bdcc Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Tue, 2 Dec 2025 18:30:31 +0100 Subject: [PATCH 31/37] revert(EtsyTests): apply workaround into PostConfigure test --- .../Etsy/EtsyTests.cs | 31 +++---------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs index e19c71abb..aa078892d 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs @@ -4,18 +4,12 @@ * for more information concerning the license and the contributors participating to this project. */ -using AspNet.Security.OAuth.Etsy; using static AspNet.Security.OAuth.Etsy.EtsyAuthenticationConstants; -namespace AspNet.Security.OAuth.Providers.Tests.Etsy; +namespace AspNet.Security.OAuth.Etsy; -public class EtsyTests : OAuthTests +public class EtsyTests(ITestOutputHelper outputHelper) : OAuthTests(outputHelper) { - public EtsyTests(ITestOutputHelper outputHelper) - : base(outputHelper) - { - } - public override string DefaultScheme => EtsyAuthenticationDefaults.AuthenticationScheme; protected internal override void RegisterAuthentication(AuthenticationBuilder builder) @@ -33,7 +27,7 @@ public async Task Can_Sign_In_Using_Etsy(string claimType, string claimValue) public async Task Does_Not_Include_Detailed_Claims_When_IncludeDetailedUserInfo_Is_False() { // Arrange: disable detailed user info enrichment - void ConfigureServices(IServiceCollection services) => services.PostConfigureAll(o => o.IncludeDetailedUserInfo = false); + static void ConfigureServices(IServiceCollection services) => services.PostConfigureAll(o => o.IncludeDetailedUserInfo = false); using var server = CreateTestServer(ConfigureServices); @@ -55,24 +49,7 @@ public async Task Does_Not_Include_Detailed_Claims_When_IncludeDetailedUserInfo_ public async Task Includes_Detailed_Claims_When_IncludeDetailedUserInfo_Is_True() { // Arrange: enable detailed user info, configure claims to map. - // Note: email_r will be auto-added by the provider's post-configure step. - void ConfigureServices(IServiceCollection services) => services.PostConfigureAll(o => - { - o.IncludeDetailedUserInfo = true; - - // Ensure the required scope is present before Validate() executes. - // BUG: This should not be necessary as the post-configure should add it. Assuming test Arrange should simulate eventual user setup. - if (!o.Scope.Contains(Scopes.EmailRead)) - { - o.Scope.Add(Scopes.EmailRead); - o.ClaimActions.MapJsonKey(ClaimTypes.Email, "primary_email"); - o.ClaimActions.MapJsonKey(ClaimTypes.GivenName, "first_name"); - o.ClaimActions.MapJsonKey(ClaimTypes.Surname, "last_name"); - } - - // Opt-in to include image claim (not auto-mapped by provider to reduce payload size) - o.ClaimActions.MapImageClaim(); - }); + static void ConfigureServices(IServiceCollection services) => services.PostConfigureAll(o => o.IncludeDetailedUserInfo = true); using var server = CreateTestServer(ConfigureServices); From 131bc0d3bc27a63fb935e3dddc156a03cbd8f5b3 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Tue, 2 Dec 2025 18:32:59 +0100 Subject: [PATCH 32/37] chore: remove tests for validating empty or not containing shop_r Option Scopes --- .../Etsy/EtsyAuthenticationOptionsTests.cs | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs index b39556353..afa6cecf7 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs @@ -24,40 +24,6 @@ public static void Validate_Does_Not_Throw_If_ClientSecret_Is_Not_Provided(strin options.Validate(); } - [Fact] - public static void Validate_Does_Throw_If_Scope_Does_Not_Contain_Scope_shop_r() - { - // Arrange - var options = new EtsyAuthenticationOptions() - { - ClientId = "my-client-id", - ClientSecret = "my-client-secret", - }; - options.Scope.Clear(); - options.Scope.Add(EtsyAuthenticationConstants.Scopes.EmailRead); - - // Act - _ = Assert.Throws(options.Validate); - } - - [Fact] - public static void Validate_Does_Not_Throw_When_IncludeDetailedUserInfo_Is_False_And_Contains_Scope_email_r() - { - // Arrange - var options = new EtsyAuthenticationOptions() - { - ClientId = "my-client-id", - ClientSecret = "my-client-secret", - IncludeDetailedUserInfo = false, - }; - - // Adding email scope should be harmless when IncludeDetailedUserInfo is false - options.Scope.Add(EtsyAuthenticationConstants.Scopes.EmailRead); - - // Act (no Assert) - options.Validate(); - } - [Fact] public static void Validate_Throws_If_AuthorizationEndpoint_Is_Null() { From 69735f1226ba9cdaa95cbf6906ebd61441f3870e Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Tue, 2 Dec 2025 18:33:25 +0100 Subject: [PATCH 33/37] chore: add user_id claim mapping test case --- test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs index aa078892d..90446b0cd 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs @@ -19,6 +19,7 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu [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); @@ -36,6 +37,7 @@ public async Task Does_Not_Include_Detailed_Claims_When_IncludeDetailedUserInfo_ // 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 From cacf36dbf3684e4003b9091bffb84ed4dd471842 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Tue, 2 Dec 2025 18:33:52 +0100 Subject: [PATCH 34/37] docs(etsy): Refactor guide and update table column headers to align with existing providers Co-authored-by: Martin Costello --- AspNet.Security.OAuth.Providers.slnx | 3 - docs/assets/Etsy-find-your-client_id.png | Bin 15779 -> 0 bytes docs/etsy.md | 421 ++--------------------- 3 files changed, 23 insertions(+), 401 deletions(-) delete mode 100644 docs/assets/Etsy-find-your-client_id.png diff --git a/AspNet.Security.OAuth.Providers.slnx b/AspNet.Security.OAuth.Providers.slnx index af3f185c5..d289d3758 100644 --- a/AspNet.Security.OAuth.Providers.slnx +++ b/AspNet.Security.OAuth.Providers.slnx @@ -39,9 +39,6 @@ - - - diff --git a/docs/assets/Etsy-find-your-client_id.png b/docs/assets/Etsy-find-your-client_id.png deleted file mode 100644 index 44805b48c99a1c58577676f68b2209dac04f207a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15779 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 J4aWk0{%>A7{~-VX diff --git a/docs/etsy.md b/docs/etsy.md index ec894f42c..435bfce49 100644 --- a/docs/etsy.md +++ b/docs/etsy.md @@ -1,36 +1,16 @@ # 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. +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) - - [Quick Links](#quick-links) - - [Quick start](#quick-start) - - [Minimal configuration](#minimal-configuration) + - [Example](#example) - [Required Additional Settings](#required-additional-settings) - [Optional Settings](#optional-settings) - - [Scope constants](#scope-constants) - - [Refreshing tokens](#refreshing-tokens) - - [Claims](#claims) - - [Basic User Information claims](#basic-user-information-claims) - - [Detailed User Information claims](#detailed-user-information-claims) - - [Automapped claims](#automapped-claims) - - [Manually Added Claims](#manually-added-claims) - - [Advanced Configuration](#advanced-configuration) - - [Accessing claims (Minimal API Sample)](#accessing-claims-minimal-api-sample) - - [Minimalistic directly in Program.cs](#minimalistic-directly-in-programcs) - - [Extended in a Feature-style Minimal API with endpoints using MapGroup](#extended-in-a-feature-style-minimal-api-with-endpoints-using-mapgroup) - - [Define record types for Typed Results](#define-record-types-for-typed-results) - - [Extension class anywhere in your project](#extension-class-anywhere-in-your-project) - - [Register the endpoints in Program.cs](#register-the-endpoints-in-programcs) - -## Quick Links - -- Register your App at [Apps You've Made](https://www.etsy.com/developers/your-apps) on Etsy. -- 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) + - [Quick Links](#quick-links) -## Quick start +## Example ```csharp using AspNet.Security.OAuth.Etsy; @@ -40,391 +20,36 @@ using Microsoft.AspNetCore.Authentication.Cookies; var builder = WebApplication.CreateBuilder(args); builder.Services - .AddAuthentication(options => - { - options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = EtsyAuthenticationDefaults.AuthenticationScheme; - }) - .AddCookie() + .AddAuthentication(options => { /* Authentication options */ }) .AddEtsy(options => { - options.ClientId = builder.Configuration["Etsy:ClientId"]!; + 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 }); - -var app = builder.Build(); - -app.UseAuthentication(); -app.UseAuthorization(); - -// Route to start the Etsy OAuth flow (challenge) -app.MapGet("/signin/etsy", (HttpContext ctx, string? returnUrl) => - Results.Challenge(new AuthenticationProperties - { - RedirectUri = returnUrl ?? "/" - }, new[] { EtsyAuthenticationDefaults.AuthenticationScheme })); - -// NOTE: The callback path '/signin-etsy' is handled automatically by the middleware. -// Do NOT map a route for it unless you change CallbackPath in options. - -app.Run(); -``` - -### Minimal configuration - -**In your appsettings.json or appsettings.Development.json file:** - -```json -{ - "Etsy": { - "ClientId": "your-etsy-api-key" - } -} -``` - -**In your `Program.cs` or `Startup.cs` file:** - -```csharp -builder.Services.Configure( - builder.Configuration.GetSection("Etsy")); ``` ## Required Additional Settings -- `ClientId` is required. - - You can obtain it by registering your application on [Etsy's developer portal](https://www.etsy.com/developers/your-apps). - - It will be stated as `keystring` in your app settings: - - ![Etsy-find-your-client_id](./assets/Etsy-find-your-client_id.png) - -> [!NOTE] -> -> - ClientSecret is optional for public clients using PKCE. -> - When `IncludeDetailedUserInfo` is enabled, `email_r` scope and standard claims are auto-mapped. -> - The `EtsyAuthenticationConstants.Claims.ImageUrl` claim must be [added if needed](#manually-added-claims). +- 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 | -|:--:|:--:|:--:|:--:| -| `Scope` | `ICollection` | Scopes to request. Use `EtsyAuthenticationConstants.Scopes.*` constants. | `["shops_r"]` | +|:--|:--|:--|:--| | `IncludeDetailedUserInfo` | `bool` | Fetch extended profile data with auto-mapped claims (Email, GivenName, Surname). | `false` | -| `UsePkce` | `bool` | Enable PKCE (required by Etsy). | `true` | -| `SaveTokens` | `bool` | Persist access and refresh tokens. | `true` | -| `CallbackPath` | `PathString` | The request path within your application where the user-agent will be returned after Etsy has authenticated the user. | `/signin-etsy` | -| `DetailedUserInfoEndpoint` | `string` | The endpoint to retrieve detailed user information. | `https://openapi.etsy.com/v3/application/users/{0}` | - -> [!NOTE] -> The `DetailedUserInfoEndpoint` uses `{0}` as a placeholder for the `user_id`. It's replaced automatically when fetching detailed user info. +| `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/` | -### Scope constants +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. -Use `EtsyAuthenticationConstants.Scopes.*` instead of string literals. Common values: +## Quick Links -| Constant | Scope Value | +| Resource | Link | |:--|:--| -| `EmailRead` | `email_r` | -| `ListingsRead` | `listings_r` | -| `ListingsWrite` | `listings_w` | -| `ShopsRead` | `shops_r` | -| `TransactionsRead` | `transactions_r` | - -## Refreshing tokens - -This provider saves tokens by default (`SaveTokens = true`). Etsy issues a refresh token; you are responsible for performing the refresh flow using the saved token when the access token expires. - -```csharp -var refreshToken = await HttpContext.GetTokenAsync("refresh_token"); -``` - -See [Requesting a Refresh OAuth Token](#quick-links) in the Quick Links above for the HTTP details. - -## Claims - -### Basic User Information claims - -**Endpoint:** [`/v3/application/users/me` `getMe`](https://developers.etsy.com/documentation/reference#operation/getMe) - -| Claim Type | Value Source | Description | -|:--|:--:|:--:| -| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` | `user_id` | Primary user identifier | -| `urn:etsy:shop_id` | `shop_id` | User's shop ID | - -### Detailed User Information claims - -Endpoint: [`/v3/application/users/{user_id}` `getUser`](https://developers.etsy.com/documentation/reference#operation/getUser) - -#### Automapped claims - -_Requires `EtsyAuthenticationOptions.IncludeDetailedUserInfo = true`_ - -| Claim Type | JSON Key | Auto-mapped | -|:--|:--:|:--:| -| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | `primary_email` | ✓ | -| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` | `first_name` | ✓ | -| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` | `last_name` | ✓ | -| `urn:etsy:image_url` | `image_url_75x75` | [Manual](#manually-added-claims) | - -> [!WARNING] -> As those claims are set in Provider side `PostConfigureOptions`, you have to include them yourself if you bind from `PostConfigure` also. - -#### Manually Added Claims - -The `image_url_75x75` claim is not auto-mapped to reduce data bloat. You can add it manually via either: - -**Direct JSON key mapping:** - -This sample does also work for regular JSON key mapping: - -```csharp -options.ClaimActions.MapJsonKey(EtsyAuthenticationConstants.Claims.ImageUrl, "image_url_75x75"); -``` - -**Claim Image using predefined extension method:** - -```csharp -options.ClaimActions.MapImageClaim(); -``` - -## Advanced Configuration - -The Etsy authentication handler can be configured in code or via configuration files. - -> [!NOTE] -> Always make sure to use proper [Secret Management for production applications](https://learn.microsoft.com/aspnet/core/security/app-secrets). - -You can keep using code-based configuration, or bind from configuration values. - -> [!WARNING] -> Avoid setting `UsePkce` from configuration, as Etsy requires PKCE for all OAuth flows. - -Here is a comprehensive `appsettings.json` example covering supported options and common scopes: - -```json -{ - "Etsy": { - "ClientId": "your-etsy-api-key", - "IncludeDetailedUserInfo": true, - "DetailedUserInfoEndpoint": "https://openapi.etsy.com/v3/application/users/{0}", - "AuthorizationEndpoint": "https://www.etsy.com/oauth/connect", - "TokenEndpoint": "https://openapi.etsy.com/v3/public/oauth/token", - "UserInformationEndpoint": "https://openapi.etsy.com/v3/application/users/me", - "CallbackPath": "/signin/etsy", - "SaveTokens": true, - "Scopes": [ "shops_r", "email_r" ] - } -} -``` - -> [!NOTE] -> We recommend saving tokens (`SaveTokens = true`) to facilitate token refresh, so the user does not need to re-authenticate frequently. -> [!NOTE] -> If `IncludeDetailedUserInfo` is set to `true` and the scopes `shops_r` and `email_r` scopes are sufficient, you don't need to set additional scopes in `appsettings.json`, they are added automatically. -> [!TIP] -> We recommend using the `EtsyAuthenticationDefaults` class in your `.AddEtsy` call which contains the default endpoint URLs. - -If you bind then from configuration, set the options in code, for example: - -```csharp -builder.Services.AddAuthentication(options => -{ - options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - // If you only have Etsy as external provider you can apply it as default challenge scheme - options.DefaultChallengeScheme = EtsyAuthenticationDefaults.AuthenticationScheme; -}) -.AddCookie(options => -{ - options.LoginPath = "/signin"; - options.LogoutPath = "/signout"; -}) -.AddEtsy(options => -{ - var section = builder.Configuration.GetSection("Etsy").Get()!; - if (section is not EtsyAuthenticationOptions - // Check if the values from appsettings.json has been properly overridden - || section.ClientId is "client-id-from-user-secrets") - { - throw new InvalidOperationException("Etsy configuration section is missing or invalid."); - } - - options.ClientId = section.ClientId; - // Optional: The Etsy App registration provides the `Shared Secret` but it's not documented to be used/required for PKCE flows. - options.ClientSecret = section.ClientSecret; - // Optional: Include detailed user info and auto-mapped claims to get e.g. email, first and last name - options.IncludeDetailedUserInfo = section.IncludeDetailedUserInfo; - - // Optional: Override the defaults from EtsyAuthenticationDefaults with your own values (not recommended! Will potentially break the handler) - // Here we just re-assign the defaults for demonstration - options.AuthorizationEndpoint = EtsyAuthenticationDefaults.AuthorizationEndpoint; - options.TokenEndpoint = EtsyAuthenticationDefaults.TokenEndpoint; - options.UserInformationEndpoint = EtsyAuthenticationDefaults.UserInformationEndpoint; - - // Optional: Override SaveTokens setting from configuration (not recommended to disable! as Etsy API uses refresh tokens) - options.SaveTokens = section.SaveTokens; - - // Optional: Add scopes from configuration - foreach (var scope in section.Scopes) - { - options.Scope.Add(scope); - } - - // Optional: Or add scopes manually with provided constants - options.Scope.Add(EtsyAuthenticationConstants.Scopes.TransactionsRead); - - // Optional: Map the image claim - options.ClaimActions.MapImageClaim(); - - // Map other Claims - options.ClaimActions.MapJsonKey("urn:etsy:listingsWrite", EtsyAuthenticationConstants.Claims.ListingsWrite); -}) -``` - -## Accessing claims (Minimal API Sample) - -If you want to access the claims provided by the Etsy provider, you can set up some Minimal API endpoints like this: - -### Minimalistic directly in Program.cs - -```csharp -using AspNet.Security.OAuth.Etsy; -using System.Security.Claims; - -app.MapGet("/etsy/profile", (ClaimsPrincipal user) => -{ - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); - var shopId = user.FindFirstValue(EtsyAuthenticationConstants.Claims.ShopId); - var email = user.FindFirstValue(ClaimTypes.Email); - var firstName = user.FindFirstValue(ClaimTypes.GivenName); - var lastName = user.FindFirstValue(ClaimTypes.Surname); - var imageUrl = user.FindFirstValue(EtsyAuthenticationConstants.Claims.ImageUrl); - - return Results.Ok(new { userId, shopId, email, firstName, lastName, imageUrl }); -}) -.RequireAuthorization() -.WithName("EtsyProfile") -.WithSummary("Get authenticated user's Etsy profile information"); -``` - -### Extended in a Feature-style Minimal API with endpoints using MapGroup - -This sample assumes you not only have Etsy as external provider and use cookie authentication for session management. - -#### Define record types for Typed Results - -Before we can start, we need some record types to hold the user profile and token information. - -The following ones are created from the json-objects returned by Etsy's API. - -```csharp -public sealed record UserInfo -{ - public required string UserId { get; init; } - public required string ShopId { get; init; } - public string? Email { get; init; } - public string? FirstName { get; init; } - public string? LastName { get; init; } - public string? ImageUrl { get; init; } -} - -public sealed record TokenInfo -{ - public string? AccessToken { get; init; } - public string? RefreshToken { get; init; } - public string? ExpiresAt { get; init; } -} -``` - -> [!NOTE] -> Make sure to add proper JSON serialization attributes if you use System.Text.Json or Newtonsoft.Json to serialize those records to JSON in the HTTP responses. - -#### Extension class anywhere in your project - -```csharp -using AspNet.Security.OAuth.Etsy; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Http.HttpResults; -using System.Security.Claims; - -namespace MyApi.Features.Authorization; - -public static class EtsyAuthEndpoints -{ - public static IEndpointRouteBuilder MapEtsyAuth(this IEndpointRouteBuilder app) - { - var group = app.MapGroup("/etsy") - .WithTags("Etsy Authentication"); - - // Sign-in: triggers the Etsy OAuth handler - group.MapGet("/signin", SignInAsync) - .WithName("EtsySignIn") - .WithSummary("Initiate Etsy OAuth authentication"); - - // Sign-out: removes the auth cookie/session - group.MapGet("/signout", SignOutAsync) - .WithName("EtsySignOut") - .WithSummary("Sign out from Etsy authentication"); - - // Protected: returns the authenticated user's profile - group.MapGet("/user-info", GetProfileAsync) - .RequireAuthorization() - .WithName("User Info") - .WithSummary("Get authenticated user's information"); - - // Protected: returns saved OAuth tokens - group.MapGet("/tokens", GetTokensAsync) - .RequireAuthorization() - .WithName("EtsyTokens") - .WithSummary("Get OAuth access and refresh tokens"); - - return app; - } - - private static Results SignInAsync(string? returnUrl) - => TypedResults.Challenge( - new AuthenticationProperties { RedirectUri = returnUrl ?? "/" }, EtsyAuthenticationDefaults.AuthenticationScheme); - - private static async Task SignOutAsync(HttpContext context) - { - await context.SignOutAsync(new AuthenticationProperties { RedirectUri = "/" }, CookieAuthenticationDefaults.AuthenticationScheme); - return TypedResults.Redirect("/"); - } - - private static Task> GetProfileAsync(ClaimsPrincipal user) - { - var profile = new UserInfo - { - UserId = user.FindFirstValue(ClaimTypes.NameIdentifier)!, - ShopId = user.FindFirstValue(EtsyAuthenticationConstants.Claims.ShopId)!, - Email = user.FindFirstValue(ClaimTypes.Email), - FirstName = user.FindFirstValue(ClaimTypes.GivenName), - LastName = user.FindFirstValue(ClaimTypes.Surname), - ImageUrl = user.FindFirstValue(EtsyAuthenticationConstants.Claims.ImageUrl) - }; - - return Task.FromResult(TypedResults.Ok(profile)); - } - - private static async Task> GetTokensAsync(HttpContext context) - { - var tokenInfo = new TokenInfo - { - AccessToken = await context.GetTokenAsync("access_token"), - RefreshToken = await context.GetTokenAsync("refresh_token"), - ExpiresAt = await context.GetTokenAsync("expires_at") - }; - - return TypedResults.Ok(tokenInfo); - } -} -``` - -#### Register the endpoints in Program.cs - -Now that we have defined the extension method to map the Etsy authentication endpoints, we need to register them in our `Program.cs` file. - -```csharp -using MyApi.Features.Authorization; -app.MapEtsyAuth(); -``` +| 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) | From ca39e8fa2b84f9bef79bd08377cb056643b90841 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Tue, 9 Dec 2025 18:43:29 +0100 Subject: [PATCH 35/37] chore: Update xml docs and introduce default value for DetailedUserInfoEndpoint --- src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs index b17840041..31721fd95 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -47,9 +47,9 @@ public EtsyAuthenticationOptions() /// Gets or sets the endpoint used to retrieve detailed user information. /// /// - /// The placeholder for user_id needs to be "{0}" and will be replaced with the authenticated user's ID. + /// Make sure to end with '/' as it will be appended with the authenticated user's ID. /// - public string? DetailedUserInfoEndpoint { get; set; } + public string? DetailedUserInfoEndpoint { get; set; } = EtsyAuthenticationDefaults.DetailedUserInfoEndpoint; /// public override void Validate() From fd8dda9af3784cd51c5655392ab6190e4ea8067b Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Thu, 11 Dec 2025 20:15:55 +0100 Subject: [PATCH 36/37] chore: Make DetailedUserInfo not nullable and add Ending failsafe --- .../EtsyAuthenticationHandler.cs | 8 ++++---- .../EtsyAuthenticationOptions.cs | 5 +---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs index ea6946756..0256188c0 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs @@ -54,9 +54,6 @@ protected override async Task CreateTicketAsync( using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); var meRoot = payload.RootElement; - // Extract user_id from the /me response required to get detailed user info. shop_id is mapped later via ClaimActions - var userId = meRoot.GetProperty("user_id").GetInt64(); - var principal = new ClaimsPrincipal(identity); var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, meRoot); @@ -66,6 +63,9 @@ protected override async Task CreateTicketAsync( // 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; @@ -98,7 +98,7 @@ protected override async Task CreateTicketAsync( /// A containing the detailed user information. protected virtual async Task GetDetailedUserInfoAsync([NotNull] OAuthTokenResponse tokens, long userId) { - var userDetailsUrl = $"{Options.DetailedUserInfoEndpoint}{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)); diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs index 31721fd95..232a93785 100644 --- a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -46,10 +46,7 @@ public EtsyAuthenticationOptions() /// /// Gets or sets the endpoint used to retrieve detailed user information. /// - /// - /// Make sure to end with '/' as it will be appended with the authenticated user's ID. - /// - public string? DetailedUserInfoEndpoint { get; set; } = EtsyAuthenticationDefaults.DetailedUserInfoEndpoint; + public string DetailedUserInfoEndpoint { get; set; } = EtsyAuthenticationDefaults.DetailedUserInfoEndpoint; /// public override void Validate() From aee5d4ad8b07089270d7d9db6617f02e58131244 Mon Sep 17 00:00:00 2001 From: DevTKSS Date: Thu, 11 Dec 2025 20:16:38 +0100 Subject: [PATCH 37/37] test: remove obsolete test as DetailedUserInfoEndpoint is now not longer nullable --- .../Etsy/EtsyAuthenticationOptionsTests.cs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs index afa6cecf7..9db6d5ac5 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs @@ -69,21 +69,6 @@ public static void Validate_Throws_If_UserInformationEndpoint_Is_Null() _ = Assert.Throws(nameof(options.UserInformationEndpoint), options.Validate); } - [Fact] - public static void Validate_Dont_Throws_If_DetailedUserInformationEndpoint_Is_Null() - { - // Arrange - var options = new EtsyAuthenticationOptions() - { - ClientId = "my-client-id", - ClientSecret = "my-client-secret", - DetailedUserInfoEndpoint = null!, - }; - - // Act (no Assert) - options.Validate(); - } - [Fact] public static void Validate_Throws_If_CallbackPath_Is_Null() {