Skip to content

Commit b6c69ec

Browse files
Implement Airtable OAuth provider (#895)
Implement Airtable OAuth provider.
1 parent 3be27ad commit b6c69ec

File tree

9 files changed

+386
-0
lines changed

9 files changed

+386
-0
lines changed

AspNet.Security.OAuth.Providers.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.PingO
296296
EndProject
297297
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}"
298298
EndProject
299+
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}"
300+
EndProject
299301
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}"
300302
EndProject
301303
Global
@@ -680,6 +682,10 @@ Global
680682
{8AF5DDBE-2631-4E71-9045-73A6356CE86B}.Debug|Any CPU.Build.0 = Debug|Any CPU
681683
{8AF5DDBE-2631-4E71-9045-73A6356CE86B}.Release|Any CPU.ActiveCfg = Release|Any CPU
682684
{8AF5DDBE-2631-4E71-9045-73A6356CE86B}.Release|Any CPU.Build.0 = Release|Any CPU
685+
{83C37AC5-51FB-47CD-8CBE-77AA114FF6F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
686+
{83C37AC5-51FB-47CD-8CBE-77AA114FF6F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
687+
{83C37AC5-51FB-47CD-8CBE-77AA114FF6F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
688+
{83C37AC5-51FB-47CD-8CBE-77AA114FF6F3}.Release|Any CPU.Build.0 = Release|Any CPU
683689
{55975423-C9C0-4C47-AD00-0F012F30AD3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
684690
{55975423-C9C0-4C47-AD00-0F012F30AD3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
685691
{55975423-C9C0-4C47-AD00-0F012F30AD3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -789,6 +795,7 @@ Global
789795
{101681FB-569F-4941-B943-2AD380039BE0} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
790796
{CF8C4235-6AE6-404E-B572-4FF4E85AB5FF} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
791797
{8AF5DDBE-2631-4E71-9045-73A6356CE86B} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
798+
{83C37AC5-51FB-47CD-8CBE-77AA114FF6F3} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
792799
{55975423-C9C0-4C47-AD00-0F012F30AD3C} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
793800
EndGlobalSection
794801
GlobalSection(ExtensibilityGlobals) = postSolution

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ If a provider you're looking for does not exist, consider making a PR to add one
160160
| Provider | Stable | Nightly | Documentation |
161161
|:-:|:-:|:-:|:-:|
162162
| 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") |
163+
| 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") |
163164
| 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") |
164165
| 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") |
165166
| 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") |
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
4+
* for more information concerning the license and the contributors participating to this project.
5+
*/
6+
7+
namespace AspNet.Security.OAuth.Airtable;
8+
9+
/// <summary>
10+
/// Default values used by the Airtable authentication middleware.
11+
/// </summary>
12+
public static class AirtableAuthenticationDefaults
13+
{
14+
/// <summary>
15+
/// Default value for <see cref="AuthenticationScheme.Name"/>.
16+
/// </summary>
17+
public const string AuthenticationScheme = "Airtable";
18+
19+
/// <summary>
20+
/// Default value for <see cref="AuthenticationScheme.DisplayName"/>.
21+
/// </summary>
22+
public static readonly string DisplayName = "Airtable";
23+
24+
/// <summary>
25+
/// Default value for <see cref="AuthenticationSchemeOptions.ClaimsIssuer"/>.
26+
/// </summary>
27+
public static readonly string Issuer = "Airtable";
28+
29+
/// <summary>
30+
/// Default value for <see cref="RemoteAuthenticationOptions.CallbackPath"/>.
31+
/// </summary>
32+
public static readonly string CallbackPath = "/signin-airtable";
33+
34+
/// <summary>
35+
/// Default value for <see cref="OAuthOptions.AuthorizationEndpoint"/>.
36+
/// </summary>
37+
public static readonly string AuthorizationEndpoint = "https://airtable.com/oauth2/v1/authorize";
38+
39+
/// <summary>
40+
/// Default value for <see cref="OAuthOptions.TokenEndpoint"/>.
41+
/// </summary>
42+
public static readonly string TokenEndpoint = "https://airtable.com/oauth2/v1/token";
43+
44+
/// <summary>
45+
/// Default value for <see cref="OAuthOptions.UserInformationEndpoint"/>.
46+
/// </summary>
47+
public static readonly string UserInformationEndpoint = "https://api.airtable.com/v0/meta/whoami";
48+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
4+
* for more information concerning the license and the contributors participating to this project.
5+
*/
6+
7+
using Microsoft.Extensions.DependencyInjection;
8+
9+
namespace AspNet.Security.OAuth.Airtable;
10+
11+
/// <summary>
12+
/// Extension methods to add Airtable authentication capabilities to an HTTP application pipeline.
13+
/// </summary>
14+
public static class AirtableAuthenticationExtensions
15+
{
16+
/// <summary>
17+
/// Adds <see cref="AirtableAuthenticationHandler"/> to the specified
18+
/// <see cref="AuthenticationBuilder"/>, which enables Airtable authentication capabilities.
19+
/// </summary>
20+
/// <param name="builder">The authentication builder.</param>
21+
/// <returns>A reference to this instance after the operation has completed.</returns>
22+
public static AuthenticationBuilder AddAirtable([NotNull] this AuthenticationBuilder builder)
23+
{
24+
return builder.AddAirtable(AirtableAuthenticationDefaults.AuthenticationScheme, options => { });
25+
}
26+
27+
/// <summary>
28+
/// Adds <see cref="AirtableAuthenticationHandler"/> to the specified
29+
/// <see cref="AuthenticationBuilder"/>, which enables Airtable authentication capabilities.
30+
/// </summary>
31+
/// <param name="builder">The authentication builder.</param>
32+
/// <param name="configuration">The delegate used to configure the OpenID 2.0 options.</param>
33+
/// <returns>A reference to this instance after the operation has completed.</returns>
34+
public static AuthenticationBuilder AddAirtable(
35+
[NotNull] this AuthenticationBuilder builder,
36+
[NotNull] Action<AirtableAuthenticationOptions> configuration)
37+
{
38+
return builder.AddAirtable(AirtableAuthenticationDefaults.AuthenticationScheme, configuration);
39+
}
40+
41+
/// <summary>
42+
/// Adds <see cref="AirtableAuthenticationHandler"/> to the specified
43+
/// <see cref="AuthenticationBuilder"/>, which enables Airtable authentication capabilities.
44+
/// </summary>
45+
/// <param name="builder">The authentication builder.</param>
46+
/// <param name="scheme">The authentication scheme associated with this instance.</param>
47+
/// <param name="configuration">The delegate used to configure the Airtable options.</param>
48+
/// <returns>The <see cref="AuthenticationBuilder"/>.</returns>
49+
public static AuthenticationBuilder AddAirtable(
50+
[NotNull] this AuthenticationBuilder builder,
51+
[NotNull] string scheme,
52+
[NotNull] Action<AirtableAuthenticationOptions> configuration)
53+
{
54+
return builder.AddAirtable(scheme, AirtableAuthenticationDefaults.DisplayName, configuration);
55+
}
56+
57+
/// <summary>
58+
/// Adds <see cref="AirtableAuthenticationHandler"/> to the specified
59+
/// <see cref="AuthenticationBuilder"/>, which enables Airtable authentication capabilities.
60+
/// </summary>
61+
/// <param name="builder">The authentication builder.</param>
62+
/// <param name="scheme">The authentication scheme associated with this instance.</param>
63+
/// <param name="caption">The optional display name associated with this instance.</param>
64+
/// <param name="configuration">The delegate used to configure the Airtable options.</param>
65+
/// <returns>The <see cref="AuthenticationBuilder"/>.</returns>
66+
public static AuthenticationBuilder AddAirtable(
67+
[NotNull] this AuthenticationBuilder builder,
68+
[NotNull] string scheme,
69+
[CanBeNull] string caption,
70+
[NotNull] Action<AirtableAuthenticationOptions> configuration)
71+
{
72+
return builder.AddOAuth<AirtableAuthenticationOptions, AirtableAuthenticationHandler>(scheme, caption, configuration);
73+
}
74+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
4+
* for more information concerning the license and the contributors participating to this project.
5+
*/
6+
7+
using System.Net;
8+
using System.Net.Http.Headers;
9+
using System.Net.Mime;
10+
using System.Security.Claims;
11+
using System.Text;
12+
using System.Text.Encodings.Web;
13+
using System.Text.Json;
14+
using Microsoft.Extensions.Logging;
15+
using Microsoft.Extensions.Options;
16+
17+
namespace AspNet.Security.OAuth.Airtable;
18+
19+
public partial class AirtableAuthenticationHandler : OAuthHandler<AirtableAuthenticationOptions>
20+
{
21+
public AirtableAuthenticationHandler(
22+
[NotNull] IOptionsMonitor<AirtableAuthenticationOptions> options,
23+
[NotNull] ILoggerFactory logger,
24+
[NotNull] UrlEncoder encoder)
25+
: base(options, logger, encoder)
26+
{
27+
}
28+
29+
protected override async Task<AuthenticationTicket> CreateTicketAsync(
30+
[NotNull] ClaimsIdentity identity,
31+
[NotNull] AuthenticationProperties properties,
32+
[NotNull] OAuthTokenResponse tokens)
33+
{
34+
using var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint);
35+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
36+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
37+
38+
using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted);
39+
if (!response.IsSuccessStatusCode)
40+
{
41+
await Log.UserProfileErrorAsync(Logger, response, Context.RequestAborted);
42+
throw new HttpRequestException("An error occurred while retrieving the user profile.");
43+
}
44+
45+
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted));
46+
47+
var principal = new ClaimsPrincipal(identity);
48+
var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload.RootElement);
49+
context.RunClaimActions();
50+
51+
await Events.CreatingTicket(context);
52+
return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name);
53+
}
54+
55+
protected override async Task<OAuthTokenResponse> ExchangeCodeAsync([NotNull]OAuthCodeExchangeContext context)
56+
{
57+
var tokenRequestParameters = new Dictionary<string, string>
58+
{
59+
{ "client_id", Options.ClientId },
60+
{ "redirect_uri", context.RedirectUri },
61+
{ "client_secret", Options.ClientSecret },
62+
{ "code", context.Code },
63+
{ "grant_type", "authorization_code" }
64+
};
65+
66+
// PKCE https://tools.ietf.org/html/rfc7636#section-4.5, see BuildChallengeUrl
67+
if (context.Properties.Items.TryGetValue(OAuthConstants.CodeVerifierKey, out var codeVerifier))
68+
{
69+
tokenRequestParameters.Add(OAuthConstants.CodeVerifierKey, codeVerifier!);
70+
context.Properties.Items.Remove(OAuthConstants.CodeVerifierKey);
71+
}
72+
73+
using var requestMessage = new HttpRequestMessage(HttpMethod.Post, Options.TokenEndpoint);
74+
requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
75+
requestMessage.Content = new FormUrlEncodedContent(tokenRequestParameters);
76+
requestMessage.Headers.Authorization = CreateAuthorizationHeader();
77+
requestMessage.Version = Backchannel.DefaultRequestVersion;
78+
79+
var response = await Backchannel.SendAsync(requestMessage, Context.RequestAborted);
80+
var body = await response.Content.ReadAsStringAsync(Context.RequestAborted);
81+
82+
return response.IsSuccessStatusCode switch
83+
{
84+
true => OAuthTokenResponse.Success(JsonDocument.Parse(body)),
85+
false => await ParseInvalidResponseAsync(response)
86+
};
87+
}
88+
89+
private AuthenticationHeaderValue CreateAuthorizationHeader()
90+
{
91+
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(
92+
string.Concat(
93+
EscapeDataString(Options.ClientId),
94+
":",
95+
EscapeDataString(Options.ClientSecret))));
96+
97+
return new AuthenticationHeaderValue("Basic", credentials);
98+
}
99+
100+
private static string EscapeDataString(string value)
101+
{
102+
if (string.IsNullOrEmpty(value))
103+
{
104+
return string.Empty;
105+
}
106+
107+
return Uri.EscapeDataString(value).Replace("%20", "+", StringComparison.Ordinal);
108+
}
109+
110+
private async Task<OAuthTokenResponse> ParseInvalidResponseAsync(HttpResponseMessage response)
111+
{
112+
await Log.ExchangeCodeErrorAsync(Logger, response, Context.RequestAborted);
113+
return OAuthTokenResponse.Failed(new Exception("An error occurred while retrieving an access token."));
114+
}
115+
116+
private static partial class Log
117+
{
118+
internal static async Task UserProfileErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken)
119+
{
120+
UserProfileError(
121+
logger,
122+
response.StatusCode,
123+
response.Headers.ToString(),
124+
await response.Content.ReadAsStringAsync(cancellationToken));
125+
}
126+
127+
internal static async Task ExchangeCodeErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken)
128+
{
129+
ExchangeCodeError(
130+
logger,
131+
response.StatusCode,
132+
response.Headers.ToString(),
133+
await response.Content.ReadAsStringAsync(cancellationToken));
134+
}
135+
136+
[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}.")]
137+
private static partial void UserProfileError(
138+
ILogger logger,
139+
System.Net.HttpStatusCode status,
140+
string headers,
141+
string body);
142+
143+
[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}.")]
144+
private static partial void ExchangeCodeError(
145+
ILogger logger,
146+
HttpStatusCode status,
147+
string headers,
148+
string body);
149+
}
150+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
4+
* for more information concerning the license and the contributors participating to this project.
5+
*/
6+
7+
using System.Security.Claims;
8+
9+
namespace AspNet.Security.OAuth.Airtable;
10+
11+
/// <summary>
12+
/// Defines a set of options used by <see cref="AirtableAuthenticationHandler"/>.
13+
/// </summary>
14+
public class AirtableAuthenticationOptions : OAuthOptions
15+
{
16+
public AirtableAuthenticationOptions()
17+
{
18+
ClaimsIssuer = AirtableAuthenticationDefaults.Issuer;
19+
CallbackPath = AirtableAuthenticationDefaults.CallbackPath;
20+
21+
AuthorizationEndpoint = AirtableAuthenticationDefaults.AuthorizationEndpoint;
22+
TokenEndpoint = AirtableAuthenticationDefaults.TokenEndpoint;
23+
UserInformationEndpoint = AirtableAuthenticationDefaults.UserInformationEndpoint;
24+
25+
Scope.Add("user.email:read");
26+
27+
ClaimActions.MapCustomJson(ClaimTypes.NameIdentifier, user => user.GetString("id"));
28+
ClaimActions.MapCustomJson(ClaimTypes.Email, user => user.GetString("email"));
29+
}
30+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
5+
</PropertyGroup>
6+
7+
<!-- TODO Enable once this provider is published to NuGet.org -->
8+
<PropertyGroup>
9+
<DisablePackageBaselineValidation>true</DisablePackageBaselineValidation>
10+
<PackageValidationBaselineVersion>8.0.1</PackageValidationBaselineVersion>
11+
</PropertyGroup>
12+
13+
<PropertyGroup>
14+
<Description>ASP.NET Core security middleware enabling Airtable authentication.</Description>
15+
<Authors>Denys Goncharenko</Authors>
16+
<PackageTags>aspnetcore;authentication;oauth;airtable;security</PackageTags>
17+
</PropertyGroup>
18+
19+
<ItemGroup>
20+
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
21+
<PackageReference Include="JetBrains.Annotations" PrivateAssets="All"/>
22+
</ItemGroup>
23+
24+
</Project>

0 commit comments

Comments
 (0)