-
Notifications
You must be signed in to change notification settings - Fork 551
Add etsy oauth provider #1126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
DevTKSS
wants to merge
32
commits into
aspnet-contrib:dev
Choose a base branch
from
DevTKSS:add-etsy-oauth-provider
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,329
−4
Open
Add etsy oauth provider #1126
Changes from 8 commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
c64822b
feat: add Etsy OAuth provider
DevTKSS 1c257bb
feat: add documentation for Etsy OAuth provider
DevTKSS 4742692
test: add tests for Etsy OAuth provider
DevTKSS 5681e48
chore: Compare and align to other existing Providers
DevTKSS 85f3d60
test(EtsyProvider): Added unit tests for EtsyAuthenticationOptions an…
DevTKSS 436441e
chore: updated and documented test data in `bundle.json`
DevTKSS 7819017
chore: Rename Public to Personal Access Type, to match the Etsy Api n…
DevTKSS 2c84ed8
docs(EtsyProvider): Add Provider usage guide with samples and specifi…
DevTKSS 87165d3
chore: remove comments
DevTKSS 80a6b9a
chore(EtsyProvider): tfm version bump
DevTKSS 66e76ab
chore: applying PR rewording suggestion
DevTKSS 9543daa
chore(EtsyAccessTypes): Remove commented member and test/-cases that …
DevTKSS b6be3ce
chore: Add DetailedUserInfoClaimMappings and add xml docs
DevTKSS f561612
chore(Etsy): align oauth scopes with the docs table
DevTKSS a415414
chore(EtsyAccessType): Remove AccessType
DevTKSS ce0a1b2
chore(EtsyAuthenticationHandler): rename variables and formating appl…
DevTKSS 683a3c5
chore(EtsyPostConfigureOptions): add DetailedUserInfo Config via Post…
DevTKSS ba8a05d
chore: update const string to static readonly string
DevTKSS f287b46
chore: Update xml docs and refactor to Property pattern with declarat…
DevTKSS 0596516
chore(EtsyOptionsValidation): apply Review suggestions
DevTKSS 8ecc592
test(EtsyProvider): Update tests accordingly to review suggestions an…
DevTKSS 505722d
docs(EtsyProvider): Add links to etsy provider docs and author, updat…
DevTKSS 2bd6bef
chore: xml docs updates and update bundle.json with the placeholder v…
DevTKSS 1dba2fa
chore: implement Options fed DetailedUserInfoEndpoint and set fallbac…
DevTKSS 1502976
revert: removed unpurposely added arch specific builds from sln file
DevTKSS c978f0c
chore: fix test builds
DevTKSS 05aa969
chore: set InlineData to magic string "urn:etsy:shop_id" because only…
DevTKSS b71fb6f
chore(EtsyTests): apply workaround into PostConfigure test
DevTKSS 0299184
docs(PostConfigure): Add Warning that he needs to add the claims hims…
DevTKSS fa13f8b
chore: create seperate named log methods
DevTKSS 36cacb2
chore: applying PR rewording suggestion
DevTKSS 3f39b3b
chore: apply PR reword suggestions
DevTKSS File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string>` | 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<EtsyAuthenticationAccessType>(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<string[]?>(); | ||
| 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<ChallengeHttpResult, RedirectHttpResult> SignInAsync(string? returnUrl) | ||
| => TypedResults.Challenge( | ||
| new AuthenticationProperties { RedirectUri = returnUrl ?? "/" }, | ||
| new[] { EtsyAuthenticationDefaults.AuthenticationScheme }); | ||
|
|
||
| private static async Task<RedirectHttpResult> SignOutAsync(HttpContext context) | ||
| { | ||
| await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); | ||
| return TypedResults.Redirect("/"); | ||
| } | ||
|
|
||
| private static Task<Ok<UserInfo>> 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<Ok<TokenInfo>> 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; } | ||
| } | ||
| } | ||
| ``` |
18 changes: 18 additions & 0 deletions
18
src/AspNet.Security.OAuth.Etsy/AspNet.Security.OAuth.Etsy.csproj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
|
|
||
| <PropertyGroup> | ||
| <TargetFrameworks>$(DefaultNetCoreTargetFramework)</TargetFrameworks> | ||
| </PropertyGroup> | ||
|
|
||
| <PropertyGroup> | ||
| <Description>ASP.NET Core security middleware enabling Etsy authentication.</Description> | ||
| <Authors>Sonja</Authors> | ||
DevTKSS marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| <PackageTags>aspnetcore;authentication;etsy;oauth;security</PackageTags> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||
| <PackageReference Include="JetBrains.Annotations" PrivateAssets="All" /> | ||
| </ItemGroup> | ||
|
|
||
| </Project> | ||
18 changes: 18 additions & 0 deletions
18
src/AspNet.Security.OAuth.Etsy/EtsyAuthenticationAccessType.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| { | ||
| /// <summary> | ||
| /// Public client access type aka 'private usage access' in Etsy App Registration. | ||
| /// </summary> | ||
| Personal, | ||
|
|
||
| //// <summary> | ||
| //// 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. | ||
| //// </summary> | ||
| // Commercial | ||
DevTKSS marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.