diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln index b98754e46..85840831d 100644 --- a/AspNet.Security.OAuth.Providers.sln +++ b/AspNet.Security.OAuth.Providers.sln @@ -327,6 +327,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Miro" 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.Contentful", "src\AspNet.Security.OAuth.Contentful\AspNet.Security.OAuth.Contentful.csproj", "{B1F6EA42-7B1B-469E-B304-6B2E6FE39852}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -761,6 +763,10 @@ Global {B1167108-CA36-4C6B-85B0-1C7F5A24E4A4}.Debug|Any CPU.Build.0 = Debug|Any CPU {B1167108-CA36-4C6B-85B0-1C7F5A24E4A4}.Release|Any CPU.ActiveCfg = Release|Any CPU {B1167108-CA36-4C6B-85B0-1C7F5A24E4A4}.Release|Any CPU.Build.0 = Release|Any CPU + {B1F6EA42-7B1B-469E-B304-6B2E6FE39852}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -879,6 +885,7 @@ Global {F5DA8A08-5089-4076-B0FC-3F4A5CBB9664} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {7F22DE22-FDE8-4A14-AA65-D5B36098533E} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {B1167108-CA36-4C6B-85B0-1C7F5A24E4A4} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} + {B1F6EA42-7B1B-469E-B304-6B2E6FE39852} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C7B54DE2-6407-4802-AD9C-CE54BF414C8C} diff --git a/README.md b/README.md index 17c3d77b7..e39b21e0b 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ If a provider you're looking for does not exist, consider making a PR to add one | Calendly | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Calendly?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Calendly/ "Download AspNet.Security.OAuth.Calendly from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Calendly?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Calendly "Download AspNet.Security.OAuth.Calendly from MyGet.org") | [Documentation](https://developer.calendly.com/api-docs/3cefb59b832eb-calendly-o-auth-2-0 "Calendly developer documentation") | | CiscoSpark (Webex Teams) | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.CiscoSpark?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.CiscoSpark/ "Download AspNet.Security.OAuth.CiscoSpark from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.CiscoSpark?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.CiscoSpark "Download AspNet.Security.OAuth.CiscoSpark from MyGet.org") | [Documentation](https://developer.webex.com/docs/api/getting-started/accounts-and-authentication "Webex Teams developer documentation") | | Coinbase | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Coinbase?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Coinbase/ "Download AspNet.Security.OAuth.Coinbase from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Coinbase?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Coinbase "Download AspNet.Security.OAuth.Coinbase from MyGet.org") | [Documentation](https://developers.coinbase.com/docs/wallet/coinbase-connect/integrating "Coinbase developer documentation") | +| Contentful | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Contentful?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Contentful/ "Download AspNet.Security.OAuth.Contentful from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Contentful?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Contentful "Download AspNet.Security.OAuth.Contentful from MyGet.org") | [Documentation](https://www.contentful.com/developers/docs/extensibility/oauth/ "Contentful developer documentation") | | DeviantArt | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.DeviantArt?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.DeviantArt/ "Download AspNet.Security.OAuth.DeviantArt from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.DeviantArt?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.DeviantArt "Download AspNet.Security.OAuth.DeviantArt from MyGet.org") | [Documentation](https://www.deviantart.com/developers/ "DeviantArt developer documentation") | | Deezer | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Deezer?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Deezer/ "Download AspNet.Security.OAuth.Deezer from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Deezer?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Deezer "Download AspNet.Security.OAuth.Deezer from MyGet.org") | [Documentation](https://developers.deezer.com/api/oauth "Deezer developer documentation") | | DigitalOcean | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.DigitalOcean?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.DigitalOcean/ "Download AspNet.Security.OAuth.DigitalOcean from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.DigitalOcean?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.DigitalOcean "Download AspNet.Security.OAuth.DigitalOcean from MyGet.org") | [Documentation](https://docs.digitalocean.com/reference/api/oauth-api/ "DigitalOcean developer documentation") | diff --git a/eng/Versions.props b/eng/Versions.props index 459a2f707..1285cc3df 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -2,8 +2,8 @@ 9 - 2 - 1 + 3 + 0 $(MajorVersion).$(MinorVersion).$(PatchVersion) 9.0.0 preview diff --git a/src/AspNet.Security.OAuth.Contentful/AspNet.Security.OAuth.Contentful.csproj b/src/AspNet.Security.OAuth.Contentful/AspNet.Security.OAuth.Contentful.csproj new file mode 100644 index 000000000..6b4056a2a --- /dev/null +++ b/src/AspNet.Security.OAuth.Contentful/AspNet.Security.OAuth.Contentful.csproj @@ -0,0 +1,18 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + ASP.NET Core security middleware enabling Contentful authentication. + Jerrie Pelser + contentful;aspnetcore;authentication;oauth;security + + + + + + + + diff --git a/src/AspNet.Security.OAuth.Contentful/ContentfulAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Contentful/ContentfulAuthenticationDefaults.cs new file mode 100644 index 000000000..8afa2bbb5 --- /dev/null +++ b/src/AspNet.Security.OAuth.Contentful/ContentfulAuthenticationDefaults.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.Contentful; + +/// +/// Default values used by the Contentful authentication middleware. +/// +public static class ContentfulAuthenticationDefaults +{ + /// + /// Default value for the . + /// + public const string AuthenticationScheme = "Contentful"; + + /// + /// Default value for the . + /// + public static readonly string DisplayName = "Contentful"; + + /// + /// Default value for the . + /// + public static readonly string Issuer = "Contentful"; + + /// + /// Default value for the . + /// + public static readonly string CallbackPath = "/signin-contentful"; + + /// + /// Default value for the . + /// + public static readonly string AuthorizationEndpoint = "https://be.contentful.com/oauth/authorize"; + + /// + /// Default value for the . + /// + public static readonly string TokenEndpointFormat = "https://be.contentful.com/oauth/token"; + + /// + /// Default value for . + /// + public static readonly string UserInformationEndpoint = "https://api.contentful.com/users/me"; +} diff --git a/src/AspNet.Security.OAuth.Contentful/ContentfulAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Contentful/ContentfulAuthenticationExtensions.cs new file mode 100644 index 000000000..245358391 --- /dev/null +++ b/src/AspNet.Security.OAuth.Contentful/ContentfulAuthenticationExtensions.cs @@ -0,0 +1,71 @@ +/* + * 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.Contentful; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class ContentfulAuthenticationExtensions +{ + /// + /// Adds to the specified + /// , which enables Contentful authentication capabilities. + /// + /// The authentication builder. + /// A reference to this instance after the operation has completed. + public static AuthenticationBuilder AddContentful([NotNull] this AuthenticationBuilder builder) + { + return builder.AddContentful(ContentfulAuthenticationDefaults.AuthenticationScheme, options => { }); + } + + /// + /// Adds to the specified + /// , which enables Contentful authentication capabilities. + /// + /// The authentication builder. + /// The delegate used to configure the Contentful options. + /// A reference to this instance after the operation has completed. + public static AuthenticationBuilder AddContentful( + [NotNull] this AuthenticationBuilder builder, + [NotNull] Action configuration) + { + return builder.AddContentful(ContentfulAuthenticationDefaults.AuthenticationScheme, configuration); + } + + /// + /// Adds to the specified + /// , which enables Contentful authentication capabilities. + /// + /// The authentication builder. + /// The authentication scheme associated with this instance. + /// The delegate used to configure the Contentful options. + /// The . + public static AuthenticationBuilder AddContentful( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [NotNull] Action configuration) + { + return builder.AddContentful(scheme, ContentfulAuthenticationDefaults.DisplayName, configuration); + } + + /// + /// Adds to the specified + /// , which enables Contentful 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 Contentful options. + /// The . + public static AuthenticationBuilder AddContentful( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [NotNull] string caption, + [NotNull] Action configuration) + { + return builder.AddOAuth(scheme, caption, configuration); + } +} diff --git a/src/AspNet.Security.OAuth.Contentful/ContentfulAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Contentful/ContentfulAuthenticationHandler.cs new file mode 100644 index 000000000..fbe0b43c5 --- /dev/null +++ b/src/AspNet.Security.OAuth.Contentful/ContentfulAuthenticationHandler.cs @@ -0,0 +1,75 @@ +/* + * 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.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.Contentful; + +public partial class ContentfulAuthenticationHandler( + [NotNull] IOptionsMonitor options, + [NotNull] ILoggerFactory logger, + [NotNull] UrlEncoder encoder) : OAuthHandler(options, logger, encoder) +{ + protected override async Task CreateTicketAsync( + [NotNull] ClaimsIdentity identity, + [NotNull] AuthenticationProperties properties, + [NotNull] OAuthTokenResponse tokens) + { + using var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + + using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); + if (!response.IsSuccessStatusCode) + { + await Log.UserProfileErrorAsync(Logger, response, Context.RequestAborted); + throw new HttpRequestException("An error occurred while retrieving the user profile from Contentful."); + } + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); + + var principal = new ClaimsPrincipal(identity); + var context = new OAuthCreatingTicketContext( + principal, + properties, + Context, + Scheme, + Options, + Backchannel, + tokens, + payload.RootElement); + context.RunClaimActions(); + + 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: 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.Contentful/ContentfulAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Contentful/ContentfulAuthenticationOptions.cs new file mode 100644 index 000000000..c3b7b219c --- /dev/null +++ b/src/AspNet.Security.OAuth.Contentful/ContentfulAuthenticationOptions.cs @@ -0,0 +1,30 @@ +/* + * 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.Contentful; + +/// +/// Defines a set of options used by . +/// +public class ContentfulAuthenticationOptions : OAuthOptions +{ + public ContentfulAuthenticationOptions() + { + ClaimsIssuer = ContentfulAuthenticationDefaults.Issuer; + CallbackPath = ContentfulAuthenticationDefaults.CallbackPath; + + AuthorizationEndpoint = ContentfulAuthenticationDefaults.AuthorizationEndpoint; + TokenEndpoint = ContentfulAuthenticationDefaults.TokenEndpointFormat; + UserInformationEndpoint = ContentfulAuthenticationDefaults.UserInformationEndpoint; + + ClaimActions.MapJsonSubKey(ClaimTypes.NameIdentifier, "sys", "id"); + ClaimActions.MapJsonKey(ClaimTypes.Email, "email"); + ClaimActions.MapJsonKey(ClaimTypes.GivenName, "firstName"); + ClaimActions.MapJsonKey(ClaimTypes.Surname, "lastName"); + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Contentful/ContentfulTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Contentful/ContentfulTests.cs new file mode 100644 index 000000000..3fb4b3869 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Contentful/ContentfulTests.cs @@ -0,0 +1,25 @@ +/* + * 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.Contentful; + +public class ContentfulTests(ITestOutputHelper outputHelper) : OAuthTests(outputHelper) +{ + public override string DefaultScheme => ContentfulAuthenticationDefaults.AuthenticationScheme; + + protected internal override void RegisterAuthentication(AuthenticationBuilder builder) + { + builder.AddContentful(options => ConfigureDefaults(builder, options)); + } + + [Theory] + [InlineData(ClaimTypes.NameIdentifier, "user-id")] + [InlineData(ClaimTypes.Email, "some@email.com")] + [InlineData(ClaimTypes.GivenName, "Some")] + [InlineData(ClaimTypes.Surname, "One")] + public async Task Can_Sign_In_Using_Contentful(string claimType, string claimValue) + => await AuthenticateUserAndAssertClaimValue(claimType, claimValue); +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Contentful/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Contentful/bundle.json new file mode 100644 index 000000000..bb886e7ae --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Contentful/bundle.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", + "items": [ + { + "uri": "https://be.contentful.com/oauth/token", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "access_token": "secret-access-token", + "token_type": "Bearer", + "refresh_token": "secret-refresh-token", + "scope": "content_management_read" + } + }, + { + "uri": "https://api.contentful.com/users/me", + "contentFormat": "json", + "contentJson": { + "firstName": "Some", + "lastName": "One", + "avatarUrl": "https://gravatar.com/avatar/27205e5c51cb03f862138b22bcb5dc20f94a342e744ff6df1b8dc8af3c865109", + "email": "some@email.com", + "signupSource": null, + "activated": true, + "signInCount": 475, + "confirmed": true, + "2faEnabled": false, + "sys": { + "type": "User", + "id": "user-id", + "version": 490, + "createdAt": "2018-08-15T04:08:47Z", + "updatedAt": "2025-05-01T03:09:46Z" + } + } + } + ] +}