diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln index 9dca8f93d..aed80739d 100644 --- a/AspNet.Security.OAuth.Providers.sln +++ b/AspNet.Security.OAuth.Providers.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31825.309 @@ -187,6 +187,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{C2CA4B38-A docs\docusign.md = docs\docusign.md docs\dropbox.md = docs\dropbox.md docs\ebay.md = docs\ebay.md + docs\etsy.md = docs\etsy.md docs\eveonline.md = docs\eveonline.md docs\foursquare.md = docs\foursquare.md docs\gitcode.md = docs\gitcode.md @@ -198,7 +199,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{C2CA4B38-A docs\kook.md = docs\kook.md docs\lichess.md = docs\lichess.md docs\line.md = docs\line.md + docs\linear.md = docs\linear.md docs\linkedin.md = docs\linkedin.md + docs\miro.md = docs\miro.md docs\moodle.md = docs\moodle.md docs\odnoklassniki.md = docs\odnoklassniki.md docs\okta.md = docs\okta.md @@ -217,13 +220,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{C2CA4B38-A docs\twitch.md = docs\twitch.md docs\twitter.md = docs\twitter.md docs\vkontakte.md = docs\vkontakte.md + docs\webflow.md = docs\webflow.md docs\weibo.md = docs\weibo.md docs\workweixin.md = docs\workweixin.md docs\xumm.md = docs\xumm.md docs\zendesk.md = docs\zendesk.md - docs\webflow.md = docs\webflow.md - docs\miro.md = docs\miro.md - docs\linear.md = docs\linear.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.Basecamp", "src\AspNet.Security.OAuth.Basecamp\AspNet.Security.OAuth.Basecamp.csproj", "{42306484-B2BF-4B52-B950-E0CDFA58B02A}" @@ -328,8 +329,11 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Linear", "src\AspNet.Security.OAuth.Linear\AspNet.Security.OAuth.Linear.csproj", "{B1167108-CA36-4C6B-85B0-1C7F5A24E4A4}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Bilibili", "src\AspNet.Security.OAuth.Bilibili\AspNet.Security.OAuth.Bilibili.csproj", "{8350C405-9E17-4110-B9A8-0AB43A8816B7}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Contentful", "src\AspNet.Security.OAuth.Contentful\AspNet.Security.OAuth.Contentful.csproj", "{B1F6EA42-7B1B-469E-B304-6B2E6FE39852}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Etsy", "src\AspNet.Security.OAuth.Etsy\AspNet.Security.OAuth.Etsy.csproj", "{53B5B8F0-023E-4D2D-84F0-5B68610682A4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -772,6 +776,10 @@ Global {B1F6EA42-7B1B-469E-B304-6B2E6FE39852}.Debug|Any CPU.Build.0 = Debug|Any CPU {B1F6EA42-7B1B-469E-B304-6B2E6FE39852}.Release|Any CPU.ActiveCfg = Release|Any CPU {B1F6EA42-7B1B-469E-B304-6B2E6FE39852}.Release|Any CPU.Build.0 = Release|Any CPU + {53B5B8F0-023E-4D2D-84F0-5B68610682A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53B5B8F0-023E-4D2D-84F0-5B68610682A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53B5B8F0-023E-4D2D-84F0-5B68610682A4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53B5B8F0-023E-4D2D-84F0-5B68610682A4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -892,6 +900,7 @@ Global {B1167108-CA36-4C6B-85B0-1C7F5A24E4A4} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {8350C405-9E17-4110-B9A8-0AB43A8816B7} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {B1F6EA42-7B1B-469E-B304-6B2E6FE39852} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} + {53B5B8F0-023E-4D2D-84F0-5B68610682A4} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C7B54DE2-6407-4802-AD9C-CE54BF414C8C} diff --git a/README.md b/README.md index 064ebc419..0367341ee 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ We would love it if you could help contributing to this repository. * [Sinan](https://github.com/SH2015) * [Stefan](https://github.com/Schlurcher) * [Steffen Wenz](https://github.com/swenz) +* [Sonja Schweitzer](https://github.com/DevTKSS) * [Tathagata Chakraborty](https://github.com/tatx) * [TheUltimateC0der](https://github.com/TheUltimateC0der) * [Tolbxela](https://github.com/tolbxela) @@ -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 000000000..44805b48c Binary files /dev/null and b/docs/assets/Etsy-find-your-client_id.png differ diff --git a/docs/etsy.md b/docs/etsy.md new file mode 100644 index 000000000..69b8a7201 --- /dev/null +++ b/docs/etsy.md @@ -0,0 +1,403 @@ +# Integrating the Etsy Provider + +Etsy's OAuth implementation uses Authorization Code with PKCE and issues refresh tokens. This provider enables PKCE by default and validates scopes to match Etsy's requirements. + +- [Integrating the Etsy Provider](#integrating-the-etsy-provider) + - [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) + - [Directly in Program.cs](#directly-in-programcs) + - [Feature-style typed Minimal API endpoints with MapGroup](#feature-style-typed-minimal-api-endpoints-with-mapgroup) + - [Extension class anywhere in your project](#extension-class-anywhere-in-your-project) + - [Sample record types](#sample-record-types) + - [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 start + +```csharp +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" + } +} +``` + +**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). + +## 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. + +### Scope constants + +Use `EtsyAuthenticationConstants.Scopes.*` instead of string literals. Common values: + +| Constant | Scope Value | +|:--|:--| +| `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", + "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; + options.DefaultChallengeScheme = EtsyAuthenticationDefaults.AuthenticationScheme; +}) +.AddCookie() +.AddEtsy(options => +{ + var section = builder.Configuration.GetSection("Etsy"); + options.ClientId = section.GetValue("ClientId") + ?? throw new InvalidOperationException("Etsy:ClientId configuration value is missing."); + + // For the Detailed User Info Endpoint and SaveTokens are default values pre-set, but you can override them here + options.IncludeDetailedUserInfo = section.GetValue("IncludeDetailedUserInfo"); + options.SaveTokens = section.GetValue("SaveTokens"); + + // Use the defaults from EtsyAuthenticationDefaults, if you want to repeat them here, but they are set automatically + options.AuthorizationEndpoint = EtsyAuthenticationDefaults.AuthorizationEndpoint; + options.TokenEndpoint = EtsyAuthenticationDefaults.TokenEndpoint; + options.UserInformationEndpoint = EtsyAuthenticationDefaults.UserInformationEndpoint; + + // Apply scopes from config if present + var scopes = section.Get("Scopes"); + + if (scopes is not null) + { + foreach (var scope in scopes) + { + options.Scope.Add(scope); + } + } + + // Optionally Map the image claim + options.ClaimActions.MapImageClaim(); + + // Map other Claims + options.ClaimActions.MapJsonKey("urn:etsy:listingsWrite", "listings_w"); +}) +``` + +## 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: + +### Directly in Program.cs + +```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 + +#### 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 ?? "/" }, + 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); + } +``` + +#### Sample record types + +```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; } + } +} +``` + +#### Register the endpoints in Program.cs + +```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 new file mode 100644 index 000000000..c779f577f --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj @@ -0,0 +1,21 @@ + + + + 9.5.0 + $(DefaultNetCoreTargetFramework) + + true + + + + ASP.NET Core security middleware enabling Etsy authentication. + Sonja Schweitzer + aspnetcore;authentication;etsy;oauth;security + + + + + + + + \ No newline at end of file diff --git a/src/AspNet.Security.OAuth.Etsy/ClaimActionCollectionExtensions.cs b/src/AspNet.Security.OAuth.Etsy/ClaimActionCollectionExtensions.cs new file mode 100644 index 000000000..d8f26be1b --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/ClaimActionCollectionExtensions.cs @@ -0,0 +1,24 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using AspNet.Security.OAuth.Etsy; +using Microsoft.AspNetCore.Authentication.OAuth.Claims; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides extension methods for to map Etsy API specific user claims. +/// +public static class ClaimActionCollectionExtensions +{ + /// + /// Maps the Etsy user's profile image URL (75x75) to the claim. + /// + public static void MapImageClaim(this ClaimActionCollection collection) + { + collection.MapJsonKey(EtsyAuthenticationConstants.Claims.ImageUrl, "image_url_75x75"); + } +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs new file mode 100644 index 000000000..054cdd330 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationConstants.cs @@ -0,0 +1,93 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System.Security.Claims; + +namespace AspNet.Security.OAuth.Etsy; + +/// +/// Contains constants specific to the . +/// +public static class EtsyAuthenticationConstants +{ + /// + /// Contains claim type constants specific to Etsy authentication. + /// + public static class Claims + { + /// The claim type for the user's Etsy 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 billing and shipping addresses. + public static readonly string AddressRead = "address_r"; + + /// Update billing and shipping addresses. + public static readonly string AddressWrite = "address_w"; + + /// Read all billing statement data. + public static readonly string BillingRead = "billing_r"; + + /// Read shopping carts. + public static readonly string CartRead = "cart_r"; + + /// Add and remove items from shopping carts. + public static readonly string CartWrite = "cart_w"; + + /// Read user profile and email address. + public static readonly string EmailRead = "email_r"; + + /// Read private favorites. + public static readonly string FavoritesRead = "favorites_r"; + + /// Add and remove favorites. + public static readonly string FavoritesWrite = "favorites_w"; + + /// Read purchase information in feedback. + public static readonly string FeedbackRead = "feedback_r"; + + /// Delete listings. + public static readonly string ListingsDelete = "listings_d"; + + /// Read all listings, including expired listings. + public static readonly string ListingsRead = "listings_r"; + + /// Create and edit listings. + public static readonly string ListingsWrite = "listings_w"; + + /// Read all profile data. + public static readonly string ProfileRead = "profile_r"; + + /// Update user profile, avatar, and related data. + public static readonly string ProfileWrite = "profile_w"; + + /// Read recommended listings. + public static readonly string RecommendRead = "recommend_r"; + + /// Accept and reject recommended listings. + public static readonly string RecommendWrite = "recommend_w"; + + /// Read private shop information. + public static readonly string ShopsRead = "shops_r"; + + /// Update shop information. + public static readonly string ShopsWrite = "shops_w"; + + /// Read all checkout and payment data. + public static readonly string TransactionsRead = "transactions_r"; + + /// Update receipts. + public static readonly string TransactionsWrite = "transactions_w"; + } +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs new file mode 100644 index 000000000..f33ef54dc --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationDefaults.cs @@ -0,0 +1,55 @@ +/* + * 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.Text; + +namespace AspNet.Security.OAuth.Etsy; + +/// +/// Default values used by the Etsy authentication middleware. +/// +public static class EtsyAuthenticationDefaults +{ + /// + /// Default value for . + /// + public const string AuthenticationScheme = "Etsy"; + + /// + /// Default value for . + /// + public static readonly string DisplayName = "Etsy"; + + /// + /// Default value for . + /// + public static readonly string Issuer = "Etsy"; + + /// + /// Default value for . + /// + public static readonly string CallbackPath = "/signin-etsy"; + + /// + /// Default value for . + /// + public static readonly string AuthorizationEndpoint = "https://www.etsy.com/oauth/connect"; + + /// + /// Default value for . + /// + public static readonly string TokenEndpoint = "https://openapi.etsy.com/v3/public/oauth/token"; + + /// + /// Default value for Etsy getMe Endpoint. + /// + public static readonly string UserInformationEndpoint = "https://openapi.etsy.com/v3/application/users/me"; + + /// + /// Default value for receiving the user profile based upon a unique user ID getUser. + /// + public static readonly CompositeFormat DetailedUserInfoEndpoint = CompositeFormat.Parse("https://openapi.etsy.com/v3/application/users/{0}"); +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs new file mode 100644 index 000000000..5b9831dd2 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationExtensions.cs @@ -0,0 +1,79 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using AspNet.Security.OAuth.Etsy; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods to add Etsy authentication capabilities to an HTTP application pipeline. +/// +public static class EtsyAuthenticationExtensions +{ + /// + /// Adds to the specified + /// , which enables Etsy authentication capabilities. + /// + /// The authentication builder. + /// The . + public static AuthenticationBuilder AddEtsy([NotNull] this AuthenticationBuilder builder) + { + return builder.AddEtsy(EtsyAuthenticationDefaults.AuthenticationScheme, options => { }); + } + + /// + /// Adds to the specified + /// , which enables Etsy authentication capabilities. + /// + /// The authentication builder. + /// The delegate used to configure the Etsy options. + /// The . + public static AuthenticationBuilder AddEtsy( + [NotNull] this AuthenticationBuilder builder, + [NotNull] Action configuration) + { + return builder.AddEtsy(EtsyAuthenticationDefaults.AuthenticationScheme, configuration); + } + + /// + /// Adds to the specified + /// , which enables Etsy authentication capabilities. + /// + /// The authentication builder. + /// The authentication scheme associated with this instance. + /// The delegate used to configure the Etsy options. + /// The . + public static AuthenticationBuilder AddEtsy( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [NotNull] Action configuration) + { + return builder.AddEtsy(scheme, EtsyAuthenticationDefaults.DisplayName, configuration); + } + + /// + /// Adds to the specified + /// , which enables Etsy authentication capabilities. + /// + /// The authentication builder. + /// The authentication scheme associated with this instance. + /// The optional display name associated with this instance. + /// The delegate used to configure the Etsy options. + /// The . + public static AuthenticationBuilder AddEtsy( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [CanBeNull] string caption, + [NotNull] Action configuration) + { + // Ensure Etsy-specific post-configuration runs after the base OAuth configuration + builder.Services.TryAddSingleton, EtsyPostConfigureOptions>(); + + return builder.AddOAuth(scheme, caption, configuration); + } +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs new file mode 100644 index 000000000..e14721531 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationHandler.cs @@ -0,0 +1,165 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System.Globalization; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AspNet.Security.OAuth.Etsy; + +public partial class EtsyAuthenticationHandler : OAuthHandler +{ + public EtsyAuthenticationHandler( + [NotNull] IOptionsMonitor options, + [NotNull] ILoggerFactory logger, + [NotNull] UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + /// + /// Creates an from the OAuth tokens and Etsy user information. + /// + /// The claims identity to populate. + /// The authentication properties. + /// The OAuth token response containing the access token. + /// An containing the user claims and properties. + /// Thrown when an error occurs while retrieving user information from Etsy. + protected override async Task CreateTicketAsync( + [NotNull] ClaimsIdentity identity, + [NotNull] AuthenticationProperties properties, + [NotNull] OAuthTokenResponse tokens) + { + // Get the basic user info (user_id and shop_id) + using var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + request.Headers.Add("x-api-key", Options.ClientId); + + using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); + if (!response.IsSuccessStatusCode) + { + await Log.BasicUserInfoErrorAsync(Logger, response, Context.RequestAborted); + throw new HttpRequestException("An error occurred while retrieving basic user information from Etsy."); + } + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); + var meRoot = payload.RootElement; + + // 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 principal = new ClaimsPrincipal(identity); + var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, meRoot); + + // Map claims from the basic payload first + context.RunClaimActions(); + + // Optionally enrich with detailed user info + if (Options.IncludeDetailedUserInfo) + { + using var detailedPayload = await GetDetailedUserInfoAsync(tokens, userId); + var detailedRoot = detailedPayload.RootElement; + + // Apply claim actions for fields that are only in the detailed payload + // We filter the ClaimActions to exclude those for user_id and shop_id + // since they were already processed from the basic /users/me endpoint + foreach (var action in Options.ClaimActions) + { + // Skip the action if it's a JsonKeyClaimAction for user_id or shop_id + if (action is Microsoft.AspNetCore.Authentication.OAuth.Claims.JsonKeyClaimAction { ClaimType: var t } && + (t == ClaimTypes.NameIdentifier + || t == EtsyAuthenticationConstants.Claims.ShopId)) + { + continue; + } + + action.Run(detailedRoot, identity, Options.ClaimsIssuer ?? ClaimsIssuer); + } + } + + await Events.CreatingTicket(context); + return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name); + } + + /// + /// Retrieves detailed user information from Etsy. + /// + /// The OAuth token response. + /// The user ID to retrieve details for. + /// A containing the detailed user information. + protected virtual async Task GetDetailedUserInfoAsync([NotNull] OAuthTokenResponse tokens, long userId) + { + 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 response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); + if (!response.IsSuccessStatusCode) + { + await Log.DetailedUserInfoErrorAsync(Logger, response, Context.RequestAborted); + throw new HttpRequestException("An error occurred while retrieving detailed user info from Etsy."); + } + + return JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); + } + + private static partial class Log + { + internal static async Task BasicUserInfoErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken) + { + BasicUserInfoError( + logger, + response.RequestMessage?.RequestUri?.ToString() ?? string.Empty, + response.StatusCode, + response.Headers.ToString(), + await response.Content.ReadAsStringAsync(cancellationToken)); + } + + internal static async Task DetailedUserInfoErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken) + { + DetailedUserInfoError( + logger, + response.RequestMessage?.RequestUri?.ToString() ?? string.Empty, + response.StatusCode, + response.Headers.ToString(), + await response.Content.ReadAsStringAsync(cancellationToken)); + } + + [LoggerMessage(1, LogLevel.Error, "Etsy basic user info request failed for '{RequestUri}': remote server returned a {Status} response with: {Headers} {Body}.")] + private static partial void BasicUserInfoError( + ILogger logger, + string requestUri, + System.Net.HttpStatusCode status, + string headers, + string body); + + [LoggerMessage(2, LogLevel.Error, "Etsy detailed user info request failed for '{RequestUri}': remote server returned a {Status} response with: {Headers} {Body}.")] + private static partial void DetailedUserInfoError( + ILogger logger, + string requestUri, + System.Net.HttpStatusCode status, + string headers, + string body); + } +} diff --git a/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs new file mode 100644 index 000000000..2cd528ae5 --- /dev/null +++ b/src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationOptions.cs @@ -0,0 +1,103 @@ +/* + * 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; + +/// +/// Defines a set of options used by . +/// +public class EtsyAuthenticationOptions : OAuthOptions +{ + public EtsyAuthenticationOptions() + { + ClaimsIssuer = EtsyAuthenticationDefaults.Issuer; + CallbackPath = EtsyAuthenticationDefaults.CallbackPath; + + AuthorizationEndpoint = EtsyAuthenticationDefaults.AuthorizationEndpoint; + TokenEndpoint = EtsyAuthenticationDefaults.TokenEndpoint; + UserInformationEndpoint = EtsyAuthenticationDefaults.UserInformationEndpoint; + + UsePkce = true; + SaveTokens = true; + + // Etsy requires at least one scope and this is the one for basic user info + Scope.Add(Scopes.ShopsRead); + + ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "user_id"); + ClaimActions.MapJsonKey(Claims.ShopId, "shop_id"); + } + + /// + /// Gets or sets a value indicating whether to fetch detailed user information + /// from the getUser Endpoint. + /// + public bool IncludeDetailedUserInfo { get; set; } + + /// + /// Gets or sets the endpoint used to retrieve detailed user information. + /// + /// + /// The placeholder for user_id needs to be "{0}" and will be replaced with the authenticated user's ID. + /// + public string? DetailedUserInfoEndpoint { 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 client access type + PKCE. + // Etsy's OAuth implementation does not require a client secret referring to the Documentation using PKCE (Proof Key for Code Exchange). + // This does mean that three checks have to be re-implemented + // because they won't be validated if the ClientSecret validation fails. + base.Validate(); + } + catch (ArgumentException ex) when (ex.ParamName == nameof(ClientSecret)) + { + // No client secret is required for Etsy API, which uses Authorization Code Flow https://datatracker.ietf.org/doc/html/rfc6749#section-1.3.1 with: + // See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers/issues/610. + } + + if (string.IsNullOrEmpty(AuthorizationEndpoint)) + { + throw new ArgumentNullException(nameof(AuthorizationEndpoint), $"The '{nameof(AuthorizationEndpoint)}' option must be provided."); + } + + if (string.IsNullOrEmpty(TokenEndpoint)) + { + throw new ArgumentNullException(nameof(TokenEndpoint), $"The '{nameof(TokenEndpoint)}' option must be provided."); + } + + if (string.IsNullOrEmpty(UserInformationEndpoint)) + { + throw new ArgumentNullException(nameof(UserInformationEndpoint), $"The '{nameof(UserInformationEndpoint)}' option must be provided."); + } + + if (!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."); + } + } +} 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/EtsyAuthenticationOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs new file mode 100644 index 000000000..b39556353 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyAuthenticationOptionsTests.cs @@ -0,0 +1,136 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +namespace AspNet.Security.OAuth.Etsy; + +public static class EtsyAuthenticationOptionsTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + public static void Validate_Does_Not_Throw_If_ClientSecret_Is_Not_Provided(string? clientSecret) + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecret = clientSecret!, + }; + + // Act (no Assert) + options.Validate(); + } + + [Fact] + public static void Validate_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() + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + AuthorizationEndpoint = null!, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + }; + + // Act and Assert + _ = Assert.Throws(nameof(options.AuthorizationEndpoint), options.Validate); + } + + [Fact] + public static void Validate_Throws_If_TokenEndpoint_Is_Null() + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + TokenEndpoint = null!, + }; + + // Act and Assert + _ = Assert.Throws(nameof(options.TokenEndpoint), options.Validate); + } + + [Fact] + public static void Validate_Throws_If_UserInformationEndpoint_Is_Null() + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + UserInformationEndpoint = null!, + }; + + // Act and Assert + _ = Assert.Throws(nameof(options.UserInformationEndpoint), options.Validate); + } + + [Fact] + public static void Validate_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() + { + // Arrange + var options = new EtsyAuthenticationOptions() + { + CallbackPath = null, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + }; + + // Act and Assert + var ex = Assert.Throws(options.Validate); + ex.ParamName.ShouldBe(nameof(options.CallbackPath)); + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/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(); + } +} 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..e19c71abb --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/EtsyTests.cs @@ -0,0 +1,88 @@ +/* + * 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 static AspNet.Security.OAuth.Etsy.EtsyAuthenticationConstants; + +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, "123456")] + [InlineData("urn:etsy:shop_id", "789012")] + public async Task Can_Sign_In_Using_Etsy(string claimType, string claimValue) + => await AuthenticateUserAndAssertClaimValue(claimType, claimValue); + + [Fact] + public async Task Does_Not_Include_Detailed_Claims_When_IncludeDetailedUserInfo_Is_False() + { + // Arrange: disable detailed user info enrichment + void ConfigureServices(IServiceCollection services) => services.PostConfigureAll(o => o.IncludeDetailedUserInfo = false); + + using var server = CreateTestServer(ConfigureServices); + + // Act + var claims = await AuthenticateUserAsync(server); + + // Assert basic claims are present + claims.ShouldContainKey(ClaimTypes.NameIdentifier); + claims.ShouldContainKey(Claims.ShopId); + + // Detailed claims should be absent when flag is false + claims.Keys.ShouldNotContain(ClaimTypes.Email); + claims.Keys.ShouldNotContain(ClaimTypes.GivenName); + claims.Keys.ShouldNotContain(ClaimTypes.Surname); + claims.Keys.ShouldNotContain(Claims.ImageUrl); + } + + [Fact] + public async Task Includes_Detailed_Claims_When_IncludeDetailedUserInfo_Is_True() + { + // Arrange: enable detailed user info, configure claims to map. + // 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(); + }); + + using var server = CreateTestServer(ConfigureServices); + + // Act + var claims = await AuthenticateUserAsync(server); + + // Assert detailed claims are present + claims.ShouldContainKey(ClaimTypes.Email); + claims.ShouldContainKey(ClaimTypes.GivenName); + claims.ShouldContainKey(ClaimTypes.Surname); + claims.ShouldContainKey(Claims.ImageUrl); + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json new file mode 100644 index 000000000..c304d6551 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Etsy/bundle.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", + "items": [ + { + "comment": "Etsy OAuth 2.0 token exchange endpoint response - returns sample token values from Etsy API Docs (domain aligned to openapi.etsy.com to match provider defaults)", + "uri": "https://openapi.etsy.com/v3/public/oauth/token", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "access_token": "12345678.12345678.O1zLuwveeKjpIqCQFfmR-PaMMpBmagH6DljRAkK9qt05OtRKiANJOyZlMx3WQ_o2FdComQGuoiAWy3dxyGI4Ke_76PR", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "12345678.JNGIJtvLmwfDMhlYoOJl8aLR1BWottyHC6yhNcET-eC7RogSR5e1GTIXGrgrelWZalvh3YvvyLfKYYqvymd-u37Sjtx" + } + }, + { + "comment": "Etsy /v3/application/users/me endpoint - returns basic user and shop IDs", + "uri": "https://openapi.etsy.com/v3/application/users/me", + "contentFormat": "json", + "contentJson": { + "user_id": 123456, + "shop_id": 789012 + } + }, + { + "comment": "Etsy /v3/application/users/{user_id} endpoint - returns detailed user information", + "uri": "https://openapi.etsy.com/v3/application/users/123456", + "contentFormat": "json", + "contentJson": { + "user_id": 123456, + "primary_email": "test@example.com", + "first_name": "Test", + "last_name": "User", + "image_url_75x75": "https://i.etsystatic.com/test/test_75x75.jpg" + } + } + ] +}